mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-13 15:20:26 +00:00
fix(fs): route thumbnail writes through FSEntryStore (#4174)
* fix(fs): route thumbnail writes through FSEntryStore Add FSEntryStore.updateEntryThumbnailByUuid, which sets a thumbnail with no owner check (callers authorize) and refreshes the entry cache, with an optional expected-value guard. The thumbnails extension's inline data: URL migration, its copy duplication, and the legacy /update-fsentry-thumbnail endpoint now write through it instead of raw UPDATEs, so cached entries stop serving the old thumbnail. The migration validates the image like the upload path, awaits the write, and only repoints a row that still holds the data URL; a read that loses that race deletes its upload. * fix(fs): report a thumbnail update by whether the row changed updateEntryThumbnailByUuid returned the refreshed entry, which is null when the cache misses and the replica hasn't seen the row yet. The read-time migration took that null for a lost race and deleted the object the row had just been pointed at. It now returns whether the UPDATE changed a row; the cache refresh still runs after it.
This commit is contained in:
1 parent
3296526e8e
commit
847a4fc031
6 files changed
+402
-114
No files matched your search
+176
-79
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = { 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<ReturnType<typeof copyNode>>,
|
||||
update: Awaited<ReturnType<typeof copyNode>>,
|
||||
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 }),
|
||||
|
||||
+46
-32
@@ -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<FSEntryStore, 'updateEntryThumbnailByUuid'>;
|
||||
|
||||
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<unknown> };
|
||||
fsEntry: ThumbnailEntryStore;
|
||||
},
|
||||
): Promise<void> => {
|
||||
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<unknown> };
|
||||
fsEntry: ThumbnailEntryStore;
|
||||
},
|
||||
): Promise<void> => {
|
||||
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<string, unknown>) => {
|
||||
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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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<boolean> {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user