draftlink 0.1.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/LICENSE +674 -0
- package/README.md +92 -0
- package/bin/draftlink +393 -0
- package/migrations/0000_baseline.sql +51 -0
- package/package.json +45 -0
- package/skills/draftlink/SKILL.md +54 -0
- package/src/assets.ts +4 -0
- package/src/drafts.ts +209 -0
- package/src/index.ts +572 -0
- package/src/ui.ts +696 -0
- package/src/util.ts +119 -0
- package/tsconfig.json +15 -0
- package/wrangler.jsonc +15 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cookieHeader,
|
|
3
|
+
getCookie,
|
|
4
|
+
randomHex,
|
|
5
|
+
sha256Hex,
|
|
6
|
+
signPayload,
|
|
7
|
+
timingSafeEqual,
|
|
8
|
+
verifyPayload,
|
|
9
|
+
} from "./util";
|
|
10
|
+
import {
|
|
11
|
+
canRead,
|
|
12
|
+
create,
|
|
13
|
+
getVersion,
|
|
14
|
+
grant,
|
|
15
|
+
listGrants,
|
|
16
|
+
listMine,
|
|
17
|
+
listProjects,
|
|
18
|
+
listShared,
|
|
19
|
+
listVersions,
|
|
20
|
+
remove,
|
|
21
|
+
resolveAccess,
|
|
22
|
+
revoke,
|
|
23
|
+
STATUS_ORDER,
|
|
24
|
+
update,
|
|
25
|
+
validStatus,
|
|
26
|
+
} from "./drafts";
|
|
27
|
+
import {
|
|
28
|
+
adminPage,
|
|
29
|
+
dashboardFragment,
|
|
30
|
+
dashboardPage,
|
|
31
|
+
draftShellHeader,
|
|
32
|
+
draftShellPage,
|
|
33
|
+
editPage,
|
|
34
|
+
errorPage,
|
|
35
|
+
injectEmbed,
|
|
36
|
+
installPage,
|
|
37
|
+
keysPage,
|
|
38
|
+
layout,
|
|
39
|
+
loginPage,
|
|
40
|
+
sharePage,
|
|
41
|
+
} from "./ui";
|
|
42
|
+
import { LOGO_PNG, FAVICON_PNG } from "./assets";
|
|
43
|
+
|
|
44
|
+
function base64ToBytes(b64: string): Uint8Array {
|
|
45
|
+
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Env {
|
|
49
|
+
DB: D1Database;
|
|
50
|
+
GITHUB_CLIENT_ID?: string;
|
|
51
|
+
GITHUB_CLIENT_SECRET?: string;
|
|
52
|
+
SESSION_SECRET?: string;
|
|
53
|
+
DEV_MODE?: string;
|
|
54
|
+
DEV_LOGIN_SECRET?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface UserRow {
|
|
58
|
+
id: number;
|
|
59
|
+
github_id: number;
|
|
60
|
+
login: string;
|
|
61
|
+
status: string;
|
|
62
|
+
is_admin?: number;
|
|
63
|
+
admin?: boolean;
|
|
64
|
+
pending?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SESSION_COOKIE = "dl_session";
|
|
68
|
+
const STATE_COOKIE = "dl_state";
|
|
69
|
+
const SESSION_TTL_S = 30 * 24 * 3600;
|
|
70
|
+
const MAX_BODY = 4 * 1024 * 1024;
|
|
71
|
+
|
|
72
|
+
function json(data: unknown, status = 200): Response {
|
|
73
|
+
return new Response(JSON.stringify(data), {
|
|
74
|
+
status,
|
|
75
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "private, no-store" },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function html(body: string, status = 200, headers: Record<string, string> = {}): Response {
|
|
80
|
+
return new Response(body, {
|
|
81
|
+
status,
|
|
82
|
+
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "private, no-store", "Content-Security-Policy": "frame-ancestors 'none'", ...headers },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Draft bodies are hostile HTML. The `sandbox` directive is not optional: an
|
|
87
|
+
// embed URL opened as a top-level document must never run same-origin, or the
|
|
88
|
+
// draft could script the viewer's session (frame /dashboard, read
|
|
89
|
+
// contentDocument, navigate out).
|
|
90
|
+
const EMBED_CSP = "default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval'; connect-src 'none'; form-action 'none'; sandbox allow-scripts allow-popups; frame-ancestors 'self'; base-uri 'none'";
|
|
91
|
+
|
|
92
|
+
const THEME_JS = `(function () {
|
|
93
|
+
var KEY = "dl-theme";
|
|
94
|
+
var root = document.documentElement;
|
|
95
|
+
function prefersDark() {
|
|
96
|
+
return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
97
|
+
}
|
|
98
|
+
function stored() {
|
|
99
|
+
try { return localStorage.getItem(KEY); } catch (e) { return null; }
|
|
100
|
+
}
|
|
101
|
+
function apply() {
|
|
102
|
+
var dark = stored() ? stored() === "dark" : prefersDark();
|
|
103
|
+
root.classList.toggle("dark", dark);
|
|
104
|
+
root.style.colorScheme = dark ? "dark" : "light";
|
|
105
|
+
}
|
|
106
|
+
window.dlApplyTheme = apply;
|
|
107
|
+
window.dlToggleTheme = function () {
|
|
108
|
+
var next = (stored() ? stored() === "dark" : prefersDark()) ? "light" : "dark";
|
|
109
|
+
try { localStorage.setItem(KEY, next); } catch (e) {}
|
|
110
|
+
apply();
|
|
111
|
+
};
|
|
112
|
+
apply();
|
|
113
|
+
})();`;
|
|
114
|
+
|
|
115
|
+
function redirect(location: string, extraHeaders: [string, string][] = []): Response {
|
|
116
|
+
return new Response(null, { status: 303, headers: [...extraHeaders, ["Location", location]] });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function baseUrl(req: Request): { origin: string; secure: boolean } {
|
|
120
|
+
const url = new URL(req.url);
|
|
121
|
+
return { origin: `${url.protocol}//${url.host}`, secure: url.protocol === "https:" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function crossSiteBlocked(req: Request, origin: string): boolean {
|
|
125
|
+
const site = req.headers.get("Sec-Fetch-Site");
|
|
126
|
+
if (site) return site !== "same-origin";
|
|
127
|
+
const originHeader = req.headers.get("Origin");
|
|
128
|
+
if (originHeader) return originHeader !== origin;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function admitted(u: UserRow | null): UserRow | null {
|
|
133
|
+
return u && (u.status === "approved" || (u.is_admin === 1 && u.status !== "banned")) ? u : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function getUser(env: Env, req: Request): Promise<UserRow | null> {
|
|
137
|
+
if (!env.SESSION_SECRET) return null;
|
|
138
|
+
const payload = await verifyPayload(env.SESSION_SECRET, getCookie(req, SESSION_COOKIE));
|
|
139
|
+
if (!payload || typeof payload.uid !== "number") return null;
|
|
140
|
+
if (typeof payload.exp === "number" && payload.exp < Date.now()) return null;
|
|
141
|
+
const user = admitted(await env.DB.prepare("SELECT id, github_id, login, status, is_admin FROM users WHERE id = ?").bind(payload.uid).first<UserRow>());
|
|
142
|
+
if (user && user.is_admin === 1) {
|
|
143
|
+
const c = await env.DB.prepare("SELECT COUNT(*) AS n FROM users WHERE status = 'pending'").first<{ n: number }>();
|
|
144
|
+
user.admin = true;
|
|
145
|
+
user.pending = c?.n ?? 0;
|
|
146
|
+
}
|
|
147
|
+
return user;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function getUserByApiKey(env: Env, req: Request, ctx: ExecutionContext): Promise<UserRow | null> {
|
|
151
|
+
const m = (req.headers.get("Authorization") ?? "").match(/^Bearer (dl_[0-9a-f]{32})$/);
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
const hash = await sha256Hex(m[1]);
|
|
154
|
+
const key = await env.DB.prepare("SELECT id, user_id FROM api_keys WHERE key_hash = ?").bind(hash).first<{ id: number; user_id: number }>();
|
|
155
|
+
if (!key) return null;
|
|
156
|
+
ctx.waitUntil(env.DB.prepare("UPDATE api_keys SET last_used_at = ? WHERE id = ?").bind(Date.now(), key.id).run());
|
|
157
|
+
return admitted(await env.DB.prepare("SELECT id, github_id, login, status, is_admin FROM users WHERE id = ?").bind(key.user_id).first<UserRow>());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function ghApi(path: string, opts: RequestInit = {}): Promise<any> {
|
|
161
|
+
const res = await fetch(`https://api.github.com${path}`, {
|
|
162
|
+
...opts,
|
|
163
|
+
headers: { "User-Agent": "draftlink", Accept: "application/vnd.github+json", ...(opts.headers ?? {}) },
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) return null;
|
|
166
|
+
return await res.json();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function resolveHandle(login: string): Promise<{ github_id: number; login: string } | null> {
|
|
170
|
+
const clean = login.trim().replace(/^@/, "");
|
|
171
|
+
if (!/^[A-Za-z0-9-]{1,39}$/.test(clean)) return null;
|
|
172
|
+
const u = await ghApi(`/users/${encodeURIComponent(clean)}`);
|
|
173
|
+
if (!u || typeof u.id !== "number") return null;
|
|
174
|
+
return { github_id: u.id, login: u.login };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseListQuery(url: URL): { q: string; filter: string; project: string } {
|
|
178
|
+
const q = (url.searchParams.get("q") ?? "").trim().slice(0, 200);
|
|
179
|
+
const filterRaw = url.searchParams.get("filter") ?? "all";
|
|
180
|
+
const filter = STATUS_ORDER.includes(filterRaw as never) ? filterRaw : "all";
|
|
181
|
+
const project = (url.searchParams.get("project") ?? "").trim().slice(0, 100);
|
|
182
|
+
return { q, filter, project };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formDraftBody(fd: FormData, fallback: string): Promise<string> {
|
|
186
|
+
const file = fd.get("file");
|
|
187
|
+
if (file && typeof file === "object" && "size" in file && (file as File).size > 0) return (file as File).text();
|
|
188
|
+
const t = String(fd.get("body") ?? "").trim();
|
|
189
|
+
return Promise.resolve(t || fallback);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export default {
|
|
193
|
+
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
194
|
+
const url = new URL(req.url);
|
|
195
|
+
const path = url.pathname;
|
|
196
|
+
const { origin, secure } = baseUrl(req);
|
|
197
|
+
|
|
198
|
+
if (path === "/healthz") return new Response("ok");
|
|
199
|
+
|
|
200
|
+
if (path === "/logo.png" || path === "/favicon.png" || path === "/favicon.ico") {
|
|
201
|
+
return new Response(base64ToBytes(path === "/logo.png" ? LOGO_PNG : FAVICON_PNG), {
|
|
202
|
+
headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=604800" },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (path === "/theme.js" && (req.method === "GET" || req.method === "HEAD")) {
|
|
207
|
+
return new Response(THEME_JS, {
|
|
208
|
+
headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "public, max-age=300" },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (path.startsWith("/d/") && path.length > 3 && (req.method === "GET" || req.method === "HEAD")) {
|
|
213
|
+
const id = path.slice(3);
|
|
214
|
+
const viewer = (await getUser(env, req)) ?? (await getUserByApiKey(env, req, ctx));
|
|
215
|
+
const access = await resolveAccess(env.DB, id, viewer);
|
|
216
|
+
if (!access || !canRead(access)) return html(errorPage(origin, 404, "No such draft."), 404);
|
|
217
|
+
const versions = await listVersions(env.DB, id);
|
|
218
|
+
const params = url.searchParams;
|
|
219
|
+
|
|
220
|
+
if (params.get("meta") === "1") {
|
|
221
|
+
return html(draftShellHeader({ access, versions, versionId: null }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const vParam = params.get("v");
|
|
225
|
+
const versionId = vParam && versions.some((v) => v.id === Number(vParam)) ? Number(vParam) : null;
|
|
226
|
+
|
|
227
|
+
if (params.get("embed") === "1") {
|
|
228
|
+
const body = versionId !== null ? ((await getVersion(env.DB, id, versionId))?.body ?? access.draft.body) : access.draft.body;
|
|
229
|
+
return new Response(injectEmbed(body), {
|
|
230
|
+
headers: {
|
|
231
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
232
|
+
"Cache-Control": "no-store",
|
|
233
|
+
"X-Robots-Tag": "noindex",
|
|
234
|
+
"X-Content-Type-Options": "nosniff",
|
|
235
|
+
"Content-Security-Policy": EMBED_CSP,
|
|
236
|
+
"Referrer-Policy": "no-referrer",
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return html(draftShellPage({ access, versions, versionId }), 200, { "X-Robots-Tag": "noindex" });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (path === "/login" && req.method === "GET") {
|
|
245
|
+
const user = await getUser(env, req);
|
|
246
|
+
if (user) return redirect("/dashboard");
|
|
247
|
+
return html(loginPage(origin, { configured: !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET && env.SESSION_SECRET), error: url.searchParams.get("error") ?? undefined }));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (path === "/auth/github" && req.method === "GET") {
|
|
251
|
+
if (!env.GITHUB_CLIENT_ID || !env.SESSION_SECRET) return redirect("/login?error=Not+configured");
|
|
252
|
+
const state = randomHex(16);
|
|
253
|
+
const signed = await signPayload(env.SESSION_SECRET, { n: state, exp: Date.now() + 600_000 });
|
|
254
|
+
const redirectUri = `${origin}/auth/callback`;
|
|
255
|
+
return redirect(
|
|
256
|
+
`https://github.com/login/oauth/authorize?client_id=${encodeURIComponent(env.GITHUB_CLIENT_ID)}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read:user&state=${state}`,
|
|
257
|
+
[cookieHeader(STATE_COOKIE, signed, 600, secure)]
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (path === "/auth/callback" && req.method === "GET") {
|
|
262
|
+
if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET || !env.SESSION_SECRET) return redirect("/login?error=Not+configured");
|
|
263
|
+
const oauthErr = url.searchParams.get("error");
|
|
264
|
+
if (oauthErr) return redirect(`/login?error=${encodeURIComponent(oauthErr)}`);
|
|
265
|
+
const stateParam = url.searchParams.get("state");
|
|
266
|
+
const stored = await verifyPayload(env.SESSION_SECRET, getCookie(req, STATE_COOKIE));
|
|
267
|
+
if (!stateParam || !stored || stored.n !== stateParam) return redirect("/login?error=Invalid+state,+try+again");
|
|
268
|
+
const code = url.searchParams.get("code");
|
|
269
|
+
if (!code) return redirect("/login?error=Missing+code");
|
|
270
|
+
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "draftlink" },
|
|
273
|
+
body: JSON.stringify({ client_id: env.GITHUB_CLIENT_ID, client_secret: env.GITHUB_CLIENT_SECRET, code, redirect_uri: `${origin}/auth/callback` }),
|
|
274
|
+
});
|
|
275
|
+
const tokenData = (await tokenRes.json()) as { access_token?: string };
|
|
276
|
+
if (!tokenData.access_token) return redirect("/login?error=Token+exchange+failed");
|
|
277
|
+
const ghUser = await ghApi("/user", { headers: { Authorization: `Bearer ${tokenData.access_token}` } });
|
|
278
|
+
if (!ghUser?.id) return redirect("/login?error=Could+not+read+GitHub+profile");
|
|
279
|
+
const inserted = await env.DB.prepare(
|
|
280
|
+
"INSERT INTO users (github_id, login, created_at, status) VALUES (?, ?, ?, 'pending') ON CONFLICT(github_id) DO UPDATE SET login = excluded.login RETURNING id, status"
|
|
281
|
+
)
|
|
282
|
+
.bind(ghUser.id, String(ghUser.login ?? ""), Date.now())
|
|
283
|
+
.first<{ id: number; status: string }>();
|
|
284
|
+
if (!inserted) return redirect("/login?error=Could+not+create+account");
|
|
285
|
+
let status = inserted.status;
|
|
286
|
+
if (status === "pending") {
|
|
287
|
+
// Bootstrap: an instance with no admin yet promotes its first sign-in.
|
|
288
|
+
// Banned users are never promoted.
|
|
289
|
+
const r = await env.DB.prepare(
|
|
290
|
+
"UPDATE users SET status = 'approved', is_admin = 1 WHERE id = ? AND NOT EXISTS (SELECT 1 FROM users WHERE is_admin = 1)"
|
|
291
|
+
)
|
|
292
|
+
.bind(inserted.id)
|
|
293
|
+
.run();
|
|
294
|
+
if ((r.meta.changes ?? 0) > 0) status = "approved";
|
|
295
|
+
}
|
|
296
|
+
if (status !== "approved") {
|
|
297
|
+
const msg =
|
|
298
|
+
status === "banned"
|
|
299
|
+
? "This account has been banned. Contact the admin if you think this is a mistake."
|
|
300
|
+
: "Your account is pending approval. An admin will review it shortly — check back later.";
|
|
301
|
+
return html(errorPage(origin, 403, msg));
|
|
302
|
+
}
|
|
303
|
+
const session = await signPayload(env.SESSION_SECRET, { uid: inserted.id, exp: Date.now() + SESSION_TTL_S * 1000 });
|
|
304
|
+
return redirect("/dashboard", [cookieHeader(STATE_COOKIE, "", 0, secure), cookieHeader(SESSION_COOKIE, session, SESSION_TTL_S, secure)]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (
|
|
308
|
+
!["GET", "HEAD", "OPTIONS"].includes(req.method) &&
|
|
309
|
+
!path.startsWith("/api/") &&
|
|
310
|
+
!path.startsWith("/auth/") &&
|
|
311
|
+
crossSiteBlocked(req, origin)
|
|
312
|
+
) {
|
|
313
|
+
return html(errorPage(origin, 403, "Cross-site request blocked."), 403);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (path === "/logout" && req.method === "POST") {
|
|
317
|
+
return redirect("/login", [cookieHeader(SESSION_COOKIE, "", 0, secure)]);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (env.DEV_MODE === "1" && path === "/dev/login" && req.method === "GET" && env.SESSION_SECRET) {
|
|
321
|
+
const keyOk = timingSafeEqual(await sha256Hex(url.searchParams.get("key") ?? ""), await sha256Hex(env.DEV_LOGIN_SECRET ?? ""));
|
|
322
|
+
if (!env.DEV_LOGIN_SECRET || !keyOk) return json({ error: "dev key required" }, 403);
|
|
323
|
+
const loginName = url.searchParams.get("as") ?? "dev";
|
|
324
|
+
const hash = await sha256Hex(loginName);
|
|
325
|
+
const syntheticId = 900_000_000 + (Number.parseInt(hash.slice(0, 8), 16) % 100_000_000);
|
|
326
|
+
const u = await env.DB.prepare(
|
|
327
|
+
"INSERT INTO users (github_id, login, created_at, status) VALUES (?, ?, ?, 'approved') ON CONFLICT(github_id) DO UPDATE SET login = excluded.login RETURNING id"
|
|
328
|
+
)
|
|
329
|
+
.bind(syntheticId, loginName, Date.now())
|
|
330
|
+
.first<{ id: number }>();
|
|
331
|
+
const session = await signPayload(env.SESSION_SECRET, { uid: u!.id, exp: Date.now() + SESSION_TTL_S * 1000 });
|
|
332
|
+
return redirect("/dashboard", [cookieHeader(SESSION_COOKIE, session, SESSION_TTL_S, secure)]);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (path === "/" && req.method === "GET") {
|
|
336
|
+
const user = await getUser(env, req);
|
|
337
|
+
return redirect(user ? "/dashboard" : "/login");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (path === "/api/drafts" || path.startsWith("/api/drafts/")) {
|
|
341
|
+
const apiUser = await getUserByApiKey(env, req, ctx);
|
|
342
|
+
if (!apiUser) return json({ error: "unauthorized: set Authorization: Bearer dl_..." }, 401);
|
|
343
|
+
|
|
344
|
+
if (req.method === "POST" && path === "/api/drafts") {
|
|
345
|
+
const len = Number(req.headers.get("Content-Length") ?? 0);
|
|
346
|
+
if (len > MAX_BODY) return json({ error: "body too large (4MB max)" }, 413);
|
|
347
|
+
const bodyText = await req.text();
|
|
348
|
+
if (!bodyText) return json({ error: "empty body: send raw HTML as request body" }, 400);
|
|
349
|
+
if (bodyText.length > MAX_BODY) return json({ error: "body too large (4MB max)" }, 413);
|
|
350
|
+
const id = await create(env.DB, apiUser.id, {
|
|
351
|
+
title: url.searchParams.get("title")?.trim() || undefined,
|
|
352
|
+
project: url.searchParams.get("project")?.trim() || undefined,
|
|
353
|
+
status: validStatus(url.searchParams.get("status")),
|
|
354
|
+
body: bodyText,
|
|
355
|
+
});
|
|
356
|
+
return json({ id, url: `${origin}/d/${id}` }, 201);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (req.method === "GET" && path === "/api/drafts") {
|
|
360
|
+
const { q, filter, project } = parseListQuery(url);
|
|
361
|
+
const [mine, shared, projects] = await Promise.all([
|
|
362
|
+
listMine(env.DB, apiUser.id, q, filter, project),
|
|
363
|
+
listShared(env.DB, apiUser.github_id, q, filter, project),
|
|
364
|
+
listProjects(env.DB, apiUser.id, apiUser.github_id),
|
|
365
|
+
]);
|
|
366
|
+
return json({ mine, shared, projects });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const apiId = path.slice("/api/drafts/".length);
|
|
370
|
+
|
|
371
|
+
if (req.method === "GET" && apiId.endsWith("/raw")) {
|
|
372
|
+
const access = await resolveAccess(env.DB, apiId.slice(0, -4), apiUser);
|
|
373
|
+
if (!access || !canRead(access)) return json({ error: "not found" }, 404);
|
|
374
|
+
return new Response(access.draft.body, {
|
|
375
|
+
headers: {
|
|
376
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
377
|
+
"Content-Disposition": `attachment; filename="${access.draft.id}.html"`,
|
|
378
|
+
"Cache-Control": "private, no-store",
|
|
379
|
+
"X-Content-Type-Options": "nosniff",
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (req.method === "PUT" || req.method === "PATCH") {
|
|
385
|
+
const access = await resolveAccess(env.DB, apiId, apiUser);
|
|
386
|
+
if (!access || !access.canEdit) return json({ error: "not found" }, 404);
|
|
387
|
+
const wantsVisibilityChange = url.searchParams.has("public");
|
|
388
|
+
if (wantsVisibilityChange && !access.isOwner) return json({ error: "only the owner can change visibility" }, 403);
|
|
389
|
+
if (Number(req.headers.get("Content-Length") ?? 0) > MAX_BODY) return json({ error: "body too large (4MB max)" }, 413);
|
|
390
|
+
const newText = await req.text();
|
|
391
|
+
if (newText.length > MAX_BODY) return json({ error: "body too large (4MB max)" }, 413);
|
|
392
|
+
const makePublic = ["true", "1", "yes"].includes((url.searchParams.get("public") ?? "").toLowerCase());
|
|
393
|
+
await update(env.DB, apiId, {
|
|
394
|
+
title: url.searchParams.get("title")?.trim() || undefined,
|
|
395
|
+
project: url.searchParams.get("project")?.trim() || undefined,
|
|
396
|
+
status: url.searchParams.has("status") ? validStatus(url.searchParams.get("status")) : undefined,
|
|
397
|
+
isPublic: wantsVisibilityChange ? (makePublic ? 1 : 0) : undefined,
|
|
398
|
+
body: newText || undefined,
|
|
399
|
+
});
|
|
400
|
+
return json({ ok: true, url: `${origin}/d/${apiId}`, is_public: wantsVisibilityChange ? makePublic : !!access.draft.is_public });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (req.method === "DELETE") {
|
|
404
|
+
const access = await resolveAccess(env.DB, apiId, apiUser);
|
|
405
|
+
if (!access || !access.isOwner) return json({ error: "not found" }, 404);
|
|
406
|
+
await remove(env.DB, apiId);
|
|
407
|
+
return json({ ok: true });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return json({ error: "method not allowed" }, 405);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const user = await getUser(env, req);
|
|
414
|
+
if (!user) return redirect("/login");
|
|
415
|
+
|
|
416
|
+
if (path === "/dashboard" && req.method === "GET") {
|
|
417
|
+
const { q, filter, project } = parseListQuery(url);
|
|
418
|
+
const [mine, shared, projects] = await Promise.all([
|
|
419
|
+
listMine(env.DB, user.id, q, filter, project),
|
|
420
|
+
listShared(env.DB, user.github_id, q, filter, project),
|
|
421
|
+
listProjects(env.DB, user.id, user.github_id),
|
|
422
|
+
]);
|
|
423
|
+
const options = { q, filter, project, projects, mine, shared };
|
|
424
|
+
return url.searchParams.get("fragment") === "1" ? html(dashboardFragment(options)) : html(dashboardPage(origin, user, options));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (path === "/drafts" && req.method === "POST") {
|
|
428
|
+
const fd = await req.formData();
|
|
429
|
+
const bodyText = await formDraftBody(fd, "");
|
|
430
|
+
if (!bodyText) return html(errorPage(origin, 400, "Draft body was empty.", user), 400);
|
|
431
|
+
if (bodyText.length > MAX_BODY) return html(errorPage(origin, 413, "Body too large (4MB max).", user), 413);
|
|
432
|
+
await create(env.DB, user.id, {
|
|
433
|
+
title: String(fd.get("title") ?? "").trim() || undefined,
|
|
434
|
+
project: String(fd.get("project") ?? "").trim() || undefined,
|
|
435
|
+
body: bodyText,
|
|
436
|
+
});
|
|
437
|
+
return redirect("/dashboard");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const draftId = path.match(/^\/drafts\/([A-Za-z0-9]+)(?:\/([a-z]+))?$/)?.[1];
|
|
441
|
+
const action = path.match(/^\/drafts\/[A-Za-z0-9]+(?:\/([a-z]+))?$/)?.[1];
|
|
442
|
+
if (draftId) {
|
|
443
|
+
const access = await resolveAccess(env.DB, draftId, user);
|
|
444
|
+
if (!access || (!access.isOwner && !access.canEdit)) return html(errorPage(origin, 404, "No such draft.", user), 404);
|
|
445
|
+
|
|
446
|
+
if (req.method === "GET" && action === "edit") {
|
|
447
|
+
if (!access.canEdit) return html(errorPage(origin, 403, "You don't have write access.", user), 403);
|
|
448
|
+
return html(editPage(origin, user, access.draft));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (req.method === "POST" && action === "edit") {
|
|
452
|
+
if (!access.canEdit) return html(errorPage(origin, 403, "You don't have write access.", user), 403);
|
|
453
|
+
const fd = await req.formData();
|
|
454
|
+
const bodyText = await formDraftBody(fd, access.draft.body);
|
|
455
|
+
if (bodyText.length > MAX_BODY) return html(errorPage(origin, 413, "Body too large (4MB max).", user), 413);
|
|
456
|
+
await update(env.DB, draftId, {
|
|
457
|
+
title: String(fd.get("title") ?? "").trim() || undefined,
|
|
458
|
+
project: String(fd.get("project") ?? "").trim() || undefined,
|
|
459
|
+
body: bodyText,
|
|
460
|
+
});
|
|
461
|
+
return redirect("/dashboard");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (req.method === "POST" && action === "status") {
|
|
465
|
+
if (!access.canEdit) return html(errorPage(origin, 403, "No write access.", user), 403);
|
|
466
|
+
const fd = await req.formData();
|
|
467
|
+
await update(env.DB, draftId, { status: validStatus(String(fd.get("status") ?? "")) });
|
|
468
|
+
return redirect("/dashboard");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (req.method === "GET" && action === "share") {
|
|
472
|
+
if (!access.isOwner) return html(errorPage(origin, 403, "Only the owner can manage sharing.", user), 403);
|
|
473
|
+
return html(sharePage(origin, user, access.draft, await listGrants(env.DB, draftId)));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (req.method === "POST" && action === "share") {
|
|
477
|
+
if (!access.isOwner) return html(errorPage(origin, 403, "Only the owner can manage sharing.", user), 403);
|
|
478
|
+
const fd = await req.formData();
|
|
479
|
+
const resolved = await resolveHandle(String(fd.get("login") ?? ""));
|
|
480
|
+
if (!resolved) {
|
|
481
|
+
return html(sharePage(origin, user, access.draft, await listGrants(env.DB, draftId), `Couldn't find GitHub user "${String(fd.get("login") ?? "")}".`), 400);
|
|
482
|
+
}
|
|
483
|
+
await grant(env.DB, draftId, resolved);
|
|
484
|
+
return redirect(`/drafts/${draftId}/share`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (req.method === "POST" && action === "revoke") {
|
|
488
|
+
if (!access.isOwner) return html(errorPage(origin, 403, "Only the owner can manage sharing.", user), 403);
|
|
489
|
+
const fd = await req.formData();
|
|
490
|
+
const gid = Number(fd.get("gid"));
|
|
491
|
+
if (!Number.isInteger(gid)) return html(errorPage(origin, 400, "Missing grant id.", user), 400);
|
|
492
|
+
await revoke(env.DB, draftId, gid);
|
|
493
|
+
return redirect(`/drafts/${draftId}/share`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (req.method === "POST" && action === "visibility") {
|
|
497
|
+
if (!access.isOwner) return html(errorPage(origin, 403, "Only the owner can change visibility.", user), 403);
|
|
498
|
+
const fd = await req.formData();
|
|
499
|
+
await update(env.DB, draftId, { isPublic: String(fd.get("value") ?? "") === "public" ? 1 : 0 });
|
|
500
|
+
return redirect(`/drafts/${draftId}/share`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (req.method === "POST" && action === "restore") {
|
|
504
|
+
if (!access.isOwner) return html(errorPage(origin, 403, "Only the owner can restore versions.", user), 403);
|
|
505
|
+
const fd = await req.formData();
|
|
506
|
+
const vid = Number(fd.get("v"));
|
|
507
|
+
const version = Number.isInteger(vid) ? await getVersion(env.DB, draftId, vid) : null;
|
|
508
|
+
if (!version) return html(errorPage(origin, 404, "That version no longer exists.", user), 404);
|
|
509
|
+
await update(env.DB, draftId, { body: version.body });
|
|
510
|
+
return redirect(`/d/${draftId}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (req.method === "POST" && action === "delete") {
|
|
514
|
+
if (!access.isOwner) return html(errorPage(origin, 403, "Only the owner can delete.", user), 403);
|
|
515
|
+
await remove(env.DB, draftId);
|
|
516
|
+
return redirect("/dashboard");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return html(errorPage(origin, 404, "Unknown action.", user), 404);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (path.startsWith("/admin") && (path === "/admin" || path === "/admin/approve" || path === "/admin/ban" || path === "/admin/make-admin" || path === "/admin/remove-admin")) {
|
|
523
|
+
if (!user || user.is_admin !== 1) return html(errorPage(origin, 404, "Nothing here.", user), 404);
|
|
524
|
+
if (req.method === "POST") {
|
|
525
|
+
const fd = await req.formData();
|
|
526
|
+
const id = Number(fd.get("id"));
|
|
527
|
+
if (Number.isInteger(id)) {
|
|
528
|
+
if (path === "/admin/approve" || path === "/admin/ban") {
|
|
529
|
+
const status = path === "/admin/approve" ? "approved" : "banned";
|
|
530
|
+
await env.DB.prepare("UPDATE users SET status = ? WHERE id = ? AND id != ?").bind(status, id, user.id).run();
|
|
531
|
+
} else if (path === "/admin/make-admin") {
|
|
532
|
+
await env.DB.prepare("UPDATE users SET is_admin = 1, status = 'approved' WHERE id = ?").bind(id).run();
|
|
533
|
+
} else {
|
|
534
|
+
// Never remove the last remaining admin — lockout prevention.
|
|
535
|
+
await env.DB.prepare("UPDATE users SET is_admin = 0 WHERE id = ? AND (SELECT COUNT(*) FROM users WHERE is_admin = 1) > 1").bind(id).run();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return redirect("/admin");
|
|
539
|
+
}
|
|
540
|
+
const { results } = await env.DB.prepare("SELECT id, login, status, is_admin, created_at FROM users ORDER BY created_at DESC").all<{ id: number; login: string; status: string; is_admin: number; created_at: number }>();
|
|
541
|
+
return html(adminPage(origin, user, results ?? []));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (path === "/install" && req.method === "GET") return html(installPage(origin, user));
|
|
545
|
+
|
|
546
|
+
if (path === "/keys" && req.method === "GET") {
|
|
547
|
+
const { results } = await env.DB.prepare("SELECT id, prefix, label, created_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC").bind(user.id).all<{ id: number; prefix: string; label: string; created_at: number; last_used_at: number | null }>();
|
|
548
|
+
return html(keysPage(origin, user, results ?? []));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (path === "/keys" && req.method === "POST") {
|
|
552
|
+
const fd = await req.formData();
|
|
553
|
+
const label = String(fd.get("label") ?? "").trim().slice(0, 60) || "cli";
|
|
554
|
+
const token = `dl_${randomHex(16)}`;
|
|
555
|
+
const prefix = token.slice(0, 9);
|
|
556
|
+
await env.DB.prepare("INSERT INTO api_keys (user_id, key_hash, prefix, label, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
557
|
+
.bind(user.id, await sha256Hex(token), prefix, label, Date.now())
|
|
558
|
+
.run();
|
|
559
|
+
const { results } = await env.DB.prepare("SELECT id, prefix, label, created_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC").bind(user.id).all<{ id: number; prefix: string; label: string; created_at: number; last_used_at: number | null }>();
|
|
560
|
+
return html(keysPage(origin, user, results ?? [], token));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (path === "/keys/revoke" && req.method === "POST") {
|
|
564
|
+
const fd = await req.formData();
|
|
565
|
+
const id = Number(fd.get("id"));
|
|
566
|
+
if (Number.isInteger(id)) await env.DB.prepare("DELETE FROM api_keys WHERE id = ? AND user_id = ?").bind(id, user.id).run();
|
|
567
|
+
return redirect("/keys");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return html(layout({ base: origin, user, title: "404", body: `<div class="mt-24 text-center text-zinc-500">Nothing here.</div>` }), 404);
|
|
571
|
+
},
|
|
572
|
+
};
|