fix(studio): review round 1 (symlink-safe writes, remote allowlist, upload cap/types/no-overwrite, owner rule at publish/sync/resolve, studio/repos layout and read-only imports, side-effect-free list)

C1: writes lstat every path component and refuse symlinks (dangling included),
check the parent's real path after mkdir, and open with O_NOFOLLOW.
I1: only https://, ssh:// and git@host: remotes; protocol.file/ext/fd=never on
every fetch and push; global/system git config ignored.
I2: STC_UPLOAD_MAX_MB cap (default 100) by Content-Length and while streaming;
extension allowlist (415); stcourse.yaml refused; 409 unless overwrite=true;
sandboxing CSP on draft raw files.
I3: owner-only visibility/source.kind rule checked on the merged result at
publish and sync, and at resolve; publish also validates the merged tree.
I4: bare repos in <dataDir>/studio/repos/<id>.git; imported courses read-only.
I5: list status comes from the bare repo; drafts are created lazily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 04:43:53 +01:00
co-authored by Claude Opus 5.5
parent 667f3e814b
commit 2d162a4282
12 changed files with 563 additions and 76 deletions
+79 -20
View File
@@ -1,15 +1,48 @@
import { basename, resolve, sep } from "node:path";
import { existsSync, realpathSync } from "node:fs";
import { basename, extname, resolve, sep } from "node:path";
import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts";
import { serveMedia } from "@stc/core";
import { ManifestSchema, parseManifest, resolveInside, type LoadedCourse } from "@stc/course-format";
import { aclRepo } from "@stc/db";
import { createDraftWriter, type DraftWriter } from "./draft-writer";
import { studioWorktreesDir } from "./paths";
import { checkRelPath, createStudioStore, MANIFEST, StudioError, type FileWrite, type StudioStore } from "./store";
import { checkRelPath, createStudioStore, MANIFEST, ownerRequiredFor, StudioError, type FileWrite, type StudioStore } from "./store";
import { createPublishedWatcher, type PublishedWatcher } from "./watch";
export const OUTSIDE_MESSAGE = "This course is managed outside STCourses";
const MAX_UPLOAD = 2 * 1024 * 1024 * 1024; // 2 GB; large videos belong in the media dir, not git, but we do not forbid them
export const IMPORTED_MESSAGE = "Imported course: read-only in the studio for now";
/** Upload cap in bytes: STC_UPLOAD_MAX_MB, default 100 (review I2). */
export function uploadMaxBytes(env: Record<string, string | undefined> = process.env): number {
const mb = Number(env.STC_UPLOAD_MAX_MB ?? "100");
return (Number.isFinite(mb) && mb > 0 ? mb : 100) * 1024 * 1024;
}
/** Server-side allowlist by extension for uploads: text, documents, images, media, archives and code. */
export const UPLOAD_EXTENSIONS = new Set([
"md", "markdown", "txt", "pdf", "png", "jpg", "jpeg", "gif", "svg", "webp", "mp4", "webm", "mp3", "vtt", "srt", "zip",
"js", "mjs", "cjs", "jsx", "ts", "tsx", "json", "yaml", "yml", "py", "sh", "css", "html", "csv", "toml", "sql",
"scala", "sc", "fs", "fsx", "fsproj", "rs", "go", "java", "kt", "c", "h", "cpp", "hpp", "cs", "rb", "php", "swift", "hs", "ex", "exs", "dockerfile",
]);
/** Reads at most `max` bytes of the body; null when it is larger (checked while streaming, not after buffering). */
async function readCapped(req: Request, max: number): Promise<Uint8Array<ArrayBuffer> | null> {
const reader = req.body?.getReader();
if (!reader) return new Uint8Array();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > max) { await reader.cancel(); return null; }
chunks.push(value);
}
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) { out.set(c, off); off += c.byteLength; }
return out;
}
export interface StudioFeature extends Feature {
store: StudioStore;
@@ -51,7 +84,19 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
(await ctx.db.sql`SELECT course_id FROM course_authors WHERE user_id = ${u.id}`).map((r: { course_id: string }) => r.course_id);
const canEdit = async (u: AuthUser, courseId: string) => hasRole(u) || (await acl.isAuthor(courseId, u.id));
const isStudioCourse = (c: LoadedCourse) => resolve(c.manifestPath).startsWith(wtDir + sep);
const who = (u: AuthUser) => ({ id: u.id, name: u.name || u.email || u.id, email: u.email || `${u.id}@users.stcourses.invalid` });
const who = (u: AuthUser) => ({ id: u.id, name: u.name || u.email || u.id, email: u.email || `${u.id}@users.stcourses.invalid`, owner: u.roles.includes("owner") });
const importedRoot = (() => { const d = resolve(ctx.config.dataDir, "courses"); return existsSync(d) ? realpathSync(d) : d; })();
/** Why a course that is not studio-managed is read-only here. */
const readOnlyMessage = (c: LoadedCourse): string => {
const kind = c.manifest?.source?.kind;
const underImports = c.root === importedRoot || c.root.startsWith(importedRoot + sep);
if (kind && kind !== "authored" && underImports) {
// TODO(studio): convert imported course to a studio-managed repo (git init into <dataDir>/studio/repos/<id>.git
// plus a published worktree), so authors can edit it and the owner-only visibility rule applies on publish.
return IMPORTED_MESSAGE;
}
return OUTSIDE_MESSAGE;
};
const afterPublishedChange = async (courseId: string) => {
await ctx.courses.reload(courseId);
@@ -80,7 +125,8 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
const courseId = rc.params.courseId ?? "";
if (!(await canEdit(user, courseId))) return fail(403, "you are not an author of this course");
if (!store.isManaged(courseId)) {
return ctx.courses.get(courseId) ? fail(403, OUTSIDE_MESSAGE) : fail(404, "no such course");
const c = ctx.courses.get(courseId);
return c ? fail(403, readOnlyMessage(c)) : fail(404, "no such course");
}
return h(req, rc, user, courseId);
});
@@ -120,7 +166,7 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
for (const id of managed) {
if (!all && !mine.has(id)) continue;
const loaded = ctx.courses.get(id);
const st = await store.status(id, user.id).catch(() => null);
const st = await store.summary(id, user.id).catch(() => null); // read-only: no worktree is created here
courses.push({
id, managed: true,
title: loaded?.manifest?.title ?? id,
@@ -133,7 +179,7 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
if (all) {
for (const c of ctx.courses.list()) {
if (isStudioCourse(c) || managed.includes(c.id)) continue;
courses.push({ id: c.id, managed: false, title: c.manifest?.title ?? c.id, pitch: c.manifest?.pitch ?? null, accent: c.manifest?.accent ?? null, publishedStatus: c.status, message: OUTSIDE_MESSAGE });
courses.push({ id: c.id, managed: false, title: c.manifest?.title ?? c.id, pitch: c.manifest?.pitch ?? null, accent: c.manifest?.accent ?? null, publishedStatus: c.status, message: readOnlyMessage(c) });
}
}
return json({ courses, canCreate: all });
@@ -157,7 +203,7 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
if (!store.isManaged(courseId)) {
const c = ctx.courses.get(courseId);
if (!c) return fail(404, "no such course");
return json({ readOnly: true, message: OUTSIDE_MESSAGE, course: { id: c.id, title: c.manifest?.title ?? c.id, managed: false, root: c.root }, diagnostics: c.diagnostics });
return json({ readOnly: true, message: readOnlyMessage(c), course: { id: c.id, title: c.manifest?.title ?? c.id, managed: false, root: c.root }, diagnostics: c.diagnostics });
}
return json(await courseState(courseId, user));
}),
@@ -179,11 +225,10 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
files.unshift({ path: MANIFEST, content: manifestToYaml(b.manifest), baseHash: b.manifestBaseHash });
}
const mf = files.find((f) => checkRelPath(f.path) === MANIFEST);
if (mf && typeof mf.content === "string") {
const parsed = parseManifest(mf.content);
if (parsed.ok && parsed.manifest.source && parsed.manifest.source.kind !== "authored" && parsed.manifest.visibility !== "restricted" && !user.roles.includes("owner")) {
return fail(403, "only the owner can make an imported course public or signed-in visible");
}
if (mf && typeof mf.content === "string" && !user.roles.includes("owner")) {
// Early feedback only; publish, sync and resolve enforce the same rule on the merged result.
const needOwner = ownerRequiredFor(await store.publishedManifest(courseId), mf.content);
if (needOwner) return fail(403, needOwner);
}
const r = await store.saveFiles(courseId, who(user), files, b.message ?? "");
if (!r.ok) return json({ ok: false, reason: "conflict", conflicts: r.conflicts }, 409);
@@ -205,18 +250,27 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
"/api/studio/courses/:courseId/upload": {
POST: course(async (req, _rc, user, courseId) => {
const max = uploadMaxBytes();
const declared = Number(req.headers.get("content-length") ?? "0");
if (declared > max) return fail(413, `file too large (limit ${Math.round(max / 1048576)} MB)`);
const bytes = await readCapped(req, max);
if (!bytes) return fail(413, `file too large (limit ${Math.round(max / 1048576)} MB)`);
let form: FormData;
try { form = await req.formData(); } catch { return fail(400, "expected multipart form data"); }
try { form = await new Response(bytes, { headers: { "content-type": req.headers.get("content-type") ?? "" } }).formData(); } catch { return fail(400, "expected multipart form data"); }
const file = form.get("file");
if (!(file instanceof Blob)) return fail(400, "file is required");
if (file.size > MAX_UPLOAD) return fail(413, "file too large");
const explicit = form.get("path");
const dir = String(form.get("dir") ?? "").replace(/^\/+|\/+$/g, "");
const name = basename((file as File).name || "upload.bin");
const path = checkRelPath(typeof explicit === "string" && explicit ? explicit : dir ? `${dir}/${name}` : name);
if (path === MANIFEST) return fail(400, "edit stcourse.yaml in the manifest editor, not by upload");
const ext = (extname(path).slice(1) || basename(path)).toLowerCase();
if (!UPLOAD_EXTENSIONS.has(ext)) return fail(415, `file type .${ext} is not accepted`);
const overwrite = String(form.get("overwrite") ?? "") === "true";
const message = String(form.get("message") ?? "") || `Upload ${path}`;
const r = await store.saveFiles(courseId, who(user), [{ path, content: new Uint8Array(await file.arrayBuffer()) }], message);
if (!r.ok) return json({ ok: false, reason: "conflict", conflicts: r.conflicts }, 409);
// baseHash null = the file must not exist yet; replacing needs an explicit overwrite=true
const r = await store.saveFiles(courseId, who(user), [{ path, content: new Uint8Array(await file.arrayBuffer()), ...(overwrite ? {} : { baseHash: null }) }], message);
if (!r.ok) return json({ ok: false, reason: "exists", error: `${path} already exists; upload again with overwrite to replace it`, conflicts: r.conflicts }, 409);
return json({ ok: true, path, commit: r.commit });
}),
},
@@ -229,7 +283,12 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
const dir = await store.draftDir(courseId, user.id);
try { checkRelPath(rel); } catch { return fail(404, "not found"); }
const abs = resolveInside(dir, rel);
return abs ? serveMedia(req, abs, { download: rc.url.searchParams.has("download") }) : fail(404, "not found");
if (!abs) return fail(404, "not found");
const res = await serveMedia(req, abs, { download: rc.url.searchParams.has("download") });
// Draft files are author content served from the app's origin: never let them run script (SVG, HTML).
res.headers.set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'; sandbox");
res.headers.set("X-Content-Type-Options", "nosniff");
return res;
}),
},
@@ -240,7 +299,7 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
await afterPublishedChange(courseId);
return json(r);
}
return json(r, r.reason === "invalid" ? 422 : 409);
return json(r, r.reason === "invalid" ? 422 : r.reason === "forbidden" ? 403 : 409);
}),
},
@@ -286,7 +345,7 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
await afterPublishedChange(courseId);
return json(r);
}
return json(r, r.reason === "no-remote" ? 400 : r.reason === "network" ? 502 : 409);
return json(r, r.reason === "no-remote" ? 400 : r.reason === "network" ? 502 : r.reason === "forbidden" ? 403 : 409);
}),
},
};
@@ -20,6 +20,9 @@ function env(who?: Identity): Record<string, string> {
if (v !== undefined && !k.startsWith("GIT_")) e[k] = v; // never inherit GIT_DIR, GIT_INDEX_FILE, ...
}
e.GIT_TERMINAL_PROMPT = "0";
// Ignore the machine's global/system git config (url.*.insteadOf, credential helpers, ...).
e.GIT_CONFIG_GLOBAL = "/dev/null";
e.GIT_CONFIG_NOSYSTEM = "1";
e.GIT_SSH_COMMAND = "ssh -o BatchMode=yes";
// A fallback identity so git never refuses a merge/commit; real saves always pass `who`.
const id = who ?? { name: "STCourses", email: "studio@stcourses.invalid" };
+9 -3
View File
@@ -6,12 +6,18 @@ export function studioWorktreesDir(dataDir: string): string {
return join(dataDir, "worktrees");
}
/** Studio bare repos: `<dataDir>/studio/repos/<id>.git` (no manifest, never discovered). */
export function studioReposDir(dataDir: string): string {
return join(dataDir, "studio", "repos");
}
/**
* The course dirs the CourseRepository must scan so studio courses load from their published worktree.
* The course dirs the CourseRepository must scan: the configured ones, the importer's plain folders in
* `<dataDir>/courses`, and studio courses through their published worktree in `<dataDir>/worktrees`.
* Phase 0's server builds the repository from `config.courseDirs` only; see docs/contract-requests/studio.md.
*/
export function studioCourseDirs(config: Pick<AppConfig, "dataDir" | "courseDirs">): string[] {
const wt = resolve(studioWorktreesDir(config.dataDir));
const dirs = config.courseDirs.map((d) => resolve(d));
return dirs.includes(wt) ? dirs : [...dirs, wt];
for (const d of [resolve(config.dataDir, "courses"), resolve(studioWorktreesDir(config.dataDir))]) if (!dirs.includes(d)) dirs.push(d);
return dirs;
}
+171 -29
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
import { mkdir, rm, unlink, writeFile } from "node:fs/promises";
import { constants, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
import { mkdir, open, rm, unlink } from "node:fs/promises";
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
import { loadCourse, type Diagnostic } from "@stc/course-format";
import { git, gitOk, splitZ, withLock, type Identity } from "./git";
@@ -63,6 +63,7 @@ export type PublishResult =
| { ok: false; reason: "conflict"; conflicts: string[] }
| { ok: false; reason: "dirty"; files: string[] }
| { ok: false; reason: "invalid"; diagnostics: Diagnostic[] }
| { ok: false; reason: "forbidden"; message: string }
| { ok: false; reason: "merging" };
export type MergeResult = { ok: true; commit: string } | { ok: false; reason: "conflict"; conflicts: string[] } | { ok: false; reason: "merging" | "no-remote" | "dirty"; message: string };
@@ -72,25 +73,75 @@ export interface ConflictFile { path: string; base: string | null; ours: string
export type SyncResult =
| { ok: true; pulled: boolean; pushed: boolean; head: string }
| { ok: false; reason: "no-remote" | "dirty" | "network"; message: string }
| { ok: false; reason: "conflict"; conflicts: string[] };
| { ok: false; reason: "conflict"; conflicts: string[] }
| { ok: false; reason: "forbidden"; message: string };
export class StudioError extends Error {
constructor(message: string, public status = 400) { super(message); }
}
export interface StudioStoreOptions { dataDir: string; publishedBranch?: string }
const HOST = String.raw`[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?`;
const USERINFO = String.raw`(?:[^@/\s:]+(?::[^@/\s]*)?@)?`;
const REMOTE_RES = [
new RegExp(String.raw`^https://${USERINFO}${HOST}(?::\d+)?/[^\s]*$`),
new RegExp(String.raw`^ssh://${USERINFO}${HOST}(?::\d+)?/[^\s]*$`),
new RegExp(String.raw`^git@${HOST}:[A-Za-z0-9_~.][^\s]*$`),
];
/** Only https://, ssh:// and git@host: remotes. Local paths, file://, ext::, fd:: and the rest are refused. */
export function isAllowedRemote(url: string): boolean {
return REMOTE_RES.some((re) => re.test(url));
}
/** Transport hardening passed to every fetch/push, on top of the allowlist (defence in depth). */
const REMOTE_GUARD = ["-c", "protocol.file.allow=never", "-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"];
type ManifestLike = { visibility?: string; source?: { kind?: string } } | null;
const kindOf = (m: ManifestLike) => m?.source?.kind ?? "authored";
function parseLoose(text: string | null): ManifestLike {
if (text === null) return null;
try { return (Bun.YAML.parse(text) as ManifestLike) ?? null; } catch { return null; }
}
/**
* Spec 12: only the owner may make an imported course public or signed-in, and (to close the obvious bypass)
* only the owner may change `source.kind`. Returns a reason when `resultText` needs the owner, else null.
*/
export function ownerRequiredFor(baseText: string | null, resultText: string | null): string | null {
const base = parseLoose(baseText);
const result = parseLoose(resultText);
if (!result) return null;
if (base && kindOf(base) !== kindOf(result)) return "only the owner can change source.kind";
const vis = result.visibility ?? "restricted";
if ((vis === "public" || vis === "signed-in") && (kindOf(result) !== "authored" || (base && kindOf(base) !== "authored"))) {
return "only the owner can make an imported course public or signed-in visible";
}
return null;
}
export type Actor = Identity & { id: string; owner?: boolean };
export interface StudioStoreOptions {
dataDir: string;
publishedBranch?: string;
/** Tests only: allow local-path remotes (the HTTP feature never sets this). */
allowLocalRemotes?: boolean;
}
export function createStudioStore(opts: StudioStoreOptions) {
const dataDir = opts.dataDir;
const main = opts.publishedBranch ?? "main";
const coursesDir = join(dataDir, "courses");
// Studio repos live apart from the importer's plain folders in <dataDir>/courses (controller ruling, review I4).
const reposDir = join(dataDir, "studio", "repos");
const importedDir = join(dataDir, "courses");
const worktreesDir = join(dataDir, "worktrees");
const remoteGuard = opts.allowLocalRemotes ? ["-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"] : REMOTE_GUARD;
const paths = (courseId: string) => {
if (!ID_RE.test(courseId)) throw new StudioError(`invalid course id: ${courseId}`, 404);
const root = join(worktreesDir, courseId);
return {
bare: join(coursesDir, courseId),
bare: join(reposDir, `${courseId}.git`),
root,
published: join(root, "published"),
draftsDir: join(root, "drafts"),
@@ -120,8 +171,46 @@ export function createStudioStore(opts: StudioStoreOptions) {
const r = await git(bare, ["remote", "get-url", "origin"]);
return r.code === 0 ? r.stdout.trim() : null;
};
const remoteOk = (url: string) => opts.allowLocalRemotes ? !/^\s*-/.test(url) && !/^(ext|fd)::/.test(url) : isAllowedRemote(url);
/** The configured remote, re-checked on every use (it could have been set outside the studio). */
const usableRemote = async (bare: string): Promise<{ ok: true } | { ok: false; message: string }> => {
const url = await remoteUrl(bare);
if (!url) return { ok: false, message: "no remote is configured for this course" };
if (!remoteOk(url)) return { ok: false, message: "the configured remote is not an https://, ssh:// or git@host: URL" };
return { ok: true };
};
const showText = async (cwd: string, spec: string): Promise<string | null> => {
const r = await git(cwd, ["show", spec]);
return r.code === 0 ? r.stdout : null;
};
/** Absolute path for `rel` inside `root`; also refuses when an existing parent is a symlink out of the root. */
const lexists = (p: string) => { try { lstatSync(p); return true; } catch { return false; } };
/**
* For writes (review C1): refuses when any existing component of `rel`, the final one included, is a symlink
* (lstat, so dangling links count), creates missing parents, then re-checks the parent's real path is inside.
*/
const safeWritePath = async (root: string, rel: string, opts: { allowFinalSymlink?: boolean } = {}): Promise<string> => {
const n = checkRelPath(rel);
const realRoot = realpathSync(root);
const parts = n.split(sep);
let cur = realRoot;
for (let i = 0; i < parts.length; i++) {
cur = join(cur, parts[i]!);
if (!lexists(cur)) break;
const st = lstatSync(cur);
const last = i === parts.length - 1;
if (st.isSymbolicLink() && !(last && opts.allowFinalSymlink)) throw new Error(`invalid path (symlink): ${rel}`);
if (!last && !st.isDirectory()) throw new Error(`invalid path (not a directory): ${rel}`);
}
const abs = join(realRoot, n);
await mkdir(dirname(abs), { recursive: true });
const realParent = realpathSync(dirname(abs));
if (realParent !== realRoot && !realParent.startsWith(realRoot + sep)) throw new Error(`invalid path (escapes the course): ${rel}`);
return abs;
};
/** For reads: absolute path for `rel` whose real path (symlinks followed) stays inside `root`. */
const inside = (root: string, rel: string): string => {
const n = checkRelPath(rel);
const abs = join(root, n);
@@ -167,12 +256,15 @@ export function createStudioStore(opts: StudioStoreOptions) {
async function writeFiles(dir: string, files: FileWrite[]): Promise<string[]> {
const touched: string[] = [];
for (const f of files) {
const abs = inside(dir, f.path);
if (f.delete) {
if (existsSync(abs)) await unlink(abs);
// unlink removes a symlink itself, never its target, so deleting a planted link is allowed
const abs = await safeWritePath(dir, f.path, { allowFinalSymlink: true });
if (lexists(abs)) await unlink(abs);
} else {
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, f.content ?? "");
const abs = await safeWritePath(dir, f.path);
// O_NOFOLLOW: a symlink swapped in after the check makes the open fail instead of writing through it
const fh = await open(abs, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o644);
try { await fh.writeFile(f.content ?? ""); } finally { await fh.close(); }
}
touched.push(checkRelPath(f.path));
}
@@ -187,8 +279,8 @@ export function createStudioStore(opts: StudioStoreOptions) {
isManaged,
async listManaged(): Promise<string[]> {
if (!existsSync(coursesDir)) return [];
return readdirSync(coursesDir).filter((id) => ID_RE.test(id) && isManaged(id)).sort();
if (!existsSync(reposDir)) return [];
return readdirSync(reposDir).filter((n) => n.endsWith(".git")).map((n) => n.slice(0, -4)).filter((id) => ID_RE.test(id) && isManaged(id)).sort();
},
async createCourse(input: { id: string; title: string; pitch?: string }, who: Identity): Promise<void> {
@@ -196,9 +288,9 @@ export function createStudioStore(opts: StudioStoreOptions) {
if (!ID_RE.test(id)) throw new StudioError(`invalid course id "${id}": use lowercase letters, digits and dashes`);
if (!title.trim()) throw new StudioError("title is required");
const p = paths(id);
if (existsSync(p.bare) || existsSync(p.root)) throw new StudioError(`course "${id}" already exists`, 409);
await lock(id, async () => {
mkdirSync(coursesDir, { recursive: true });
if (existsSync(p.bare) || existsSync(p.root) || existsSync(join(importedDir, id))) throw new StudioError(`course "${id}" already exists`, 409);
mkdirSync(reposDir, { recursive: true });
const tmp = join(dataDir, "tmp", `new-${id}-${Date.now()}`);
mkdirSync(join(tmp, "lessons"), { recursive: true });
try {
@@ -244,7 +336,7 @@ export function createStudioStore(opts: StudioStoreOptions) {
draftDir: (courseId: string, authorId: string) => api.ensureDraft(courseId, authorId),
async saveFiles(courseId: string, who: Identity & { id: string }, files: FileWrite[], message: string): Promise<SaveResult> {
async saveFiles(courseId: string, who: Actor, files: FileWrite[], message: string): Promise<SaveResult> {
for (const f of files) checkRelPath(f.path);
if (files.length === 0) throw new StudioError("nothing to save");
return lock(courseId, async () => {
@@ -281,6 +373,26 @@ export function createStudioStore(opts: StudioStoreOptions) {
};
},
/** List view status, read from the bare repo only: never creates a worktree or branch (review I5). */
async summary(courseId: string, authorId: string): Promise<{ hasDraft: boolean; ahead: number; behind: number; merging: boolean; remote: string | null }> {
const p = requireManaged(courseId);
const branch = branchOf(authorId);
const hasDraft = (await git(p.bare, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`])).code === 0;
const dir = p.draft(authorId);
return {
hasDraft,
ahead: hasDraft ? await count(p.bare, `${main}..${branch}`) : 0,
behind: hasDraft ? await count(p.bare, `${branch}..${main}`) : 0,
merging: existsSync(join(dir, ".git")) ? await isMerging(dir) : false,
remote: await remoteUrl(p.bare),
};
},
/** The published manifest text (base for the owner-only rule). */
async publishedManifest(courseId: string): Promise<string | null> {
return showText(requireManaged(courseId).bare, `${main}:${MANIFEST}`);
},
async publishedHead(courseId: string): Promise<string> {
return rev(requireManaged(courseId).published, "HEAD");
},
@@ -290,7 +402,7 @@ export function createStudioStore(opts: StudioStoreOptions) {
return api.validateDraftUnlocked(courseId, await api.ensureDraft(courseId, authorId));
},
publish(courseId: string, who: Identity & { id: string }): Promise<PublishResult> {
publish(courseId: string, who: Actor): Promise<PublishResult> {
return lock(courseId, async () => {
const p = requireManaged(courseId);
const dir = await ensureDraftUnlocked(courseId, who.id);
@@ -301,13 +413,27 @@ export function createStudioStore(opts: StudioStoreOptions) {
const diags = (await api.validateDraftUnlocked(courseId, dir)).filter((d) => d.level === "error");
if (diags.length) return { ok: false, reason: "invalid", diagnostics: diags };
if ((await count(p.bare, `${main}..${branch}`)) === 0) return { ok: true, commit: await rev(p.published, "HEAD"), published: false };
const r = await git(p.published, ["merge", "--no-ff", "-m", `Publish ${branch}`, branch], who);
const baseManifest = await showText(p.published, `HEAD:${MANIFEST}`);
// Merge without committing, so the merged result is checked before it becomes the published version.
const r = await git(p.published, ["merge", "--no-ff", "--no-commit", branch], who);
if (r.code !== 0) {
const conflicts = await unmerged(p.published);
await git(p.published, ["merge", "--abort"]);
if (!conflicts.length) throw new StudioError(`publish failed: ${r.stderr.trim() || r.stdout.trim()}`, 500);
return { ok: false, reason: "conflict", conflicts };
}
const merged = (await readFileIn(p.published, MANIFEST))?.content ?? null;
const needOwner = ownerRequiredFor(baseManifest, merged);
if (needOwner && !who.owner) {
await git(p.published, ["merge", "--abort"]);
return { ok: false, reason: "forbidden", message: needOwner };
}
const mergedErrors = (await api.validateDraftUnlocked(courseId, p.published)).filter((d) => d.level === "error");
if (mergedErrors.length) {
await git(p.published, ["merge", "--abort"]);
return { ok: false, reason: "invalid", diagnostics: mergedErrors };
}
await gitOk(p.published, ["commit", "-q", "-m", `Publish ${branch}`], who);
// Bring the draft level with what was just published, so "behind" stays meaningful.
await git(dir, ["merge", "-q", "--ff-only", main]);
return { ok: true, commit: await rev(p.published, "HEAD"), published: true };
@@ -323,15 +449,16 @@ export function createStudioStore(opts: StudioStoreOptions) {
},
/** Merges the published branch (or the remote's) into the author's draft. Conflicts stay in the draft for the author to resolve. */
updateDraft(courseId: string, who: Identity & { id: string }, from: "published" | "remote" = "published"): Promise<MergeResult> {
updateDraft(courseId: string, who: Actor, from: "published" | "remote" = "published"): Promise<MergeResult> {
return lock(courseId, async () => {
const p = requireManaged(courseId);
const dir = await ensureDraftUnlocked(courseId, who.id);
if (await isMerging(dir)) return { ok: false, reason: "merging", message: "a merge is already in progress" };
let target = main;
if (from === "remote") {
if (!(await remoteUrl(p.bare))) return { ok: false, reason: "no-remote", message: "no remote is configured" };
const f = await git(p.bare, ["fetch", "-q", "origin"]);
const ru = await usableRemote(p.bare);
if (!ru.ok) return { ok: false, reason: "no-remote", message: ru.message };
const f = await git(p.bare, [...remoteGuard, "fetch", "-q", "origin"]);
if (f.code !== 0) return { ok: false, reason: "no-remote", message: f.stderr.trim() };
target = `origin/${main}`;
if ((await git(p.bare, ["rev-parse", "-q", "--verify", `refs/remotes/${target}`])).code !== 0) return { ok: true, commit: await rev(dir, "HEAD") };
@@ -364,10 +491,15 @@ export function createStudioStore(opts: StudioStoreOptions) {
},
/** Writes the author's resolution (`content: null` deletes) and commits the merge once nothing is unmerged. */
resolve(courseId: string, who: Identity & { id: string }, files: { path: string; content: string | null }[]): Promise<{ ok: true; remaining: string[]; commit: string | null }> {
resolve(courseId: string, who: Actor, files: { path: string; content: string | null }[]): Promise<{ ok: true; remaining: string[]; commit: string | null }> {
return lock(courseId, async () => {
const dir = await ensureDraftUnlocked(courseId, who.id);
if (!(await isMerging(dir))) throw new StudioError("no merge in progress", 409);
const m = files.find((f) => checkRelPath(f.path) === MANIFEST);
if (m && !who.owner) {
const needOwner = ownerRequiredFor(await showText(requireManaged(courseId).bare, `${main}:${MANIFEST}`), m.content);
if (needOwner) throw new StudioError(needOwner, 403);
}
const touched = await writeFiles(dir, files.map((f) => (f.content === null ? { path: f.path, delete: true } : { path: f.path, content: f.content })));
await gitOk(dir, ["add", "-A", "--", ...touched]);
const remaining = await unmerged(dir);
@@ -396,7 +528,7 @@ export function createStudioStore(opts: StudioStoreOptions) {
if (cur) await gitOk(p.bare, ["remote", "remove", "origin"]);
return;
}
if (/^\s*-/.test(url) || /\s/.test(url.trim())) throw new StudioError("invalid remote url");
if (!remoteOk(url.trim())) throw new StudioError("remote must be an https://, ssh:// or git@host: URL");
await gitOk(p.bare, cur ? ["remote", "set-url", "origin", url.trim()] : ["remote", "add", "origin", url.trim()]);
// Bare repos created with `init --bare` have no fetch refspec for remote-tracking branches by default.
await gitOk(p.bare, ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"]);
@@ -407,13 +539,14 @@ export function createStudioStore(opts: StudioStoreOptions) {
* Explicit Sync with the remote: pull (fast-forward, or a merge without conflicts) and push the published branch.
* A conflicting divergence is aborted and reported; the author merges the remote into the draft to resolve it.
*/
sync(courseId: string, who: Identity): Promise<SyncResult> {
sync(courseId: string, who: Actor): Promise<SyncResult> {
return lock(courseId, async () => {
const p = requireManaged(courseId);
if (!(await remoteUrl(p.bare))) return { ok: false, reason: "no-remote", message: "no remote is configured for this course" };
const ru = await usableRemote(p.bare);
if (!ru.ok) return { ok: false, reason: "no-remote", message: ru.message };
const dirty = await dirtyFiles(p.published);
if (dirty.length) return { ok: false, reason: "dirty", message: `uncommitted changes in the published worktree: ${dirty.join(", ")}` };
const f = await git(p.bare, ["fetch", "-q", "origin"]);
const f = await git(p.bare, [...remoteGuard, "fetch", "-q", "origin"]);
if (f.code !== 0) return { ok: false, reason: "network", message: f.stderr.trim() || "fetch failed" };
const remoteRef = `refs/remotes/origin/${main}`;
const hasRemote = (await git(p.bare, ["rev-parse", "-q", "--verify", remoteRef])).code === 0;
@@ -425,25 +558,34 @@ export function createStudioStore(opts: StudioStoreOptions) {
if (local !== remote) {
const localBehind = (await git(p.bare, ["merge-base", "--is-ancestor", local, remote])).code === 0;
const remoteBehind = (await git(p.bare, ["merge-base", "--is-ancestor", remote, local])).code === 0;
const baseManifest = await showText(p.bare, `${main}:${MANIFEST}`);
if (localBehind) {
const needOwner = ownerRequiredFor(baseManifest, await showText(p.bare, `${remoteRef}:${MANIFEST}`));
if (needOwner && !who.owner) return { ok: false, reason: "forbidden", message: needOwner };
await gitOk(p.published, ["merge", "-q", "--ff-only", remoteRef]);
pulled = true;
} else if (!remoteBehind) {
const r = await git(p.published, ["merge", "--no-ff", "-m", `Sync with origin/${main}`, remoteRef], who);
const r = await git(p.published, ["merge", "--no-ff", "--no-commit", remoteRef], who);
if (r.code !== 0) {
const conflicts = await unmerged(p.published);
await git(p.published, ["merge", "--abort"]);
return conflicts.length ? { ok: false, reason: "conflict", conflicts } : { ok: false, reason: "network", message: r.stderr.trim() };
}
const needOwner = ownerRequiredFor(baseManifest, (await readFileIn(p.published, MANIFEST))?.content ?? null);
if (needOwner && !who.owner) {
await git(p.published, ["merge", "--abort"]);
return { ok: false, reason: "forbidden", message: needOwner };
}
await gitOk(p.published, ["commit", "-q", "-m", `Sync with origin/${main}`], who);
pulled = true;
}
}
}
const ahead = hasRemote ? await count(p.bare, `${remoteRef}..${main}`) : 1;
if (ahead > 0) {
const r = await git(p.bare, ["push", "-q", "origin", `${main}:${main}`]);
const r = await git(p.bare, [...remoteGuard, "push", "-q", "origin", `${main}:${main}`]);
if (r.code !== 0) return { ok: false, reason: "network", message: r.stderr.trim() || "push failed" };
await git(p.bare, ["fetch", "-q", "origin"]);
await git(p.bare, [...remoteGuard, "fetch", "-q", "origin"]);
pushed = true;
}
return { ok: true, pulled, pushed, head: await rev(p.published, "HEAD") };