feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam

This commit is contained in:
jubnl
2026-08-20 20:20:43 +02:00
parent bbede7ab98
commit 49976ab383
10 changed files with 74 additions and 35 deletions
@@ -14,7 +14,7 @@ const S3_MASKED = {
function baseState(overrides: Partial<StorageAdminState> = {}): 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<StorageAdminState> = {}): 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' },
@@ -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' },
@@ -9,7 +9,7 @@ import MAdminStoragePanel from '../../../../src/mobile/screens/admin/MAdminStora
function baseState(overrides: Partial<StorageAdminState> = {}): 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<StorageAdminState> = {}): 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' },
@@ -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<string, StorageDriver>;
categories: Map<StorageCategory, { backendName: string; keyPrefix: string }>;
categories: Map<ServedCategory, { backendName: string; keyPrefix: string }>;
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<StorageCategory, string> = {
export const CATEGORY_PREFIXES: Record<ServedCategory, string> = {
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<StorageCategory, string>();
for (const category of STORAGE_CATEGORIES) categoryBackends.set(category, 'uploads-local');
const categoryBackends = new Map<ServedCategory, string>();
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<StorageCategory, 'default' | 'settings'>();
const categorySources = new Map<ServedCategory, 'default' | 'settings'>();
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<StorageCategory, { backendName: string; keyPrefix: string }>();
const categories = new Map<ServedCategory, { backendName: string; keyPrefix: string }>();
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<string, BackendConfig>, categories: Map<StorageCategory, string>): void {
function validateConfig(backends: Map<string, BackendConfig>, categories: Map<ServedCategory, string>): void {
for (const config of backends.values()) {
if (config.type !== 'mirror') continue;
for (const target of [config.options.primary, ...config.options.replicas]) {
+11 -11
View File
@@ -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<ObjectStat | null> {
async stat(category: ServedCategory, name: string): Promise<ObjectStat | null> {
const { driver, key } = this.resolve(category, name);
return driver.stat(key);
}
async exists(category: StorageCategory, name: string): Promise<boolean> {
async exists(category: ServedCategory, name: string): Promise<boolean> {
return (await this.stat(category, name)) !== null;
}
async delete(category: StorageCategory, name: string): Promise<void> {
async delete(category: ServedCategory, name: string): Promise<void> {
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<ObjectStat> {
async *list(category: ServedCategory, subPrefix = ''): AsyncIterable<ObjectStat> {
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<void> {
async sendToResponse(category: ServedCategory, name: string, res: Response, opts?: SendOptions): Promise<void> {
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<T>(category: StorageCategory, name: string, fn: (absPath: string) => Promise<T>): Promise<T> {
async withLocalFile<T>(category: ServedCategory, name: string, fn: (absPath: string) => Promise<T>): Promise<T> {
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);
+11
View File
@@ -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;
+1 -1
View File
@@ -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: [] });
@@ -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 });
+6 -2
View File
@@ -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);
});
});
+5 -4
View File
@@ -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',