Small files are committed unchanged (visibility and source.kind stay); large media are referenced from .stcourses/media.json, ignored, and hard-linked into every worktree from <dataDir>/media/<id>, where the import folder is moved. Studio writes to referenced media are refused and never write through a shared inode. Co-Authored-By: Claude Opus 5.5 <[email protected]>
87 lines
3.1 KiB
TypeScript
87 lines
3.1 KiB
TypeScript
// A synthetic imported course, laid out as the importer leaves it in <dataDir>/courses/<id>. Nothing here is real material.
|
||
import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||
import { dirname, join } from "node:path";
|
||
import { LARGE_FILE_BYTES } from "../src/server/media-refs";
|
||
import { tempDir } from "./helpers";
|
||
|
||
export const VIDEO = "media/01 Intro – Übersicht.mp4";
|
||
export const VIDEO2 = "media/[2] Next step.mp4";
|
||
export const BIG_PDF = "decks/big deck.pdf";
|
||
/** Fake MP4 bytes: the server only streams them, nothing decodes them. */
|
||
export const videoBytes = (n = 4096, seed = 7) => new Uint8Array(n).map((_, i) => (i * seed) % 256);
|
||
|
||
export interface ImportFixtureOpts {
|
||
id?: string;
|
||
kind?: string;
|
||
manifestId?: string;
|
||
root?: string;
|
||
visibility?: string;
|
||
/** Extra hostile bits: a symlink to outside, a .git dir, a nested .gitignore that hides a lesson. */
|
||
hostile?: boolean;
|
||
}
|
||
|
||
function put(dir: string, rel: string, content: string | Uint8Array) {
|
||
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
||
writeFileSync(join(dir, rel), content);
|
||
}
|
||
|
||
export function manifestYaml(o: ImportFixtureOpts = {}): string {
|
||
const id = o.manifestId ?? o.id ?? "imp";
|
||
return [
|
||
"schema: 1",
|
||
`id: ${id}`,
|
||
"title: Imported Course",
|
||
`visibility: ${o.visibility ?? "restricted"}`,
|
||
...(o.root ? [`root: ${o.root}`] : []),
|
||
"cover: media/cover.jpg",
|
||
"source:",
|
||
` kind: ${o.kind ?? "video-folder"}`,
|
||
" origin: /import/synthetic",
|
||
"decks:",
|
||
" - id: d1",
|
||
` file: ${BIG_PDF}`,
|
||
" attachTo: l1",
|
||
"modules:",
|
||
" - id: m1",
|
||
" title: Videos",
|
||
" lessons:",
|
||
" - id: l1",
|
||
" title: Intro",
|
||
" type: video",
|
||
` file: "${VIDEO}"`,
|
||
" transcript: transcripts/l1.md",
|
||
" - id: l2",
|
||
" title: Next step",
|
||
" type: video",
|
||
` file: "${VIDEO2}"`,
|
||
" - id: notes",
|
||
" title: Reading",
|
||
" type: text",
|
||
" file: code/S01 - Basics/README.md",
|
||
"",
|
||
].join("\n");
|
||
}
|
||
|
||
/** Writes the course into `<dataDir>/courses/<id>` and returns that folder. */
|
||
export function makeImportedCourse(dataDir: string, o: ImportFixtureOpts = {}): string {
|
||
const id = o.id ?? "imp";
|
||
const dir = join(dataDir, "courses", id);
|
||
mkdirSync(dir, { recursive: true });
|
||
put(dir, "stcourse.yaml", manifestYaml(o));
|
||
put(dir, VIDEO, videoBytes(4096, 7));
|
||
put(dir, VIDEO2, videoBytes(2048, 11));
|
||
put(dir, "media/cover.jpg", new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1, 0xff, 0xd9]));
|
||
put(dir, BIG_PDF, new Uint8Array(LARGE_FILE_BYTES + 100).fill(0x25));
|
||
put(dir, "transcripts/l1.md", "# Intro\n\n[0:00] Hello.\n");
|
||
put(dir, "code/S01 - Basics/README.md", "# Basics\n\nRead me.\n");
|
||
if (o.hostile) {
|
||
// the companion code repo's own .gitignore hides Markdown: the lesson must still be committed
|
||
put(dir, "code/S01 - Basics/.gitignore", "*.md\n");
|
||
put(dir, "code/.git/config", "[core]\n");
|
||
const outside = join(tempDir(), "secret.txt");
|
||
writeFileSync(outside, "outside\n");
|
||
symlinkSync(outside, join(dir, "code/leak.txt"));
|
||
}
|
||
return dir;
|
||
}
|