refactor(server): boot dir-creation to driver init

This commit is contained in:
jubnl
2026-08-17 07:34:15 +02:00
parent 633103a246
commit ceb9af2480
5 changed files with 38 additions and 48 deletions
+9 -21
View File
@@ -10,28 +10,16 @@ import http from 'node:http';
import type { INestApplication } from '@nestjs/common';
import { buildApp, getHttpServer } from './bootstrap';
// Create upload and data directories on startup.
// Every uploads subdir the app writes to must be listed here (#1762): a dir
// created lazily on first upload needs write permission on the uploads mount
// point itself at request time, which fails with EACCES on Docker hosts whose
// bind-mounted uploads dir isn't writable by the runtime user — while every
// feature writing into an already-existing subdir keeps working. Creating them
// at boot turns that into one loud startup failure instead of a stray 500.
// Keep in sync with the `mkdir -p` list in the Dockerfile (guarded by
// tests/unit/uploads-dirs.test.ts).
const uploadsDir = path.join(__dirname, '../uploads');
const photosDir = path.join(uploadsDir, 'photos');
const filesDir = path.join(uploadsDir, 'files');
const coversDir = path.join(uploadsDir, 'covers');
const avatarsDir = path.join(uploadsDir, 'avatars');
const journeyDir = path.join(uploadsDir, 'journey');
const placesDir = path.join(uploadsDir, 'places');
const backupsDir = path.join(__dirname, '../data/backups');
// data/tmp is the driver-agnostic global scratch dir (restore-upload spool,
// mirror stream staging) and stays boot-created here. Driver-owned roots — the
// uploads/ category tree and data/backups — are ensured by LocalDriver.init on
// every storage-registry load (boot and reload(), storage-registry.service.ts),
// which keeps the #1762 EACCES guarantee: a non-writable bind mount still
// fails loudly at startup, inside app.init(), instead of as a stray 500 on
// first upload. The Dockerfile `mkdir -p` list is pinned to the registry's
// category prefixes by tests/unit/uploads-dirs.test.ts.
const tmpDir = path.join(__dirname, '../data/tmp');
[uploadsDir, photosDir, filesDir, coversDir, avatarsDir, journeyDir, placesDir, backupsDir, tmpDir].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
import { getAppUrl, getMcpSafeUrl, readEnv } from './app-config';
-9
View File
@@ -1,7 +1,5 @@
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
import { User } from '../../types';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
@@ -38,13 +36,6 @@ export const ADMIN_SETTINGS_KEYS = [
'passkey_login', 'webauthn_rp_id', 'webauthn_origins',
];
// One directory deeper than the legacy src/services location, so the extra
// '../' keeps resolving to server/uploads/avatars (same anchor the multer
// diskStorage in auth.controller.ts uses). Import-time mkdir on purpose
// (legacy parity): the upload dir must exist before the first request.
export const avatarDir = path.join(__dirname, '../../../uploads/avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
// ---------------------------------------------------------------------------
// Helpers (exported for route-level use where needed)
// ---------------------------------------------------------------------------
-1
View File
@@ -37,7 +37,6 @@ import {
BCRYPT_COST,
DUMMY_PASSWORD_HASH,
EMAIL_REGEX,
avatarDir,
generateBackupCodes,
hashBackupCodeBcrypt,
mask_stored_api_key,
@@ -45,8 +45,11 @@ const REPLICA_FAILURE_RING_SIZE = 50;
* (the backend root IS the backups dir — spec rev 3.1), and `photos-google`
* flips to bare keys when it resolves to `place-photos-local` (the relocated
* TREK_PLACE_PHOTO_DIR layout, place-photo-cache.service.ts).
*
* Exported for tests/unit/uploads-dirs.test.ts, which pins the Dockerfile's
* `mkdir -p /app/uploads/...` list to these prefixes.
*/
const CATEGORY_PREFIXES: Record<StorageCategory, string> = {
export const CATEGORY_PREFIXES: Record<StorageCategory, string> = {
files: 'files/',
journey: 'journey/',
covers: 'covers/',
+25 -16
View File
@@ -1,37 +1,46 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { CATEGORY_PREFIXES } from '../../src/nest/storage/storage-registry.service';
// The set of uploads subdirectories is hand-mirrored between the image build and
// the boot-time mkdir in server/src/index.ts. Drift is invisible until a user on
// a host whose bind-mounted uploads dir isn't writable by node hits an EACCES on
// first upload to the missing dir (#1762), so pin the two copies together.
// Driver-owned roots are created by LocalDriver.init on every storage-registry
// load now (boot and reload()) — index.ts no longer mkdirs the uploads tree.
// The Dockerfile still pre-creates the subdirs (and chowns them) at image
// build, so its `mkdir -p` list must stay in sync with the category prefixes
// rooted under uploads/. Drift is invisible until a user on a host whose
// bind-mounted uploads dir isn't writable by node hits an EACCES on first
// upload to the missing dir (#1762), so pin the two lists together.
const repoRoot = path.resolve(__dirname, '../../..');
const readSubdirs = (file: string, pattern: RegExp): Set<string> => {
const source = fs.readFileSync(path.join(repoRoot, file), 'utf8');
const readDockerfileSubdirs = (): Set<string> => {
const source = fs.readFileSync(path.join(repoRoot, 'Dockerfile'), 'utf8');
const found = new Set<string>();
for (const m of source.matchAll(pattern)) found.add(m[1]);
for (const m of source.matchAll(/\/app\/uploads\/([A-Za-z0-9_-]+)/g)) found.add(m[1]);
return found;
};
describe('uploads subdirectory parity', () => {
const fromDockerfile = readSubdirs('Dockerfile', /\/app\/uploads\/([A-Za-z0-9_-]+)/g);
const fromBoot = readSubdirs('server/src/index.ts', /path\.join\(uploadsDir, '([A-Za-z0-9_-]+)'\)/g);
const fromDockerfile = readDockerfileSubdirs();
// The registry's real values: every non-empty category prefix lives under
// uploads/ (backups' empty prefix is rooted in data/, not uploads/), and the
// nested cache prefixes (photos/google, photos/trek) collapse into their
// top-level dir.
const fromRegistry = new Set(
Object.values(CATEGORY_PREFIXES)
.filter((p) => p !== '')
.map((p) => p.split('/')[0]),
);
it('finds both lists (guards against the regexes silently matching nothing)', () => {
it('finds the Dockerfile list (guards against the regex silently matching nothing)', () => {
expect(fromDockerfile.size).toBeGreaterThan(0);
expect(fromBoot.size).toBeGreaterThan(0);
});
it('creates the same uploads subdirs in the Dockerfile and at boot', () => {
expect([...fromBoot].sort()).toEqual([...fromDockerfile].sort());
it('creates the same uploads subdirs in the Dockerfile and at registry init', () => {
expect([...fromRegistry].sort()).toEqual([...fromDockerfile].sort());
});
it('covers every uploads subdir the server writes to', () => {
// journey/ and places/ are written by journey.controller.ts and
// services/placeImage.ts; the rest are the long-standing upload stores.
expect([...fromBoot].sort()).toEqual([
expect([...fromRegistry].sort()).toEqual([
'avatars',
'covers',
'files',