diff --git a/client/src/components/Admin/storage/AdminStoragePanel.test.tsx b/client/src/components/Admin/storage/AdminStoragePanel.test.tsx index e769d6270..81b65095f 100644 --- a/client/src/components/Admin/storage/AdminStoragePanel.test.tsx +++ b/client/src/components/Admin/storage/AdminStoragePanel.test.tsx @@ -14,7 +14,7 @@ const S3_MASKED = { function baseState(overrides: Partial = {}): StorageAdminState { return { backends: [ - { name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files', 'journey', 'covers', 'avatars', 'photos', 'photos-google', 'photos-trek'] }, + { name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files', 'journey', 'covers', 'avatars', 'photos-google', 'photos-trek'] }, { name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: ['backups'] }, { name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] }, { name: 'off-box', type: 's3', source: 'settings', options: S3_MASKED, categories: ['covers'] }, @@ -25,7 +25,6 @@ function baseState(overrides: Partial = {}): StorageAdminStat covers: { backend: 'off-box', source: 'settings' }, avatars: { backend: 'uploads-local', source: 'default' }, places: { backend: 'place-photos-local', source: 'default' }, - photos: { backend: 'uploads-local', source: 'default' }, 'photos-google': { backend: 'uploads-local', source: 'default' }, 'photos-trek': { backend: 'uploads-local', source: 'default' }, backups: { backend: 'backups-local', source: 'default' }, diff --git a/client/src/components/Admin/storage/storageModel.test.ts b/client/src/components/Admin/storage/storageModel.test.ts index 56d145eee..dd120be07 100644 --- a/client/src/components/Admin/storage/storageModel.test.ts +++ b/client/src/components/Admin/storage/storageModel.test.ts @@ -41,7 +41,6 @@ const STATE: StorageAdminState = { covers: { backend: 'off-box', source: 'settings' }, avatars: { backend: 'uploads-local', source: 'default' }, places: { backend: 'place-photos-local', source: 'default' }, - photos: { backend: 'uploads-local', source: 'default' }, 'photos-google': { backend: 'place-photos-local', source: 'default' }, 'photos-trek': { backend: 'uploads-local', source: 'default' }, backups: { backend: 'backups-local', source: 'default' }, diff --git a/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx b/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx index a0ed078de..51560d953 100644 --- a/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminStoragePanel.test.tsx @@ -9,7 +9,7 @@ import MAdminStoragePanel from '../../../../src/mobile/screens/admin/MAdminStora function baseState(overrides: Partial = {}): StorageAdminState { return { backends: [ - { name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files', 'journey', 'covers', 'avatars', 'photos', 'photos-google', 'photos-trek'] }, + { name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files', 'journey', 'covers', 'avatars', 'photos-google', 'photos-trek'] }, { name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: ['backups'] }, { name: 'off-box', type: 's3', source: 'settings', @@ -23,7 +23,6 @@ function baseState(overrides: Partial = {}): StorageAdminStat covers: { backend: 'uploads-local', source: 'default' }, avatars: { backend: 'uploads-local', source: 'default' }, places: { backend: 'uploads-local', source: 'default' }, - photos: { backend: 'uploads-local', source: 'default' }, 'photos-google': { backend: 'uploads-local', source: 'default' }, 'photos-trek': { backend: 'uploads-local', source: 'default' }, backups: { backend: 'backups-local', source: 'default' }, diff --git a/server/src/nest/storage/storage-registry.service.ts b/server/src/nest/storage/storage-registry.service.ts index cbae84a22..c86ea57ee 100644 --- a/server/src/nest/storage/storage-registry.service.ts +++ b/server/src/nest/storage/storage-registry.service.ts @@ -15,8 +15,10 @@ import { listPlaintextSecrets, } from './storage-secrets'; import { + SERVED_CATEGORIES, STORAGE_CATEGORIES, StorageBackendError, + type ServedCategory, type StorageCategory, type StorageDriver, } from './storage.types'; @@ -68,7 +70,7 @@ type BackendConfig = LocalBackendConfig | MirrorBackendConfig | S3BackendConfig; interface RegistryState { drivers: Map; - categories: Map; + categories: Map; snapshot: RegistrySnapshot; } @@ -86,7 +88,7 @@ const REPLICA_FAILURE_RING_SIZE = 50; * Exported for tests/unit/uploads-dirs.test.ts, which pins the Dockerfile's * `mkdir -p /app/uploads/...` list to these prefixes. */ -export const CATEGORY_PREFIXES: Record = { +export const CATEGORY_PREFIXES: Record = { files: 'files/', journey: 'journey/', covers: 'covers/', @@ -144,7 +146,7 @@ export class StorageRegistryService implements OnModuleInit { void this.build(candidate, false); } - resolve(category: StorageCategory): ResolvedCategory { + resolve(category: ServedCategory): ResolvedCategory { if (!this.state) throw new StorageBackendError('storage registry not initialized'); const assignment = this.state.categories.get(category); if (!assignment) throw new StorageBackendError(`unknown storage category: ${category}`); @@ -289,11 +291,11 @@ export class StorageRegistryService implements OnModuleInit { backendSources.set(config.name, 'settings'); } - const categoryBackends = new Map(); - for (const category of STORAGE_CATEGORIES) categoryBackends.set(category, 'uploads-local'); + const categoryBackends = new Map(); + for (const category of SERVED_CATEGORIES) categoryBackends.set(category, 'uploads-local'); categoryBackends.set('backups', 'backups-local'); if (placePhotoDir) categoryBackends.set('photos-google', 'place-photos-local'); - const categorySources = new Map(); + const categorySources = new Map(); for (const [category, backendName] of parseCategoryMap(settings.categories)) { categoryBackends.set(category, backendName); categorySources.set(category, 'settings'); @@ -303,7 +305,7 @@ export class StorageRegistryService implements OnModuleInit { validateConfig(backends, categoryBackends); // 4. Category prefixes (photos-google mode decided from the final map). - const categories = new Map(); + const categories = new Map(); for (const [category, backendName] of categoryBackends) { const keyPrefix = category === 'photos-google' && backendName === 'place-photos-local' ? '' : CATEGORY_PREFIXES[category]; @@ -356,10 +358,14 @@ export class StorageRegistryService implements OnModuleInit { options: { ...config.options }, })), categories: Object.fromEntries( - [...categoryBackends.entries()].map(([category, backendName]) => [ - category, - { backend: backendName, source: categorySources.get(category) ?? 'default' }, - ]), + [...categoryBackends.entries()] + // photos is served-legacy, never configurable — the admin world + // exposes only the 8 configurable categories. + .filter(([category]) => (STORAGE_CATEGORIES as readonly string[]).includes(category)) + .map(([category, backendName]) => [ + category, + { backend: backendName, source: categorySources.get(category) ?? 'default' }, + ]), ) as RegistrySnapshot['categories'], }; @@ -441,7 +447,7 @@ function decryptedSecret(config: S3BackendConfig): string { return plain; } -function validateConfig(backends: Map, categories: Map): void { +function validateConfig(backends: Map, categories: Map): void { for (const config of backends.values()) { if (config.type !== 'mirror') continue; for (const target of [config.options.primary, ...config.options.replicas]) { diff --git a/server/src/nest/storage/storage.service.ts b/server/src/nest/storage/storage.service.ts index 2e0b59712..0bb65f311 100644 --- a/server/src/nest/storage/storage.service.ts +++ b/server/src/nest/storage/storage.service.ts @@ -14,7 +14,7 @@ import { type LocalTempFile, type ObjectStat, type PutOptions, - type StorageCategory, + type ServedCategory, type StorageDriver, } from './storage.types'; @@ -51,7 +51,7 @@ export class StorageService { } /** The category backend's same-volume spool dir, else tempDir(). Resolved per call. */ - spoolDirFor(category: StorageCategory): string { + spoolDirFor(category: ServedCategory): string { return this.registry.resolve(category).driver.getSpoolDir?.() ?? this.tempDir(); } @@ -59,7 +59,7 @@ export class StorageService { // rejections, never synchronous throws — callers treat storage uniformly. async put( - category: StorageCategory, + category: ServedCategory, name: string, source: Readable | LocalTempFile, opts?: PutOptions, @@ -69,7 +69,7 @@ export class StorageService { } async getStream( - category: StorageCategory, + category: ServedCategory, name: string, range?: ByteRange, ): Promise<{ stream: Readable; stat: ObjectStat }> { @@ -77,22 +77,22 @@ export class StorageService { return driver.getStream(key, range); } - async stat(category: StorageCategory, name: string): Promise { + async stat(category: ServedCategory, name: string): Promise { const { driver, key } = this.resolve(category, name); return driver.stat(key); } - async exists(category: StorageCategory, name: string): Promise { + async exists(category: ServedCategory, name: string): Promise { return (await this.stat(category, name)) !== null; } - async delete(category: StorageCategory, name: string): Promise { + async delete(category: ServedCategory, name: string): Promise { const { driver, key } = this.resolve(category, name); return driver.delete(key); } /** Names in and out are category-relative — the key prefix stays a registry detail. */ - async *list(category: StorageCategory, subPrefix = ''): AsyncIterable { + async *list(category: ServedCategory, subPrefix = ''): AsyncIterable { const { driver, keyPrefix } = this.registry.resolve(category); for await (const stat of driver.list(keyPrefix + subPrefix)) { yield { ...stat, key: stat.key.slice(keyPrefix.length) }; @@ -108,7 +108,7 @@ export class StorageService { * StorageNotFoundError: the caller owns the route's miss contract * (404 envelope, 204-empty, or next()). */ - async sendToResponse(category: StorageCategory, name: string, res: Response, opts?: SendOptions): Promise { + async sendToResponse(category: ServedCategory, name: string, res: Response, opts?: SendOptions): Promise { const { driver, key } = this.resolve(category, name); const localPath = driver.getLocalPath?.(key) ?? null; @@ -140,7 +140,7 @@ export class StorageService { * tempDir() and clean up afterwards — exactly the branch a remote driver * will exercise. */ - async withLocalFile(category: StorageCategory, name: string, fn: (absPath: string) => Promise): Promise { + async withLocalFile(category: ServedCategory, name: string, fn: (absPath: string) => Promise): Promise { const { driver, key } = this.resolve(category, name); const localPath = driver.getLocalPath?.(key) ?? null; @@ -164,7 +164,7 @@ export class StorageService { return { replicaFailures: this.registry.replicaFailures() }; } - private resolve(category: StorageCategory, name: string): Resolved { + private resolve(category: ServedCategory, name: string): Resolved { const { driver, keyPrefix } = this.registry.resolve(category); const key = keyPrefix + name; assertValidKey(key); diff --git a/server/src/nest/storage/storage.types.ts b/server/src/nest/storage/storage.types.ts index e9bfc8ea9..8950da81f 100644 --- a/server/src/nest/storage/storage.types.ts +++ b/server/src/nest/storage/storage.types.ts @@ -14,6 +14,17 @@ import type { Readable } from 'node:stream'; // Relocated to shared (the wire schema needs it; a second copy is forbidden). // Re-exported so every existing `./storage.types` import keeps working. export { STORAGE_CATEGORIES, type StorageCategory } from '@trek/shared'; +import { STORAGE_CATEGORIES as CONFIGURABLE_CATEGORIES, type StorageCategory as ConfigurableCategory } from '@trek/shared'; + +/** + * Categories the byte-paths serve. `photos` is the legacy shared photo + * directory (/uploads/photos): earlier TREK versions wrote place photos there + * and its objects are still served and backed up, but it is NOT configurable — + * it left STORAGE_CATEGORIES (category-labels spec) and always resolves to + * uploads-local (following a name-override, never a category entry). + */ +export const SERVED_CATEGORIES = [...CONFIGURABLE_CATEGORIES, 'photos'] as const; +export type ServedCategory = ConfigurableCategory | 'photos'; export interface ObjectStat { key: string; diff --git a/server/tests/e2e/storage-admin.e2e.test.ts b/server/tests/e2e/storage-admin.e2e.test.ts index bbb1791bc..a055b7c8d 100644 --- a/server/tests/e2e/storage-admin.e2e.test.ts +++ b/server/tests/e2e/storage-admin.e2e.test.ts @@ -125,7 +125,7 @@ describe('Storage admin e2e (real auth + admin guard + managed guard + temp SQLi expect(res.status).toBe(200); const names = (res.body.backends as Array<{ name: string; source: string }>).map((b) => [b.name, b.source]); expect(names).toEqual(expect.arrayContaining([['uploads-local', 'built-in'], ['backups-local', 'built-in']])); - expect(Object.keys(res.body.categories)).toHaveLength(9); + expect(Object.keys(res.body.categories)).toHaveLength(8); expect(res.body.encryptionReady).toBe(true); expect(res.body.seedFilePresent).toBe(false); expect(res.body.health).toEqual({ replicaFailures: [] }); diff --git a/server/tests/unit/nest/storage/storage-registry.service.test.ts b/server/tests/unit/nest/storage/storage-registry.service.test.ts index 0caf86c1c..ab226c796 100644 --- a/server/tests/unit/nest/storage/storage-registry.service.test.ts +++ b/server/tests/unit/nest/storage/storage-registry.service.test.ts @@ -193,6 +193,25 @@ describe('StorageRegistryService defaults', () => { path.join(fs.realpathSync(relocated), 'files/x.pdf'), ); }); + + it('serves the legacy photos category from uploads-local without any config (ServedCategory seam)', () => { + const relocated = makeTmpDir(); + const { registry } = makeRegistry({ uploadsRoot: relocated }); + const photos = registry.resolve('photos'); + expect(photos.backendName).toBe('uploads-local'); + expect(photos.keyPrefix).toBe('photos/'); + // Override-following: relocating uploads moves the legacy photos with it. + expect(photos.driver.getLocalPath!('photos/x.jpg')).toBe( + path.join(fs.realpathSync(relocated), 'photos/x.jpg'), + ); + }); + + it('the admin snapshot exposes only the 8 configurable categories — photos is served, not configurable', () => { + const { registry } = makeRegistry(); + const keys = Object.keys(registry.snapshot().categories); + expect(keys).toHaveLength(8); + expect(keys).not.toContain('photos'); + }); }); // ── settings merge + validation ─────────────────────────────────────────────── @@ -251,6 +270,7 @@ describe('StorageRegistryService settings', () => { undefined, ], ['unknown category name', undefined, JSON.stringify({ 'not-a-category': 'uploads-local' })], + ['retired photos category in settings', undefined, JSON.stringify({ photos: 'uploads-local' })], ['malformed JSON', 'not json at all', undefined], ])('falls back to built-in defaults at boot on invalid settings: %s', (_label, backendsRow, categoriesRow) => { const { registry } = makeRegistry({ boot: false }); diff --git a/shared/src/storage/storage.schema.spec.ts b/shared/src/storage/storage.schema.spec.ts index 2454a057d..d36427f65 100644 --- a/shared/src/storage/storage.schema.spec.ts +++ b/shared/src/storage/storage.schema.spec.ts @@ -127,17 +127,21 @@ describe('storageConfigSchema wire compatibility', () => { expect(storageConfigSchema.safeParse({ backends: [], categories: { backups: '' } }).success).toBe(false); }); - it('exposes the canonical nine-category list (relocated from the server)', () => { + it('exposes the canonical eight-category list — photos is served-legacy, retired from config', () => { expect(STORAGE_CATEGORIES).toEqual([ 'files', 'journey', 'covers', 'avatars', 'places', - 'photos', 'photos-google', 'photos-trek', 'backups', ]); }); + + it('rejects the retired photos category in a config document', () => { + const result = storageConfigSchema.safeParse({ backends: [], categories: { photos: 'uploads-local' } }); + expect(result.success).toBe(false); + }); }); diff --git a/shared/src/storage/storage.schema.ts b/shared/src/storage/storage.schema.ts index 602337417..aa59bf71d 100644 --- a/shared/src/storage/storage.schema.ts +++ b/shared/src/storage/storage.schema.ts @@ -17,9 +17,11 @@ import { z } from 'zod'; */ /** - * Canonical category list — relocated here from server storage.types.ts - * (which re-exports it) because the wire schema needs it and a second copy - * is forbidden. + * Canonical CONFIGURABLE category list — relocated here from server + * storage.types.ts (which re-exports it) because the wire schema needs it and + * a second copy is forbidden. The legacy `photos` directory is served and + * backed up but not configurable — the server's SERVED_CATEGORIES extension + * (storage.types.ts) carries it. */ export const STORAGE_CATEGORIES = [ 'files', @@ -27,7 +29,6 @@ export const STORAGE_CATEGORIES = [ 'covers', 'avatars', 'places', - 'photos', 'photos-google', 'photos-trek', 'backups',