papilot 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/bin/doctor.mjs +11 -0
  4. package/dist/assets/index-BqX4tIMz.js +217 -0
  5. package/dist/assets/index-C0hHDQdZ.css +1 -0
  6. package/dist/assets/{math-BTm2hvOz.js → math-CUuXX16h.js} +7 -7
  7. package/dist/assets/{pdf-view-BEn1EztO.js → pdf-view-CNYCI_LH.js} +2 -2
  8. package/dist/index.html +2 -2
  9. package/package.json +4 -2
  10. package/src/notes.ts +1 -1
  11. package/src/server/access.ts +170 -0
  12. package/src/server/accounts.ts +226 -0
  13. package/src/server/app.ts +345 -0
  14. package/src/server/changes.ts +295 -0
  15. package/src/server/frontend.ts +1 -1
  16. package/src/server/main.ts +22 -223
  17. package/src/server/manuscript.ts +40 -18
  18. package/src/server/paths.ts +4 -0
  19. package/src/server/security.ts +20 -0
  20. package/src/server/store.ts +5 -0
  21. package/src/server/workspace.ts +48 -18
  22. package/src/shared.ts +17 -4
  23. package/vendor/latex-review/dist/web/assets/index-Bq8CnbPr.js +19 -0
  24. package/vendor/latex-review/dist/web/assets/index-vftKrRf_.css +1 -0
  25. package/vendor/latex-review/dist/web/index.html +2 -2
  26. package/vendor/latex-review/src/render/pdf.ts +35 -1
  27. package/vendor/latex-review/src/web/main.tsx +8 -1
  28. package/vendor/latex-review/src/web/review.tsx +249 -109
  29. package/vendor/latex-review/src/web/style.css +6 -0
  30. package/vendor/latex-review/src/web/zh-CN.ts +35 -0
  31. package/dist/assets/index-BkowXmKr.js +0 -206
  32. package/dist/assets/index-Dq3s-vhH.css +0 -1
  33. package/vendor/latex-review/dist/web/assets/index-Bg-VqnPa.css +0 -1
  34. package/vendor/latex-review/dist/web/assets/index-xLeqYBJt.js +0 -19
@@ -0,0 +1,295 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { DATA, ENGINE } from './paths.ts';
6
+ import { mergeInProgress, workingTree } from './manuscript.ts';
7
+ import { ignorePattern } from '../commit.ts';
8
+
9
+ // The manuscript's uncommitted changes as a PDF comparison: the last commit
10
+ // against the working tree, rendered by the review engine like a task's round
11
+ // and shown in its viewer. Each change can be staged or unstaged in the real
12
+ // index, or reverted on disk; a revert can be undone. A commit then takes what
13
+ // is staged, so one set of edits can become several commits.
14
+
15
+ type Op = ['equal' | 'replace' | 'insert' | 'delete', number, number, number, number];
16
+ export interface Unit { id: string; file: string; status: 'added' | 'deleted' | 'modified'; kind: string; whole_file: boolean; ops: Op[]; old_start: number; old_end: number; new_start: number; new_end: number; old_text: string; new_text: string; truncated: boolean }
17
+ type Decision = 'pending' | 'accepted' | 'rejected' | 'commented';
18
+ export type ChangeAction = 'stage' | 'unstage' | 'revert';
19
+ interface Round { number: number; before: string; after: string; units: Unit[]; folder: string; warnings: string[]; decisions: Record<string, Decision> }
20
+ interface Job { key: string; phase: 'build' | 'render'; status: 'running' | 'failed'; created: number; error?: string; details?: string[]; log?: string }
21
+ interface Snapshot { before: string | null; after: string }
22
+ interface Entry { mode: string; sha: string }
23
+ interface Revert { id: string; files: { path: string; disk: Buffer | null; written: Buffer | null; index: Entry | null; indexAfter: Entry | null }[]; signatures: string[] }
24
+ interface Comparison { rounds: Round[]; job: Job | null; next: number; undo: Revert | null; reverted: Set<string>; snapshot?: { at: number; value: Promise<Snapshot> }; current?: { key: string; units: Unit[] } }
25
+
26
+ const conflict = (message: string) => Object.assign(new Error(message), { statusCode: 409 });
27
+
28
+ async function engine() {
29
+ const load = (file: string) => import(pathToFileURL(path.join(ENGINE, file)).href);
30
+ const [{ Builder, BuildError }, { Repo }, { loadProject, isFigure }, { diffUnits, revertUnits, splitLines, textOf }, { opcodes }, { render }] = await Promise.all(
31
+ ['src/core/build.ts', 'src/core/git.ts', 'src/core/config.ts', 'src/core/changes.ts', 'src/core/diff.ts', 'src/core/publish.ts'].map(load));
32
+ return { Builder, BuildError, Repo, loadProject, isFigure, diffUnits, revertUnits, splitLines, textOf, opcodes, render } as {
33
+ Builder: any; BuildError: any; Repo: any; loadProject: (root: string) => any; isFigure: (project: any, file: string) => boolean;
34
+ diffUnits: (repo: any, before: string, after: string, isFigure: (file: string) => boolean) => Unit[];
35
+ revertUnits: (old: Buffer | null, next: Buffer | null, units: Unit[]) => Buffer | null;
36
+ splitLines: (text: string) => string[]; textOf: (data: Buffer | null) => string | null;
37
+ opcodes: (a: string[], b: string[]) => Op[]; render: (output: string, request: unknown) => Promise<void>;
38
+ };
39
+ }
40
+
41
+ // A change is the same change in a later comparison when it replaces the same
42
+ // committed lines with the same text; reverting another change leaves both.
43
+ export const signature = (unit: Unit) => createHash('sha256').update(JSON.stringify([unit.file, unit.status, unit.old_start, unit.old_end, unit.old_text, unit.new_text])).digest('hex').slice(0, 24);
44
+
45
+ // Where a change sits in the committed file and in the working file, as line ranges.
46
+ const headRange = (unit: Unit): [number, number] => [unit.ops[0][1], unit.ops[unit.ops.length - 1][2]];
47
+ const workRange = (unit: Unit): [number, number] => [unit.ops[0][3], unit.ops[unit.ops.length - 1][4]];
48
+ // Two line ranges meet when they overlap, or when one is empty and sits inside or at the edge of the other.
49
+ const meets = ([a, b]: [number, number], [c, d]: [number, number]) => a === b || c === d ? a <= d && c <= b : a < d && c < b;
50
+
51
+ // The index's lines with one change's working-tree lines staged: every
52
+ // difference between the index and the working file inside the change.
53
+ export function stageLines(index: string[], work: string[], range: [number, number], opcodes: (a: string[], b: string[]) => Op[]) {
54
+ const result = [...index];
55
+ for (const [tag, i, j, x, y] of opcodes(index, work).reverse()) if (tag !== 'equal' && meets([x, y], range)) result.splice(i, j - i, ...work.slice(x, y));
56
+ return result;
57
+ }
58
+ // The index's lines with one change unstaged: the committed lines back in its place.
59
+ export function unstageLines(head: string[], index: string[], range: [number, number], opcodes: (a: string[], b: string[]) => Op[]) {
60
+ const result = [...index];
61
+ for (const [tag, i, j, x, y] of opcodes(head, index).reverse()) if (tag !== 'equal' && meets([i, j], range)) result.splice(x, y - x, ...head.slice(i, j));
62
+ return result;
63
+ }
64
+ // A change is staged when the index already has the working tree's text there.
65
+ export const stagedLines = (index: string[], work: string[], range: [number, number], opcodes: (a: string[], b: string[]) => Op[]) =>
66
+ !opcodes(index, work).some(([tag, , , x, y]) => tag !== 'equal' && meets([x, y], range));
67
+
68
+ // A path's index entry, if it has one.
69
+ function indexEntry(repo: any, file: string): Entry | null {
70
+ const [mode, sha] = repo.text(['ls-files', '-s', '-z', '--', file]).split(/\s+/);
71
+ return sha ? { mode, sha } : null;
72
+ }
73
+ const same = (a: Buffer | null, b: Buffer | null) => a === null || b === null ? a === b : a.equals(b);
74
+ const readBytes = (file: string) => { try { return fs.readFileSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; } };
75
+
76
+ export class Changes {
77
+ private comparisons = new Map<string, Comparison>();
78
+ constructor(private dir = path.join(DATA, 'changes')) { fs.rmSync(dir, { recursive: true, force: true }); }
79
+
80
+ private comparison(projectId: string) {
81
+ let comparison = this.comparisons.get(projectId);
82
+ if (!comparison) this.comparisons.set(projectId, comparison = { rounds: [], job: null, next: 1, undo: null, reverted: new Set() });
83
+ return comparison;
84
+ }
85
+
86
+ // The last commit's tree and the working tree, read at most once a second.
87
+ private snapshot(projectId: string, root: string, fresh = false): Promise<Snapshot> {
88
+ const comparison = this.comparison(projectId);
89
+ if (!fresh && comparison.snapshot && Date.now() - comparison.snapshot.at < 1000) return comparison.snapshot.value;
90
+ const value = (async () => {
91
+ const { Repo } = await engine(), repo = new Repo(root);
92
+ return { before: repo.hasHead() ? repo.treeId('HEAD') : null, after: await workingTree(root) };
93
+ })();
94
+ comparison.snapshot = { at: Date.now(), value };
95
+ value.catch(() => { if (comparison.snapshot?.value === value) comparison.snapshot = undefined; });
96
+ return value;
97
+ }
98
+
99
+ // The changes between the two trees, leaving out untracked build output,
100
+ // which the Git panel offers to ignore.
101
+ private async units(projectId: string, root: string, snapshot: Snapshot) {
102
+ const comparison = this.comparison(projectId), key = `${snapshot.before}:${snapshot.after}`;
103
+ if (comparison.current?.key === key) return comparison.current.units;
104
+ const { Repo, loadProject, isFigure, diffUnits } = await engine(), project = loadProject(root);
105
+ const units = diffUnits(new Repo(root), snapshot.before!, snapshot.after, file => isFigure(project, file)).filter(unit => !(unit.status === 'added' && ignorePattern(unit.file)));
106
+ comparison.current = { key, units };
107
+ return units;
108
+ }
109
+
110
+ // Which of the current changes are staged, read from the index.
111
+ private async staged(root: string, snapshot: Snapshot, units: Unit[]) {
112
+ const { Repo, splitLines, textOf, opcodes } = await engine(), repo = new Repo(root);
113
+ const files = [...new Set(units.map(unit => unit.file))];
114
+ const work = repo.blobs(snapshot.after, files), index = repo.blobs('', files);
115
+ const staged = new Set<string>();
116
+ for (const unit of units) {
117
+ const ready = unit.whole_file ? same(index[unit.file], work[unit.file])
118
+ : index[unit.file] !== null && stagedLines(splitLines(textOf(index[unit.file]) ?? ''), splitLines(textOf(work[unit.file]) ?? ''), workRange(unit), opcodes);
119
+ if (ready) staged.add(unit.id);
120
+ }
121
+ return staged;
122
+ }
123
+
124
+ // Compile both trees once the working tree or the last commit changes, and
125
+ // render their comparison. The previous comparison stays readable meanwhile.
126
+ private schedule(projectId: string, root: string, snapshot: Snapshot) {
127
+ const comparison = this.comparison(projectId), latest = comparison.rounds.at(-1), key = `${snapshot.before}:${snapshot.after}`;
128
+ if (!snapshot.before || (latest && `${latest.before}:${latest.after}` === key) || comparison.job?.status === 'running' || comparison.job?.key === key) return;
129
+ const job: Job = { key, phase: 'build', status: 'running', created: Date.now() };
130
+ comparison.job = job;
131
+ void (async () => {
132
+ const { Builder, BuildError, Repo, loadProject, render } = await engine();
133
+ const project = loadProject(root), repo = new Repo(root), number = comparison.next++;
134
+ const units = await this.units(projectId, root, snapshot), folder = path.join(this.dir, projectId, String(number));
135
+ let warnings: string[] = [];
136
+ fs.rmSync(folder, { recursive: true, force: true }); fs.mkdirSync(folder, { recursive: true });
137
+ if (units.length) {
138
+ const builder = new Builder(project, repo);
139
+ const build = async (tree: string, what: string) => {
140
+ try { return await builder.build(tree, ['full']); }
141
+ catch (error) { throw error instanceof BuildError ? Object.assign(new Error(what), { details: (error as any).details, log: (error as any).log }) : error; }
142
+ };
143
+ const old = await build(snapshot.before!, 'The last commit could not be compiled.'), next = await build(snapshot.after, 'The working tree could not be compiled.');
144
+ job.phase = 'render';
145
+ await render(folder, { main: project.main, source_dir: project.source_dir, figures: project.figures, old, new: next, units });
146
+ warnings = next.warnings.filter((warning: string) => !old.warnings.includes(warning));
147
+ } else fs.writeFileSync(path.join(folder, 'round.json'), JSON.stringify({ units: {}, sections: [], loose: [] }));
148
+ comparison.rounds.push({ number, before: snapshot.before!, after: snapshot.after, units, folder, warnings, decisions: {} });
149
+ // The viewer may still show the round before; older ones go.
150
+ for (const old of comparison.rounds.splice(0, Math.max(0, comparison.rounds.length - 3))) fs.rmSync(old.folder, { recursive: true, force: true });
151
+ if (comparison.job === job) comparison.job = null;
152
+ })().catch(error => {
153
+ const detail = error as { message?: string; details?: string[]; log?: string };
154
+ Object.assign(job, { status: 'failed', error: detail.message || 'The comparison could not be built.', details: detail.details || [], ...(detail.log ? { log: detail.log } : {}) });
155
+ });
156
+ }
157
+
158
+ // What the viewer polls, in the review engine's page state shape: the
159
+ // latest comparison, and for the round it shows, which changes are staged
160
+ // (accepted), unstaged (pending), reverted (rejected) or edited since.
161
+ async state(projectId: string, root: string, csrf: string, number?: number) {
162
+ const comparison = this.comparison(projectId), snapshot = await this.snapshot(projectId, root);
163
+ this.schedule(projectId, root, snapshot);
164
+ const latest = comparison.rounds.at(-1) ?? null, round = comparison.rounds.find(item => item.number === number) ?? latest;
165
+ let decisionLock: string | null = mergeInProgress(root) ? 'Finish or abort the merge in the Git panel before staging or reverting changes.' : null;
166
+ if (round && snapshot.before && round.before === snapshot.before) {
167
+ const units = await this.units(projectId, root, snapshot), staged = await this.staged(root, snapshot, units);
168
+ const current = new Map(units.map(unit => [signature(unit), unit]));
169
+ round.decisions = Object.fromEntries(round.units.map(unit => {
170
+ const key = signature(unit), found = current.get(key);
171
+ return [unit.id, found ? (staged.has(found.id) ? 'accepted' : 'pending') : comparison.reverted.has(key) ? 'rejected' : 'commented'];
172
+ }));
173
+ } else if (round) decisionLock ??= 'The last commit changed. The comparison is being updated.';
174
+ const job = comparison.job, decisions = round?.decisions ?? {}, values = Object.values(decisions);
175
+ const building = job?.status === 'running' && (!latest || job.key !== `${latest.before}:${latest.after}`);
176
+ const undo = comparison.undo && round ? round.units.filter(unit => comparison.undo!.signatures.includes(signature(unit))).map(unit => unit.id) : [];
177
+ return {
178
+ csrf, task: 'changes', title: 'Uncommitted changes', closed: false, decisionLock, commentLock: null,
179
+ state: !snapshot.before ? 'build_failed' : building || (!latest && job?.status !== 'failed') ? 'building' : job?.status === 'failed' ? 'build_failed' : values.includes('pending') ? 'reviewing' : 'done',
180
+ latest: latest?.number ?? null, round: round?.number ?? null, revision: round?.after ?? null,
181
+ decisions, comments: {},
182
+ undo: comparison.undo ? { id: comparison.undo.id, kind: 'reject', changes: undo } : null,
183
+ job: !snapshot.before ? { id: 'changes', status: 'failed', phase: 'build', created: 0, ahead: 0, error: 'This repository has no commits yet. Commit once to compare later changes in the PDF.' }
184
+ : job ? { id: job.key, status: job.status === 'running' ? 'running' : 'failed', phase: job.phase, created: job.created, ahead: 0, ...(job.error ? { error: job.error, details: job.details, log: job.log } : {}) } : null,
185
+ };
186
+ }
187
+
188
+ detail(projectId: string, number: number) {
189
+ const round = this.comparison(projectId).rounds.find(item => item.number === number);
190
+ if (!round) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
191
+ let render: { units: Record<string, object>; sections: unknown[]; loose: unknown[] } = { units: {}, sections: [], loose: [] };
192
+ try { render = JSON.parse(fs.readFileSync(path.join(round.folder, 'round.json'), 'utf8')); } catch { /* An empty comparison has no render. */ }
193
+ return {
194
+ task: 'changes', round: round.number, before: round.before, after: round.after,
195
+ units: round.units.map(unit => ({ ...unit, ...render.units[unit.id] })), sections: render.sections, loose: render.loose,
196
+ warnings: round.warnings, files: `/review/${projectId}/changes/files/${round.number}/`,
197
+ };
198
+ }
199
+
200
+ file(projectId: string, number: number, name: string) {
201
+ const round = this.comparison(projectId).rounds.find(item => item.number === number);
202
+ const file = round && /^[\w.-]+\.(svg|png)$/.test(name) ? path.join(round.folder, name) : '';
203
+ if (!file || !fs.existsSync(file)) throw Object.assign(new Error('No such file.'), { statusCode: 404 });
204
+ return file;
205
+ }
206
+
207
+ // The chosen changes of a round the viewer shows, found again among the
208
+ // changes on disk now. Each must still be there exactly as shown.
209
+ private async chosen(projectId: string, root: string, number: number, ids: string[]) {
210
+ const comparison = this.comparison(projectId), round = comparison.rounds.find(item => item.number === number);
211
+ if (!round) throw conflict('This comparison is out of date. Wait for it to update.');
212
+ const snapshot = await this.snapshot(projectId, root, true);
213
+ if (round.before !== snapshot.before) throw conflict('The last commit changed. Wait for the comparison to update.');
214
+ if (mergeInProgress(root)) throw conflict('Finish or abort the merge in the Git panel before staging or reverting changes.');
215
+ const current = new Map((await this.units(projectId, root, snapshot)).map(unit => [signature(unit), unit]));
216
+ const units = ids.map(id => {
217
+ const shown = round.units.find(unit => unit.id === id);
218
+ const found = shown && current.get(signature(shown));
219
+ if (!found) throw conflict(shown ? `${shown.file} changed since this comparison. Wait for it to update.` : 'Select a change from this comparison.');
220
+ return found;
221
+ });
222
+ if (!units.length) throw new Error('Select at least one change.');
223
+ return { comparison, snapshot, units };
224
+ }
225
+
226
+ // Stage or unstage writes the index only; revert writes the files too, and
227
+ // keeps what it replaced for undo.
228
+ async act(projectId: string, root: string, csrf: string, action: ChangeAction, number: number, ids: string[]) {
229
+ const { comparison, snapshot, units } = await this.chosen(projectId, root, number, ids);
230
+ const { Repo, splitLines, textOf, opcodes, revertUnits } = await engine(), repo = new Repo(root);
231
+ const files = [...new Set(units.map(unit => unit.file))];
232
+ const head = repo.blobs(snapshot.before, files), work = repo.blobs(snapshot.after, files), index = repo.blobs('', files);
233
+ const lines = (data: Buffer | null) => splitLines(textOf(data) ?? '');
234
+ const entry = (file: string) => indexEntry(repo, file);
235
+ const writeIndex = (file: string, text: string[]) => {
236
+ const mode = entry(file)?.mode || repo.mode(snapshot.before, file) || '100644';
237
+ repo.run(['update-index', '--add', '--cacheinfo', `${mode},${repo.hashObject(Buffer.from(text.join('')))},${file}`]);
238
+ };
239
+ const unstage = (file: string, part: Unit[]) => {
240
+ if (part.some(unit => unit.whole_file)) { repo.run(['reset', '-q', 'HEAD', '--', file]); return; }
241
+ if (index[file] === null) return;
242
+ let next = lines(index[file]);
243
+ for (const unit of part) next = unstageLines(lines(head[file]), next, headRange(unit), opcodes);
244
+ if (next.join('') !== lines(index[file]).join('')) writeIndex(file, next);
245
+ };
246
+ const revert: Revert = { id: randomUUID(), files: [], signatures: units.map(signature) };
247
+ for (const file of files) {
248
+ const part = units.filter(unit => unit.file === file), whole = part.some(unit => unit.whole_file);
249
+ if (action === 'stage') {
250
+ if (whole) repo.run(['add', '-A', '--', file]);
251
+ else {
252
+ let next = index[file] === null ? lines(head[file]) : lines(index[file]);
253
+ for (const unit of part) next = stageLines(next, lines(work[file]), workRange(unit), opcodes);
254
+ writeIndex(file, next);
255
+ }
256
+ } else if (action === 'unstage') unstage(file, part);
257
+ else {
258
+ const absolute = path.join(root, file), disk = readBytes(absolute), before = entry(file);
259
+ if (!same(disk, work[file])) throw conflict(`${file} changed on disk since this comparison. Wait for it to update.`);
260
+ const stat = fs.lstatSync(absolute, { throwIfNoEntry: false });
261
+ if (stat && !stat.isFile()) throw new Error(`${file} is not a regular file, so it cannot be reverted here.`);
262
+ if (head[file] === null) { repo.run(['rm', '--cached', '-q', '--ignore-unmatch', '--', file]); fs.rmSync(absolute, { force: true }); }
263
+ else if (whole) repo.run(['checkout', 'HEAD', '--', file]);
264
+ else { fs.writeFileSync(absolute, revertUnits(head[file], work[file], part)!); unstage(file, part); }
265
+ revert.files.push({ path: file, disk, written: readBytes(absolute), index: before, indexAfter: entry(file) });
266
+ }
267
+ }
268
+ if (action === 'revert') { comparison.undo = revert; revert.signatures.forEach(key => comparison.reverted.add(key)); }
269
+ comparison.snapshot = undefined;
270
+ return this.state(projectId, root, csrf, number);
271
+ }
272
+
273
+ // Undo puts back what the last revert replaced, unless a file changed since.
274
+ async undo(projectId: string, root: string, csrf: string, id: string) {
275
+ const comparison = this.comparison(projectId), revert = comparison.undo;
276
+ if (!revert || revert.id !== id) throw conflict('There is no revert to undo.');
277
+ const { Repo } = await engine(), repo = new Repo(root);
278
+ for (const file of revert.files) if (!same(readBytes(path.join(root, file.path)), file.written)) throw conflict(`${file.path} changed after the revert, so it was not restored.`);
279
+ for (const file of revert.files) {
280
+ const absolute = path.join(root, file.path);
281
+ if (file.disk === null) fs.rmSync(absolute, { force: true });
282
+ else { fs.mkdirSync(path.dirname(absolute), { recursive: true }); fs.writeFileSync(absolute, file.disk); }
283
+ const now = indexEntry(repo, file.path);
284
+ if (now?.sha === file.indexAfter?.sha && now?.mode === file.indexAfter?.mode) {
285
+ if (file.index) repo.run(['update-index', '--add', '--cacheinfo', `${file.index.mode},${file.index.sha},${file.path}`]);
286
+ else if (now) repo.run(['update-index', '--force-remove', '--', file.path]);
287
+ }
288
+ }
289
+ comparison.undo = null;
290
+ revert.signatures.forEach(key => comparison.reverted.delete(key));
291
+ comparison.snapshot = undefined;
292
+ const latest = comparison.rounds.at(-1);
293
+ return { ...(await this.state(projectId, root, csrf)), restored: latest ? latest.units.filter(unit => revert.signatures.includes(signature(unit))).map(unit => unit.id) : [] };
294
+ }
295
+ }
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  export async function registerFrontend(app: FastifyInstance, root: string) {
7
7
  // Resolve assets at request time, so a frontend build can be deployed without
8
8
  // restarting the server or interrupting active Codex turns.
9
- await app.register(staticFiles, { root, prefix: '/', wildcard: true, cacheControl: false });
9
+ await app.register(staticFiles, { root, prefix: '/', wildcard: true, cacheControl: false, dotfiles: 'deny' });
10
10
  app.setNotFoundHandler((request, reply) => {
11
11
  const pathname = new URL(request.url, 'http://localhost').pathname;
12
12
  if (/^\/(api|review|assets)(\/|$)/.test(pathname)) return reply.code(404).send({ error: 'Not found' });
@@ -1,234 +1,31 @@
1
- import Fastify from 'fastify';
2
- import { registerFrontend } from './frontend.ts';
3
- import compress from '@fastify/compress';
4
1
  import fs from 'node:fs';
5
2
  import path from 'node:path';
6
- import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { randomBytes } from 'node:crypto';
7
4
  import { Store } from './store.ts';
8
5
  import { Workspace } from './workspace.ts';
9
- import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER, VERSION } from './paths.ts';
10
- import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
11
- import { authorize } from './security.ts';
12
- import { fullPaperPdf } from './pdf.ts';
13
- import { Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStatus } from './manuscript.ts';
14
- import { findSkill, listedSkill, loadSkills } from './skills.ts';
15
- import type { AgentId } from '../shared.ts';
6
+ import { Accounts } from './accounts.ts';
7
+ import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER, VERSION, ACCOUNT_ORIGIN, ACCOUNT_PORT } from './paths.ts';
8
+ import { accountHsts, accountProblem } from './security.ts';
9
+ import { Manuscripts } from './manuscript.ts';
10
+ import { Changes } from './changes.ts';
11
+ import { buildApp, gitQueue } from './app.ts';
16
12
 
13
+ const problem = accountProblem(ACCOUNT_ORIGIN, ACCOUNT_PORT, PORT, PUBLIC_ORIGIN);
14
+ if (problem) { console.error(problem); process.exit(1); }
17
15
  const store = new Store(path.join(DATA, 'papilot.sqlite'));
18
16
  const workspace = new Workspace(store);
19
- const manuscripts = new Manuscripts();
20
- const app = Fastify({ logger: false, bodyLimit: 1024 * 1024 });
21
- const csrf = randomBytes(32).toString('base64url');
22
- const equals = (a: string, b: string) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
23
- await app.register(compress);
24
- app.addHook('onRequest', async (request, reply) => {
25
- reply.header('X-Content-Type-Options', 'nosniff').header('Referrer-Policy', 'same-origin').header('X-Frame-Options', 'SAMEORIGIN').header('Cache-Control', 'no-store');
26
- const denied = authorize(request.headers, request.raw.socket.remoteAddress, PORT, PUBLIC_ORIGIN, OWNER);
27
- if (denied) return reply.code(403).send({ error: denied });
28
- if (request.method === 'POST' && !request.url.startsWith('/review/') && !equals(String(request.headers['x-papilot-token'] || ''), csrf)) return reply.code(403).send({ error: 'This page has expired. Refresh it and try again.' });
29
- });
30
- app.setErrorHandler((error, _request, reply) => { reply.code((error as any).statusCode || 400).send({ error: (error as Error).message }); });
31
- app.get('/api/health', () => ({ ok: true, name: 'Papilot', version: VERSION }));
32
- app.get('/api/state', () => ({ ...workspace.snapshot(), csrf }));
33
- app.get('/api/events', (request, reply) => {
34
- reply.hijack();
35
- reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
36
- reply.raw.write('event: change\ndata: {}\n\n');
37
- let timer: NodeJS.Timeout | undefined;
38
- const change = () => { timer ??= setTimeout(() => { timer = undefined; reply.raw.write('event: change\ndata: {}\n\n'); }, 400); };
39
- workspace.on('change', change);
40
- const heartbeat = setInterval(() => reply.raw.write(': heartbeat\n\n'), 15000);
41
- reply.raw.on('close', () => { clearInterval(heartbeat); if (timer) clearTimeout(timer); workspace.off('change', change); });
42
- });
43
- app.post('/api/reconnect', async () => { await workspace.connect(); return workspace.runtime; });
44
- // Codex names reasoning efforts with short lowercase words (low, medium, xhigh).
45
- const agent = (value: unknown): AgentId => value === 'claude' || value === 'antigravity' ? value : 'codex';
46
- const effort = (value: unknown) => typeof value === 'string' && /^[a-z]{1,16}$/.test(value) ? value : undefined;
47
- const string = (value: unknown, name: string, max = 20000) => { if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${name} is required and must be no longer than ${max} characters.`); return value.trim(); };
48
- app.post('/api/projects', async request => { const b = request.body as any; return workspace.addProject(string(b.path, 'Repository path', 4000), b.name ? string(b.name, 'Paper name', 120) : undefined); });
49
- app.post<{ Params: { id: string } }>('/api/projects/:id/rename', request => workspace.renameProject(request.params.id, string((request.body as any).name, 'Paper name', 120)));
50
- // A skill's name: what the author typed after "/".
51
- const skillName = (value: unknown) => typeof value === 'string' && /^[a-z0-9][a-z0-9-]{0,47}$/.test(value) ? value : undefined;
52
- // Text the author may leave empty when a skill or review feedback says enough.
53
- const optional = (value: unknown, name: string) => value === undefined || value === '' ? '' : string(value, name);
54
- app.get<{ Params: { id: string } }>('/api/projects/:id/skills', request => ({ skills: loadSkills(store.project(request.params.id).path).map(listedSkill) }));
55
- app.post('/api/tasks', async request => {
56
- const b = request.body as any, skill = skillName(b.skill);
57
- return workspace.createTask({ id: string(b.id, 'Task ID', 64), projectId: string(b.projectId, 'Paper ID', 64), title: string(b.title, 'Task title', 120), instructions: skill ? optional(b.instructions, 'Instructions') : string(b.instructions, 'Instructions'), skill, agent: agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined, effort: effort(b.effort) });
58
- });
59
- app.get<{ Params: { id: string } }>('/api/tasks/:id/items', request => { store.task(request.params.id); return { items: store.items(request.params.id) }; });
60
- app.get<{ Params: { id: string }; Querystring: { round?: string; version?: string; download?: string } }>('/api/tasks/:id/pdf', async (request, reply) => {
61
- const task = store.task(request.params.id), project = store.project(task.projectId);
62
- const round = request.query.round === undefined ? task.review?.round : Number(request.query.round);
63
- const side = request.query.version || 'after';
64
- if (!round || !Number.isSafeInteger(round) || round < 1 || (side !== 'before' && side !== 'after')) return reply.code(400).send({ error: 'Select an existing review round and either the before or after PDF.' });
65
- const pdf = await fullPaperPdf(project.path, task.name, round, side);
66
- const filename = `papilot-${task.name}-r${round}-${side}.pdf`;
67
- return reply.type('application/pdf').header('Content-Disposition', `${request.query.download === '1' ? 'attachment' : 'inline'}; filename="${filename}"`).sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
68
- });
69
- app.post<{ Params: { id: string } }>('/api/tasks/:id/messages', async request => {
70
- const b = request.body as any, task = store.task(request.params.id), feedback = b.feedback === true, name = skillName(b.skill);
71
- const skill = name ? findSkill(store.project(task.projectId).path, name) : undefined;
72
- const text = feedback || skill ? optional(b.text, 'Revision instructions') : string(b.text, 'Revision instructions');
73
- return workspace.enqueue(task.id, text, string(b.requestKey, 'Request ID', 100), true, { skill, feedback });
74
- });
75
- // Commits what the author accepted from this task, with the message they confirmed.
76
- app.post<{ Params: { id: string } }>('/api/tasks/:id/commit', request => {
77
- const task = store.task(request.params.id), b = request.body as any;
78
- return serialGit(task.projectId, () => workspace.commitTask(task.id, string(b.message, 'Commit message', 5000), b.push === true));
79
- });
80
- app.post<{ Params: { id: string } }>('/api/tasks/:id/rename', request => workspace.renameTask(request.params.id, string((request.body as any).title, 'Task title', 120)));
81
- app.post<{ Params: { id: string } }>('/api/tasks/:id/interrupt', request => workspace.interrupt(request.params.id).then(() => ({ ok: true })));
82
- app.post<{ Params: { id: string } }>('/api/tasks/:id/delete', request => {
83
- if ((request.body as any)?.confirm !== true) throw new Error('Confirm permanent task deletion before continuing.');
84
- return workspace.deleteTask(request.params.id);
85
- });
86
- app.post<{ Params: { id: string } }>('/api/tasks/:id/reconcile', request => workspace.reconcile(request.params.id));
87
- app.post<{ Params: { id: string } }>('/api/tasks/:id/publish', request => {
88
- const task = store.task(request.params.id); if (['running', 'waiting_input', 'building'].includes(task.status)) throw new Error('This task is busy. Wait for it to finish or pause it first.');
89
- void workspace.publish(task.id); return { ok: true };
90
- });
91
- // The manuscript preview compiles each paper's working tree and reports its Git status.
92
- app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript', async request => ({ ...(await manuscripts.state(request.params.id, store.project(request.params.id).path)), annotations: store.annotations(request.params.id).filter(note => !note.taskId) }));
93
- app.get<{ Params: { id: string } }>('/api/projects/:id/git', request => gitStatus(store.project(request.params.id).path));
94
- // Git writes to one repository run one at a time; each returns the new status.
95
- const gitQueue = new Map<string, Promise<unknown>>();
96
- function serialGit<T>(projectId: string, work: () => Promise<T>) {
97
- const run = (gitQueue.get(projectId) || Promise.resolve()).catch(() => {}).then(work);
98
- gitQueue.set(projectId, run);
99
- return run.finally(() => { if (gitQueue.get(projectId) === run) gitQueue.delete(projectId); });
100
- }
101
- app.get<{ Params: { id: string }; Querystring: { path?: string } }>('/api/projects/:id/git/diff', request => gitDiff(store.project(request.params.id).path, string(request.query.path, 'File path', 4096)));
102
- app.post<{ Params: { id: string } }>('/api/projects/:id/git/commit', request => {
103
- const project = store.project(request.params.id), b = request.body as any;
104
- const message = string(b.message, 'Commit message', 5000), paths = Array.isArray(b.paths) ? b.paths.filter((p: unknown) => typeof p === 'string').slice(0, 5000) : [];
105
- return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) })));
106
- });
107
- const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
108
- // An agent writes a commit message for the chosen files; nothing is written.
109
- app.post<{ Params: { id: string } }>('/api/projects/:id/git/message', request => {
110
- const b = request.body as any;
111
- return workspace.commitMessage(store.project(request.params.id).id, paths(b.paths), { agent: b.agent === undefined ? undefined : agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined });
112
- });
113
- // A merge in progress is resolved by an agent, committed, or abandoned.
114
- app.post<{ Params: { id: string } }>('/api/projects/:id/git/resolve', request => {
115
- const project = store.project(request.params.id), b = request.body as any;
116
- return serialGit(project.id, () => workspace.resolveConflicts(project.id, { agent: b.agent === undefined ? undefined : agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined }));
117
- });
118
- app.post<{ Params: { id: string } }>('/api/projects/:id/git/finish', request => {
119
- const project = store.project(request.params.id);
120
- return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitFinishMerge(project.path), git: await gitStatus(project.path) })));
121
- });
122
- app.post<{ Params: { id: string } }>('/api/projects/:id/git/abort', request => {
123
- const project = store.project(request.params.id);
124
- return serialGit(project.id, async () => { await gitAbortMerge(project.path); return { git: await gitStatus(project.path) }; });
125
- });
126
- // Chosen untracked build files go into .gitignore by kind.
127
- app.post<{ Params: { id: string } }>('/api/projects/:id/git/ignore', request => {
128
- const project = store.project(request.params.id);
129
- return serialGit(project.id, async () => ({ git: await gitIgnore(project.path, paths((request.body as any).paths)) }));
130
- });
131
- app.post<{ Params: { id: string; action: string } }>('/api/projects/:id/git/:action', request => {
132
- const project = store.project(request.params.id), action = request.params.action;
133
- if (action !== 'fetch' && action !== 'pull' && action !== 'merge' && action !== 'push') throw Object.assign(new Error('Unknown Git action.'), { statusCode: 404 });
134
- return serialGit(project.id, async () => { await gitRemote(project.path, action); return { git: await gitStatus(project.path) }; });
135
- });
136
- app.post<{ Params: { id: string } }>('/api/projects/:id/manuscript', async request => ({ ...(await manuscripts.state(request.params.id, store.project(request.params.id).path, true)), annotations: store.annotations(request.params.id).filter(note => !note.taskId) }));
137
- // Notes marked on the manuscript PDF become one agent task when sent.
138
- app.post<{ Params: { id: string } }>('/api/projects/:id/annotations', async request => {
139
- const project = store.project(request.params.id), b = request.body as any;
140
- const isRect = (value: unknown): value is [number, number, number, number] => Array.isArray(value) && value.length === 4 && value.every(v => typeof v === 'number' && v >= 0 && v <= 1) && value[2] > value[0] && value[3] > value[1];
141
- if (!isRect(b.rect)) throw new Error('Select text or mark a region on the page first.');
142
- const rects = Array.isArray(b.rects) ? b.rects.slice(0, 80).filter(isRect) : [];
143
- const comment = string(b.comment, 'Note', 4000), key = string(b.key, 'Preview key', 64), page = Number(b.page);
144
- const selected = typeof b.quote === 'string' ? b.quote.slice(0, 2000) : '';
145
- const { quote, source } = await manuscripts.locate(project.id, key, page, b.rect, rects, selected);
146
- return store.saveAnnotation({ id: randomUUID(), projectId: project.id, key, page, rect: b.rect, ...(rects.length ? { rects } : {}), comment, quote, source, createdAt: Date.now() });
147
- });
148
- app.post<{ Params: { id: string; note: string } }>('/api/projects/:id/annotations/:note/delete', request => { store.deleteAnnotation(store.project(request.params.id).id, request.params.note); return { ok: true }; });
149
- app.post<{ Params: { id: string } }>('/api/projects/:id/annotations/send', async request => {
150
- const project = store.project(request.params.id), b = request.body as any, id = string(b.id, 'Task ID', 64);
151
- const existing = store.tasks().find(task => task.id === id);
152
- if (existing) return existing;
153
- const notes = store.annotations(project.id).filter(note => !note.taskId);
154
- if (!notes.length) throw new Error('Mark a region on the PDF and add a note first.');
155
- const { title, instructions } = annotationTask(notes);
156
- const task = await workspace.createTask({ id, projectId: project.id, title, instructions, agent: agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined, effort: effort(b.effort) });
157
- for (const note of notes) store.saveAnnotation({ ...note, taskId: task.id });
158
- return task;
159
- });
160
- // Exact word places for the PDF view's selection layer; immutable per build key.
161
- app.get<{ Params: { id: string }; Querystring: { key?: string; page?: string } }>('/api/projects/:id/manuscript/words', async (request, reply) => {
162
- const project = store.project(request.params.id);
163
- const words = await manuscripts.words(project.id, String(request.query.key || ''), Number(request.query.page));
164
- return reply.header('Cache-Control', 'private, max-age=31536000, immutable').send(words);
165
- });
166
- app.get<{ Params: { id: string }; Querystring: { key?: string; download?: string } }>('/api/projects/:id/manuscript.pdf', (request, reply) => {
167
- const project = store.project(request.params.id), pdf = manuscripts.pdf(project.id, String(request.query.key || ''));
168
- const name = (project.name.replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'manuscript') + '.pdf';
169
- // Each key names one immutable build, so the viewer can cache and range-read it.
170
- return reply.type('application/pdf').header('Content-Disposition', `${request.query.download === '1' ? 'attachment' : 'inline'}; filename="${name}"`).header('Cache-Control', 'private, max-age=31536000, immutable').sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
171
- });
172
- app.post<{ Params: { id: string } }>('/api/requests/:id', request => { workspace.answer(request.params.id, request.body as any); return { ok: true }; });
173
-
174
- app.route<{ Params: { projectId: string; '*': string } }>({
175
- method: ['GET', 'POST'], url: '/review/:projectId/*', handler: async (request, reply) => {
176
- const project = store.project(request.params.projectId);
177
- const route = '/' + request.params['*'];
178
- if (route.includes('..') || !allowedReviewRoute(route, request.method)) return reply.code(404).send({ error: 'Review resource not found.' });
179
- const client = await reviewClient(project.path);
180
- if (request.method === 'POST') {
181
- if (!equals(String(request.headers['x-review-token'] || ''), client.token)) return reply.code(403).send({ error: 'This review page has expired. Refresh it and try again.' });
182
- }
183
- const reviewTask = request.method === 'POST' ? store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]) : undefined;
184
- const writesFiles = route.endsWith('/undo') || (route.endsWith('/decide') && (request.body as any)?.action !== 'comment');
185
- if (request.method === 'POST' && writesFiles) string((request.body as any)?.id, 'Review action ID', 80);
186
- const forward = async () => {
187
- const search = new URL(request.url, 'http://localhost').search;
188
- const response = await fetch(client.url + route + search, {
189
- method: request.method, headers: { 'Content-Type': 'application/json', 'X-Review-Token': client.token },
190
- ...(request.method === 'POST' ? { body: JSON.stringify(request.body) } : {}),
191
- signal: AbortSignal.timeout(120000), redirect: 'error',
192
- });
193
- reply.code(response.status);
194
- const type = response.headers.get('content-type') || 'application/octet-stream';
195
- const prefix = `/review/${project.id}`;
196
- if (type.includes('application/json')) {
197
- const value = await response.json();
198
- if (reviewTask && writesFiles && !confirmedReviewResponse(response.status, value, reviewTask.name)) throw new Error('The review engine could not confirm this action. Select Check status before continuing.');
199
- if (!response.ok && request.method === 'POST' && (value as any).error) {
200
- const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
201
- if (task) store.updateTask(task.id, { error: 'Review action failed: ' + (value as any).error });
202
- }
203
- if (response.ok && request.method === 'POST') {
204
- const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
205
- if (task) {
206
- store.updateTask(task.id, { error: null });
207
- await workspace.refresh(task.id);
208
- }
209
- }
210
- return rewriteReviewJson(value, prefix);
211
- }
212
- if (reviewTask && writesFiles) throw new Error('The review engine returned an unexpected response. Select Check status before continuing.');
213
- reply.type(type);
214
- if (type.includes('text/html')) return rewriteReviewHtml(await response.text(), prefix);
215
- const cache = response.headers.get('cache-control'); if (cache && route.startsWith('/files/')) reply.header('Cache-Control', cache);
216
- if (route.startsWith('/assets/')) reply.header('Cache-Control', 'private, max-age=31536000, immutable');
217
- return Buffer.from(await response.arrayBuffer());
218
- };
219
- if (request.method === 'POST' && !reviewTask) throw new Error('This review task does not belong to this workspace.');
220
- const result = await (reviewTask ? workspace.reviewWrite(reviewTask.id, writesFiles, forward, writesFiles ? { route, body: request.body as Record<string, unknown> } : undefined) : forward());
221
- return annotateReviewState(result, store.tasks().filter(task => task.projectId === project.id));
222
- },
223
- });
224
-
225
- if (fs.existsSync(path.join(ROOT, 'dist/index.html'))) {
226
- await registerFrontend(app, path.join(ROOT, 'dist'));
227
- } else app.get('/', (_request, reply) => reply.type('text/plain').send('Papilot: run npm run build, then restart the server.'));
17
+ const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
18
+ const shared = {
19
+ store, workspace, accounts, manuscripts: new Manuscripts(), changes: new Changes(), csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),
20
+ dist: fs.existsSync(path.join(ROOT, 'dist/index.html')) ? path.join(ROOT, 'dist') : null, version: VERSION,
21
+ };
22
+ const apps = [await buildApp({ ...shared, listener: { kind: 'local', port: PORT, publicOrigin: PUBLIC_ORIGIN, owner: OWNER } })];
23
+ if (ACCOUNT_ORIGIN) apps.push(await buildApp({ ...shared, listener: { kind: 'account', origin: ACCOUNT_ORIGIN, hsts: accountHsts(ACCOUNT_ORIGIN, PUBLIC_ORIGIN) } }));
228
24
 
229
- await app.listen({ host: '127.0.0.1', port: PORT });
25
+ await apps[0].listen({ host: '127.0.0.1', port: PORT });
26
+ if (apps[1]) await apps[1].listen({ host: '127.0.0.1', port: ACCOUNT_PORT });
230
27
  store.recover();
231
- console.log(`Papilot is running at http://127.0.0.1:${PORT}${PUBLIC_ORIGIN ? `\nRemote: ${PUBLIC_ORIGIN}` : ''}`);
28
+ console.log(`Papilot is running at http://127.0.0.1:${PORT}${PUBLIC_ORIGIN ? `\nRemote: ${PUBLIC_ORIGIN}` : ''}${ACCOUNT_ORIGIN ? `\nSign-in for invited people: ${ACCOUNT_ORIGIN} (forwarded to 127.0.0.1:${ACCOUNT_PORT})` : ''}`);
232
29
  void workspace.connect();
233
30
  // Recompute cached feedback counts, including decisions from before this restart.
234
31
  for (const task of store.tasks()) {
@@ -237,9 +34,11 @@ for (const task of store.tasks()) {
237
34
  void workspace.refresh(task.id);
238
35
  } else if (task.review?.round && task.status !== 'deleting') void workspace.refresh(task.id);
239
36
  }
37
+ // Expired invites and sessions are removed as they lapse.
38
+ setInterval(() => accounts.purge(), 3600000).unref();
240
39
  let shuttingDown = false;
241
40
  for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
242
41
  if (shuttingDown) return; shuttingDown = true; workspace.close();
243
- void app.close().finally(() => { store.close(); process.exit(0); });
42
+ void Promise.all(apps.map(app => app.close())).finally(() => { store.close(); process.exit(0); });
244
43
  setTimeout(() => process.exit(0), 2500).unref();
245
44
  });