diff --git a/extensions/thumbnails.test.ts b/extensions/thumbnails.test.ts index 8dddd097b..a47a598a4 100644 --- a/extensions/thumbnails.test.ts +++ b/extensions/thumbnails.test.ts @@ -304,8 +304,6 @@ describe('thumbnails extension — handleThumbnailRead', () => { let s3: S3Client; let s3Presign: S3Client; - const stubDb = { write: vi.fn().mockResolvedValue(undefined) }; - beforeAll(async () => { server = await setupTestServer(); s3 = server.clients.s3.get(); @@ -344,7 +342,7 @@ describe('thumbnails extension — handleThumbnailRead', () => { s3Presign, bucketName: BUCKET, bucketEndpoint: 'http://127.0.0.1:4566/puter-local/', - db: stubDb, + fsEntry: server.stores.fsEntry, }); expect(typeof entry.thumbnail).toBe('string'); @@ -383,7 +381,7 @@ describe('thumbnails extension — handleThumbnailRead', () => { s3Presign, bucketName: BUCKET, bucketEndpoint: 'http://127.0.0.1:4566/puter-local/', - db: stubDb, + fsEntry: server.stores.fsEntry, }); expect(entry.thumbnail).toBeNull(); }); @@ -398,7 +396,7 @@ describe('thumbnails extension — handleThumbnailRead', () => { s3Presign, bucketName: BUCKET, bucketEndpoint: 'http://127.0.0.1:4566/puter-local/', - db: stubDb, + fsEntry: server.stores.fsEntry, }); // Signed for OUR bucket; `attacker-named-bucket` never reached S3. const signed = entry.thumbnail as string; @@ -414,7 +412,7 @@ describe('thumbnails extension — handleThumbnailRead', () => { s3Presign, bucketName: BUCKET, bucketEndpoint: 'http://127.0.0.1:4566/puter-local/', - db: stubDb, + fsEntry: server.stores.fsEntry, }); expect(entry.thumbnail).toBe('about:blank'); }); @@ -426,43 +424,157 @@ describe('thumbnails extension — handleThumbnailRead', () => { s3Presign, bucketName: BUCKET, bucketEndpoint: 'http://127.0.0.1:4566/puter-local/', - db: stubDb, + fsEntry: server.stores.fsEntry, }); expect(entry.thumbnail).toBeUndefined(); }); - it('migrates an inline data: URL by uploading to S3 and updating the DB row', async () => { - const entryUuid = crypto.randomUUID(); - const entry: Record = { - uuid: entryUuid, - thumbnail: `data:image/png;base64,${TINY_PNG_BASE64}`, - }; + // -- inline data: URL migration -- + // A file whose thumbnail was stored inline, as rows from before storage + // pointers are. Creating it leaves it in the entry cache. + const inlineThumbnailEntry = async (thumbnail: string) => { + const username = `thumb-${Math.random().toString(36).slice(2, 10)}`; + const user = await server.stores.user.create({ + username, + uuid: crypto.randomUUID(), + password: null, + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + await generateDefaultFsentries( + server.clients.db, + server.stores.user, + user, + ); + const documents = (await server.stores.fsEntry.getEntryByPath( + `/${username}/Documents`, + ))!; + return server.stores.fsEntry.createNonFileEntry({ + parent: documents, + name: 'legacy.png', + kind: 'empty-file', + thumbnail, + }); + }; + + // Passes every command through, keeping the keys of the uploads. + const recordingClient = () => { + const putKeys: string[] = []; + const client = { + send: async (command: unknown) => { + if (command instanceof PutObjectCommand) { + putKeys.push(command.input.Key!); + } + return s3.send(command as never); + }, + } as unknown as S3Client; + return { client, putKeys }; + }; + + // What a listing does: read the entry through the store, then sign it. + const readThumbnail = async (uuid: string, client: S3Client) => { + const stored = (await server.stores.fsEntry.getEntryByUuid(uuid))!; + const entry: Record = { + uuid, + thumbnail: stored.thumbnail, + }; await handleThumbnailRead(entry, { - s3, + s3: client, s3Presign, bucketName: BUCKET, - bucketEndpoint: 'http://127.0.0.1:4566/puter-local/', - db: stubDb, + bucketEndpoint: BUCKET_ENDPOINT, + fsEntry: server.stores.fsEntry, }); + return entry.thumbnail; + }; - // The handler should have replaced the data URL with a signed - // S3 URL and kicked off the DB migration write. - expect(typeof entry.thumbnail).toBe('string'); - expect((entry.thumbnail as string).startsWith('http')).toBe(true); - // Allow the best-effort write microtask to settle. - await Promise.resolve(); - expect(stubDb.write).toHaveBeenCalledWith( - 'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?', - [ - expect.stringMatching( - new RegExp(`^s3://${BUCKET}/thumbnails/${entryUuid}/`), - ), - entryUuid, - ], + it('migrates an inline data: URL to storage once, then serves the stored pointer', async () => { + const entry = await inlineThumbnailEntry( + `data:image/png;base64,${TINY_PNG_BASE64}`, ); + const { client, putKeys } = recordingClient(); + + const first = await readThumbnail(entry.uuid, client); + const second = await readThumbnail(entry.uuid, client); + + expect(putKeys).toHaveLength(1); + const [key] = putKeys; + expect(key).toMatch(new RegExp(`^thumbnails/${entry.uuid}/`)); + const pointer = `s3://${BUCKET}/${key}`; + // The cached entry and the row both hold the new pointer. + expect( + (await server.stores.fsEntry.getEntryByUuid(entry.uuid))!.thumbnail, + ).toBe(pointer); + expect( + (await server.stores.fsEntry.getEntryByUuidFromPrimary(entry.uuid))! + .thumbnail, + ).toBe(pointer); + for (const signed of [first, second]) { + expect(signed).toMatch(/^http/); + expect(signed).toContain(key); + } }); + it('keeps one object when reads of the same entry migrate it concurrently', async () => { + const thumbnail = `data:image/png;base64,${TINY_PNG_BASE64}`; + const entry = await inlineThumbnailEntry(thumbnail); + const { client, putKeys } = recordingClient(); + const read = async () => { + const payload: Record = { + uuid: entry.uuid, + thumbnail, + }; + await handleThumbnailRead(payload, { + s3: client, + s3Presign, + bucketName: BUCKET, + bucketEndpoint: BUCKET_ENDPOINT, + fsEntry: server.stores.fsEntry, + }); + return payload.thumbnail; + }; + + await Promise.all([read(), read()]); + + expect(putKeys).toHaveLength(2); + const stored = (await server.stores.fsEntry.getEntryByUuidFromPrimary( + entry.uuid, + ))!.thumbnail; + const winner = putKeys.find( + (key) => stored === `s3://${BUCKET}/${key}`, + ); + expect(winner).toBeDefined(); + // The losing read's upload is not left behind. + for (const key of putKeys) { + expect(await objectExists(s3, key)).toBe(key === winner); + } + }); + + it.each([ + [ + 'does not decode to an image', + `data:image/png;base64,${Buffer.from('not an image').toString('base64')}`, + ], + ['has no payload', 'data:image/png;base64'], + ])( + 'does not migrate an inline thumbnail that %s', + async (_label, thumbnail) => { + const entry = await inlineThumbnailEntry(thumbnail); + const { client, putKeys } = recordingClient(); + + expect(await readThumbnail(entry.uuid, client)).toBe(thumbnail); + + expect(putKeys).toHaveLength(0); + expect( + (await server.stores.fsEntry.getEntryByUuidFromPrimary( + entry.uuid, + ))!.thumbnail, + ).toBe(thumbnail); + }, + ); + it('leaves an inline data: URL as is when there is no entry to bind it to', async () => { const thumbnail = `data:image/png;base64,${TINY_PNG_BASE64}`; const entry: Record = { thumbnail }; @@ -471,7 +583,7 @@ describe('thumbnails extension — handleThumbnailRead', () => { s3Presign, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT, - db: stubDb, + fsEntry: server.stores.fsEntry, }); expect(entry.thumbnail).toBe(thumbnail); }); @@ -568,32 +680,28 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => { copyUuid: string, client: S3Client = s3, ) => { - const db = { write: vi.fn().mockResolvedValue(undefined) }; + const fsEntry = { + updateEntryThumbnailByUuid: vi.fn().mockResolvedValue(true), + }; await handleFsCopyNodeThumbnail( { copy: { thumbnail, uuid: copyUuid } }, { s3: client, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT, - db, + fsEntry, }, ); - return db; + return fsEntry.updateEntryThumbnailByUuid; }; // The key the copied row was repointed at, asserting it is bound to it. const repointedKey = ( - db: Awaited>, + update: Awaited>, copyUuid: string, ): string => { - expect(db.write).toHaveBeenCalledTimes(1); - const [sql, [pointer, uuid]] = db.write.mock.calls[0] as [ - string, - [string, string], - ]; - expect(sql).toBe( - 'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?', - ); + expect(update).toHaveBeenCalledTimes(1); + const [uuid, pointer] = update.mock.calls[0] as [string, string]; expect(uuid).toBe(copyUuid); expect(pointer).toMatch( new RegExp(`^s3://${BUCKET}/thumbnails/${copyUuid}/`), @@ -612,15 +720,17 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => { await putObject(s3, sourceKey, body); const copyUuid = crypto.randomUUID(); - const db = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid); + const update = await copyNode( + `s3://${BUCKET}/${sourceKey}`, + copyUuid, + ); // The copied row was repointed at a fresh object of its own... - expect(db.write).toHaveBeenCalledTimes(1); - const [, params] = db.write.mock.calls[0] as [ + expect(update).toHaveBeenCalledTimes(1); + const [updatedUuid, newPointer] = update.mock.calls[0] as [ + string, string, - [string, string], ]; - const [newPointer, updatedUuid] = params; expect(updatedUuid).toBe(copyUuid); expect( newPointer.startsWith(`s3://${BUCKET}/thumbnails/${copyUuid}/`), @@ -656,7 +766,7 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => { await putObject(client, sourceKey, body); const copyUuid = crypto.randomUUID(); - const db = await copyNode( + const update = await copyNode( `s3://${BUCKET}/${sourceKey}`, copyUuid, client, @@ -665,7 +775,7 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => { const duplicated = await client.send( new GetObjectCommand({ Bucket: BUCKET, - Key: repointedKey(db, copyUuid), + Key: repointedKey(update, copyUuid), }), ); expect(duplicated.ContentType).toBe('image/png'); @@ -708,15 +818,12 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => { it('drops the pointer when the shared object is already gone', async () => { const copyUuid = crypto.randomUUID(); - const db = await copyNode( + const update = await copyNode( `s3://${BUCKET}/${mintedKey()}`, // never uploaded copyUuid, ); - expect(db.write).toHaveBeenCalledTimes(1); - const [sql, params] = db.write.mock.calls[0] as [string, [string]]; - expect(sql).toContain('NULL'); - expect(params).toEqual([copyUuid]); + expect(update.mock.calls).toEqual([[copyUuid, null]]); }); // Only reachable for objects uploaded before upload URLs were size-bound; @@ -726,12 +833,9 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => { await putObject(s3, sourceKey, Buffer.alloc(2 * 1024 * 1024 + 1)); const copyUuid = crypto.randomUUID(); - const db = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid); + const update = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid); - expect(db.write).toHaveBeenCalledTimes(1); - const [sql, params] = db.write.mock.calls[0] as [string, [string]]; - expect(sql).toContain('NULL'); - expect(params).toEqual([copyUuid]); + expect(update.mock.calls).toEqual([[copyUuid, null]]); }); it('drops the pointer when the object outgrows the bound after its size was checked', async () => { @@ -755,41 +859,38 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => { } as unknown as S3Client; const copyUuid = crypto.randomUUID(); - const db = await copyNode( + const update = await copyNode( `s3://${BUCKET}/${sourceKey}`, copyUuid, racing, ); - expect(db.write).toHaveBeenCalledTimes(1); - const [sql, params] = db.write.mock.calls[0] as [string, [string]]; - expect(sql).toContain('NULL'); - expect(params).toEqual([copyUuid]); + expect(update.mock.calls).toEqual([[copyUuid, null]]); expect(sent.some((c) => c instanceof PutObjectCommand)).toBe(false); }); it('does not duplicate an object the pointer names but we did not mint', async () => { const foreignKey = crypto.randomUUID(); // shaped like an fs object key - const db = await copyNode( + const update = await copyNode( `s3://${BUCKET}/${foreignKey}`, crypto.randomUUID(), ); - expect(db.write).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); }); it('leaves an external image URL to ride along as is', async () => { const sourceKey = legacyKey(); await putObject(s3, sourceKey, Buffer.from(TINY_PNG_BASE64, 'base64')); - const db = await copyNode( + const update = await copyNode( `https://cdn.example.com/${BUCKET}/${sourceKey}`, crypto.randomUUID(), ); - expect(db.write).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); }); it('is a no-op when the copy has no thumbnail', async () => { - const db = await copyNode(null, crypto.randomUUID()); - expect(db.write).not.toHaveBeenCalled(); + const update = await copyNode(null, crypto.randomUUID()); + expect(update).not.toHaveBeenCalled(); }); }); @@ -973,14 +1074,10 @@ describe('thumbnails extension — signed batch upload through /fs', () => { const desktop = (await server.stores.fsEntry.getEntryByPath( `/${username}/Desktop`, ))!; - // The listener writes the row directly, so read it the same way. - const storedThumbnail = async (uuid: string) => { - const [row] = (await server.clients.db.read( - 'SELECT `thumbnail` FROM `fsentries` WHERE `uuid` = ?', - [uuid], - )) as Array<{ thumbnail: string | null }>; - return row?.thumbnail ?? null; - }; + // Through the entry cache, which the listener's write refreshes. + const storedThumbnail = async (uuid: string) => + (await server.stores.fsEntry.getEntryByUuid(uuid))?.thumbnail ?? + null; const copy = await runWithContext({ actor }, () => fs.copy(userId, { source, destinationParent: desktop }), diff --git a/extensions/thumbnails.ts b/extensions/thumbnails.ts index a6674e47f..d2c1012c1 100644 --- a/extensions/thumbnails.ts +++ b/extensions/thumbnails.ts @@ -7,11 +7,15 @@ import { } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { extension } from '@heyputer/backend/src/extensions'; +import type { FSEntryStore } from '@heyputer/backend/src/stores/fs/FSEntryStore'; import { isMissingObjectError } from '@heyputer/backend/src/stores/fs/S3ObjectStore'; import crypto from 'node:crypto'; import type { Readable } from 'node:stream'; import sharp from 'sharp'; const clients = extension.import('client'); +const stores = extension.import('store'); + +type ThumbnailEntryStore = Pick; const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024; const MAX_THUMBNAIL_PIXELS = 64e6; @@ -135,13 +139,6 @@ function getPresignClient(): S3Client { return resolveClients().presign; } -function base64ParseDataUrl(dataURL: string) { - dataURL = dataURL.slice(5); - const mimeType = dataURL.split(';')[0]; - const data = Buffer.from(dataURL.split(',')[1], 'base64'); - return { mimeType, data }; -} - // Strictly decode a data: URL and validate the decoded image. Encoded-string // length lies about decoded byte count (whitespace, padding) and says nothing // about pixel count — a 2MB PNG can decompress to hundreds of MB of raster. @@ -304,7 +301,7 @@ export const handleThumbnailRead = async ( s3Presign: S3Client; bucketName: string; bucketEndpoint: string; - db: { write: (sql: string, params: unknown[]) => Promise }; + fsEntry: ThumbnailEntryStore; }, ): Promise => { const thumb = entry.thumbnail; @@ -325,32 +322,52 @@ export const handleThumbnailRead = async ( { expiresIn: 604800 }, ); } else if (thumb.startsWith('data')) { - // Inline data-URL migration: upload to S3 and update the DB entry. + // Move an inline thumbnail into storage. The row only takes the new + // pointer while it still holds this data URL, so of concurrent reads + // one wins and the rest discard their upload. const uuid = entry.uuid ?? entry.uid; if (!isUuid(uuid)) return; + // Same bar as an upload; anything else stays inline, unmigrated. + const decoded = await decodeAndValidateThumbnail(thumb); + if (!decoded) return; const key = mintThumbnailKey(uuid); - const { mimeType, data } = base64ParseDataUrl(thumb); - const newUrl = `s3://${deps.bucketName}/${key}`; await deps.s3.send( new PutObjectCommand({ Bucket: deps.bucketName, Key: key, - Body: data, - ContentType: mimeType, + Body: decoded.data, + ContentType: decoded.mimeType, }), ); - // Best-effort async DB update - if (uuid) { - deps.db - .write( - 'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?', - [newUrl, uuid], - ) - .catch((err: unknown) => - console.warn('[thumbnails] inline migration failed', err), + let migrated: boolean; + try { + migrated = await deps.fsEntry.updateEntryThumbnailByUuid( + uuid, + `s3://${deps.bucketName}/${key}`, + thumb, + ); + } catch (err) { + // The row may or may not point at the object now, so keep it. + console.warn('[thumbnails] inline migration failed', err); + return; + } + if (!migrated) { + try { + await deps.s3.send( + new DeleteObjectCommand({ + Bucket: deps.bucketName, + Key: key, + }), ); + } catch (err) { + console.warn( + '[thumbnails] failed to remove unused thumbnail', + err, + ); + } + return; } entry.thumbnail = await getSignedUrl( @@ -367,7 +384,7 @@ export const handleFsCopyNodeThumbnail = async ( s3: S3Client; bucketName: string; bucketEndpoint: string; - db: { write: (sql: string, params: unknown[]) => Promise }; + fsEntry: ThumbnailEntryStore; }, ): Promise => { const copy = payload.copy; @@ -419,17 +436,14 @@ export const handleFsCopyNodeThumbnail = async ( } catch (err) { // The shared object is gone or oversized — drop the pointer rather // than leave the row advertising a thumbnail it doesn't have. - await deps.db.write( - 'UPDATE `fsentries` SET `thumbnail` = NULL WHERE `uuid` = ?', - [copy.uuid], - ); + await deps.fsEntry.updateEntryThumbnailByUuid(copy.uuid, null); console.warn('[thumbnails] failed to duplicate thumbnail on copy', err); return; } - await deps.db.write( - 'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?', - [`s3://${deps.bucketName}/${newKey}`, copy.uuid], + await deps.fsEntry.updateEntryThumbnailByUuid( + copy.uuid, + `s3://${deps.bucketName}/${newKey}`, ); }; @@ -485,7 +499,7 @@ extension.on('thumbnail.read', async (_key, entry: Record) => { s3Presign: getPresignClient(), bucketName: thumbnailBucketName, bucketEndpoint: extensionBucketEndpoint, - db: clients.db, + fsEntry: stores.fsEntry, }); }); @@ -502,7 +516,7 @@ extension.on('fs.copy.node', async (_key, payload) => { s3: getClient(), bucketName: thumbnailBucketName, bucketEndpoint: extensionBucketEndpoint, - db: clients.db, + fsEntry: stores.fsEntry, }, ); }); diff --git a/src/backend/controllers/fs/LegacyFSController.routes.test.ts b/src/backend/controllers/fs/LegacyFSController.routes.test.ts index 6c896e1e4..4f60f1401 100644 --- a/src/backend/controllers/fs/LegacyFSController.routes.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.routes.test.ts @@ -535,6 +535,60 @@ describe('LegacyFSController.updateFsentryThumbnail', () => { expect(row?.thumbnail).toBe(thumbnail); }); + it('serves the new thumbnail from the entry cache on the next read', async () => { + const { actor, username } = await makeUser(); + const path = `/${username}/Documents/cached-thumb.txt`; + const { uuid } = await writeFileEntry(actor, path, 'body'); + const before = await server.stores.fsEntry.getEntryByUuid(uuid); + expect(before?.thumbnail ?? null).toBeNull(); + + const thumbnail = 'data:image/png;base64,aGVsbG8='; + const { res } = makeRes(); + await withActor(actor, () => + controller.updateFsentryThumbnail( + makeReq({ body: { uid: uuid, thumbnail }, actor }), + res, + ), + ); + + expect( + (await server.stores.fsEntry.getEntryByUuid(uuid))?.thumbnail, + ).toBe(thumbnail); + expect( + (await server.stores.fsEntry.getEntryByPath(path))?.thumbnail, + ).toBe(thumbnail); + }); + + it('lets a caller with write access to a shared file set its thumbnail', async () => { + const owner = await makeUser(); + const writer = await makeUser(); + const path = `/${owner.username}/Documents/shared-thumb.txt`; + const { uuid } = await writeFileEntry(owner.actor, path, 'body'); + await server.services.permission.grantUserUserPermission( + owner.actor, + writer.username, + `fs:${uuid}:write`, + {}, + ); + + const thumbnail = 'data:image/png;base64,aGVsbG8='; + const { res, captured } = makeRes(); + await withActor(writer.actor, () => + controller.updateFsentryThumbnail( + makeReq({ + body: { uid: uuid, thumbnail }, + actor: writer.actor, + }), + res, + ), + ); + + expect(captured.body).toEqual({ thumbnail }); + expect( + (await server.stores.fsEntry.getEntryByUuid(uuid))?.thumbnail, + ).toBe(thumbnail); + }); + it('tells thumbnail listeners which entry the thumbnail is for', async () => { const { actor, username } = await makeUser(); const path = `/${username}/Documents/bound-thumb.txt`; diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index ebb958dd0..d8ad54b25 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -1206,9 +1206,10 @@ export class LegacyFSController extends PuterController { const event = { url: thumbnail, uuid: entry.uuid }; await this.clients.event.emitAndWait('thumbnail.created', event, {}); - await this.clients.db.write( - 'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?', - [event.url, uid], + // Not the owner-scoped update: a shared writer passed the check above. + await this.stores.fsEntry.updateEntryThumbnailByUuid( + entry.uuid, + event.url, ); res.json({ thumbnail: event.url }); }; diff --git a/src/backend/stores/fs/FSEntryStore.test.ts b/src/backend/stores/fs/FSEntryStore.test.ts index 49814469f..5883200bd 100644 --- a/src/backend/stores/fs/FSEntryStore.test.ts +++ b/src/backend/stores/fs/FSEntryStore.test.ts @@ -1235,6 +1235,106 @@ describe('FSEntryStore timestamps and updates', () => { }); }); + it('updates a thumbnail by uuid and refreshes every cached key', async () => { + const user = await makeUser(); + const file = await createFile( + user, + `${user.home}/Documents/th-any.txt`, + ); + await store.getEntryByUuid(file.uuid); + + await expect( + store.updateEntryThumbnailByUuid( + file.uuid, + 'data:image/png;base64,CC', + ), + ).resolves.toBe(true); + + await expect(store.getEntryByUuid(file.uuid)).resolves.toMatchObject({ + modified: file.modified, + }); + for (const cached of [ + await store.getEntryByUuid(file.uuid), + await store.getEntryById(file.id), + await store.getEntryByPath(file.path), + ]) { + expect(cached?.thumbnail).toBe('data:image/png;base64,CC'); + } + }); + + it('updates a thumbnail by uuid only while it holds the expected one', async () => { + const user = await makeUser(); + const file = await createFile( + user, + `${user.home}/Documents/th-cas.txt`, + { thumbnail: 'data:image/png;base64,OLD' }, + ); + + await expect( + store.updateEntryThumbnailByUuid( + file.uuid, + 's3://puter-local/x', + 'data:image/png;base64,OTHER', + ), + ).resolves.toBe(false); + expect( + (await store.getEntryByUuidFromPrimary(file.uuid))?.thumbnail, + ).toBe('data:image/png;base64,OLD'); + + await expect( + store.updateEntryThumbnailByUuid( + file.uuid, + 's3://puter-local/x', + 'data:image/png;base64,OLD', + ), + ).resolves.toBe(true); + expect( + (await store.getEntryByUuidFromPrimary(file.uuid))?.thumbnail, + ).toBe('s3://puter-local/x'); + }); + + it('reports a thumbnail update even when the replica has not seen the row', async () => { + const user = await makeUser(); + const file = await createFile( + user, + `${user.home}/Documents/th-lag.txt`, + { thumbnail: 'data:image/png;base64,OLD' }, + ); + await store.invalidateEntryCacheByUuid(file.uuid); + + const db = server.clients.db; + const originalRead = (Object.getPrototypeOf(db) as typeof db).read; + const readSpy = vi + .spyOn(db, 'read') + .mockImplementation( + async (query: string, params: unknown[] = []) => + query.includes('WHERE uuid = ? LIMIT 1') && + params[0] === file.uuid + ? [] + : originalRead.call(db, query, params), + ); + try { + await expect( + store.updateEntryThumbnailByUuid( + file.uuid, + 's3://puter-local/lag', + 'data:image/png;base64,OLD', + ), + ).resolves.toBe(true); + } finally { + readSpy.mockRestore(); + } + expect( + (await store.getEntryByUuidFromPrimary(file.uuid))?.thumbnail, + ).toBe('s3://puter-local/lag'); + }); + + it('updates no thumbnail for an unknown uuid', async () => { + await expect( + store.updateEntryThumbnailByUuid(uuidv4(), 'data:x'), + ).resolves.toBe(false); + }); + it('updateEntry returns and caches the primary row when the replica lags', async () => { const user = await makeUser(); const file = await createFile(user, `${user.home}/Documents/stale.txt`); diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index 212ee0ae3..9525652f7 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -1485,6 +1485,28 @@ export class FSEntryStore extends PuterStore { return result; } + /** + * Set an entry's thumbnail with no owner check; the caller authorizes. With + * `expectedThumbnail`, only a row still holding it changes. Whether a row + * changed, whatever the cache refresh after it finds. + */ + async updateEntryThumbnailByUuid( + uuid: string, + thumbnail: string | null, + expectedThumbnail?: string, + ): Promise { + const compare = expectedThumbnail !== undefined; + const writeResult = await this.clients.db.write( + `UPDATE fsentries SET thumbnail = ? WHERE uuid = ?${ + compare ? ' AND thumbnail = ?' : '' + }`, + compare ? [thumbnail, uuid, expectedThumbnail] : [thumbnail, uuid], + ); + if (this.#affectedRows(writeResult) === 0) return false; + await this.#entryAfterPatch(uuid, { thumbnail }); + return true; + } + async updateEntryThumbnailByUuidForUser( userId: number, uuid: string,