@@ -12,14 +12,10 @@ import {
1212 workspaceFileVersion ,
1313 workspace as workspaceTable ,
1414} from '@sim/db/schema'
15- import { chunkArray } from '@sim/utils/helpers'
1615import { isRecordLike } from '@sim/utils/object'
17- import { inArray , isNotNull , like , or , type SQL , type SQLWrapper , sql } from 'drizzle-orm'
16+ import { inArray , isNotNull , like , or } from 'drizzle-orm'
1817import { extractStorageKey , isPublicStorageKey } from '@/lib/uploads/utils/file'
1918
20- const MIN_NEEDLE_LENGTH = 4
21- const NEEDLES_PER_QUERY = 50
22-
2319/** Every string inside a URL column or a JSON settings blob, including JSON held as a string. */
2420function collectStrings ( value : unknown , into : string [ ] ) : void {
2521 if ( typeof value === 'string' ) {
@@ -81,54 +77,40 @@ function keysNamedByUrl(value: string): string[] {
8177 return [ ...keys ]
8278}
8379
84- /**
85- * The longest run of URL-safe characters after a key's root. Percent-encoding never splits such a
86- * run, so any stored URL naming the key contains it; `null` when the key has none long enough.
87- */
88- function urlNeedle ( key : string ) : string | null {
89- let best = ''
90- for ( const run of key . slice ( key . indexOf ( '/' ) + 1 ) . match ( / [ A - Z a - z 0 - 9 _ - ] + / g) ?? [ ] )
91- if ( run . length > best . length ) best = run
92- return best . length >= MIN_NEEDLE_LENGTH ? best : null
93- }
94-
9580/**
9681 * Keys named by the columns that hold a stored asset as a URL rather than a row:
9782 * `user.image`, `workspace.logo_url`, `organization.logo`, `organization.whitelabel_settings`,
9883 * `organization.metadata`, `custom_block.icon_url`, `chat.customizations` (deployed-chat logo
99- * and avatar), and `oauth_client.icon`. With `needles`, reads only rows whose value contains one;
100- * without, every row that holds a URL.
84+ * and avatar), and `oauth_client.icon`. Every non-empty value is read and decoded here rather than
85+ * matched in SQL, since a URL may percent-encode any character of the key it names. Only
86+ * `user.image`, mostly external OAuth avatars, is narrowed, to values that point at this app's
87+ * file-serve route or name a world-readable root.
10188 */
102- async function loadUrlReferencedKeys ( needles : readonly string [ ] | null ) : Promise < Set < string > > {
103- const matching = ( column : SQLWrapper , fallback : SQL | undefined ) : SQL | undefined =>
104- needles ? or ( ...needles . map ( ( needle ) => sql `${ column } ::text LIKE ${ `%${ needle } %` } ` ) ) : fallback
89+ async function loadUrlReferencedKeys ( ) : Promise < Set < string > > {
10590 const values : string [ ] = [ ]
10691 const sources = [
10792 db
10893 . select ( { value : user . image } )
10994 . from ( user )
11095 . where (
111- matching (
112- user . image ,
113- or ( like ( user . image , '%/api/files/serve/%' ) , like ( user . image , '%profile-pictures/%' ) )
96+ or (
97+ like ( user . image , '%/api/files/serve/%' ) ,
98+ like ( user . image , '%profile-pictures%' ) ,
99+ like ( user . image , '%og-images%' ) ,
100+ like ( user . image , '%workspace-logos%' ) ,
101+ like ( user . image , '%organization-logos%' )
114102 )
115103 ) ,
116104 db
117105 . select ( { value : workspaceTable . logoUrl } )
118106 . from ( workspaceTable )
119- . where ( matching ( workspaceTable . logoUrl , isNotNull ( workspaceTable . logoUrl ) ) ) ,
107+ . where ( isNotNull ( workspaceTable . logoUrl ) ) ,
120108 db
121109 . select ( { value : customBlock . iconUrl } )
122110 . from ( customBlock )
123- . where ( matching ( customBlock . iconUrl , isNotNull ( customBlock . iconUrl ) ) ) ,
124- db
125- . select ( { value : chat . customizations } )
126- . from ( chat )
127- . where ( matching ( chat . customizations , sql `${ chat . customizations } ::text LIKE '%Url%'` ) ) ,
128- db
129- . select ( { value : oauthClient . icon } )
130- . from ( oauthClient )
131- . where ( matching ( oauthClient . icon , isNotNull ( oauthClient . icon ) ) ) ,
111+ . where ( isNotNull ( customBlock . iconUrl ) ) ,
112+ db . select ( { value : chat . customizations } ) . from ( chat ) . where ( isNotNull ( chat . customizations ) ) ,
113+ db . select ( { value : oauthClient . icon } ) . from ( oauthClient ) . where ( isNotNull ( oauthClient . icon ) ) ,
132114 ]
133115 for ( const source of sources ) for ( const row of await source ) collectStrings ( row . value , values )
134116 const organizations = await db
@@ -140,9 +122,9 @@ async function loadUrlReferencedKeys(needles: readonly string[] | null): Promise
140122 . from ( organization )
141123 . where (
142124 or (
143- matching ( organization . logo , isNotNull ( organization . logo ) ) ,
144- matching ( organization . whitelabelSettings , isNotNull ( organization . whitelabelSettings ) ) ,
145- matching ( organization . metadata , isNotNull ( organization . metadata ) )
125+ isNotNull ( organization . logo ) ,
126+ isNotNull ( organization . whitelabelSettings ) ,
127+ isNotNull ( organization . metadata )
146128 )
147129 )
148130 for ( const row of organizations ) collectStrings ( [ row . logo , row . whitelabel , row . metadata ] , values )
@@ -158,8 +140,8 @@ async function loadUrlReferencedKeys(needles: readonly string[] | null): Promise
158140 * `workspace_file_version.key`, `document.storage_key`, `execution_large_values.key`; or
159141 * - it sits under a world-readable root (`isPublicStorageKey`, the same prefixes the file-serve
160142 * route's `isPublicByKeyPrefix` answers without a session) and a stored asset URL names it (see
161- * {@link loadUrlReferencedKeys}). Only rows containing the key's longest URL-safe run are read,
162- * each run at most once per guard . No URL column is read for any other key:
143+ * {@link loadUrlReferencedKeys}), read at most once per guard and only once a public key comes
144+ * up . No URL column is read for any other key:
163145 * `verifyFileAccess` serves it only through a row, by its key path under a workspace that still
164146 * exists (which exact-key cleanup keeps; see `keysUnderLiveWorkspaces`), or by object metadata
165147 * to a signed-in holder of a hand-pasted URL to a private file this deletion means to erase.
@@ -171,25 +153,7 @@ async function loadUrlReferencedKeys(needles: readonly string[] | null): Promise
171153 * attachments that is the intended erasure. A failed lookup throws, so nothing is deleted.
172154 */
173155export function createStorageReferenceGuard ( ) : ( keys : readonly string [ ] ) => Promise < Set < string > > {
174- const loadedNeedles = new Set < string > ( )
175- const named = new Set < string > ( )
176- let loadedAll = false
177- const loadNamedKeys = async ( publicKeys : readonly string [ ] ) => {
178- if ( loadedAll || publicKeys . length === 0 ) return
179- const needles = publicKeys . map ( urlNeedle )
180- if ( needles . includes ( null ) ) {
181- for ( const key of await loadUrlReferencedKeys ( null ) ) named . add ( key )
182- loadedAll = true
183- return
184- }
185- const pending = [ ...new Set ( needles . flatMap ( ( needle ) => needle ?? [ ] ) ) ] . filter (
186- ( needle ) => ! loadedNeedles . has ( needle )
187- )
188- for ( const batch of chunkArray ( pending , NEEDLES_PER_QUERY ) ) {
189- for ( const key of await loadUrlReferencedKeys ( batch ) ) named . add ( key )
190- for ( const needle of batch ) loadedNeedles . add ( needle )
191- }
192- }
156+ let named : Set < string > | null = null
193157 return async ( keys ) => {
194158 const referenced = new Set < string > ( )
195159 if ( keys . length === 0 ) return referenced
@@ -218,8 +182,10 @@ export function createStorageReferenceGuard(): (keys: readonly string[]) => Prom
218182 ]
219183 for ( const row of rows ) if ( row . key ) referenced . add ( row . key )
220184 const publicKeys = list . filter ( isPublicStorageKey )
221- await loadNamedKeys ( publicKeys )
222- for ( const key of publicKeys ) if ( named . has ( key ) ) referenced . add ( key )
185+ if ( publicKeys . length > 0 ) {
186+ named ??= await loadUrlReferencedKeys ( )
187+ for ( const key of publicKeys ) if ( named . has ( key ) ) referenced . add ( key )
188+ }
223189 return referenced
224190 }
225191}
0 commit comments