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:
co-authored by
Claude Opus 5.5
parent
667f3e814b
commit
2d162a4282
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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") };
|
||||
|
||||
Reference in New Issue
Block a user