ework-web 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/src/store.ts ADDED
@@ -0,0 +1,1020 @@
1
+ // SQLite-backed data layer for ework. Single DB (see db.ts) holds projects +
2
+ // issues + comments + labels + reactions + attachments + users. All write ops
3
+ // run in transactions; per-project issue numbers are allocated atomically.
4
+
5
+ import { rawDB } from "./db";
6
+ import { createHash } from "node:crypto";
7
+
8
+ export type UserKind = "human" | "bot" | "system";
9
+
10
+ export interface UserRow {
11
+ login: string;
12
+ kind: UserKind;
13
+ display_name: string | null;
14
+ password_hash: string | null;
15
+ email: string | null;
16
+ is_admin: number;
17
+ is_active: number;
18
+ created_at: string;
19
+ updated_at: string;
20
+ }
21
+
22
+ export interface CreateUserInput {
23
+ login: string;
24
+ password: string;
25
+ kind?: UserKind;
26
+ email?: string;
27
+ display_name?: string;
28
+ is_admin?: boolean;
29
+ is_active?: boolean;
30
+ }
31
+
32
+ export interface ProjectRow {
33
+ id: number;
34
+ owner: string;
35
+ name: string;
36
+ description: string;
37
+ upstream_urls: string;
38
+ created_at: string;
39
+ updated_at: string;
40
+ }
41
+
42
+ export interface IssueRow {
43
+ id: number;
44
+ project_id: number;
45
+ number: number;
46
+ title: string;
47
+ body: string;
48
+ state: "open" | "closed";
49
+ author: string;
50
+ created_at: string;
51
+ updated_at: string;
52
+ }
53
+
54
+ export interface IssueWithMeta extends IssueRow {
55
+ project_owner: string;
56
+ project_name: string;
57
+ comment_count: number;
58
+ }
59
+
60
+ export interface CommentRow {
61
+ id: number;
62
+ issue_id: number;
63
+ author: string;
64
+ body: string;
65
+ created_at: string;
66
+ updated_at: string;
67
+ author_kind?: UserKind;
68
+ }
69
+
70
+ export interface LabelRow {
71
+ id: number;
72
+ project_id: number;
73
+ name: string;
74
+ color: string;
75
+ }
76
+
77
+ export interface AttachmentRow {
78
+ uuid: string;
79
+ issue_id: number;
80
+ filename: string;
81
+ content_type: string;
82
+ size: number;
83
+ blob_path: string;
84
+ uploaded_by: string;
85
+ created_at: string;
86
+ }
87
+
88
+ export class StoreError extends Error {
89
+ status: number;
90
+ constructor(status: number, message: string) {
91
+ super(message);
92
+ this.status = status;
93
+ this.name = "StoreError";
94
+ }
95
+ }
96
+
97
+ type DB = ReturnType<typeof rawDB>;
98
+
99
+ function now(): string {
100
+ return new Date().toISOString();
101
+ }
102
+
103
+ function tx<T>(db: DB, fn: () => T): T {
104
+ db.exec("BEGIN");
105
+ try {
106
+ const r = fn();
107
+ db.exec("COMMIT");
108
+ return r;
109
+ } catch (e) {
110
+ try {
111
+ db.exec("ROLLBACK");
112
+ } catch {
113
+ // already rolled back
114
+ }
115
+ throw e;
116
+ }
117
+ }
118
+
119
+ export function ensureUser(login: string, kind: UserKind = "human"): UserRow {
120
+ const db = rawDB();
121
+ const existing = db.query("SELECT * FROM users WHERE login = ?").get(login) as UserRow | null;
122
+ if (existing) return existing;
123
+ const ts = now();
124
+ db.query(
125
+ "INSERT INTO users (login, kind, created_at, updated_at) VALUES (?, ?, ?, ?)"
126
+ ).run(login, kind, ts, ts);
127
+ return {
128
+ login,
129
+ kind,
130
+ display_name: null,
131
+ password_hash: null,
132
+ email: null,
133
+ is_admin: 0,
134
+ is_active: 1,
135
+ created_at: ts,
136
+ updated_at: ts,
137
+ };
138
+ }
139
+
140
+ export function getProject(owner: string, name: string): ProjectRow | null {
141
+ return (rawDB().query("SELECT * FROM projects WHERE owner = ? AND name = ?").get(owner, name) as ProjectRow | null) ?? null;
142
+ }
143
+
144
+ export function getProjectById(id: number): ProjectRow | null {
145
+ return (rawDB().query("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | null) ?? null;
146
+ }
147
+
148
+ export function getProjectByIssueId(issueId: number): ProjectRow | null {
149
+ const row = rawDB()
150
+ .query("SELECT p.* FROM projects p JOIN issues i ON i.project_id = p.id WHERE i.id = ?")
151
+ .get(issueId) as ProjectRow | null;
152
+ return row ?? null;
153
+ }
154
+
155
+ export interface ProjectWithCounts extends ProjectRow {
156
+ open_count: number;
157
+ total_count: number;
158
+ }
159
+
160
+ export function listProjectsWithCounts(): ProjectWithCounts[] {
161
+ return rawDB()
162
+ .query(
163
+ `SELECT p.*,
164
+ COALESCE(SUM(CASE WHEN i.state = 'open' THEN 1 ELSE 0 END), 0) AS open_count,
165
+ COUNT(i.id) AS total_count
166
+ FROM projects p
167
+ LEFT JOIN issues i ON i.project_id = p.id
168
+ GROUP BY p.id
169
+ ORDER BY p.updated_at DESC`
170
+ )
171
+ .all() as ProjectWithCounts[];
172
+ }
173
+
174
+ export function createProject(owner: string, name: string, description: string): ProjectRow {
175
+ owner = owner.trim();
176
+ name = name.trim();
177
+ if (!/^[A-Za-z0-9_.-]+$/.test(owner)) throw new StoreError(400, "owner 含非法字符");
178
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) throw new StoreError(400, "name 含非法字符");
179
+ if (getProject(owner, name)) throw new StoreError(409, `项目 ${owner}/${name} 已存在`);
180
+ const ts = now();
181
+ const db = rawDB();
182
+ const info = db
183
+ .query("INSERT INTO projects (owner, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
184
+ .run(owner, name, description ?? "", ts, ts);
185
+ return getProjectById(Number(info.lastInsertRowid))!;
186
+ }
187
+
188
+ export function touchProject(projectId: number): void {
189
+ rawDB().query("UPDATE projects SET updated_at = ? WHERE id = ?").run(now(), projectId);
190
+ }
191
+
192
+ const MAX_UPSTREAM_URLS = 10;
193
+ const UPSTREAM_URL_RE = /^(https?|ssh|git):\/\/[^\s]+$/i;
194
+ const GIT_SCP_RE = /^[A-Za-z0-9_./-]+@[A-Za-z0-9._-]+:.+$/;
195
+
196
+ export function getProjectUpstreamUrls(project: Pick<ProjectRow, "upstream_urls">): string[] {
197
+ try {
198
+ const arr = JSON.parse(project.upstream_urls) as unknown[];
199
+ return arr.filter((s): s is string => typeof s === "string" && s.length > 0);
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ export function getDefaultUpstreamUrl(project: Pick<ProjectRow, "upstream_urls">): string | null {
206
+ const urls = getProjectUpstreamUrls(project);
207
+ return urls.length > 0 ? urls[0]! : null;
208
+ }
209
+
210
+ export function setProjectUpstreamUrls(projectId: number, urls: string[]): string[] {
211
+ const seen = new Set<string>();
212
+ const cleaned: string[] = [];
213
+ for (const raw of urls) {
214
+ const u = String(raw ?? "").trim();
215
+ if (!u) continue;
216
+ if (seen.has(u)) continue;
217
+ seen.add(u);
218
+ if (u.length > 2048) throw new StoreError(400, "上游 URL 过长(≤2048)");
219
+ if (!UPSTREAM_URL_RE.test(u) && !GIT_SCP_RE.test(u)) {
220
+ throw new StoreError(400, `上游 URL 协议非法(须 http(s)/ssh/git 或 git@host:path): ${u}`);
221
+ }
222
+ cleaned.push(u);
223
+ }
224
+ if (cleaned.length > MAX_UPSTREAM_URLS) {
225
+ throw new StoreError(400, `上游 URL 数量超过上限(${MAX_UPSTREAM_URLS})`);
226
+ }
227
+ const ts = now();
228
+ rawDB()
229
+ .query("UPDATE projects SET upstream_urls = ?, updated_at = ? WHERE id = ?")
230
+ .run(JSON.stringify(cleaned), ts, projectId);
231
+ return cleaned;
232
+ }
233
+
234
+ export function getIssue(projectId: number, number: number): IssueRow | null {
235
+ return (
236
+ (rawDB()
237
+ .query("SELECT * FROM issues WHERE project_id = ? AND number = ?")
238
+ .get(projectId, number) as IssueRow | null) ?? null
239
+ );
240
+ }
241
+
242
+ export function getIssueById(id: number): IssueRow | null {
243
+ return (rawDB().query("SELECT * FROM issues WHERE id = ?").get(id) as IssueRow | null) ?? null;
244
+ }
245
+
246
+ export function getIssueWithMeta(projectId: number, number: number): IssueWithMeta | null {
247
+ return (
248
+ (rawDB()
249
+ .query(
250
+ `SELECT i.*, p.owner AS project_owner, p.name AS project_name,
251
+ (SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comment_count
252
+ FROM issues i JOIN projects p ON p.id = i.project_id
253
+ WHERE i.project_id = ? AND i.number = ?`
254
+ )
255
+ .get(projectId, number) as IssueWithMeta | null) ?? null
256
+ );
257
+ }
258
+
259
+ export interface ListIssuesOpts {
260
+ state?: "open" | "closed" | "all";
261
+ q?: string;
262
+ limit?: number;
263
+ }
264
+
265
+ export function listIssues(projectId: number, opts: ListIssuesOpts = {}): IssueWithMeta[] {
266
+ const state = opts.state ?? "open";
267
+ const q = (opts.q ?? "").trim();
268
+ const limit = Math.min(opts.limit ?? 50, 200);
269
+ let sql = `SELECT i.*, p.owner AS project_owner, p.name AS project_name,
270
+ (SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comment_count
271
+ FROM issues i JOIN projects p ON p.id = i.project_id
272
+ WHERE i.project_id = ?`;
273
+ const args: (string | number)[] = [projectId];
274
+ if (state !== "all") {
275
+ sql += " AND i.state = ?";
276
+ args.push(state);
277
+ }
278
+ if (q) {
279
+ sql += " AND (i.title LIKE ? ESCAPE '\\' OR i.body LIKE ? ESCAPE '\\')";
280
+ const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
281
+ args.push(like, like);
282
+ }
283
+ sql += " ORDER BY i.updated_at DESC LIMIT ?";
284
+ args.push(limit);
285
+ return rawDB().query(sql).all(...args) as IssueWithMeta[];
286
+ }
287
+
288
+ export function listAllIssues(opts: ListIssuesOpts = {}): IssueWithMeta[] {
289
+ const state = opts.state ?? "open";
290
+ const q = (opts.q ?? "").trim();
291
+ const limit = Math.min(opts.limit ?? 50, 200);
292
+ let sql = `SELECT i.*, p.owner AS project_owner, p.name AS project_name,
293
+ (SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comment_count
294
+ FROM issues i JOIN projects p ON p.id = i.project_id
295
+ WHERE 1=1`;
296
+ const args: (string | number)[] = [];
297
+ if (state !== "all") {
298
+ sql += " AND i.state = ?";
299
+ args.push(state);
300
+ }
301
+ if (q) {
302
+ sql += " AND (i.title LIKE ? ESCAPE '\\' OR i.body LIKE ? ESCAPE '\\' OR p.owner LIKE ? ESCAPE '\\' OR p.name LIKE ? ESCAPE '\\')";
303
+ const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
304
+ args.push(like, like, like, like);
305
+ }
306
+ sql += " ORDER BY i.updated_at DESC LIMIT ?";
307
+ args.push(limit);
308
+ return rawDB().query(sql).all(...args) as IssueWithMeta[];
309
+ }
310
+
311
+ export function createIssue(
312
+ projectId: number,
313
+ title: string,
314
+ body: string,
315
+ author: string
316
+ ): IssueRow {
317
+ title = title.trim();
318
+ if (!title) throw new StoreError(400, "标题不能为空");
319
+ if (title.length > 255) throw new StoreError(400, "标题过长(≤255)");
320
+ if (body.length > 65536) throw new StoreError(413, "正文过长(≤65536)");
321
+ ensureUser(author);
322
+ const ts = now();
323
+ const db = rawDB();
324
+ return tx(db, () => {
325
+ const next = (
326
+ db.query("SELECT COALESCE(MAX(number), 0) + 1 AS n FROM issues WHERE project_id = ?").get(projectId) as {
327
+ n: number;
328
+ }
329
+ ).n;
330
+ const info = db
331
+ .query(
332
+ "INSERT INTO issues (project_id, number, title, body, state, author, created_at, updated_at) VALUES (?, ?, ?, ?, 'open', ?, ?, ?)"
333
+ )
334
+ .run(projectId, next, title, body, author, ts, ts);
335
+ db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(ts, projectId);
336
+ return getIssueById(Number(info.lastInsertRowid))!;
337
+ });
338
+ }
339
+
340
+ export function setIssueState(issueId: number, state: "open" | "closed"): void {
341
+ const ts = now();
342
+ const db = rawDB();
343
+ tx(db, () => {
344
+ db.query("UPDATE issues SET state = ?, updated_at = ? WHERE id = ?").run(state, ts, issueId);
345
+ const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number };
346
+ db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(ts, row.project_id);
347
+ });
348
+ }
349
+
350
+ export interface IssuePatch {
351
+ title?: string;
352
+ body?: string;
353
+ state?: "open" | "closed";
354
+ }
355
+
356
+ export function editIssue(issueId: number, patch: IssuePatch): void {
357
+ const sets: string[] = [];
358
+ const args: (string | number)[] = [];
359
+ if (patch.title !== undefined) {
360
+ const t = patch.title.trim();
361
+ if (!t) throw new StoreError(400, "标题不能为空");
362
+ if (t.length > 255) throw new StoreError(400, "标题过长(≤255)");
363
+ sets.push("title = ?");
364
+ args.push(t);
365
+ }
366
+ if (patch.body !== undefined) {
367
+ if (patch.body.length > 65536) throw new StoreError(413, "正文过长(≤65536)");
368
+ sets.push("body = ?");
369
+ args.push(patch.body);
370
+ }
371
+ if (patch.state !== undefined) {
372
+ if (patch.state !== "open" && patch.state !== "closed") {
373
+ throw new StoreError(400, "非法 state");
374
+ }
375
+ sets.push("state = ?");
376
+ args.push(patch.state);
377
+ }
378
+ if (sets.length === 0) return;
379
+ sets.push("updated_at = ?");
380
+ args.push(now());
381
+ args.push(issueId);
382
+ const db = rawDB();
383
+ tx(db, () => {
384
+ db.query(`UPDATE issues SET ${sets.join(", ")} WHERE id = ?`).run(...args);
385
+ const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number } | null;
386
+ if (row) db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(now(), row.project_id);
387
+ });
388
+ }
389
+
390
+ export function countComments(issueId: number): number {
391
+ const row = rawDB().query("SELECT COUNT(*) AS n FROM comments WHERE issue_id = ?").get(issueId) as { n: number };
392
+ return row.n;
393
+ }
394
+
395
+ export function listCommentsPage(issueId: number, page: number, pageSize: number): { rows: CommentRow[]; page: number } {
396
+ const total = countComments(issueId);
397
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
398
+ const clamped = Math.min(Math.max(1, page), totalPages);
399
+ const offset = (clamped - 1) * pageSize;
400
+ const rows = rawDB()
401
+ .query(
402
+ `SELECT c.*, u.kind AS author_kind
403
+ FROM comments c
404
+ LEFT JOIN users u ON u.login = c.author
405
+ WHERE c.issue_id = ?
406
+ ORDER BY c.created_at ASC, c.id ASC
407
+ LIMIT ? OFFSET ?`
408
+ )
409
+ .all(issueId, pageSize, offset) as CommentRow[];
410
+ return { rows, page: clamped };
411
+ }
412
+
413
+ export function listCommentsSince(issueId: number, sinceISO: string): CommentRow[] {
414
+ return rawDB()
415
+ .query(
416
+ `SELECT c.*, u.kind AS author_kind
417
+ FROM comments c
418
+ LEFT JOIN users u ON u.login = c.author
419
+ WHERE c.issue_id = ? AND c.created_at > ?
420
+ ORDER BY c.created_at ASC, c.id ASC`
421
+ )
422
+ .all(issueId, sinceISO) as CommentRow[];
423
+ }
424
+
425
+ export function listCommentsForIssue(issueId: number): CommentRow[] {
426
+ return rawDB()
427
+ .query(
428
+ `SELECT c.*, u.kind AS author_kind
429
+ FROM comments c
430
+ LEFT JOIN users u ON u.login = c.author
431
+ WHERE c.issue_id = ?
432
+ ORDER BY c.created_at ASC, c.id ASC`
433
+ )
434
+ .all(issueId) as CommentRow[];
435
+ }
436
+
437
+ export function postComment(issueId: number, body: string, author: string): CommentRow {
438
+ if (!body || !body.trim()) throw new StoreError(400, "评论不能为空");
439
+ if (body.length > 65536) throw new StoreError(413, "评论过长(≤65536)");
440
+ ensureUser(author);
441
+ const ts = now();
442
+ const db = rawDB();
443
+ return tx(db, () => {
444
+ const info = db
445
+ .query("INSERT INTO comments (issue_id, author, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
446
+ .run(issueId, author, body, ts, ts);
447
+ db.query("UPDATE issues SET updated_at = ? WHERE id = ?").run(ts, issueId);
448
+ const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number };
449
+ db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(ts, row.project_id);
450
+ return db.query(
451
+ `SELECT c.*, u.kind AS author_kind
452
+ FROM comments c LEFT JOIN users u ON u.login = c.author
453
+ WHERE c.id = ?`
454
+ ).get(Number(info.lastInsertRowid)) as CommentRow;
455
+ });
456
+ }
457
+
458
+ export function getComment(commentId: number): CommentRow | null {
459
+ const row = rawDB().query(
460
+ `SELECT c.*, u.kind AS author_kind
461
+ FROM comments c LEFT JOIN users u ON u.login = c.author
462
+ WHERE c.id = ?`
463
+ ).get(commentId) as CommentRow | undefined;
464
+ return row ?? null;
465
+ }
466
+
467
+ export function editComment(commentId: number, body: string): CommentRow {
468
+ const trimmed = String(body ?? "");
469
+ if (!trimmed.trim()) throw new StoreError(400, "评论不能为空");
470
+ if (trimmed.length > 65536) throw new StoreError(413, "评论过长(≤65536)");
471
+ const ts = now();
472
+ const db = rawDB();
473
+ const info = db
474
+ .query("UPDATE comments SET body = ?, updated_at = ? WHERE id = ?")
475
+ .run(trimmed, ts, commentId);
476
+ if (info.changes === 0) throw new StoreError(404, `评论 ${commentId} 不存在`);
477
+ return getComment(commentId)!;
478
+ }
479
+
480
+ export function deleteComment(commentId: number): void {
481
+ const info = rawDB().query("DELETE FROM comments WHERE id = ?").run(commentId);
482
+ if (info.changes === 0) throw new StoreError(404, `评论 ${commentId} 不存在`);
483
+ }
484
+
485
+ export interface ReactionAgg {
486
+ comment_id: number;
487
+ content: string;
488
+ n: number;
489
+ }
490
+
491
+ export function listReactionsFor(commentIds: number[]): ReactionAgg[] {
492
+ if (commentIds.length === 0) return [];
493
+ const placeholders = commentIds.map(() => "?").join(",");
494
+ return rawDB()
495
+ .query(
496
+ `SELECT comment_id, content, COUNT(*) AS n
497
+ FROM reactions WHERE comment_id IN (${placeholders})
498
+ GROUP BY comment_id, content`
499
+ )
500
+ .all(...commentIds) as ReactionAgg[];
501
+ }
502
+
503
+ export function addReaction(commentId: number, userLogin: string, content: string): void {
504
+ if (!content || content.length > 32) throw new StoreError(400, "非法的 reaction content");
505
+ ensureUser(userLogin);
506
+ rawDB()
507
+ .query("INSERT OR IGNORE INTO reactions (comment_id, user_login, content) VALUES (?, ?, ?)")
508
+ .run(commentId, userLogin, content);
509
+ }
510
+
511
+ export function removeReaction(commentId: number, userLogin: string, content: string): void {
512
+ rawDB()
513
+ .query("DELETE FROM reactions WHERE comment_id = ? AND user_login = ? AND content = ?")
514
+ .run(commentId, userLogin, content);
515
+ }
516
+
517
+ export function listLabels(projectId: number): LabelRow[] {
518
+ return rawDB().query("SELECT * FROM labels WHERE project_id = ? ORDER BY name").all(projectId) as LabelRow[];
519
+ }
520
+
521
+ export function listLabelsForIssue(issueId: number): LabelRow[] {
522
+ return rawDB()
523
+ .query(
524
+ `SELECT l.* FROM labels l
525
+ JOIN issue_labels il ON il.label_id = l.id
526
+ WHERE il.issue_id = ? ORDER BY l.name`
527
+ )
528
+ .all(issueId) as LabelRow[];
529
+ }
530
+
531
+ export function createLabel(projectId: number, name: string, color: string): LabelRow {
532
+ name = name.trim();
533
+ if (!name) throw new StoreError(400, "标签名不能为空");
534
+ if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new StoreError(400, "颜色须为 #RRGGBB");
535
+ const db = rawDB();
536
+ const info = db
537
+ .query("INSERT INTO labels (project_id, name, color) VALUES (?, ?, ?)")
538
+ .run(projectId, name, color);
539
+ return db.query("SELECT * FROM labels WHERE id = ?").get(Number(info.lastInsertRowid)) as LabelRow;
540
+ }
541
+
542
+ export function setIssueLabel(issueId: number, labelId: number, on: boolean): void {
543
+ const db = rawDB();
544
+ if (on) {
545
+ db.query("INSERT OR IGNORE INTO issue_labels (issue_id, label_id) VALUES (?, ?)").run(issueId, labelId);
546
+ } else {
547
+ db.query("DELETE FROM issue_labels WHERE issue_id = ? AND label_id = ?").run(issueId, labelId);
548
+ }
549
+ }
550
+
551
+ export function createAttachment(a: Omit<AttachmentRow, "created_at">): AttachmentRow {
552
+ const ts = now();
553
+ const db = rawDB();
554
+ db
555
+ .query(
556
+ `INSERT INTO attachments (uuid, issue_id, filename, content_type, size, blob_path, uploaded_by, created_at)
557
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
558
+ )
559
+ .run(a.uuid, a.issue_id, a.filename, a.content_type, a.size, a.blob_path, a.uploaded_by, ts);
560
+ return db.query("SELECT * FROM attachments WHERE uuid = ?").get(a.uuid) as AttachmentRow;
561
+ }
562
+
563
+ export function getAttachment(uuid: string): AttachmentRow | null {
564
+ return (rawDB().query("SELECT * FROM attachments WHERE uuid = ?").get(uuid) as AttachmentRow | null) ?? null;
565
+ }
566
+
567
+ const LOGIN_RE = /^[A-Za-z0-9_-]{1,64}$/;
568
+
569
+ async function hashPassword(plain: string): Promise<string> {
570
+ // Algorithm is encoded in the modular crypt string ($2b$10$...), so future
571
+ // migration to argon2 only affects newly-set passwords; legacy verifies work.
572
+ return Bun.password.hash(plain, { algorithm: "bcrypt", cost: 10 });
573
+ }
574
+
575
+ export async function createUser(input: CreateUserInput): Promise<UserRow> {
576
+ const login = input.login.trim();
577
+ if (!LOGIN_RE.test(login)) {
578
+ throw new StoreError(400, "login 含非法字符或长度不在 1-64 内");
579
+ }
580
+ if (input.password.length < 8) {
581
+ throw new StoreError(400, "密码至少 8 位");
582
+ }
583
+ if (input.password.length > 200) {
584
+ throw new StoreError(400, "密码过长(≤200)");
585
+ }
586
+ const db = rawDB();
587
+ if (db.query("SELECT 1 FROM users WHERE login = ?").get(login)) {
588
+ throw new StoreError(409, `用户 ${login} 已存在`);
589
+ }
590
+ const ts = now();
591
+ const hash = await hashPassword(input.password);
592
+ db.query(
593
+ `INSERT INTO users (login, kind, display_name, password_hash, email, is_admin, is_active, created_at, updated_at)
594
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
595
+ ).run(
596
+ login,
597
+ input.kind ?? "human",
598
+ input.display_name ?? null,
599
+ hash,
600
+ input.email ?? null,
601
+ input.is_admin ? 1 : 0,
602
+ input.is_active === false ? 0 : 1,
603
+ ts,
604
+ ts
605
+ );
606
+ return getUserByLogin(login)!;
607
+ }
608
+
609
+ export function getUserByLogin(login: string): UserRow | null {
610
+ return (rawDB().query("SELECT * FROM users WHERE login = ?").get(login) as UserRow | null) ?? null;
611
+ }
612
+
613
+ export function listUsers(): UserRow[] {
614
+ return rawDB()
615
+ .query("SELECT * FROM users ORDER BY is_admin DESC, created_at ASC")
616
+ .all() as UserRow[];
617
+ }
618
+
619
+ export function listAdmins(): UserRow[] {
620
+ return rawDB().query("SELECT * FROM users WHERE is_admin = 1").all() as UserRow[];
621
+ }
622
+
623
+ export async function verifyUserPassword(login: string, password: string): Promise<UserRow | null> {
624
+ const user = getUserByLogin(login);
625
+ if (!user || !user.password_hash || !user.is_active) return null;
626
+ const ok = await Bun.password.verify(password, user.password_hash);
627
+ return ok ? user : null;
628
+ }
629
+
630
+ export interface UpdateUserPatch {
631
+ email?: string | null;
632
+ display_name?: string | null;
633
+ is_admin?: boolean;
634
+ is_active?: boolean;
635
+ kind?: UserKind;
636
+ }
637
+
638
+ export function updateUser(login: string, patch: UpdateUserPatch): UserRow {
639
+ const existing = getUserByLogin(login);
640
+ if (!existing) throw new StoreError(404, `用户 ${login} 不存在`);
641
+ const ts = now();
642
+ const db = rawDB();
643
+ db.query(
644
+ `UPDATE users SET
645
+ email = COALESCE(?, email),
646
+ display_name = COALESCE(?, display_name),
647
+ is_admin = COALESCE(?, is_admin),
648
+ is_active = COALESCE(?, is_active),
649
+ kind = COALESCE(?, kind),
650
+ updated_at = ?
651
+ WHERE login = ?`
652
+ ).run(
653
+ patch.email !== undefined ? patch.email : null,
654
+ patch.display_name !== undefined ? patch.display_name : null,
655
+ patch.is_admin !== undefined ? (patch.is_admin ? 1 : 0) : null,
656
+ patch.is_active !== undefined ? (patch.is_active ? 1 : 0) : null,
657
+ patch.kind ?? null,
658
+ ts,
659
+ login
660
+ );
661
+ return getUserByLogin(login)!;
662
+ }
663
+
664
+ export async function setUserPassword(login: string, newPassword: string): Promise<void> {
665
+ if (newPassword.length < 8) throw new StoreError(400, "密码至少 8 位");
666
+ if (newPassword.length > 200) throw new StoreError(400, "密码过长(≤200)");
667
+ const existing = getUserByLogin(login);
668
+ if (!existing) throw new StoreError(404, `用户 ${login} 不存在`);
669
+ const ts = now();
670
+ const hash = await hashPassword(newPassword);
671
+ rawDB()
672
+ .query("UPDATE users SET password_hash = ?, updated_at = ? WHERE login = ?")
673
+ .run(hash, ts, login);
674
+ }
675
+
676
+ export function countAdmins(): number {
677
+ const row = rawDB().query("SELECT COUNT(*) AS n FROM users WHERE is_admin = 1").get() as { n: number };
678
+ return row.n;
679
+ }
680
+
681
+ export interface PatRow {
682
+ id: number;
683
+ user_login: string;
684
+ name: string;
685
+ salt: string;
686
+ token_hash: string;
687
+ token_last_eight: string;
688
+ scopes: string;
689
+ ip_allowlist: string;
690
+ expires_at: string | null;
691
+ last_used_at: string | null;
692
+ created_at: string;
693
+ revoked_at: string | null;
694
+ }
695
+
696
+ export interface CreatePatInput {
697
+ user_login: string;
698
+ name: string;
699
+ scopes?: string[];
700
+ ip_allowlist?: string[];
701
+ expires_at?: string | null;
702
+ }
703
+
704
+ export interface CreatePatResult {
705
+ row: PatRow;
706
+ plaintext: string;
707
+ }
708
+
709
+ const PAT_NAME_RE = /^[\w\u4e00-\u9fa5 .\-()]{1,64}$/;
710
+ const CIDR4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/;
711
+
712
+ function parseIPv4(s: string): number[] | null {
713
+ const m = s.trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
714
+ if (!m) return null;
715
+ const parts = [m[1], m[2], m[3], m[4]].map((x) => parseInt(x ?? "", 10));
716
+ if (parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
717
+ return parts;
718
+ }
719
+
720
+ function ipInCidr(ip: string, cidr: string): boolean {
721
+ const m = cidr.trim().match(CIDR4_RE);
722
+ if (!m) return false;
723
+ const net = [m[1], m[2], m[3], m[4]].map((x) => parseInt(x ?? "", 10));
724
+ if (net.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
725
+ const ipParts = parseIPv4(ip);
726
+ if (!ipParts) return false;
727
+ const prefix = m[5] !== undefined ? parseInt(m[5], 10) : 32;
728
+ if (prefix < 0 || prefix > 32) return false;
729
+ const netLong = (((net[0] ?? 0) << 24) | ((net[1] ?? 0) << 16) | ((net[2] ?? 0) << 8) | (net[3] ?? 0)) >>> 0;
730
+ const ipLong = (((ipParts[0] ?? 0) << 24) | ((ipParts[1] ?? 0) << 16) | ((ipParts[2] ?? 0) << 8) | (ipParts[3] ?? 0)) >>> 0;
731
+ const mask = prefix === 0 ? 0 : (prefix === 32 ? -1 : (-1 << (32 - prefix))) >>> 0;
732
+ return (netLong & mask) === (ipLong & mask);
733
+ }
734
+
735
+ export function parsePatIpAllowlist(raw: string): string[] {
736
+ try {
737
+ const v = JSON.parse(raw);
738
+ if (!Array.isArray(v)) return [];
739
+ return v.filter((x): x is string => typeof x === "string");
740
+ } catch {
741
+ return [];
742
+ }
743
+ }
744
+
745
+ function normalizeIpV4(ip: string): string | null {
746
+ if (!ip) return null;
747
+ let s = ip.trim();
748
+ if (s.startsWith("[")) {
749
+ const end = s.indexOf("]");
750
+ if (end > 0) s = s.slice(1, end);
751
+ }
752
+ const v4mapped = s.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
753
+ if (v4mapped) {
754
+ const v4 = v4mapped[1] ?? "";
755
+ return parseIPv4(v4) ? v4 : null;
756
+ }
757
+ const v4port = s.match(/^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)$/);
758
+ if (v4port) {
759
+ const v4 = v4port[1] ?? "";
760
+ return parseIPv4(v4) ? v4 : null;
761
+ }
762
+ return parseIPv4(s) ? s : null;
763
+ }
764
+
765
+ // Returns true if `ip` satisfies the allowlist. Empty allowlist = allow all.
766
+ // ip is required (caller must pass the request IP, "unknown" never matches).
767
+ export function ipAllowedBy(ip: string | null | undefined, allowlist: string[]): boolean {
768
+ if (allowlist.length === 0) return true;
769
+ const cleanIp = normalizeIpV4(typeof ip === "string" ? ip : "");
770
+ if (!cleanIp) return false;
771
+ return allowlist.some((cidr) => ipInCidr(cleanIp, cidr));
772
+ }
773
+
774
+ function validateCidrList(input: string[] | undefined): string[] {
775
+ if (!input || input.length === 0) return [];
776
+ if (input.length > 16) throw new StoreError(400, "IP allowlist 最多 16 条 CIDR");
777
+ const out: string[] = [];
778
+ for (const raw of input) {
779
+ const cidr = raw.trim();
780
+ if (!cidr) continue;
781
+ if (!CIDR4_RE.test(cidr)) throw new StoreError(400, `非法 CIDR: ${cidr}(仅支持 IPv4)`);
782
+ out.push(cidr);
783
+ }
784
+ return out;
785
+ }
786
+
787
+ function randomHex(bytes: number): string {
788
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("hex");
789
+ }
790
+
791
+ function sha256Hex(msg: string): string {
792
+ const h = createHash("sha256");
793
+ h.update(msg);
794
+ return h.digest("hex");
795
+ }
796
+
797
+ function ctEqualBuf(a: Uint8Array, b: Uint8Array): boolean {
798
+ if (a.length !== b.length) return false;
799
+ let diff = 0;
800
+ for (let i = 0; i < a.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
801
+ return diff === 0;
802
+ }
803
+
804
+ export async function createPat(input: CreatePatInput): Promise<CreatePatResult> {
805
+ const login = input.user_login.trim();
806
+ const name = input.name.trim();
807
+ const user = getUserByLogin(login);
808
+ if (!user) throw new StoreError(404, `用户 ${login} 不存在`);
809
+ if (!PAT_NAME_RE.test(name)) throw new StoreError(400, "token 名称含非法字符或长度不在 1-64 内");
810
+ const scopes = input.scopes ?? ["read", "write"];
811
+ for (const s of scopes) {
812
+ if (!/^[a-z:_]{1,32}$/.test(s)) throw new StoreError(400, `scope 含非法字符: ${s}`);
813
+ }
814
+ const ipAllowlist = validateCidrList(input.ip_allowlist);
815
+ let expiresAt: string | null = null;
816
+ if (input.expires_at) {
817
+ const t = Date.parse(input.expires_at);
818
+ if (!Number.isFinite(t)) throw new StoreError(400, "expires_at 不是合法时间");
819
+ expiresAt = new Date(t).toISOString();
820
+ }
821
+ // 40-char hex token (matches Gitea format) + 20-char hex per-token salt.
822
+ const plaintext = randomHex(20);
823
+ const salt = randomHex(10);
824
+ const tokenHash = sha256Hex(salt + plaintext);
825
+ const lastEight = plaintext.slice(-8);
826
+ const ts = now();
827
+ const db = rawDB();
828
+ const info = db
829
+ .query(
830
+ `INSERT INTO personal_access_tokens
831
+ (user_login, name, salt, token_hash, token_last_eight, scopes, ip_allowlist, expires_at, created_at)
832
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
833
+ )
834
+ .run(login, name, salt, tokenHash, lastEight, JSON.stringify(scopes), JSON.stringify(ipAllowlist), expiresAt, ts);
835
+ const row = getPat(Number(info.lastInsertRowid));
836
+ if (!row) throw new StoreError(500, "PAT 创建后无法读取");
837
+ return { row, plaintext };
838
+ }
839
+
840
+ export function getPat(id: number): PatRow | null {
841
+ return (rawDB().query("SELECT * FROM personal_access_tokens WHERE id = ?").get(id) as PatRow | null) ?? null;
842
+ }
843
+
844
+ export function listPatsForUser(login: string): PatRow[] {
845
+ return rawDB()
846
+ .query("SELECT * FROM personal_access_tokens WHERE user_login = ? ORDER BY created_at DESC")
847
+ .all(login) as PatRow[];
848
+ }
849
+
850
+ export interface PatWithUser extends PatRow {
851
+ user_kind: UserKind | null;
852
+ user_is_admin: number | null;
853
+ user_is_active: number | null;
854
+ }
855
+
856
+ export function listAllPatsWithUsers(): PatWithUser[] {
857
+ return rawDB()
858
+ .query(
859
+ `SELECT p.*, u.kind AS user_kind, u.is_admin AS user_is_admin, u.is_active AS user_is_active
860
+ FROM personal_access_tokens p
861
+ LEFT JOIN users u ON u.login = p.user_login
862
+ ORDER BY p.created_at DESC`
863
+ )
864
+ .all() as PatWithUser[];
865
+ }
866
+
867
+ export function revokePat(id: number, login: string): void {
868
+ const row = getPat(id);
869
+ if (!row) throw new StoreError(404, "token 不存在");
870
+ if (row.user_login !== login) throw new StoreError(403, "无权操作他人 token");
871
+ if (row.revoked_at) return;
872
+ rawDB()
873
+ .query("UPDATE personal_access_tokens SET revoked_at = ? WHERE id = ?")
874
+ .run(now(), id);
875
+ }
876
+
877
+ // Admin override: revoke any token regardless of owner. Caller MUST check
878
+ // ctx.user.is_admin === 1 before invoking.
879
+ export function revokePatAsAdmin(id: number): void {
880
+ const row = getPat(id);
881
+ if (!row) throw new StoreError(404, "token 不存在");
882
+ if (row.revoked_at) return;
883
+ rawDB()
884
+ .query("UPDATE personal_access_tokens SET revoked_at = ? WHERE id = ?")
885
+ .run(now(), id);
886
+ }
887
+
888
+ export async function verifyPat(rawToken: string, ip?: string | null): Promise<UserRow | null> {
889
+ if (!/^[0-9a-f]{40}$/.test(rawToken)) return null;
890
+ const lastEight = rawToken.slice(-8);
891
+ const candidates = rawDB()
892
+ .query("SELECT * FROM personal_access_tokens WHERE token_last_eight = ?")
893
+ .all(lastEight) as PatRow[];
894
+ const nowMs = Date.now();
895
+ for (const row of candidates) {
896
+ if (row.revoked_at) continue;
897
+ if (row.expires_at && Date.parse(row.expires_at) < nowMs) continue;
898
+ const expected = sha256Hex(row.salt + rawToken);
899
+ if (!ctEqualBuf(Buffer.from(expected, "hex"), Buffer.from(row.token_hash, "hex"))) continue;
900
+ const allowlist = parsePatIpAllowlist(row.ip_allowlist ?? "[]");
901
+ if (!ipAllowedBy(ip, allowlist)) continue;
902
+ const user = getUserByLogin(row.user_login);
903
+ if (!user || !user.is_active) return null;
904
+ rawDB()
905
+ .query("UPDATE personal_access_tokens SET last_used_at = ? WHERE id = ?")
906
+ .run(now(), row.id);
907
+ return user;
908
+ }
909
+ return null;
910
+ }
911
+
912
+ export type ProjectRole = "reader" | "writer" | "admin";
913
+
914
+ export interface ProjectMembership {
915
+ project_id: number;
916
+ user_login: string;
917
+ role: ProjectRole;
918
+ created_at: string;
919
+ }
920
+
921
+ const ROLE_RANK: Record<ProjectRole, number> = { reader: 1, writer: 2, admin: 3 };
922
+
923
+ export interface ProjectMemberWithUser extends ProjectMembership {
924
+ user_kind: UserKind;
925
+ user_display_name: string | null;
926
+ user_is_active: number;
927
+ }
928
+
929
+ export function listProjectMembersWithUsers(projectId: number): ProjectMemberWithUser[] {
930
+ return rawDB()
931
+ .query(
932
+ `SELECT m.*, u.kind AS user_kind, u.display_name AS user_display_name, u.is_active AS user_is_active
933
+ FROM project_members m JOIN users u ON u.login = m.user_login
934
+ WHERE m.project_id = ?
935
+ ORDER BY CASE m.role WHEN 'admin' THEN 0 WHEN 'writer' THEN 1 ELSE 2 END, m.created_at ASC`
936
+ )
937
+ .all(projectId) as ProjectMemberWithUser[];
938
+ }
939
+
940
+ export function listMembershipsForUser(userLogin: string): ProjectMembership[] {
941
+ return rawDB()
942
+ .query("SELECT * FROM project_members WHERE user_login = ? ORDER BY created_at ASC")
943
+ .all(userLogin) as ProjectMembership[];
944
+ }
945
+
946
+ export function getProjectMembership(projectId: number, userLogin: string): ProjectMembership | null {
947
+ return (
948
+ (rawDB()
949
+ .query("SELECT * FROM project_members WHERE project_id = ? AND user_login = ?")
950
+ .get(projectId, userLogin) as ProjectMembership | null) ?? null
951
+ );
952
+ }
953
+
954
+ // Returns the user's effective role on a project, or null. Site-admins return
955
+ // "admin" (the highest project role) so caller's canWrite/canAdmin work without
956
+ // a separate code path. Caller still needs to check user.is_admin separately
957
+ // for site-wide admin powers (user management, etc.).
958
+ export function getRoleOnProject(projectId: number, user: { login: string; is_admin: number } | null): ProjectRole | null {
959
+ if (!user) return null;
960
+ if (user.is_admin === 1) return "admin";
961
+ const m = getProjectMembership(projectId, user.login);
962
+ return m?.role ?? null;
963
+ }
964
+
965
+ export function canWriteProject(projectId: number, user: { login: string; is_admin: number } | null): boolean {
966
+ const r = getRoleOnProject(projectId, user);
967
+ return r !== null && ROLE_RANK[r] >= ROLE_RANK.writer;
968
+ }
969
+
970
+ export function canAdminProject(projectId: number, user: { login: string; is_admin: number } | null): boolean {
971
+ const r = getRoleOnProject(projectId, user);
972
+ return r === "admin";
973
+ }
974
+
975
+ export function addProjectMember(projectId: number, userLogin: string, role: ProjectRole): ProjectMembership {
976
+ const login = userLogin.trim();
977
+ if (!getUserByLogin(login)) throw new StoreError(404, `用户 ${login} 不存在`);
978
+ const ts = now();
979
+ const db = rawDB();
980
+ db.query(
981
+ "INSERT OR IGNORE INTO project_members (project_id, user_login, role, created_at) VALUES (?, ?, ?, ?)"
982
+ ).run(projectId, login, role, ts);
983
+ return getProjectMembership(projectId, login)!;
984
+ }
985
+
986
+ export function setProjectMemberRole(projectId: number, userLogin: string, role: ProjectRole): void {
987
+ const m = getProjectMembership(projectId, userLogin);
988
+ if (!m) throw new StoreError(404, `用户 ${userLogin} 不是该项目成员`);
989
+ rawDB()
990
+ .query("UPDATE project_members SET role = ? WHERE project_id = ? AND user_login = ?")
991
+ .run(role, projectId, userLogin);
992
+ }
993
+
994
+ export function removeProjectMember(projectId: number, userLogin: string): void {
995
+ rawDB()
996
+ .query("DELETE FROM project_members WHERE project_id = ? AND user_login = ?")
997
+ .run(projectId, userLogin);
998
+ }
999
+
1000
+ export function countProjectAdmins(projectId: number): number {
1001
+ const row = rawDB()
1002
+ .query("SELECT COUNT(*) AS n FROM project_members WHERE project_id = ? AND role = 'admin'")
1003
+ .get(projectId) as { n: number };
1004
+ return row.n;
1005
+ }
1006
+
1007
+ // Idempotent bootstrap: if the project has zero members, add `login` as admin.
1008
+ // Called from project-create flow + lazily from project settings page when a
1009
+ // site-admin first opens it on an old (pre-RBAC) project.
1010
+ export function ensureProjectBootstrapAdmin(projectId: number, login: string): void {
1011
+ const existing = rawDB()
1012
+ .query("SELECT COUNT(*) AS n FROM project_members WHERE project_id = ?")
1013
+ .get(projectId) as { n: number };
1014
+ if (existing.n > 0) return;
1015
+ if (!getUserByLogin(login)) return;
1016
+ const ts = now();
1017
+ rawDB()
1018
+ .query("INSERT OR IGNORE INTO project_members (project_id, user_login, role, created_at) VALUES (?, ?, 'admin', ?)")
1019
+ .run(projectId, login, ts);
1020
+ }