provision <root>/media, beside the checkout

where the images and the files will live. same row shape as the checkout above it,
because it is the same kind of fact: a thing that either is or is not at a known
path under the root, with one button that puts it there.

BESIDE `website`, never inside it, and that is the point of it being here rather
than a folder in the repository:

  - it must SURVIVE the checkout. deleting `website` and cloning it again is a
    normal thing to do when something is wrong with it, and it must never be the
    thing that destroys the client's photographs.
  - it must never be TRACKED. media in git is a repository nobody can clone a year
    from now, and a .gitignore is a promise somebody eventually breaks with
    `git add -f`.

both under one root, so a backup of the root is the whole site — the code and the
things it serves. that is the property that makes the root worth being a setting.

NOTHING ABOUT ITS INSIDES IS DECIDED. this creates the directory and reports
whether it is there; the layout, who writes to it and what goes where are still
being described. inventing a folder tree now would settle that in the file least
able to change its mind, because directories on disk are not a migration you can
run.

`mkdir -p`, so it also creates a root that is still only a default — saving a root
creates nothing, and provisioning into a path that does not exist is the ordinary
first run. idempotent by the same flag, which is what makes the button safe to
offer next to a status that might be stale.

no console for this one: making a directory either works or gives one line about
why not, and a black transcript box for a single sentence makes a small thing look
like a large one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 01:03:28 +01:00
co-authored by Claude Opus 5
parent c268e8438a
commit 003613872d
5 changed files with 202 additions and 1 deletions
+77
View File
@@ -0,0 +1,77 @@
import { mkdir, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { readSettings } from './settings';
/**
* The media directory under the root — where the images and files live.
*
* ── BESIDE the checkout, never inside it ──
*
* `<root>/media`, a sibling of `<root>/website`. That is the whole point of it being here rather than
* a folder in the repository:
*
* - it must survive the checkout. deleting `website` and cloning it again is a normal thing to do
* when something is wrong with it, and it must never be a thing that destroys the client's photos.
* - it must never be tracked. media in git is a repository nobody can clone a year from now, and a
* `.gitignore` is a promise somebody eventually breaks with `git add -f`.
*
* The two live under one root so a backup of the root is the whole site — the code and the things it
* serves — which is the property that makes the root worth being a setting at all.
*
* ── Nothing about its INSIDES is decided here ──
*
* This creates the directory and reports whether it is there. What goes in it, how it is laid out, and
* who writes to it are not settled, and inventing a folder tree now would be deciding that here — in
* the file least able to change its mind later, because directories on disk are not a migration you
* can run.
*/
const DIR_NAME = 'media';
export type MediaStatus = {
/** Absolute — `<root>/media`. */
path: string;
present: boolean;
/** How many things are directly inside. Null when it is not there. */
entries: number | null;
/** It exists but is not a directory — a file in the way, which `mkdir` would fail on. */
blocked: boolean;
};
export const mediaPath = async (email: string): Promise<string> => {
const settings = await readSettings(email);
return join(settings.rootPath, DIR_NAME);
};
export async function readMediaStatus(email: string): Promise<MediaStatus> {
const path = await mediaPath(email);
const info = await stat(path).catch(() => null);
if (!info) return { path, present: false, entries: null, blocked: false };
if (!info.isDirectory()) return { path, present: true, entries: null, blocked: true };
const entries = await readdir(path).catch(() => null);
return { path, present: true, entries: entries?.length ?? null, blocked: false };
}
/**
* Make it.
*
* `recursive`, so it also creates the root when the root is still only a default — saving a root does
* not create anything, and provisioning into a path that does not exist yet is the ordinary first run
* rather than an error worth reporting.
*
* Idempotent by the same flag: pressing it on a directory that already exists changes nothing and
* fails at nothing, which is what makes it safe to offer next to a status that might be stale.
*/
export async function createMediaDir(email: string): Promise<{ ok: boolean; error?: string }> {
const status = await readMediaStatus(email);
if (status.blocked) return { ok: false, error: `${status.path} exists and is not a directory.` };
try {
await mkdir(status.path, { recursive: true });
return { ok: true };
} catch (error) {
return { ok: false, error: (error as Error).message };
}
}
+17
View File
@@ -4,6 +4,7 @@ import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes';
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
import { readSettings, saveRootPath } from './settings';
import { readWebsiteStatus, cloneWebsite, updateWebsite } from './website';
import { readMediaStatus, createMediaDir } from './media';
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
/**
@@ -211,6 +212,22 @@ router.post('/_config/website/update', async (ctx) => {
return ctx.json({ ...result, status: await readWebsiteStatus(user.email) }, result.ok ? 200 : 400);
});
/**
* The media directory beside the checkout. Same `/_config` prefix, so the same owner-only rule.
*/
router.get('/_config/media', async (ctx) => {
const user = ctx.get('user');
return ctx.json(await readMediaStatus(user.email));
});
router.post('/_config/media/create', async (ctx) => {
const user = ctx.get('user');
const result = await createMediaDir(user.email);
// The status is re-read either way, so what the panel shows is what is on disk rather than what was
// asked for.
return ctx.json({ ...result, status: await readMediaStatus(user.email) }, result.ok ? 200 : 400);
});
/**
* Is this plugin usable? Asked once by the detail panel rather than by all nine sections.
*