papilot 0.3.0 → 0.4.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.
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/bin/doctor.mjs +11 -0
- package/dist/assets/{index-Dq3s-vhH.css → index-BB0zC0wj.css} +1 -1
- package/dist/assets/index-DTScOfwx.js +206 -0
- package/dist/assets/{math-BTm2hvOz.js → math-C3k2egGJ.js} +7 -7
- package/dist/assets/{pdf-view-BEn1EztO.js → pdf-view-Df0q8hSk.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/notes.ts +1 -1
- package/src/server/access.ts +170 -0
- package/src/server/accounts.ts +226 -0
- package/src/server/app.ts +298 -0
- package/src/server/frontend.ts +1 -1
- package/src/server/main.ts +21 -223
- package/src/server/manuscript.ts +4 -4
- package/src/server/paths.ts +4 -0
- package/src/server/security.ts +20 -0
- package/src/server/store.ts +5 -0
- package/src/server/workspace.ts +47 -17
- package/src/shared.ts +15 -3
- package/vendor/latex-review/src/render/pdf.ts +35 -1
- package/dist/assets/index-BkowXmKr.js +0 -206
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import compress from '@fastify/compress';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { registerFrontend } from './frontend.ts';
|
|
6
|
+
import type { Store } from './store.ts';
|
|
7
|
+
import type { Workspace } from './workspace.ts';
|
|
8
|
+
import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
|
|
9
|
+
import { fullPaperPdf } from './pdf.ts';
|
|
10
|
+
import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStatus } from './manuscript.ts';
|
|
11
|
+
import { findSkill, listedSkill, loadSkills } from './skills.ts';
|
|
12
|
+
import { type Accounts, deviceLabel } from './accounts.ts';
|
|
13
|
+
import { type Listener, Limiter, SESSION_COOKIE, author, clientAddress, cookie, equals, guard, viewerInfo, visibleSnapshot } from './access.ts';
|
|
14
|
+
import type { AgentId } from '../shared.ts';
|
|
15
|
+
|
|
16
|
+
// Git writes to one repository run one at a time; each returns the new status.
|
|
17
|
+
// Both listeners share one queue, so the owner and a collaborator take turns.
|
|
18
|
+
export function gitQueue() {
|
|
19
|
+
const queue = new Map<string, Promise<unknown>>();
|
|
20
|
+
return function serialGit<T>(projectId: string, work: () => Promise<T>) {
|
|
21
|
+
const run = (queue.get(projectId) || Promise.resolve()).catch(() => {}).then(work);
|
|
22
|
+
queue.set(projectId, run);
|
|
23
|
+
return run.finally(() => { if (queue.get(projectId) === run) queue.delete(projectId); });
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AppOptions {
|
|
28
|
+
listener: Listener; store: Store; workspace: Workspace; manuscripts: Manuscripts; accounts: Accounts;
|
|
29
|
+
csrf: string; serialGit: ReturnType<typeof gitQueue>; dist: string | null; version: string;
|
|
30
|
+
connectReview?: typeof reviewClient;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One Fastify instance per listener: the local one for this host and Tailscale,
|
|
34
|
+
// the account one for the public URL. Both serve the same workspace.
|
|
35
|
+
export async function buildApp(options: AppOptions) {
|
|
36
|
+
const { listener, store, workspace, manuscripts, accounts, serialGit } = options, connectReview = options.connectReview || reviewClient;
|
|
37
|
+
// A public request must arrive in full within a minute; held-open bodies tie up nothing.
|
|
38
|
+
const app = Fastify({ logger: false, bodyLimit: 1024 * 1024, ...(listener.kind === 'account' ? { requestTimeout: 60000 } : {}) });
|
|
39
|
+
const { csrfFor } = guard(app, { listener, accounts, store, csrf: options.csrf, request: id => workspace.requests.get(id) });
|
|
40
|
+
await app.register(compress);
|
|
41
|
+
app.setErrorHandler((error, _request, reply) => { reply.code((error as any).statusCode || 400).send({ error: (error as Error).message }); });
|
|
42
|
+
const any = { config: { access: 'any' as const } }, owner = { config: { access: 'owner' as const } };
|
|
43
|
+
const project = (param = 'id') => ({ config: { access: { project: param } } }), task = { config: { access: { task: 'id' } } };
|
|
44
|
+
|
|
45
|
+
app.get('/api/health', { config: { access: 'public' } }, () => listener.kind === 'local' ? { ok: true, name: 'Papilot', version: options.version } : { ok: true });
|
|
46
|
+
app.get('/api/state', any, request => {
|
|
47
|
+
const viewer = request.viewer!;
|
|
48
|
+
return { ...visibleSnapshot(workspace.snapshot(), viewer, accounts), csrf: csrfFor(viewer), viewer: viewerInfo(viewer, accounts) };
|
|
49
|
+
});
|
|
50
|
+
// Signed-in people get at most a few event streams each.
|
|
51
|
+
const streams = new Map<string, number>();
|
|
52
|
+
app.get('/api/events', any, (request, reply) => {
|
|
53
|
+
const viewer = request.viewer!, session = viewer.listener === 'account' ? viewer.session.id : '';
|
|
54
|
+
if (session && (streams.get(session) || 0) >= 6) return reply.code(429).send({ error: 'Too many open Papilot tabs. Close one and try again.' });
|
|
55
|
+
if (session) streams.set(session, (streams.get(session) || 0) + 1);
|
|
56
|
+
reply.hijack();
|
|
57
|
+
reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
58
|
+
reply.raw.write('event: change\ndata: {}\n\n');
|
|
59
|
+
let timer: NodeJS.Timeout | undefined;
|
|
60
|
+
const change = () => { timer ??= setTimeout(() => { timer = undefined; reply.raw.write('event: change\ndata: {}\n\n'); }, 400); };
|
|
61
|
+
// Removing someone or unsharing a paper reaches their open tabs at once;
|
|
62
|
+
// a stream whose session ended closes, and the tab then asks to sign in.
|
|
63
|
+
const revoked = (event: { userId?: string; sessionId?: string }) => {
|
|
64
|
+
if (viewer.listener !== 'account' || (event.userId !== viewer.user.id && event.sessionId !== viewer.session.id)) return;
|
|
65
|
+
if (!accounts.session(cookie(request.headers.cookie, SESSION_COOKIE))) { reply.raw.write('event: change\ndata: {}\n\n'); reply.raw.end(); } else change();
|
|
66
|
+
};
|
|
67
|
+
workspace.on('change', change);
|
|
68
|
+
accounts.on('revoked', revoked);
|
|
69
|
+
const heartbeat = setInterval(() => reply.raw.write(': heartbeat\n\n'), 15000);
|
|
70
|
+
reply.raw.on('close', () => {
|
|
71
|
+
clearInterval(heartbeat); if (timer) clearTimeout(timer); workspace.off('change', change); accounts.off('revoked', revoked);
|
|
72
|
+
if (session) { const open = (streams.get(session) || 1) - 1; if (open) streams.set(session, open); else streams.delete(session); }
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
app.post('/api/reconnect', any, async request => { await workspace.connect(); return visibleSnapshot(workspace.snapshot(), request.viewer!, accounts).runtime; });
|
|
76
|
+
// Codex names reasoning efforts with short lowercase words (low, medium, xhigh).
|
|
77
|
+
const agent = (value: unknown): AgentId => value === 'claude' || value === 'antigravity' ? value : 'codex';
|
|
78
|
+
const effort = (value: unknown) => typeof value === 'string' && /^[a-z]{1,16}$/.test(value) ? value : undefined;
|
|
79
|
+
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(); };
|
|
80
|
+
// Adding a paper names a folder on this host, so only the owner can.
|
|
81
|
+
app.post('/api/projects', owner, 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); });
|
|
82
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/rename', project(), request => workspace.renameProject(request.params.id, string((request.body as any).name, 'Paper name', 120)));
|
|
83
|
+
// A skill's name: what the author typed after "/".
|
|
84
|
+
const skillName = (value: unknown) => typeof value === 'string' && /^[a-z0-9][a-z0-9-]{0,47}$/.test(value) ? value : undefined;
|
|
85
|
+
// Text the author may leave empty when a skill or review feedback says enough.
|
|
86
|
+
const optional = (value: unknown, name: string) => value === undefined || value === '' ? '' : string(value, name);
|
|
87
|
+
app.get<{ Params: { id: string } }>('/api/projects/:id/skills', project(), request => ({ skills: loadSkills(store.project(request.params.id).path).map(listedSkill) }));
|
|
88
|
+
app.post('/api/tasks', { config: { access: { project: 'projectId', from: 'body' } } }, async request => {
|
|
89
|
+
const b = request.body as any, skill = skillName(b.skill);
|
|
90
|
+
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), author: author(request.viewer) });
|
|
91
|
+
});
|
|
92
|
+
app.get<{ Params: { id: string } }>('/api/tasks/:id/items', task, request => { store.task(request.params.id); return { items: store.items(request.params.id) }; });
|
|
93
|
+
app.get<{ Params: { id: string }; Querystring: { round?: string; version?: string; download?: string } }>('/api/tasks/:id/pdf', task, async (request, reply) => {
|
|
94
|
+
const task = store.task(request.params.id), project = store.project(task.projectId);
|
|
95
|
+
const round = request.query.round === undefined ? task.review?.round : Number(request.query.round);
|
|
96
|
+
const side = request.query.version || 'after';
|
|
97
|
+
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.' });
|
|
98
|
+
const pdf = await fullPaperPdf(project.path, task.name, round, side);
|
|
99
|
+
const filename = `papilot-${task.name}-r${round}-${side}.pdf`;
|
|
100
|
+
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 });
|
|
101
|
+
});
|
|
102
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/messages', task, async request => {
|
|
103
|
+
const b = request.body as any, task = store.task(request.params.id), feedback = b.feedback === true, name = skillName(b.skill);
|
|
104
|
+
const skill = name ? findSkill(store.project(task.projectId).path, name) : undefined;
|
|
105
|
+
const text = feedback || skill ? optional(b.text, 'Revision instructions') : string(b.text, 'Revision instructions');
|
|
106
|
+
return workspace.enqueue(task.id, text, string(b.requestKey, 'Request ID', 100), true, { skill, feedback, author: author(request.viewer) });
|
|
107
|
+
});
|
|
108
|
+
// Commits what the author accepted from this task, with the message they confirmed.
|
|
109
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/commit', task, request => {
|
|
110
|
+
const task = store.task(request.params.id), b = request.body as any;
|
|
111
|
+
return serialGit(task.projectId, () => workspace.commitTask(task.id, string(b.message, 'Commit message', 5000), b.push === true));
|
|
112
|
+
});
|
|
113
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/rename', task, request => workspace.renameTask(request.params.id, string((request.body as any).title, 'Task title', 120)));
|
|
114
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/interrupt', task, request => workspace.interrupt(request.params.id).then(() => ({ ok: true })));
|
|
115
|
+
// Deleting a task that is already gone succeeds, so a lost response can be retried; a hidden one answers the same.
|
|
116
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/delete', { config: { access: { task: 'id' }, hidden: 'ok' } }, request => {
|
|
117
|
+
if ((request.body as any)?.confirm !== true) throw new Error('Confirm permanent task deletion before continuing.');
|
|
118
|
+
return workspace.deleteTask(request.params.id);
|
|
119
|
+
});
|
|
120
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/reconcile', task, request => workspace.reconcile(request.params.id));
|
|
121
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/publish', task, request => {
|
|
122
|
+
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.');
|
|
123
|
+
void workspace.publish(task.id); return { ok: true };
|
|
124
|
+
});
|
|
125
|
+
// The manuscript preview compiles each paper's working tree and reports its Git status.
|
|
126
|
+
app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript', project(), async request => ({ ...(await manuscripts.state(request.params.id, store.project(request.params.id).path)), annotations: store.annotations(request.params.id).filter(note => !note.taskId) }));
|
|
127
|
+
app.get<{ Params: { id: string } }>('/api/projects/:id/git', project(), request => gitStatus(store.project(request.params.id).path));
|
|
128
|
+
app.get<{ Params: { id: string }; Querystring: { path?: string } }>('/api/projects/:id/git/diff', project(), request => gitDiff(store.project(request.params.id).path, string(request.query.path, 'File path', 4096)));
|
|
129
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/commit', project(), request => {
|
|
130
|
+
const project = store.project(request.params.id), b = request.body as any;
|
|
131
|
+
const message = string(b.message, 'Commit message', 5000), paths = Array.isArray(b.paths) ? b.paths.filter((p: unknown) => typeof p === 'string').slice(0, 5000) : [];
|
|
132
|
+
return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) })));
|
|
133
|
+
});
|
|
134
|
+
const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
|
|
135
|
+
// An agent writes a commit message for the chosen files, or a fresh one
|
|
136
|
+
// instead of the message it already wrote for them; nothing is written.
|
|
137
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/message', project(), request => {
|
|
138
|
+
const b = request.body as any;
|
|
139
|
+
return workspace.commitMessage(store.project(request.params.id).id, paths(b.paths), { agent: b.agent === undefined ? undefined : agent(b.agent), fresh: b.fresh === true });
|
|
140
|
+
});
|
|
141
|
+
// A merge in progress is resolved by an agent, committed, or abandoned.
|
|
142
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/resolve', project(), request => {
|
|
143
|
+
const project = store.project(request.params.id), b = request.body as any;
|
|
144
|
+
return serialGit(project.id, () => workspace.resolveConflicts(project.id, { agent: b.agent === undefined ? undefined : agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined }));
|
|
145
|
+
});
|
|
146
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/finish', project(), request => {
|
|
147
|
+
const project = store.project(request.params.id);
|
|
148
|
+
return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitFinishMerge(project.path), git: await gitStatus(project.path) })));
|
|
149
|
+
});
|
|
150
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/abort', project(), request => {
|
|
151
|
+
const project = store.project(request.params.id);
|
|
152
|
+
return serialGit(project.id, async () => { await gitAbortMerge(project.path); return { git: await gitStatus(project.path) }; });
|
|
153
|
+
});
|
|
154
|
+
// Chosen untracked build files go into .gitignore by kind.
|
|
155
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/ignore', project(), request => {
|
|
156
|
+
const project = store.project(request.params.id);
|
|
157
|
+
return serialGit(project.id, async () => ({ git: await gitIgnore(project.path, paths((request.body as any).paths)) }));
|
|
158
|
+
});
|
|
159
|
+
app.post<{ Params: { id: string; action: string } }>('/api/projects/:id/git/:action', project(), request => {
|
|
160
|
+
const project = store.project(request.params.id), action = request.params.action;
|
|
161
|
+
if (action !== 'fetch' && action !== 'pull' && action !== 'merge' && action !== 'push') throw Object.assign(new Error('Unknown Git action.'), { statusCode: 404 });
|
|
162
|
+
return serialGit(project.id, async () => { await gitRemote(project.path, action); return { git: await gitStatus(project.path) }; });
|
|
163
|
+
});
|
|
164
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/manuscript', project(), 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) }));
|
|
165
|
+
// Notes marked on the manuscript PDF become one agent task when sent.
|
|
166
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/annotations', project(), async request => {
|
|
167
|
+
const project = store.project(request.params.id), b = request.body as any;
|
|
168
|
+
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];
|
|
169
|
+
if (!isRect(b.rect)) throw new Error('Select text or mark a region on the page first.');
|
|
170
|
+
const rects = Array.isArray(b.rects) ? b.rects.slice(0, 80).filter(isRect) : [];
|
|
171
|
+
const comment = string(b.comment, 'Note', 4000), key = string(b.key, 'Preview key', 64), page = Number(b.page);
|
|
172
|
+
const selected = typeof b.quote === 'string' ? b.quote.slice(0, 2000) : '';
|
|
173
|
+
const { quote, source } = await manuscripts.locate(project.id, key, page, b.rect, rects, selected);
|
|
174
|
+
const by = author(request.viewer);
|
|
175
|
+
return store.saveAnnotation({ id: randomUUID(), projectId: project.id, key, page, rect: b.rect, ...(rects.length ? { rects } : {}), comment, quote, source, createdAt: Date.now(), ...(by ? { author: by } : {}) });
|
|
176
|
+
});
|
|
177
|
+
app.post<{ Params: { id: string; note: string } }>('/api/projects/:id/annotations/:note/delete', project(), request => { store.deleteAnnotation(store.project(request.params.id).id, request.params.note); return { ok: true }; });
|
|
178
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/annotations/send', project(), async request => {
|
|
179
|
+
const project = store.project(request.params.id), b = request.body as any, id = string(b.id, 'Task ID', 64);
|
|
180
|
+
// A retried send returns its task; an ID from another paper is refused.
|
|
181
|
+
const existing = store.tasks().find(task => task.id === id);
|
|
182
|
+
if (existing) { if (existing.projectId !== project.id) throw new Error('Invalid task ID.'); return existing; }
|
|
183
|
+
const notes = store.annotations(project.id).filter(note => !note.taskId);
|
|
184
|
+
if (!notes.length) throw new Error('Mark a region on the PDF and add a note first.');
|
|
185
|
+
const { title, instructions } = annotationTask(notes);
|
|
186
|
+
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), author: author(request.viewer) });
|
|
187
|
+
for (const note of notes) store.saveAnnotation({ ...note, taskId: task.id });
|
|
188
|
+
return task;
|
|
189
|
+
});
|
|
190
|
+
// Exact word places for the PDF view's selection layer; immutable per build key.
|
|
191
|
+
app.get<{ Params: { id: string }; Querystring: { key?: string; page?: string } }>('/api/projects/:id/manuscript/words', project(), async (request, reply) => {
|
|
192
|
+
const project = store.project(request.params.id);
|
|
193
|
+
const words = await manuscripts.words(project.id, String(request.query.key || ''), Number(request.query.page));
|
|
194
|
+
return reply.header('Cache-Control', 'private, max-age=31536000, immutable').send(words);
|
|
195
|
+
});
|
|
196
|
+
app.get<{ Params: { id: string }; Querystring: { key?: string; download?: string } }>('/api/projects/:id/manuscript.pdf', project(), (request, reply) => {
|
|
197
|
+
const project = store.project(request.params.id), pdf = manuscripts.pdf(project.id, String(request.query.key || ''));
|
|
198
|
+
const name = (project.name.replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'manuscript') + '.pdf';
|
|
199
|
+
// Each key names one immutable build, so the viewer can cache and range-read it.
|
|
200
|
+
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 });
|
|
201
|
+
});
|
|
202
|
+
app.post<{ Params: { id: string } }>('/api/requests/:id', { config: { access: { request: 'id' } } }, request => { workspace.answer(request.params.id, request.body as any); return { ok: true }; });
|
|
203
|
+
|
|
204
|
+
// People and sharing, for the owner. Papers shared with someone must exist.
|
|
205
|
+
const papers = (value: unknown) => { const known = new Set(store.projects().map(p => p.id)); return paths(value).filter(id => known.has(id)); };
|
|
206
|
+
app.get('/api/people', owner, () => accounts.people());
|
|
207
|
+
app.post('/api/people', owner, request => { const b = request.body as any; return accounts.addPerson(string(b.name, 'Name', 80), papers(b.projects)); });
|
|
208
|
+
app.post<{ Params: { person: string } }>('/api/people/:person/rename', owner, request => { accounts.rename(request.params.person, string((request.body as any).name, 'Name', 80)); return accounts.people(); });
|
|
209
|
+
app.post<{ Params: { person: string } }>('/api/people/:person/share', owner, request => { accounts.share(request.params.person, papers((request.body as any).projects)); return accounts.people(); });
|
|
210
|
+
app.post<{ Params: { person: string } }>('/api/people/:person/invite', owner, request => ({ link: accounts.invite(request.params.person) }));
|
|
211
|
+
app.post<{ Params: { person: string } }>('/api/people/:person/remove', owner, request => { accounts.remove(request.params.person); return accounts.people(); });
|
|
212
|
+
app.post<{ Params: { person: string; device: string } }>('/api/people/:person/devices/:device/remove', owner, request => { accounts.revoke(request.params.person, request.params.device); return accounts.people(); });
|
|
213
|
+
app.post<{ Params: { person: string } }>('/api/people/:person/sign-out', owner, request => { accounts.signOutEverywhere(request.params.person); return accounts.people(); });
|
|
214
|
+
|
|
215
|
+
// Signing in at the public URL: an invite registers a passkey, which then signs in.
|
|
216
|
+
if (listener.kind === 'account') {
|
|
217
|
+
// __Host- cookies must be Secure; browsers accept that on http://localhost too.
|
|
218
|
+
const limiter = new Limiter();
|
|
219
|
+
const auth = { config: { access: 'public' as const, csrf: false as const }, bodyLimit: 16 * 1024 };
|
|
220
|
+
const limited = (request: any, reply: any) => { if (!limiter.take(clientAddress(request))) { reply.code(429).send({ error: 'Too many attempts. Wait a minute and try again.' }); return true; } return false; };
|
|
221
|
+
const signIn = (reply: any, started: { token: string }) => reply.header('Set-Cookie', `${SESSION_COOKIE}=${started.token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${90 * 86400}`).send({ ok: true });
|
|
222
|
+
const paperName = (id: string) => store.projects().find(p => p.id === id)?.name;
|
|
223
|
+
app.post('/api/auth/invite', auth, (request, reply) => limited(request, reply) || reply.send(accounts.inviteInfo(String((request.body as any)?.token || ''), paperName)));
|
|
224
|
+
app.post('/api/auth/invite/options', auth, async (request, reply) => limited(request, reply) || reply.send(await accounts.registrationOptions(String((request.body as any)?.token || ''))));
|
|
225
|
+
app.post('/api/auth/invite/verify', auth, async (request, reply) => {
|
|
226
|
+
if (limited(request, reply)) return;
|
|
227
|
+
const b = request.body as any;
|
|
228
|
+
return signIn(reply, await accounts.register(String(b?.token || ''), b?.response, deviceLabel(request.headers['user-agent'])));
|
|
229
|
+
});
|
|
230
|
+
app.post('/api/auth/options', auth, async (request, reply) => limited(request, reply) || reply.send(await accounts.authenticationOptions()));
|
|
231
|
+
app.post('/api/auth/verify', auth, async (request, reply) => {
|
|
232
|
+
if (limited(request, reply)) return;
|
|
233
|
+
return signIn(reply, await accounts.authenticate((request.body as any)?.response, deviceLabel(request.headers['user-agent'])));
|
|
234
|
+
});
|
|
235
|
+
app.post('/api/auth/logout', any, (request, reply) => {
|
|
236
|
+
const viewer = request.viewer!;
|
|
237
|
+
if (viewer.listener === 'account') accounts.endSession(viewer.session.id);
|
|
238
|
+
return reply.header('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0`).header('Clear-Site-Data', '"cache"').send({ ok: true });
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
app.route<{ Params: { projectId: string; '*': string } }>({
|
|
243
|
+
// The review page posts with the review engine's token rather than Papilot's.
|
|
244
|
+
method: ['GET', 'POST'], url: '/review/:projectId/*', config: { access: { project: 'projectId' }, csrf: false }, handler: async (request, reply) => {
|
|
245
|
+
const project = store.project(request.params.projectId);
|
|
246
|
+
const route = '/' + request.params['*'];
|
|
247
|
+
if (route.includes('..') || !allowedReviewRoute(route, request.method)) return reply.code(404).send({ error: 'Review resource not found.' });
|
|
248
|
+
const client = await connectReview(project.path);
|
|
249
|
+
if (request.method === 'POST') {
|
|
250
|
+
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.' });
|
|
251
|
+
}
|
|
252
|
+
const reviewTask = request.method === 'POST' ? store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]) : undefined;
|
|
253
|
+
const writesFiles = route.endsWith('/undo') || (route.endsWith('/decide') && (request.body as any)?.action !== 'comment');
|
|
254
|
+
if (request.method === 'POST' && writesFiles) string((request.body as any)?.id, 'Review action ID', 80);
|
|
255
|
+
const forward = async () => {
|
|
256
|
+
const search = new URL(request.url, 'http://localhost').search;
|
|
257
|
+
const response = await fetch(client.url + route + search, {
|
|
258
|
+
method: request.method, headers: { 'Content-Type': 'application/json', 'X-Review-Token': client.token },
|
|
259
|
+
...(request.method === 'POST' ? { body: JSON.stringify(request.body) } : {}),
|
|
260
|
+
signal: AbortSignal.timeout(120000), redirect: 'error',
|
|
261
|
+
});
|
|
262
|
+
reply.code(response.status);
|
|
263
|
+
const type = response.headers.get('content-type') || 'application/octet-stream';
|
|
264
|
+
const prefix = `/review/${project.id}`;
|
|
265
|
+
if (type.includes('application/json')) {
|
|
266
|
+
const value = await response.json();
|
|
267
|
+
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.');
|
|
268
|
+
if (!response.ok && request.method === 'POST' && (value as any).error) {
|
|
269
|
+
const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
|
|
270
|
+
if (task) store.updateTask(task.id, { error: 'Review action failed: ' + (value as any).error });
|
|
271
|
+
}
|
|
272
|
+
if (response.ok && request.method === 'POST') {
|
|
273
|
+
const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
|
|
274
|
+
if (task) {
|
|
275
|
+
store.updateTask(task.id, { error: null });
|
|
276
|
+
await workspace.refresh(task.id);
|
|
277
|
+
void workspace.prepareCommitMessage(task.id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return rewriteReviewJson(value, prefix);
|
|
281
|
+
}
|
|
282
|
+
if (reviewTask && writesFiles) throw new Error('The review engine returned an unexpected response. Select Check status before continuing.');
|
|
283
|
+
reply.type(type);
|
|
284
|
+
if (type.includes('text/html')) return rewriteReviewHtml(await response.text(), prefix);
|
|
285
|
+
const cache = response.headers.get('cache-control'); if (cache && route.startsWith('/files/')) reply.header('Cache-Control', cache);
|
|
286
|
+
if (route.startsWith('/assets/')) reply.header('Cache-Control', 'private, max-age=31536000, immutable');
|
|
287
|
+
return Buffer.from(await response.arrayBuffer());
|
|
288
|
+
};
|
|
289
|
+
if (request.method === 'POST' && !reviewTask) throw new Error('This review task does not belong to this workspace.');
|
|
290
|
+
const result = await (reviewTask ? workspace.reviewWrite(reviewTask.id, writesFiles, forward, writesFiles ? { route, body: request.body as Record<string, unknown> } : undefined) : forward());
|
|
291
|
+
return annotateReviewState(result, store.tasks().filter(task => task.projectId === project.id));
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (options.dist) await registerFrontend(app, options.dist);
|
|
296
|
+
else app.get('/', { config: { access: 'public' } }, (_request, reply) => reply.type('text/plain').send('Papilot: run npm run build, then restart the server.'));
|
|
297
|
+
return app;
|
|
298
|
+
}
|
package/src/server/frontend.ts
CHANGED
|
@@ -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' });
|
package/src/server/main.ts
CHANGED
|
@@ -1,234 +1,30 @@
|
|
|
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
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
7
4
|
import { Store } from './store.ts';
|
|
8
5
|
import { Workspace } from './workspace.ts';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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 { buildApp, gitQueue } from './app.ts';
|
|
16
11
|
|
|
12
|
+
const problem = accountProblem(ACCOUNT_ORIGIN, ACCOUNT_PORT, PORT, PUBLIC_ORIGIN);
|
|
13
|
+
if (problem) { console.error(problem); process.exit(1); }
|
|
17
14
|
const store = new Store(path.join(DATA, 'papilot.sqlite'));
|
|
18
15
|
const workspace = new Workspace(store);
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.'));
|
|
16
|
+
const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
|
|
17
|
+
const shared = {
|
|
18
|
+
store, workspace, accounts, manuscripts: new Manuscripts(), csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),
|
|
19
|
+
dist: fs.existsSync(path.join(ROOT, 'dist/index.html')) ? path.join(ROOT, 'dist') : null, version: VERSION,
|
|
20
|
+
};
|
|
21
|
+
const apps = [await buildApp({ ...shared, listener: { kind: 'local', port: PORT, publicOrigin: PUBLIC_ORIGIN, owner: OWNER } })];
|
|
22
|
+
if (ACCOUNT_ORIGIN) apps.push(await buildApp({ ...shared, listener: { kind: 'account', origin: ACCOUNT_ORIGIN, hsts: accountHsts(ACCOUNT_ORIGIN, PUBLIC_ORIGIN) } }));
|
|
228
23
|
|
|
229
|
-
await
|
|
24
|
+
await apps[0].listen({ host: '127.0.0.1', port: PORT });
|
|
25
|
+
if (apps[1]) await apps[1].listen({ host: '127.0.0.1', port: ACCOUNT_PORT });
|
|
230
26
|
store.recover();
|
|
231
|
-
console.log(`Papilot is running at http://127.0.0.1:${PORT}${PUBLIC_ORIGIN ? `\nRemote: ${PUBLIC_ORIGIN}` : ''}`);
|
|
27
|
+
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
28
|
void workspace.connect();
|
|
233
29
|
// Recompute cached feedback counts, including decisions from before this restart.
|
|
234
30
|
for (const task of store.tasks()) {
|
|
@@ -237,9 +33,11 @@ for (const task of store.tasks()) {
|
|
|
237
33
|
void workspace.refresh(task.id);
|
|
238
34
|
} else if (task.review?.round && task.status !== 'deleting') void workspace.refresh(task.id);
|
|
239
35
|
}
|
|
36
|
+
// Expired invites and sessions are removed as they lapse.
|
|
37
|
+
setInterval(() => accounts.purge(), 3600000).unref();
|
|
240
38
|
let shuttingDown = false;
|
|
241
39
|
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
242
40
|
if (shuttingDown) return; shuttingDown = true; workspace.close();
|
|
243
|
-
void app.close().finally(() => { store.close(); process.exit(0); });
|
|
41
|
+
void Promise.all(apps.map(app => app.close())).finally(() => { store.close(); process.exit(0); });
|
|
244
42
|
setTimeout(() => process.exit(0), 2500).unref();
|
|
245
43
|
});
|