mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-12 14:50:39 +00:00
* 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.
1117 lines
39 KiB
TypeScript
1117 lines
39 KiB
TypeScript
import {
|
|
GetObjectCommand,
|
|
HeadObjectCommand,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
} from '@aws-sdk/client-s3';
|
|
import type { Request, Response } from 'express';
|
|
import crypto from 'node:crypto';
|
|
import {
|
|
afterAll,
|
|
beforeAll,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from 'vitest';
|
|
import type { FSController } from '../src/backend/controllers/fs/FSController.ts';
|
|
import type {
|
|
ClientSignedWriteResponse,
|
|
CompleteWriteRequest,
|
|
SignedWriteRequest,
|
|
} from '../src/backend/controllers/fs/requestTypes.ts';
|
|
import type { Actor } from '../src/backend/core/actor.ts';
|
|
import { runWithContext } from '../src/backend/core/context.ts';
|
|
import { PuterServer } from '../src/backend/server.ts';
|
|
import { setupTestServer } from '../src/backend/testUtil.ts';
|
|
import { generateDefaultFsentries } from '../src/backend/util/userProvisioning.ts';
|
|
import {
|
|
handleFsCopyNodeThumbnail,
|
|
handleFsRemoveNodeThumbnail,
|
|
handleThumbnailCreated,
|
|
handleThumbnailRead,
|
|
handleThumbnailUploadPrepare,
|
|
} from './thumbnails.ts';
|
|
|
|
// 1x1 transparent PNG — smallest valid image sharp will accept.
|
|
const TINY_PNG_BASE64 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
|
|
const BUCKET = 'puter-local';
|
|
const BUCKET_ENDPOINT = 'http://127.0.0.1:4566/puter-local/';
|
|
|
|
// Keys the extension mints: `thumbnails/<entry uuid>/<uuid>`.
|
|
const mintedKey = (entryUuid: string = crypto.randomUUID()) =>
|
|
`thumbnails/${entryUuid}/${crypto.randomUUID()}`;
|
|
// Minted before keys were bound to an entry.
|
|
const legacyKey = () => `thumbnails/${crypto.randomUUID()}`;
|
|
|
|
const putObject = async (s3: S3Client, key: string, body: Buffer) => {
|
|
await s3.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
Body: body,
|
|
ContentType: 'image/png',
|
|
}),
|
|
);
|
|
};
|
|
|
|
const objectExists = async (s3: S3Client, key: string) => {
|
|
try {
|
|
await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const streamToBuffer = async (
|
|
body: { transformToByteArray: () => Promise<Uint8Array> } | undefined,
|
|
): Promise<Buffer> => {
|
|
if (!body) throw new Error('s3 GetObject returned no body');
|
|
return Buffer.from(await body.transformToByteArray());
|
|
};
|
|
|
|
describe('thumbnails extension — handleThumbnailCreated', () => {
|
|
let server: PuterServer;
|
|
let s3: S3Client;
|
|
|
|
beforeAll(async () => {
|
|
server = await setupTestServer();
|
|
s3 = server.clients.s3.get();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
const created = async (url: unknown, uuid?: unknown) => {
|
|
const event: Record<string, unknown> = { url, uuid };
|
|
await handleThumbnailCreated(event, {
|
|
s3,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: BUCKET_ENDPOINT,
|
|
});
|
|
return event.url;
|
|
};
|
|
|
|
it('uploads a valid data: URL thumbnail under a key bound to the entry', async () => {
|
|
const entryUuid = crypto.randomUUID();
|
|
const newUrl = await created(
|
|
`data:image/png;base64,${TINY_PNG_BASE64}`,
|
|
entryUuid,
|
|
);
|
|
|
|
expect(typeof newUrl).toBe('string');
|
|
expect(
|
|
(newUrl as string).startsWith(
|
|
`s3://${BUCKET}/thumbnails/${entryUuid}/`,
|
|
),
|
|
).toBe(true);
|
|
|
|
const key = (newUrl as string).slice(`s3://${BUCKET}/`.length);
|
|
const obj = await s3.send(
|
|
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
|
|
);
|
|
expect(obj.ContentType).toBe('image/png');
|
|
|
|
const expected = Buffer.from(TINY_PNG_BASE64, 'base64');
|
|
const actual = await streamToBuffer(obj.Body as never);
|
|
expect(actual.equals(expected)).toBe(true);
|
|
});
|
|
|
|
it('sets event.url to null when the data: URL does not decode to a valid image', async () => {
|
|
const url = await created(
|
|
`data:image/png;base64,${Buffer.from('not an image').toString('base64')}`,
|
|
crypto.randomUUID(),
|
|
);
|
|
expect(url).toBeNull();
|
|
});
|
|
|
|
it('sets event.url to null for a data: URL with no entry to bind it to', async () => {
|
|
const url = await created(`data:image/png;base64,${TINY_PNG_BASE64}`);
|
|
expect(url).toBeNull();
|
|
});
|
|
|
|
// A documented SDK input: `thumbnail` / `thumbnailGenerator` may return
|
|
// a plain URL. It never names our storage, so it is stored as given.
|
|
it('leaves an external image URL untouched', async () => {
|
|
const original = 'https://example.com/thumb.png';
|
|
expect(await created(original, crypto.randomUUID())).toBe(original);
|
|
});
|
|
|
|
it('returns without writing to S3 when event.url is missing', async () => {
|
|
const event: Record<string, unknown> = {};
|
|
|
|
await handleThumbnailCreated(event, {
|
|
s3,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: BUCKET_ENDPOINT,
|
|
});
|
|
|
|
expect(event.url).toBeUndefined();
|
|
});
|
|
|
|
it('keeps an uploaded pointer minted for the entry, normalised to our bucket', async () => {
|
|
const entryUuid = crypto.randomUUID();
|
|
const key = mintedKey(entryUuid);
|
|
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
|
|
|
expect(
|
|
await created(`s3://client-named-bucket/${key}`, entryUuid),
|
|
).toBe(`s3://${BUCKET}/${key}`);
|
|
});
|
|
|
|
// A thumbnail key is visible to anyone who can list the entry holding it.
|
|
// Planting it on a file of one's own used to hand its deletion to them.
|
|
it.each([
|
|
['a key minted for another entry', () => mintedKey()],
|
|
['a key that predates entry binding', legacyKey],
|
|
])('drops a pointer naming %s', async (_label, makeKey) => {
|
|
const key = makeKey();
|
|
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
|
|
|
expect(
|
|
await created(`s3://${BUCKET}/${key}`, crypto.randomUUID()),
|
|
).toBeNull();
|
|
expect(await objectExists(s3, key)).toBe(true);
|
|
});
|
|
|
|
it('drops a pointer whose object was never uploaded', async () => {
|
|
const entryUuid = crypto.randomUUID();
|
|
expect(
|
|
await created(`s3://${BUCKET}/${mintedKey(entryUuid)}`, entryUuid),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('drops and removes an uploaded object over the size bound', async () => {
|
|
const entryUuid = crypto.randomUUID();
|
|
const key = mintedKey(entryUuid);
|
|
await putObject(s3, key, Buffer.alloc(2 * 1024 * 1024 + 1));
|
|
|
|
expect(await created(`s3://${BUCKET}/${key}`, entryUuid)).toBeNull();
|
|
expect(await objectExists(s3, key)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
|
|
let server: PuterServer;
|
|
let s3Presign: S3Client;
|
|
|
|
beforeAll(async () => {
|
|
server = await setupTestServer();
|
|
s3Presign = server.clients.s3.getForPresign();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
it('returns early when event has no items array', async () => {
|
|
const event: Record<string, unknown> = {};
|
|
await handleThumbnailUploadPrepare(event, {
|
|
s3Presign,
|
|
bucketName: BUCKET,
|
|
});
|
|
// No items property added — handler is a no-op.
|
|
expect(event).toEqual({});
|
|
});
|
|
|
|
it('throws when items array contains a non-object entry', async () => {
|
|
await expect(
|
|
handleThumbnailUploadPrepare(
|
|
{ items: ['not-an-object'] } as unknown as Record<
|
|
string,
|
|
unknown
|
|
>,
|
|
{ s3Presign, bucketName: BUCKET },
|
|
),
|
|
).rejects.toThrow('thumbnail.upload.prepare item is invalid');
|
|
});
|
|
|
|
it('skips items without a contentType (no upload URL minted)', async () => {
|
|
const item: Record<string, unknown> = { contentType: '' };
|
|
await handleThumbnailUploadPrepare(
|
|
{ items: [item] },
|
|
{ s3Presign, bucketName: BUCKET },
|
|
);
|
|
expect(item.uploadUrl).toBeUndefined();
|
|
expect(item.thumbnailUrl).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
['exceeds the max thumbnail bytes', { size: 999_999_999 }],
|
|
['is not declared', {}],
|
|
['is not a whole number', { size: 10.5 }],
|
|
])('skips items whose size %s', async (_label, sizeField) => {
|
|
const item: Record<string, unknown> = {
|
|
contentType: 'image/png',
|
|
item_uid: crypto.randomUUID(),
|
|
...sizeField,
|
|
};
|
|
await handleThumbnailUploadPrepare(
|
|
{ items: [item] },
|
|
{ s3Presign, bucketName: BUCKET },
|
|
);
|
|
expect(item.uploadUrl).toBeUndefined();
|
|
expect(item.thumbnailUrl).toBeUndefined();
|
|
});
|
|
|
|
it('skips items with no entry to bind the key to', async () => {
|
|
const item: Record<string, unknown> = {
|
|
contentType: 'image/png',
|
|
size: 1024,
|
|
item_uid: 'not-a-uuid',
|
|
};
|
|
await handleThumbnailUploadPrepare(
|
|
{ items: [item] },
|
|
{ s3Presign, bucketName: BUCKET },
|
|
);
|
|
expect(item.uploadUrl).toBeUndefined();
|
|
expect(item.thumbnailUrl).toBeUndefined();
|
|
});
|
|
|
|
it('mints a size-bound upload URL for a key bound to the entry', async () => {
|
|
const entryUuid = crypto.randomUUID();
|
|
const item: Record<string, unknown> = {
|
|
contentType: 'image/png',
|
|
size: 1024,
|
|
item_uid: entryUuid,
|
|
};
|
|
await handleThumbnailUploadPrepare(
|
|
{ items: [item] },
|
|
{ s3Presign, bucketName: BUCKET },
|
|
);
|
|
expect(typeof item.uploadUrl).toBe('string');
|
|
expect((item.uploadUrl as string).startsWith('http')).toBe(true);
|
|
expect(typeof item.thumbnailUrl).toBe('string');
|
|
expect(
|
|
(item.thumbnailUrl as string).startsWith(
|
|
`s3://${BUCKET}/thumbnails/${entryUuid}/`,
|
|
),
|
|
).toBe(true);
|
|
// A signed `content-length` makes the store refuse any other body size.
|
|
const signedHeaders = new URL(
|
|
item.uploadUrl as string,
|
|
).searchParams.get('X-Amz-SignedHeaders');
|
|
expect(signedHeaders).toContain('content-length');
|
|
});
|
|
});
|
|
|
|
describe('thumbnails extension — handleThumbnailRead', () => {
|
|
let server: PuterServer;
|
|
let s3: S3Client;
|
|
let s3Presign: S3Client;
|
|
|
|
beforeAll(async () => {
|
|
server = await setupTestServer();
|
|
s3 = server.clients.s3.get();
|
|
s3Presign = server.clients.s3.getForPresign();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
// Thumbnails stored before keys were bound to entries keep displaying.
|
|
it.each([
|
|
['an entry-bound key', () => mintedKey()],
|
|
['a key that predates entry binding', legacyKey],
|
|
])(
|
|
'rewrites an s3:// thumbnail naming %s into a presigned https URL',
|
|
async (_label, makeKey) => {
|
|
// Seed an object so the presigned URL points at something real
|
|
// (the signer itself doesn't validate existence, but this keeps
|
|
// the test honest).
|
|
const key = makeKey();
|
|
await s3.send(
|
|
new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: key,
|
|
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
|
|
ContentType: 'image/png',
|
|
}),
|
|
);
|
|
|
|
const entry: Record<string, unknown> = {
|
|
thumbnail: `s3://${BUCKET}/${key}`,
|
|
};
|
|
await handleThumbnailRead(entry, {
|
|
s3,
|
|
s3Presign,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
|
fsEntry: server.stores.fsEntry,
|
|
});
|
|
|
|
expect(typeof entry.thumbnail).toBe('string');
|
|
expect((entry.thumbnail as string).startsWith('http')).toBe(true);
|
|
},
|
|
);
|
|
|
|
// `fsentries.thumbnail` is writable through the FS API, so a stored
|
|
// pointer is attacker input. Signing one the extension didn't mint would
|
|
// hand out a presigned read of an arbitrary object — including another
|
|
// user's file, whose key is its fsentry uuid in this same bucket.
|
|
it.each([
|
|
// Shaped exactly like an fs object key (and like a legacy thumbnail
|
|
// row) — indistinguishable from a planted pointer, so it fails closed.
|
|
[
|
|
"another user's file object",
|
|
`s3://${BUCKET}/${crypto.randomUUID()}`,
|
|
],
|
|
[
|
|
'a key outside the thumbnails namespace',
|
|
`s3://${BUCKET}/secrets/dump`,
|
|
],
|
|
[
|
|
'a namespace-lookalike key',
|
|
`s3://${BUCKET}/thumbnails/../${crypto.randomUUID()}`,
|
|
],
|
|
['a non-uuid inside the namespace', `s3://${BUCKET}/thumbnails/etc`],
|
|
[
|
|
'a key nested deeper than the namespace allows',
|
|
`s3://${BUCKET}/${mintedKey()}/${crypto.randomUUID()}`,
|
|
],
|
|
])('refuses to presign a pointer naming %s', async (_label, thumbnail) => {
|
|
const entry: Record<string, unknown> = { thumbnail };
|
|
await handleThumbnailRead(entry, {
|
|
s3,
|
|
s3Presign,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
|
fsEntry: server.stores.fsEntry,
|
|
});
|
|
expect(entry.thumbnail).toBeNull();
|
|
});
|
|
|
|
it('signs against its own bucket, ignoring the one in the pointer', async () => {
|
|
const key = mintedKey();
|
|
const entry: Record<string, unknown> = {
|
|
thumbnail: `s3://attacker-named-bucket/${key}`,
|
|
};
|
|
await handleThumbnailRead(entry, {
|
|
s3,
|
|
s3Presign,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
|
fsEntry: server.stores.fsEntry,
|
|
});
|
|
// Signed for OUR bucket; `attacker-named-bucket` never reached S3.
|
|
const signed = entry.thumbnail as string;
|
|
expect(signed.startsWith('http')).toBe(true);
|
|
expect(signed).not.toContain('attacker-named-bucket');
|
|
expect(signed).toContain(BUCKET);
|
|
});
|
|
|
|
it('leaves the thumbnail untouched when not s3/https/data', async () => {
|
|
const entry: Record<string, unknown> = { thumbnail: 'about:blank' };
|
|
await handleThumbnailRead(entry, {
|
|
s3,
|
|
s3Presign,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
|
fsEntry: server.stores.fsEntry,
|
|
});
|
|
expect(entry.thumbnail).toBe('about:blank');
|
|
});
|
|
|
|
it('returns early when the thumbnail is missing or non-string', async () => {
|
|
const entry: Record<string, unknown> = {};
|
|
await handleThumbnailRead(entry, {
|
|
s3,
|
|
s3Presign,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
|
fsEntry: server.stores.fsEntry,
|
|
});
|
|
expect(entry.thumbnail).toBeUndefined();
|
|
});
|
|
|
|
// -- 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: client,
|
|
s3Presign,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: BUCKET_ENDPOINT,
|
|
fsEntry: server.stores.fsEntry,
|
|
});
|
|
return entry.thumbnail;
|
|
};
|
|
|
|
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 };
|
|
await handleThumbnailRead(entry, {
|
|
s3,
|
|
s3Presign,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: BUCKET_ENDPOINT,
|
|
fsEntry: server.stores.fsEntry,
|
|
});
|
|
expect(entry.thumbnail).toBe(thumbnail);
|
|
});
|
|
});
|
|
|
|
describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
|
|
let server: PuterServer;
|
|
let s3: S3Client;
|
|
|
|
beforeAll(async () => {
|
|
server = await setupTestServer();
|
|
s3 = server.clients.s3.get();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
const remove = (target: { thumbnail?: string | null; uuid?: string }) =>
|
|
handleFsRemoveNodeThumbnail(
|
|
{ target },
|
|
{ s3, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT },
|
|
);
|
|
|
|
it('deletes the object minted for the removed entry', async () => {
|
|
const entryUuid = crypto.randomUUID();
|
|
const key = mintedKey(entryUuid);
|
|
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
|
|
|
await remove({ thumbnail: `s3://${BUCKET}/${key}`, uuid: entryUuid });
|
|
|
|
expect(await objectExists(s3, key)).toBe(false);
|
|
});
|
|
|
|
// The destructive half of the same confused deputy: the stored pointer
|
|
// decides which object is deleted, so only a key minted for the removed
|
|
// entry may reach DeleteObject.
|
|
it.each([
|
|
// Shaped like an fs object key.
|
|
[
|
|
'an object outside the thumbnails namespace',
|
|
() => crypto.randomUUID(),
|
|
],
|
|
// Visible to anyone who can list the entry it belongs to.
|
|
["another entry's thumbnail", () => mintedKey()],
|
|
// Can't be told apart from a planted or shared pointer.
|
|
['a thumbnail that predates entry binding', legacyKey],
|
|
])('does not delete %s', async (_label, makeKey) => {
|
|
const victimKey = makeKey();
|
|
await putObject(s3, victimKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
|
|
|
await remove({
|
|
thumbnail: `s3://${BUCKET}/${victimKey}`,
|
|
uuid: crypto.randomUUID(),
|
|
});
|
|
|
|
expect(await objectExists(s3, victimKey)).toBe(true);
|
|
});
|
|
|
|
it('does not delete through an external URL that names our key', async () => {
|
|
const entryUuid = crypto.randomUUID();
|
|
const key = mintedKey(entryUuid);
|
|
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
|
|
|
await remove({
|
|
thumbnail: `https://cdn.example.com/${BUCKET}/${key}`,
|
|
uuid: entryUuid,
|
|
});
|
|
|
|
expect(await objectExists(s3, key)).toBe(true);
|
|
});
|
|
|
|
it('is a no-op when the target has no thumbnail', async () => {
|
|
// Should not throw or attempt a delete.
|
|
await remove({});
|
|
});
|
|
});
|
|
|
|
describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
|
|
let server: PuterServer;
|
|
let s3: S3Client;
|
|
|
|
beforeAll(async () => {
|
|
server = await setupTestServer();
|
|
s3 = server.clients.s3.get();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
const copyNode = async (
|
|
thumbnail: string | null,
|
|
copyUuid: string,
|
|
client: S3Client = s3,
|
|
) => {
|
|
const fsEntry = {
|
|
updateEntryThumbnailByUuid: vi.fn().mockResolvedValue(true),
|
|
};
|
|
await handleFsCopyNodeThumbnail(
|
|
{ copy: { thumbnail, uuid: copyUuid } },
|
|
{
|
|
s3: client,
|
|
bucketName: BUCKET,
|
|
bucketEndpoint: BUCKET_ENDPOINT,
|
|
fsEntry,
|
|
},
|
|
);
|
|
return fsEntry.updateEntryThumbnailByUuid;
|
|
};
|
|
|
|
// The key the copied row was repointed at, asserting it is bound to it.
|
|
const repointedKey = (
|
|
update: Awaited<ReturnType<typeof copyNode>>,
|
|
copyUuid: string,
|
|
): string => {
|
|
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}/`),
|
|
);
|
|
return pointer.slice(`s3://${BUCKET}/`.length);
|
|
};
|
|
|
|
it.each([
|
|
['an entry-bound key', () => mintedKey()],
|
|
['a key that predates entry binding', legacyKey],
|
|
])(
|
|
'duplicates a thumbnail naming %s under a key bound to the copy',
|
|
async (_label, makeKey) => {
|
|
const sourceKey = makeKey();
|
|
const body = Buffer.from(TINY_PNG_BASE64, 'base64');
|
|
await putObject(s3, sourceKey, body);
|
|
|
|
const copyUuid = crypto.randomUUID();
|
|
const update = await copyNode(
|
|
`s3://${BUCKET}/${sourceKey}`,
|
|
copyUuid,
|
|
);
|
|
|
|
// The copied row was repointed at a fresh object of its own...
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
const [updatedUuid, newPointer] = update.mock.calls[0] as [
|
|
string,
|
|
string,
|
|
];
|
|
expect(updatedUuid).toBe(copyUuid);
|
|
expect(
|
|
newPointer.startsWith(`s3://${BUCKET}/thumbnails/${copyUuid}/`),
|
|
).toBe(true);
|
|
|
|
// ...whose content matches, while the source object survives — so
|
|
// deleting either entry can no longer break the other's thumbnail.
|
|
const newKey = newPointer.slice(`s3://${BUCKET}/`.length);
|
|
const duplicated = await s3.send(
|
|
new GetObjectCommand({ Bucket: BUCKET, Key: newKey }),
|
|
);
|
|
expect(duplicated.ContentType).toBe('image/png');
|
|
expect(
|
|
(await streamToBuffer(duplicated.Body as never)).equals(body),
|
|
).toBe(true);
|
|
expect(await objectExists(s3, sourceKey)).toBe(true);
|
|
},
|
|
);
|
|
|
|
// The SDK puts an endpoint's path in front of every Bucket/Key request but
|
|
// not in front of a CopyObject source, so on such a store HeadObject finds
|
|
// the source and CopyObject reports NoSuchKey.
|
|
it('duplicates through a client whose endpoint carries a path', async () => {
|
|
const endpoint = await s3.config.endpoint!();
|
|
const client = new S3Client({
|
|
region: await s3.config.region(),
|
|
endpoint: `${endpoint.protocol}//${endpoint.hostname}:${endpoint.port}/${BUCKET}`,
|
|
credentials: await s3.config.credentials(),
|
|
forcePathStyle: true,
|
|
});
|
|
const sourceKey = mintedKey();
|
|
const body = Buffer.from(TINY_PNG_BASE64, 'base64');
|
|
await putObject(client, sourceKey, body);
|
|
|
|
const copyUuid = crypto.randomUUID();
|
|
const update = await copyNode(
|
|
`s3://${BUCKET}/${sourceKey}`,
|
|
copyUuid,
|
|
client,
|
|
);
|
|
|
|
const duplicated = await client.send(
|
|
new GetObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: repointedKey(update, copyUuid),
|
|
}),
|
|
);
|
|
expect(duplicated.ContentType).toBe('image/png');
|
|
expect(
|
|
(await streamToBuffer(duplicated.Body as never)).equals(body),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("lets either entry's removal leave the other's thumbnail in place", async () => {
|
|
const sourceUuid = crypto.randomUUID();
|
|
const sourceKey = mintedKey(sourceUuid);
|
|
const sourcePointer = `s3://${BUCKET}/${sourceKey}`;
|
|
await putObject(s3, sourceKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
|
const remove = (thumbnail: string, uuid: string) =>
|
|
handleFsRemoveNodeThumbnail(
|
|
{ target: { thumbnail, uuid } },
|
|
{ s3, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT },
|
|
);
|
|
|
|
// Removing a copy leaves the source's object...
|
|
const firstUuid = crypto.randomUUID();
|
|
const firstKey = repointedKey(
|
|
await copyNode(sourcePointer, firstUuid),
|
|
firstUuid,
|
|
);
|
|
await remove(`s3://${BUCKET}/${firstKey}`, firstUuid);
|
|
expect(await objectExists(s3, firstKey)).toBe(false);
|
|
expect(await objectExists(s3, sourceKey)).toBe(true);
|
|
|
|
// ...and removing the source leaves a copy's.
|
|
const secondUuid = crypto.randomUUID();
|
|
const secondKey = repointedKey(
|
|
await copyNode(sourcePointer, secondUuid),
|
|
secondUuid,
|
|
);
|
|
await remove(sourcePointer, sourceUuid);
|
|
expect(await objectExists(s3, sourceKey)).toBe(false);
|
|
expect(await objectExists(s3, secondKey)).toBe(true);
|
|
});
|
|
|
|
it('drops the pointer when the shared object is already gone', async () => {
|
|
const copyUuid = crypto.randomUUID();
|
|
const update = await copyNode(
|
|
`s3://${BUCKET}/${mintedKey()}`, // never uploaded
|
|
copyUuid,
|
|
);
|
|
|
|
expect(update.mock.calls).toEqual([[copyUuid, null]]);
|
|
});
|
|
|
|
// Only reachable for objects uploaded before upload URLs were size-bound;
|
|
// copying one would multiply bytes no allowance counts.
|
|
it('drops the pointer instead of duplicating an object over the size bound', async () => {
|
|
const sourceKey = legacyKey();
|
|
await putObject(s3, sourceKey, Buffer.alloc(2 * 1024 * 1024 + 1));
|
|
|
|
const copyUuid = crypto.randomUUID();
|
|
const update = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid);
|
|
|
|
expect(update.mock.calls).toEqual([[copyUuid, null]]);
|
|
});
|
|
|
|
it('drops the pointer when the object outgrows the bound after its size was checked', async () => {
|
|
const sourceKey = mintedKey();
|
|
await putObject(s3, sourceKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
|
|
const sent: unknown[] = [];
|
|
// Replaces the object between the HEAD and the GET.
|
|
const racing = {
|
|
send: async (command: unknown) => {
|
|
sent.push(command);
|
|
const result = await s3.send(command as never);
|
|
if (command instanceof HeadObjectCommand) {
|
|
await putObject(
|
|
s3,
|
|
sourceKey,
|
|
Buffer.alloc(2 * 1024 * 1024 + 1),
|
|
);
|
|
}
|
|
return result;
|
|
},
|
|
} as unknown as S3Client;
|
|
|
|
const copyUuid = crypto.randomUUID();
|
|
const update = await copyNode(
|
|
`s3://${BUCKET}/${sourceKey}`,
|
|
copyUuid,
|
|
racing,
|
|
);
|
|
|
|
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 update = await copyNode(
|
|
`s3://${BUCKET}/${foreignKey}`,
|
|
crypto.randomUUID(),
|
|
);
|
|
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 update = await copyNode(
|
|
`https://cdn.example.com/${BUCKET}/${sourceKey}`,
|
|
crypto.randomUUID(),
|
|
);
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('is a no-op when the copy has no thumbnail', async () => {
|
|
const update = await copyNode(null, crypto.randomUUID());
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// The flow `puter.fs.upload()` runs — start, PUT to the signed URLs, complete
|
|
// with the returned pointer — through the real controller and the listeners
|
|
// this extension registers.
|
|
describe('thumbnails extension — signed batch upload through /fs', () => {
|
|
let server: PuterServer;
|
|
let controller: FSController;
|
|
const png = Buffer.from(TINY_PNG_BASE64, 'base64');
|
|
|
|
beforeAll(async () => {
|
|
server = await setupTestServer();
|
|
controller = server.controllers.fs as unknown as FSController;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
const makeActor = async (): Promise<{ actor: Actor; username: 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,
|
|
);
|
|
return {
|
|
username,
|
|
actor: {
|
|
user: {
|
|
id: user.id,
|
|
uuid: user.uuid,
|
|
username,
|
|
email: user.email ?? null,
|
|
email_confirmed: true,
|
|
} as Actor['user'],
|
|
},
|
|
};
|
|
};
|
|
|
|
const call = async <B>(
|
|
actor: Actor,
|
|
handler: (req: Request, res: Response) => Promise<void>,
|
|
body: B,
|
|
): Promise<unknown> => {
|
|
let captured: unknown;
|
|
const res = {
|
|
json: (value: unknown) => {
|
|
captured = value;
|
|
return res;
|
|
},
|
|
status: () => res,
|
|
setHeader: () => res,
|
|
};
|
|
const req = {
|
|
body,
|
|
query: {},
|
|
headers: { 'content-type': 'application/json' },
|
|
actor,
|
|
user: { id: actor.user!.id, username: actor.user!.username },
|
|
};
|
|
await runWithContext({ actor }, () =>
|
|
handler.call(
|
|
controller,
|
|
req as unknown as Request,
|
|
res as unknown as Response,
|
|
),
|
|
);
|
|
return captured;
|
|
};
|
|
|
|
const upload = async (
|
|
actor: Actor,
|
|
path: string,
|
|
thumbnailData: (
|
|
started: ClientSignedWriteResponse,
|
|
) => string | undefined,
|
|
) => {
|
|
const [started] = (await call<SignedWriteRequest[]>(
|
|
actor,
|
|
controller.startBatchWrites,
|
|
[
|
|
{
|
|
fileMetadata: { path, size: 4 },
|
|
thumbnailMetadata: {
|
|
contentType: 'image/png',
|
|
size: png.length,
|
|
},
|
|
},
|
|
],
|
|
)) as ClientSignedWriteResponse[];
|
|
await fetch(started!.url!, { method: 'PUT', body: 'abcd' });
|
|
if (started!.thumbnailUploadUrl) {
|
|
await fetch(started!.thumbnailUploadUrl, {
|
|
method: 'PUT',
|
|
body: png,
|
|
headers: { 'content-type': 'image/png' },
|
|
});
|
|
}
|
|
const thumbnail = thumbnailData(started!);
|
|
await call<CompleteWriteRequest[]>(
|
|
actor,
|
|
controller.completeBatchWrites,
|
|
[
|
|
{
|
|
uploadId: started!.sessionId,
|
|
...(thumbnail === undefined
|
|
? {}
|
|
: { thumbnailData: thumbnail }),
|
|
},
|
|
],
|
|
);
|
|
return {
|
|
started: started!,
|
|
entry: (await server.stores.fsEntry.getEntryByPath(path))!,
|
|
};
|
|
};
|
|
|
|
it('stores the pointer minted for the uploaded entry', async () => {
|
|
const { actor, username } = await makeActor();
|
|
const { started, entry } = await upload(
|
|
actor,
|
|
`/${username}/Documents/photo.png`,
|
|
(s) => s.thumbnailUrl,
|
|
);
|
|
|
|
expect(started.thumbnailUrl).toMatch(
|
|
new RegExp(`^s3://${BUCKET}/thumbnails/${entry.uuid}/`),
|
|
);
|
|
expect(entry.thumbnail).toBe(started.thumbnailUrl);
|
|
});
|
|
|
|
it("drops a pointer minted for another user's entry", async () => {
|
|
const victim = await makeActor();
|
|
const { entry: victimEntry } = await upload(
|
|
victim.actor,
|
|
`/${victim.username}/Documents/photo.png`,
|
|
(s) => s.thumbnailUrl,
|
|
);
|
|
|
|
const attacker = await makeActor();
|
|
const { entry } = await upload(
|
|
attacker.actor,
|
|
`/${attacker.username}/Documents/decoy.png`,
|
|
() => victimEntry.thumbnail!,
|
|
);
|
|
|
|
expect(entry.thumbnail).toBeNull();
|
|
});
|
|
|
|
it('keeps an external image URL as given', async () => {
|
|
const { actor, username } = await makeActor();
|
|
const { entry } = await upload(
|
|
actor,
|
|
`/${username}/Documents/linked.png`,
|
|
() => 'https://example.com/thumb.png',
|
|
);
|
|
expect(entry.thumbnail).toBe('https://example.com/thumb.png');
|
|
});
|
|
|
|
it('gives a copy its own thumbnail that survives moves and the source being removed', async () => {
|
|
const { actor, username } = await makeActor();
|
|
const s3 = server.clients.s3.get();
|
|
const fs = server.services.fs;
|
|
const { entry: source } = await upload(
|
|
actor,
|
|
`/${username}/Documents/photo.png`,
|
|
(s) => s.thumbnailUrl,
|
|
);
|
|
const userId = source.userId;
|
|
const sourceKey = source.thumbnail!.slice(`s3://${BUCKET}/`.length);
|
|
const desktop = (await server.stores.fsEntry.getEntryByPath(
|
|
`/${username}/Desktop`,
|
|
))!;
|
|
// 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 }),
|
|
);
|
|
// `fs.copy.node` is fire-and-forget.
|
|
const copyPointer = await vi.waitFor(async () => {
|
|
const pointer = await storedThumbnail(copy.uuid);
|
|
expect(pointer).toMatch(
|
|
new RegExp(`^s3://${BUCKET}/thumbnails/${copy.uuid}/`),
|
|
);
|
|
return pointer!;
|
|
});
|
|
const copyKey = copyPointer.slice(`s3://${BUCKET}/`.length);
|
|
|
|
const documents = (await server.stores.fsEntry.getEntryByPath(
|
|
`/${username}/Documents`,
|
|
))!;
|
|
const moved = await runWithContext({ actor }, () =>
|
|
fs.move(userId, {
|
|
source: copy,
|
|
destinationParent: documents,
|
|
newName: 'moved.png',
|
|
}),
|
|
);
|
|
expect(moved.uuid).toBe(copy.uuid);
|
|
expect(await storedThumbnail(moved.uuid)).toBe(copyPointer);
|
|
|
|
await runWithContext({ actor }, () =>
|
|
fs.remove(userId, { entry: source }),
|
|
);
|
|
await vi.waitFor(async () => {
|
|
expect(await objectExists(s3, sourceKey)).toBe(false);
|
|
});
|
|
expect(await objectExists(s3, copyKey)).toBe(true);
|
|
});
|
|
});
|