Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/library-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,20 @@ export function createLibraryApp(o: LibraryOptions) {
return c.json({ key, status: 'withdrawn', usedByBooks: used.length > 0 });
});

// ---- a disk library delivers its own files (readers' store): immutable, ranges for video ----
// ---- stable public file URLs: disk bytes, or a short-lived S3 redirect after a storage move ----
app.get('/files/*', async (c) => {
if (!disk) return c.notFound();
if (!disk && o.publicRead === false) return c.notFound();
const path = decodeURIComponent(c.req.path.slice('/files/'.length));
// the standard layout, or a file adopted in place: the asset recorded at this path
const row = await assetAt(db, path);
if (!disk) {
if (row?.status !== 'available') return c.body(row?.status === 'withdrawn' ? 'gone' : 'not found', row?.status === 'withdrawn' ? 410 : 404);
const { url } = await storage.presignGet(path, 300, { contentType: row.type });
return c.body(null, 302, {
Location: url, 'Cache-Control': 'public, max-age=60',
'Access-Control-Allow-Origin': '*', 'Referrer-Policy': 'no-referrer',
});
}
const file = row?.status === 'available' ? disk.file(path) : null;
if (!file) return c.body(row?.status === 'withdrawn' ? 'gone' : 'not found', row?.status === 'withdrawn' ? 410 : 404);
const size = statSync(file).size, range = /bytes=(\d*)-(\d*)/.exec(c.req.header('range') ?? '');
Expand Down
46 changes: 46 additions & 0 deletions packages/library-server/src/delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { storeDescriptorSchema } from '@chalk-format/model';
import { createRegistryClient } from '@chalk-format/store';
import type { Storage } from './storage.js';
import { checkLibrary, createLibraryApp, diskStorage, libraryFromEnv, openDb, tokenCallers, type Db, type DeliveryOptions, type LibraryOptions } from './index.js';

// a private library on disk (PUBLIC_READ=false) next to a public one over the same database and folder
Expand Down Expand Up @@ -197,6 +198,51 @@ describe('a public library (PUBLIC_READ=true, the default) is unchanged', () =>
});
});

describe('public file URLs after moving from disk to S3', () => {
const s3 = (publicRead = true) => {
const presignGet = vi.fn(async () => ({ url: 'https://bucket.test/object?signature=temporary', expiresAt: new Date(Date.now() + 300_000).toISOString() }));
const storage: Storage = {
id: 's3:staging', presignGet,
presignPut: async () => ({ url: 'https://bucket.test/upload', headers: {} }),
head: async () => null, read: async () => null,
async *list() { yield* []; }, copy: async () => {}, remove: async () => {},
};
return { app: createLibraryApp(options({ storage, publicRead })), presignGet };
};

it('keeps existing public /files/ links usable with a short-lived signed S3 redirect', async () => {
const { app, presignGet } = s3();
const r = await get(app, `/files/${path}`, { Range: 'bytes=2-8' });
expect(r.status).toBe(302);
expect(r.headers.get('location')).toBe('https://bucket.test/object?signature=temporary');
expect(r.headers.get('cache-control')).toBe('public, max-age=60');
expect(r.headers.get('access-control-allow-origin')).toBe('*');
expect(r.headers.get('referrer-policy')).toBe('no-referrer');
expect(presignGet).toHaveBeenCalledWith(path, 300, { contentType: 'text/html' });
});

it('does not sign unknown or withdrawn assets', async () => {
const { app, presignGet } = s3();
expect((await get(app, '/files/c1/xx/yy/not-a-key')).status).toBe(404);
const fetchPub = (async (input: RequestInfo | URL, init?: RequestInit) => pub.fetch(new Request(input, init))) as typeof fetch;
const withdrawn = await createRegistryClient(BASE, { token: 't-1', fetch: fetchPub }).add(new TextEncoder().encode('withdrawn S3 migration fixture'), { type: 'image/png' });
await db.query("UPDATE assets SET status = 'withdrawn' WHERE key = $1", [withdrawn.key]);
const wh = withdrawn.key.slice(3);
expect((await get(app, `/files/c1/${wh.slice(0, 2)}/${wh.slice(2, 4)}/${wh}`)).status).toBe(410);
expect(presignGet).not.toHaveBeenCalled();
});

it('keeps private S3 libraries behind the authenticated file endpoint', async () => {
const { app, presignGet } = s3(false);
expect((await get(app, `/files/${path}`)).status).toBe(404);
expect((await get(app, `/files/${path}`, bearer())).status).toBe(404);
expect((await get(app, `/v1/files/${h}`)).status).toBe(401);
expect(presignGet).not.toHaveBeenCalled();
expect((await get(app, `/v1/files/${h}`, bearer())).status).toBe(302);
expect(presignGet).toHaveBeenCalledOnce();
});
});

describe('settings', () => {
it('FILE_LINK_SECRET signs disk links (else derived from UPLOAD_SECRET); SERVER_URL only when set', async () => {
const env = { DATABASE_URL: 'pglite:', STORAGE: 'disk', DISK_DIR: dir, PUBLIC_READ: 'false', AUTH_KEY_SECRET: 'a test secret of at least thirty-two characters' };
Expand Down
Loading