mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam
This commit is contained in:
@@ -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]) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user