ework-web 0.2.1 → 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/package.json +4 -2
- package/src/auth.ts +8 -8
- package/src/config.ts +2 -2
- package/src/db-admin.ts +566 -0
- package/src/db.ts +286 -49
- package/src/giteaApi.ts +53 -53
- package/src/index.ts +261 -102
- package/src/reactions.ts +2 -2
- package/src/schema-mysql.sql +178 -0
- package/src/schema.sql +40 -40
- package/src/store.ts +339 -379
- package/src/views/home.ts +10 -9
- package/src/views/issueList.ts +4 -4
- package/src/views/issueThread.ts +21 -21
- package/src/views/issues.ts +3 -3
- package/src/views/projectMembers.ts +8 -9
- package/src/views/projectUpstreams.ts +3 -3
- package/src/views/settings.ts +82 -0
- package/src/webhooks.ts +63 -73
package/src/store.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// issues + comments + labels + reactions + attachments + users. All write ops
|
|
3
3
|
// run in transactions; per-project issue numbers are allocated atomically.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { getDB } from "./db";
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
|
|
8
8
|
export type UserKind = "human" | "bot" | "system";
|
|
@@ -101,36 +101,19 @@ export class StoreError extends Error {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
type DB = ReturnType<typeof rawDB>;
|
|
105
|
-
|
|
106
104
|
function now(): string {
|
|
107
105
|
return new Date().toISOString();
|
|
108
106
|
}
|
|
109
107
|
|
|
110
|
-
function
|
|
111
|
-
db
|
|
112
|
-
|
|
113
|
-
const r = fn();
|
|
114
|
-
db.exec("COMMIT");
|
|
115
|
-
return r;
|
|
116
|
-
} catch (e) {
|
|
117
|
-
try {
|
|
118
|
-
db.exec("ROLLBACK");
|
|
119
|
-
} catch {
|
|
120
|
-
// already rolled back
|
|
121
|
-
}
|
|
122
|
-
throw e;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function ensureUser(login: string, kind: UserKind = "human"): UserRow {
|
|
127
|
-
const db = rawDB();
|
|
128
|
-
const existing = db.query("SELECT * FROM users WHERE login = ?").get(login) as UserRow | null;
|
|
108
|
+
export async function ensureUser(login: string, kind: UserKind = "human"): Promise<UserRow> {
|
|
109
|
+
const db = getDB();
|
|
110
|
+
const existing = await db.get<UserRow>("SELECT * FROM {{users}} WHERE login = ?", [login]);
|
|
129
111
|
if (existing) return existing;
|
|
130
112
|
const ts = now();
|
|
131
|
-
db.
|
|
132
|
-
"INSERT INTO users (login, kind, created_at, updated_at) VALUES (?, ?, ?, ?)"
|
|
133
|
-
|
|
113
|
+
await db.run(
|
|
114
|
+
"INSERT INTO {{users}} (login, kind, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
115
|
+
[login, kind, ts, ts]
|
|
116
|
+
);
|
|
134
117
|
return {
|
|
135
118
|
login,
|
|
136
119
|
kind,
|
|
@@ -144,18 +127,19 @@ export function ensureUser(login: string, kind: UserKind = "human"): UserRow {
|
|
|
144
127
|
};
|
|
145
128
|
}
|
|
146
129
|
|
|
147
|
-
export function getProject(owner: string, name: string): ProjectRow | null {
|
|
148
|
-
return (
|
|
130
|
+
export async function getProject(owner: string, name: string): Promise<ProjectRow | null> {
|
|
131
|
+
return (await getDB().get<ProjectRow>("SELECT * FROM {{projects}} WHERE owner = ? AND name = ?", [owner, name])) ?? null;
|
|
149
132
|
}
|
|
150
133
|
|
|
151
|
-
export function getProjectById(id: number): ProjectRow | null {
|
|
152
|
-
return (
|
|
134
|
+
export async function getProjectById(id: number): Promise<ProjectRow | null> {
|
|
135
|
+
return (await getDB().get<ProjectRow>("SELECT * FROM {{projects}} WHERE id = ?", [id])) ?? null;
|
|
153
136
|
}
|
|
154
137
|
|
|
155
|
-
export function getProjectByIssueId(issueId: number): ProjectRow | null {
|
|
156
|
-
const row =
|
|
157
|
-
|
|
158
|
-
|
|
138
|
+
export async function getProjectByIssueId(issueId: number): Promise<ProjectRow | null> {
|
|
139
|
+
const row = await getDB().get<ProjectRow>(
|
|
140
|
+
"SELECT p.* FROM {{projects}} p JOIN {{issues}} i ON i.project_id = p.id WHERE i.id = ?",
|
|
141
|
+
[issueId]
|
|
142
|
+
);
|
|
159
143
|
return row ?? null;
|
|
160
144
|
}
|
|
161
145
|
|
|
@@ -164,36 +148,35 @@ export interface ProjectWithCounts extends ProjectRow {
|
|
|
164
148
|
total_count: number;
|
|
165
149
|
}
|
|
166
150
|
|
|
167
|
-
export function listProjectsWithCounts(): ProjectWithCounts[] {
|
|
168
|
-
return
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
)
|
|
178
|
-
.all() as ProjectWithCounts[];
|
|
151
|
+
export async function listProjectsWithCounts(): Promise<ProjectWithCounts[]> {
|
|
152
|
+
return await getDB().all<ProjectWithCounts>(
|
|
153
|
+
`SELECT p.*,
|
|
154
|
+
COALESCE(SUM(CASE WHEN i.state = 'open' THEN 1 ELSE 0 END), 0) AS open_count,
|
|
155
|
+
COUNT(i.id) AS total_count
|
|
156
|
+
FROM {{projects}} p
|
|
157
|
+
LEFT JOIN {{issues}} i ON i.project_id = p.id
|
|
158
|
+
GROUP BY p.id
|
|
159
|
+
ORDER BY p.updated_at DESC`
|
|
160
|
+
);
|
|
179
161
|
}
|
|
180
162
|
|
|
181
|
-
export function createProject(owner: string, name: string, description: string): ProjectRow {
|
|
163
|
+
export async function createProject(owner: string, name: string, description: string): Promise<ProjectRow> {
|
|
182
164
|
owner = owner.trim();
|
|
183
165
|
name = name.trim();
|
|
184
166
|
if (!/^[A-Za-z0-9_.-]+$/.test(owner)) throw new StoreError(400, "owner 含非法字符");
|
|
185
167
|
if (!/^[A-Za-z0-9_.-]+$/.test(name)) throw new StoreError(400, "name 含非法字符");
|
|
186
|
-
if (getProject(owner, name)) throw new StoreError(409, `项目 ${owner}/${name} 已存在`);
|
|
168
|
+
if (await getProject(owner, name)) throw new StoreError(409, `项目 ${owner}/${name} 已存在`);
|
|
187
169
|
const ts = now();
|
|
188
|
-
const db =
|
|
189
|
-
const info = db
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
170
|
+
const db = getDB();
|
|
171
|
+
const info = await db.run(
|
|
172
|
+
"INSERT INTO {{projects}} (owner, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
|
173
|
+
[owner, name, description ?? "", ts, ts]
|
|
174
|
+
);
|
|
175
|
+
return (await getProjectById(info.insertId))!;
|
|
193
176
|
}
|
|
194
177
|
|
|
195
|
-
export function touchProject(projectId: number): void {
|
|
196
|
-
|
|
178
|
+
export async function touchProject(projectId: number): Promise<void> {
|
|
179
|
+
await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [now(), projectId]);
|
|
197
180
|
}
|
|
198
181
|
|
|
199
182
|
const MAX_UPSTREAM_URLS = 10;
|
|
@@ -214,7 +197,7 @@ export function getDefaultUpstreamUrl(project: Pick<ProjectRow, "upstream_urls">
|
|
|
214
197
|
return urls.length > 0 ? urls[0]! : null;
|
|
215
198
|
}
|
|
216
199
|
|
|
217
|
-
export function setProjectUpstreamUrls(projectId: number, urls: string[]): string[] {
|
|
200
|
+
export async function setProjectUpstreamUrls(projectId: number, urls: string[]): Promise<string[]> {
|
|
218
201
|
const seen = new Set<string>();
|
|
219
202
|
const cleaned: string[] = [];
|
|
220
203
|
for (const raw of urls) {
|
|
@@ -232,9 +215,10 @@ export function setProjectUpstreamUrls(projectId: number, urls: string[]): strin
|
|
|
232
215
|
throw new StoreError(400, `上游 URL 数量超过上限(${MAX_UPSTREAM_URLS})`);
|
|
233
216
|
}
|
|
234
217
|
const ts = now();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
218
|
+
await getDB().run(
|
|
219
|
+
"UPDATE {{projects}} SET upstream_urls = ?, updated_at = ? WHERE id = ?",
|
|
220
|
+
[JSON.stringify(cleaned), ts, projectId]
|
|
221
|
+
);
|
|
238
222
|
return cleaned;
|
|
239
223
|
}
|
|
240
224
|
|
|
@@ -242,14 +226,15 @@ export function setProjectUpstreamUrls(projectId: number, urls: string[]): strin
|
|
|
242
226
|
// Same regex as OpencodeClient.listModels — keep in sync.
|
|
243
227
|
const MODEL_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
|
|
244
228
|
|
|
245
|
-
export function setProjectModel(projectId: number, model: string): string {
|
|
229
|
+
export async function setProjectModel(projectId: number, model: string): Promise<string> {
|
|
246
230
|
const trimmed = String(model ?? "").trim();
|
|
247
231
|
if (trimmed && !MODEL_RE.test(trimmed)) {
|
|
248
232
|
throw new StoreError(400, `模型格式非法(须 provider/model): ${trimmed}`);
|
|
249
233
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
234
|
+
await getDB().run(
|
|
235
|
+
"UPDATE {{projects}} SET model = ?, updated_at = ? WHERE id = ?",
|
|
236
|
+
[trimmed, now(), projectId]
|
|
237
|
+
);
|
|
253
238
|
return trimmed;
|
|
254
239
|
}
|
|
255
240
|
|
|
@@ -262,13 +247,13 @@ export function resolveModel(projectModel: string, globalDefault: string): strin
|
|
|
262
247
|
return String(globalDefault ?? "").trim();
|
|
263
248
|
}
|
|
264
249
|
|
|
265
|
-
export function listCachedModels(): CachedModel[] {
|
|
266
|
-
const rows =
|
|
250
|
+
export async function listCachedModels(): Promise<CachedModel[]> {
|
|
251
|
+
const rows = await getDB().all<CachedModel>("SELECT provider_model AS id, label FROM {{model_cache}} ORDER BY id");
|
|
267
252
|
return rows;
|
|
268
253
|
}
|
|
269
254
|
|
|
270
|
-
export function replaceCachedModels(ids: string[]): void {
|
|
271
|
-
const db =
|
|
255
|
+
export async function replaceCachedModels(ids: string[]): Promise<void> {
|
|
256
|
+
const db = getDB();
|
|
272
257
|
const ts = now();
|
|
273
258
|
// Dedupe to avoid UNIQUE constraint violations — caller may pass raw
|
|
274
259
|
// output from `opencode models` which could (theoretically) repeat.
|
|
@@ -280,38 +265,33 @@ export function replaceCachedModels(ids: string[]): void {
|
|
|
280
265
|
unique.push(id);
|
|
281
266
|
}
|
|
282
267
|
}
|
|
283
|
-
|
|
284
|
-
db.exec("DELETE FROM model_cache");
|
|
285
|
-
const stmt = db.query("INSERT INTO model_cache (provider_model, label, refreshed_at) VALUES (?, ?, ?)");
|
|
268
|
+
await db.transaction(async () => {
|
|
269
|
+
await db.exec("DELETE FROM {{model_cache}}");
|
|
286
270
|
for (const id of unique) {
|
|
287
|
-
|
|
271
|
+
await db.run("INSERT INTO {{model_cache}} (provider_model, label, refreshed_at) VALUES (?, ?, ?)", [id, id, ts]);
|
|
288
272
|
}
|
|
289
273
|
});
|
|
290
|
-
tx();
|
|
291
274
|
}
|
|
292
275
|
|
|
293
|
-
export function getIssue(projectId: number, number: number): IssueRow | null {
|
|
276
|
+
export async function getIssue(projectId: number, number: number): Promise<IssueRow | null> {
|
|
294
277
|
return (
|
|
295
|
-
(
|
|
296
|
-
.query("SELECT * FROM issues WHERE project_id = ? AND number = ?")
|
|
297
|
-
.get(projectId, number) as IssueRow | null) ?? null
|
|
278
|
+
(await getDB().get<IssueRow>("SELECT * FROM {{issues}} WHERE project_id = ? AND number = ?", [projectId, number])) ?? null
|
|
298
279
|
);
|
|
299
280
|
}
|
|
300
281
|
|
|
301
|
-
export function getIssueById(id: number): IssueRow | null {
|
|
302
|
-
return (
|
|
282
|
+
export async function getIssueById(id: number): Promise<IssueRow | null> {
|
|
283
|
+
return (await getDB().get<IssueRow>("SELECT * FROM {{issues}} WHERE id = ?", [id])) ?? null;
|
|
303
284
|
}
|
|
304
285
|
|
|
305
|
-
export function getIssueWithMeta(projectId: number, number: number): IssueWithMeta | null {
|
|
286
|
+
export async function getIssueWithMeta(projectId: number, number: number): Promise<IssueWithMeta | null> {
|
|
306
287
|
return (
|
|
307
|
-
(
|
|
308
|
-
.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
.get(projectId, number) as IssueWithMeta | null) ?? null
|
|
288
|
+
(await getDB().get<IssueWithMeta>(
|
|
289
|
+
`SELECT i.*, p.owner AS project_owner, p.name AS project_name,
|
|
290
|
+
(SELECT COUNT(*) FROM {{comments}} c WHERE c.issue_id = i.id) AS comment_count
|
|
291
|
+
FROM {{issues}} i JOIN {{projects}} p ON p.id = i.project_id
|
|
292
|
+
WHERE i.project_id = ? AND i.number = ?`,
|
|
293
|
+
[projectId, number]
|
|
294
|
+
)) ?? null
|
|
315
295
|
);
|
|
316
296
|
}
|
|
317
297
|
|
|
@@ -321,13 +301,13 @@ export interface ListIssuesOpts {
|
|
|
321
301
|
limit?: number;
|
|
322
302
|
}
|
|
323
303
|
|
|
324
|
-
export function listIssues(projectId: number, opts: ListIssuesOpts = {}): IssueWithMeta[] {
|
|
304
|
+
export async function listIssues(projectId: number, opts: ListIssuesOpts = {}): Promise<IssueWithMeta[]> {
|
|
325
305
|
const state = opts.state ?? "open";
|
|
326
306
|
const q = (opts.q ?? "").trim();
|
|
327
307
|
const limit = Math.min(opts.limit ?? 50, 200);
|
|
328
308
|
let sql = `SELECT i.*, p.owner AS project_owner, p.name AS project_name,
|
|
329
|
-
(SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comment_count
|
|
330
|
-
FROM issues i JOIN projects p ON p.id = i.project_id
|
|
309
|
+
(SELECT COUNT(*) FROM {{comments}} c WHERE c.issue_id = i.id) AS comment_count
|
|
310
|
+
FROM {{issues}} i JOIN {{projects}} p ON p.id = i.project_id
|
|
331
311
|
WHERE i.project_id = ?`;
|
|
332
312
|
const args: (string | number)[] = [projectId];
|
|
333
313
|
if (state !== "all") {
|
|
@@ -341,16 +321,16 @@ export function listIssues(projectId: number, opts: ListIssuesOpts = {}): IssueW
|
|
|
341
321
|
}
|
|
342
322
|
sql += " ORDER BY i.updated_at DESC LIMIT ?";
|
|
343
323
|
args.push(limit);
|
|
344
|
-
return
|
|
324
|
+
return await getDB().all<IssueWithMeta>(sql, args);
|
|
345
325
|
}
|
|
346
326
|
|
|
347
|
-
export function listAllIssues(opts: ListIssuesOpts = {}): IssueWithMeta[] {
|
|
327
|
+
export async function listAllIssues(opts: ListIssuesOpts = {}): Promise<IssueWithMeta[]> {
|
|
348
328
|
const state = opts.state ?? "open";
|
|
349
329
|
const q = (opts.q ?? "").trim();
|
|
350
330
|
const limit = Math.min(opts.limit ?? 50, 200);
|
|
351
331
|
let sql = `SELECT i.*, p.owner AS project_owner, p.name AS project_name,
|
|
352
|
-
(SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comment_count
|
|
353
|
-
FROM issues i JOIN projects p ON p.id = i.project_id
|
|
332
|
+
(SELECT COUNT(*) FROM {{comments}} c WHERE c.issue_id = i.id) AS comment_count
|
|
333
|
+
FROM {{issues}} i JOIN {{projects}} p ON p.id = i.project_id
|
|
354
334
|
WHERE 1=1`;
|
|
355
335
|
const args: (string | number)[] = [];
|
|
356
336
|
if (state !== "all") {
|
|
@@ -364,7 +344,7 @@ export function listAllIssues(opts: ListIssuesOpts = {}): IssueWithMeta[] {
|
|
|
364
344
|
}
|
|
365
345
|
sql += " ORDER BY i.updated_at DESC LIMIT ?";
|
|
366
346
|
args.push(limit);
|
|
367
|
-
return
|
|
347
|
+
return await getDB().all<IssueWithMeta>(sql, args);
|
|
368
348
|
}
|
|
369
349
|
|
|
370
350
|
export interface CreateIssueOpts {
|
|
@@ -381,56 +361,51 @@ function isoOr(value: string | undefined, fallback: string): string {
|
|
|
381
361
|
return new Date(parsed).toISOString();
|
|
382
362
|
}
|
|
383
363
|
|
|
384
|
-
export function createIssue(
|
|
364
|
+
export async function createIssue(
|
|
385
365
|
projectId: number,
|
|
386
366
|
title: string,
|
|
387
367
|
body: string,
|
|
388
368
|
author: string,
|
|
389
369
|
opts: CreateIssueOpts = {}
|
|
390
|
-
): IssueRow {
|
|
370
|
+
): Promise<IssueRow> {
|
|
391
371
|
title = title.trim();
|
|
392
372
|
if (!title) throw new StoreError(400, "标题不能为空");
|
|
393
373
|
if (title.length > 255) throw new StoreError(400, "标题过长(≤255)");
|
|
394
374
|
if (body.length > 65536) throw new StoreError(413, "正文过长(≤65536)");
|
|
395
|
-
ensureUser(author);
|
|
375
|
+
await ensureUser(author);
|
|
396
376
|
const createdAt = isoOr(opts.createdAt, now());
|
|
397
377
|
const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
|
|
398
378
|
const state: "open" | "closed" = opts.state ?? "open";
|
|
399
379
|
const closedAt = state === "closed" ? isoOr(opts.closedAt ?? undefined, updatedAt) : null;
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
)
|
|
411
|
-
.run(projectId, next, title, body, state, author, createdAt, updatedAt, closedAt);
|
|
412
|
-
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(updatedAt, projectId);
|
|
413
|
-
return getIssueById(Number(info.lastInsertRowid))!;
|
|
380
|
+
return await getDB().transaction(async () => {
|
|
381
|
+
const next = (await getDB().get<{ n: number }>(
|
|
382
|
+
"SELECT COALESCE(MAX(number), 0) + 1 AS n FROM {{issues}} WHERE project_id = ?", [projectId]
|
|
383
|
+
))!;
|
|
384
|
+
const info = await getDB().run(
|
|
385
|
+
"INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
386
|
+
[projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt]
|
|
387
|
+
);
|
|
388
|
+
await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, projectId]);
|
|
389
|
+
return (await getIssueById(info.insertId))!;
|
|
414
390
|
});
|
|
415
391
|
}
|
|
416
392
|
|
|
417
|
-
export function setIssueState(
|
|
393
|
+
export async function setIssueState(
|
|
418
394
|
issueId: number,
|
|
419
395
|
state: "open" | "closed",
|
|
420
396
|
opts: { closedAt?: string; updatedAt?: string } = {}
|
|
421
|
-
): void {
|
|
397
|
+
): Promise<void> {
|
|
422
398
|
const ts = opts.updatedAt ? isoOr(opts.updatedAt, now()) : now();
|
|
423
399
|
const closedAt = state === "closed" ? isoOr(opts.closedAt ?? undefined, ts) : null;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
db.query("UPDATE issues SET state = ?, updated_at = ?, closed_at = ? WHERE id = ?").run(
|
|
400
|
+
await getDB().transaction(async () => {
|
|
401
|
+
await getDB().run("UPDATE {{issues}} SET state = ?, updated_at = ?, closed_at = ? WHERE id = ?", [
|
|
427
402
|
state,
|
|
428
403
|
ts,
|
|
429
404
|
closedAt,
|
|
430
|
-
issueId
|
|
431
|
-
);
|
|
432
|
-
const row =
|
|
433
|
-
|
|
405
|
+
issueId,
|
|
406
|
+
]);
|
|
407
|
+
const row = await getDB().get<{ project_id: number }>("SELECT project_id FROM {{issues}} WHERE id = ?", [issueId]);
|
|
408
|
+
await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [ts, row!.project_id]);
|
|
434
409
|
});
|
|
435
410
|
}
|
|
436
411
|
|
|
@@ -442,7 +417,7 @@ export interface IssuePatch {
|
|
|
442
417
|
updatedAt?: string;
|
|
443
418
|
}
|
|
444
419
|
|
|
445
|
-
export function editIssue(issueId: number, patch: IssuePatch): void {
|
|
420
|
+
export async function editIssue(issueId: number, patch: IssuePatch): Promise<void> {
|
|
446
421
|
const sets: string[] = [];
|
|
447
422
|
const args: (string | number | null)[] = [];
|
|
448
423
|
if (patch.title !== undefined) {
|
|
@@ -478,59 +453,55 @@ export function editIssue(issueId: number, patch: IssuePatch): void {
|
|
|
478
453
|
args.push(now());
|
|
479
454
|
}
|
|
480
455
|
args.push(issueId);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (row) db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(now(), row.project_id);
|
|
456
|
+
await getDB().transaction(async () => {
|
|
457
|
+
await getDB().run(`UPDATE {{issues}} SET ${sets.join(", ")} WHERE id = ?`, args);
|
|
458
|
+
const row = await getDB().get<{ project_id: number }>("SELECT project_id FROM {{issues}} WHERE id = ?", [issueId]);
|
|
459
|
+
if (row) await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [now(), row.project_id]);
|
|
486
460
|
});
|
|
487
461
|
}
|
|
488
462
|
|
|
489
|
-
export function countComments(issueId: number): number {
|
|
490
|
-
const row =
|
|
491
|
-
return row
|
|
463
|
+
export async function countComments(issueId: number): Promise<number> {
|
|
464
|
+
const row = await getDB().get<{ n: number }>("SELECT COUNT(*) AS n FROM {{comments}} WHERE issue_id = ?", [issueId]);
|
|
465
|
+
return row!.n;
|
|
492
466
|
}
|
|
493
467
|
|
|
494
|
-
export function listCommentsPage(issueId: number, page: number, pageSize: number): { rows: CommentRow[]; page: number } {
|
|
495
|
-
const total = countComments(issueId);
|
|
468
|
+
export async function listCommentsPage(issueId: number, page: number, pageSize: number): Promise<{ rows: CommentRow[]; page: number }> {
|
|
469
|
+
const total = await countComments(issueId);
|
|
496
470
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
497
471
|
const clamped = Math.min(Math.max(1, page), totalPages);
|
|
498
472
|
const offset = (clamped - 1) * pageSize;
|
|
499
|
-
const rows =
|
|
500
|
-
.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
.all(issueId, pageSize, offset) as CommentRow[];
|
|
473
|
+
const rows = await getDB().all<CommentRow>(
|
|
474
|
+
`SELECT c.*, u.kind AS author_kind
|
|
475
|
+
FROM {{comments}} c
|
|
476
|
+
LEFT JOIN {{users}} u ON u.login = c.author
|
|
477
|
+
WHERE c.issue_id = ?
|
|
478
|
+
ORDER BY c.created_at ASC, c.id ASC
|
|
479
|
+
LIMIT ? OFFSET ?`,
|
|
480
|
+
[issueId, pageSize, offset]
|
|
481
|
+
);
|
|
509
482
|
return { rows, page: clamped };
|
|
510
483
|
}
|
|
511
484
|
|
|
512
|
-
export function listCommentsSince(issueId: number, sinceISO: string): CommentRow[] {
|
|
513
|
-
return
|
|
514
|
-
.
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
.all(issueId, sinceISO) as CommentRow[];
|
|
485
|
+
export async function listCommentsSince(issueId: number, sinceISO: string): Promise<CommentRow[]> {
|
|
486
|
+
return await getDB().all<CommentRow>(
|
|
487
|
+
`SELECT c.*, u.kind AS author_kind
|
|
488
|
+
FROM {{comments}} c
|
|
489
|
+
LEFT JOIN {{users}} u ON u.login = c.author
|
|
490
|
+
WHERE c.issue_id = ? AND c.created_at > ?
|
|
491
|
+
ORDER BY c.created_at ASC, c.id ASC`,
|
|
492
|
+
[issueId, sinceISO]
|
|
493
|
+
);
|
|
522
494
|
}
|
|
523
495
|
|
|
524
|
-
export function listCommentsForIssue(issueId: number): CommentRow[] {
|
|
525
|
-
return
|
|
526
|
-
.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
.all(issueId) as CommentRow[];
|
|
496
|
+
export async function listCommentsForIssue(issueId: number): Promise<CommentRow[]> {
|
|
497
|
+
return await getDB().all<CommentRow>(
|
|
498
|
+
`SELECT c.*, u.kind AS author_kind
|
|
499
|
+
FROM {{comments}} c
|
|
500
|
+
LEFT JOIN {{users}} u ON u.login = c.author
|
|
501
|
+
WHERE c.issue_id = ?
|
|
502
|
+
ORDER BY c.created_at ASC, c.id ASC`,
|
|
503
|
+
[issueId]
|
|
504
|
+
);
|
|
534
505
|
}
|
|
535
506
|
|
|
536
507
|
export interface CreateCommentOpts {
|
|
@@ -538,57 +509,56 @@ export interface CreateCommentOpts {
|
|
|
538
509
|
updatedAt?: string;
|
|
539
510
|
}
|
|
540
511
|
|
|
541
|
-
export function postComment(
|
|
512
|
+
export async function postComment(
|
|
542
513
|
issueId: number,
|
|
543
514
|
body: string,
|
|
544
515
|
author: string,
|
|
545
516
|
opts: CreateCommentOpts = {}
|
|
546
|
-
): CommentRow {
|
|
517
|
+
): Promise<CommentRow> {
|
|
547
518
|
if (!body || !body.trim()) throw new StoreError(400, "评论不能为空");
|
|
548
519
|
if (body.length > 65536) throw new StoreError(413, "评论过长(≤65536)");
|
|
549
|
-
ensureUser(author);
|
|
520
|
+
await ensureUser(author);
|
|
550
521
|
const createdAt = isoOr(opts.createdAt, now());
|
|
551
522
|
const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const row =
|
|
559
|
-
|
|
560
|
-
return
|
|
523
|
+
return await getDB().transaction(async () => {
|
|
524
|
+
const info = await getDB().run(
|
|
525
|
+
"INSERT INTO {{comments}} (issue_id, author, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
|
526
|
+
[issueId, author, body, createdAt, updatedAt]
|
|
527
|
+
);
|
|
528
|
+
await getDB().run("UPDATE {{issues}} SET updated_at = ? WHERE id = ?", [updatedAt, issueId]);
|
|
529
|
+
const row = await getDB().get<{ project_id: number }>("SELECT project_id FROM {{issues}} WHERE id = ?", [issueId]);
|
|
530
|
+
await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, row!.project_id]);
|
|
531
|
+
return (await getDB().get<CommentRow>(
|
|
561
532
|
`SELECT c.*, u.kind AS author_kind
|
|
562
|
-
FROM comments c LEFT JOIN users u ON u.login = c.author
|
|
563
|
-
WHERE c.id =
|
|
564
|
-
|
|
533
|
+
FROM {{comments}} c LEFT JOIN {{users}} u ON u.login = c.author
|
|
534
|
+
WHERE c.id = ?`,
|
|
535
|
+
[info.insertId]
|
|
536
|
+
))!;
|
|
565
537
|
});
|
|
566
538
|
}
|
|
567
539
|
|
|
568
|
-
export function getComment(commentId: number): CommentRow | null {
|
|
569
|
-
const row =
|
|
540
|
+
export async function getComment(commentId: number): Promise<CommentRow | null> {
|
|
541
|
+
const row = await getDB().get<CommentRow>(
|
|
570
542
|
`SELECT c.*, u.kind AS author_kind
|
|
571
|
-
FROM comments c LEFT JOIN users u ON u.login = c.author
|
|
572
|
-
WHERE c.id =
|
|
573
|
-
|
|
543
|
+
FROM {{comments}} c LEFT JOIN {{users}} u ON u.login = c.author
|
|
544
|
+
WHERE c.id = ?`,
|
|
545
|
+
[commentId]
|
|
546
|
+
);
|
|
574
547
|
return row ?? null;
|
|
575
548
|
}
|
|
576
549
|
|
|
577
|
-
export function editComment(commentId: number, body: string): CommentRow {
|
|
550
|
+
export async function editComment(commentId: number, body: string): Promise<CommentRow> {
|
|
578
551
|
const trimmed = String(body ?? "");
|
|
579
552
|
if (!trimmed.trim()) throw new StoreError(400, "评论不能为空");
|
|
580
553
|
if (trimmed.length > 65536) throw new StoreError(413, "评论过长(≤65536)");
|
|
581
554
|
const ts = now();
|
|
582
|
-
const
|
|
583
|
-
const info = db
|
|
584
|
-
.query("UPDATE comments SET body = ?, updated_at = ? WHERE id = ?")
|
|
585
|
-
.run(trimmed, ts, commentId);
|
|
555
|
+
const info = await getDB().run("UPDATE {{comments}} SET body = ?, updated_at = ? WHERE id = ?", [trimmed, ts, commentId]);
|
|
586
556
|
if (info.changes === 0) throw new StoreError(404, `评论 ${commentId} 不存在`);
|
|
587
|
-
return getComment(commentId)!;
|
|
557
|
+
return (await getComment(commentId))!;
|
|
588
558
|
}
|
|
589
559
|
|
|
590
|
-
export function deleteComment(commentId: number): void {
|
|
591
|
-
const info =
|
|
560
|
+
export async function deleteComment(commentId: number): Promise<void> {
|
|
561
|
+
const info = await getDB().run("DELETE FROM {{comments}} WHERE id = ?", [commentId]);
|
|
592
562
|
if (info.changes === 0) throw new StoreError(404, `评论 ${commentId} 不存在`);
|
|
593
563
|
}
|
|
594
564
|
|
|
@@ -598,80 +568,74 @@ export interface ReactionAgg {
|
|
|
598
568
|
n: number;
|
|
599
569
|
}
|
|
600
570
|
|
|
601
|
-
export function listReactionsFor(commentIds: number[]): ReactionAgg[] {
|
|
571
|
+
export async function listReactionsFor(commentIds: number[]): Promise<ReactionAgg[]> {
|
|
602
572
|
if (commentIds.length === 0) return [];
|
|
603
573
|
const placeholders = commentIds.map(() => "?").join(",");
|
|
604
|
-
return
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
.all(...commentIds) as ReactionAgg[];
|
|
574
|
+
return await getDB().all<ReactionAgg>(
|
|
575
|
+
`SELECT comment_id, content, COUNT(*) AS n
|
|
576
|
+
FROM {{reactions}} WHERE comment_id IN (${placeholders})
|
|
577
|
+
GROUP BY comment_id, content`,
|
|
578
|
+
commentIds
|
|
579
|
+
);
|
|
611
580
|
}
|
|
612
581
|
|
|
613
|
-
export function addReaction(commentId: number, userLogin: string, content: string): void {
|
|
582
|
+
export async function addReaction(commentId: number, userLogin: string, content: string): Promise<void> {
|
|
614
583
|
if (!content || content.length > 32) throw new StoreError(400, "非法的 reaction content");
|
|
615
|
-
ensureUser(userLogin);
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
584
|
+
await ensureUser(userLogin);
|
|
585
|
+
await getDB().run(
|
|
586
|
+
"INSERT OR IGNORE INTO {{reactions}} (comment_id, user_login, content) VALUES (?, ?, ?)",
|
|
587
|
+
[commentId, userLogin, content]
|
|
588
|
+
);
|
|
619
589
|
}
|
|
620
590
|
|
|
621
|
-
export function removeReaction(commentId: number, userLogin: string, content: string): void {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
591
|
+
export async function removeReaction(commentId: number, userLogin: string, content: string): Promise<void> {
|
|
592
|
+
await getDB().run(
|
|
593
|
+
"DELETE FROM {{reactions}} WHERE comment_id = ? AND user_login = ? AND content = ?",
|
|
594
|
+
[commentId, userLogin, content]
|
|
595
|
+
);
|
|
625
596
|
}
|
|
626
597
|
|
|
627
|
-
export function listLabels(projectId: number): LabelRow[] {
|
|
628
|
-
return
|
|
598
|
+
export async function listLabels(projectId: number): Promise<LabelRow[]> {
|
|
599
|
+
return await getDB().all<LabelRow>("SELECT * FROM {{labels}} WHERE project_id = ? ORDER BY name", [projectId]);
|
|
629
600
|
}
|
|
630
601
|
|
|
631
|
-
export function listLabelsForIssue(issueId: number): LabelRow[] {
|
|
632
|
-
return
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
.all(issueId) as LabelRow[];
|
|
602
|
+
export async function listLabelsForIssue(issueId: number): Promise<LabelRow[]> {
|
|
603
|
+
return await getDB().all<LabelRow>(
|
|
604
|
+
`SELECT l.* FROM {{labels}} l
|
|
605
|
+
JOIN {{issue_labels}} il ON il.label_id = l.id
|
|
606
|
+
WHERE il.issue_id = ? ORDER BY l.name`,
|
|
607
|
+
[issueId]
|
|
608
|
+
);
|
|
639
609
|
}
|
|
640
610
|
|
|
641
|
-
export function createLabel(projectId: number, name: string, color: string): LabelRow {
|
|
611
|
+
export async function createLabel(projectId: number, name: string, color: string): Promise<LabelRow> {
|
|
642
612
|
name = name.trim();
|
|
643
613
|
if (!name) throw new StoreError(400, "标签名不能为空");
|
|
644
614
|
if (!/^#[0-9a-fA-F]{6}$/.test(color)) throw new StoreError(400, "颜色须为 #RRGGBB");
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
.query("INSERT INTO labels (project_id, name, color) VALUES (?, ?, ?)")
|
|
648
|
-
.run(projectId, name, color);
|
|
649
|
-
return db.query("SELECT * FROM labels WHERE id = ?").get(Number(info.lastInsertRowid)) as LabelRow;
|
|
615
|
+
const info = await getDB().run("INSERT INTO {{labels}} (project_id, name, color) VALUES (?, ?, ?)", [projectId, name, color]);
|
|
616
|
+
return (await getDB().get<LabelRow>("SELECT * FROM {{labels}} WHERE id = ?", [info.insertId]))!;
|
|
650
617
|
}
|
|
651
618
|
|
|
652
|
-
export function setIssueLabel(issueId: number, labelId: number, on: boolean): void {
|
|
653
|
-
const db = rawDB();
|
|
619
|
+
export async function setIssueLabel(issueId: number, labelId: number, on: boolean): Promise<void> {
|
|
654
620
|
if (on) {
|
|
655
|
-
|
|
621
|
+
await getDB().run("INSERT OR IGNORE INTO {{issue_labels}} (issue_id, label_id) VALUES (?, ?)", [issueId, labelId]);
|
|
656
622
|
} else {
|
|
657
|
-
|
|
623
|
+
await getDB().run("DELETE FROM {{issue_labels}} WHERE issue_id = ? AND label_id = ?", [issueId, labelId]);
|
|
658
624
|
}
|
|
659
625
|
}
|
|
660
626
|
|
|
661
|
-
export function createAttachment(a: Omit<AttachmentRow, "created_at">): AttachmentRow {
|
|
627
|
+
export async function createAttachment(a: Omit<AttachmentRow, "created_at">): Promise<AttachmentRow> {
|
|
662
628
|
const ts = now();
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
.run(a.uuid, a.issue_id, a.filename, a.content_type, a.size, a.blob_path, a.uploaded_by, ts);
|
|
670
|
-
return db.query("SELECT * FROM attachments WHERE uuid = ?").get(a.uuid) as AttachmentRow;
|
|
629
|
+
await getDB().run(
|
|
630
|
+
`INSERT INTO {{attachments}} (uuid, issue_id, filename, content_type, size, blob_path, uploaded_by, created_at)
|
|
631
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
632
|
+
[a.uuid, a.issue_id, a.filename, a.content_type, a.size, a.blob_path, a.uploaded_by, ts]
|
|
633
|
+
);
|
|
634
|
+
return (await getDB().get<AttachmentRow>("SELECT * FROM {{attachments}} WHERE uuid = ?", [a.uuid]))!;
|
|
671
635
|
}
|
|
672
636
|
|
|
673
|
-
export function getAttachment(uuid: string): AttachmentRow | null {
|
|
674
|
-
return (
|
|
637
|
+
export async function getAttachment(uuid: string): Promise<AttachmentRow | null> {
|
|
638
|
+
return (await getDB().get<AttachmentRow>("SELECT * FROM {{attachments}} WHERE uuid = ?", [uuid])) ?? null;
|
|
675
639
|
}
|
|
676
640
|
|
|
677
641
|
const LOGIN_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
@@ -693,45 +657,44 @@ export async function createUser(input: CreateUserInput): Promise<UserRow> {
|
|
|
693
657
|
if (input.password.length > 200) {
|
|
694
658
|
throw new StoreError(400, "密码过长(≤200)");
|
|
695
659
|
}
|
|
696
|
-
const db =
|
|
697
|
-
if (db.
|
|
660
|
+
const db = getDB();
|
|
661
|
+
if (await db.get("SELECT 1 FROM {{users}} WHERE login = ?", [login])) {
|
|
698
662
|
throw new StoreError(409, `用户 ${login} 已存在`);
|
|
699
663
|
}
|
|
700
664
|
const ts = now();
|
|
701
665
|
const hash = await hashPassword(input.password);
|
|
702
|
-
db.
|
|
703
|
-
`INSERT INTO users (login, kind, display_name, password_hash, email, is_admin, is_active, created_at, updated_at)
|
|
704
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
666
|
+
await db.run(
|
|
667
|
+
`INSERT INTO {{users}} (login, kind, display_name, password_hash, email, is_admin, is_active, created_at, updated_at)
|
|
668
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
669
|
+
[
|
|
670
|
+
login,
|
|
671
|
+
input.kind ?? "human",
|
|
672
|
+
input.display_name ?? null,
|
|
673
|
+
hash,
|
|
674
|
+
input.email ?? null,
|
|
675
|
+
input.is_admin ? 1 : 0,
|
|
676
|
+
input.is_active === false ? 0 : 1,
|
|
677
|
+
ts,
|
|
678
|
+
ts
|
|
679
|
+
]
|
|
715
680
|
);
|
|
716
|
-
return getUserByLogin(login)!;
|
|
681
|
+
return (await getUserByLogin(login))!;
|
|
717
682
|
}
|
|
718
683
|
|
|
719
|
-
export function getUserByLogin(login: string): UserRow | null {
|
|
720
|
-
return (
|
|
684
|
+
export async function getUserByLogin(login: string): Promise<UserRow | null> {
|
|
685
|
+
return (await getDB().get<UserRow>("SELECT * FROM {{users}} WHERE login = ?", [login])) ?? null;
|
|
721
686
|
}
|
|
722
687
|
|
|
723
|
-
export function listUsers(): UserRow[] {
|
|
724
|
-
return
|
|
725
|
-
.query("SELECT * FROM users ORDER BY is_admin DESC, created_at ASC")
|
|
726
|
-
.all() as UserRow[];
|
|
688
|
+
export async function listUsers(): Promise<UserRow[]> {
|
|
689
|
+
return await getDB().all<UserRow>("SELECT * FROM {{users}} ORDER BY is_admin DESC, created_at ASC");
|
|
727
690
|
}
|
|
728
691
|
|
|
729
|
-
export function listAdmins(): UserRow[] {
|
|
730
|
-
return
|
|
692
|
+
export async function listAdmins(): Promise<UserRow[]> {
|
|
693
|
+
return await getDB().all<UserRow>("SELECT * FROM {{users}} WHERE is_admin = 1");
|
|
731
694
|
}
|
|
732
695
|
|
|
733
696
|
export async function verifyUserPassword(login: string, password: string): Promise<UserRow | null> {
|
|
734
|
-
const user = getUserByLogin(login);
|
|
697
|
+
const user = await getUserByLogin(login);
|
|
735
698
|
if (!user || !user.password_hash || !user.is_active) return null;
|
|
736
699
|
const ok = await Bun.password.verify(password, user.password_hash);
|
|
737
700
|
return ok ? user : null;
|
|
@@ -745,47 +708,46 @@ export interface UpdateUserPatch {
|
|
|
745
708
|
kind?: UserKind;
|
|
746
709
|
}
|
|
747
710
|
|
|
748
|
-
export function updateUser(login: string, patch: UpdateUserPatch): UserRow {
|
|
749
|
-
const existing = getUserByLogin(login);
|
|
711
|
+
export async function updateUser(login: string, patch: UpdateUserPatch): Promise<UserRow> {
|
|
712
|
+
const existing = await getUserByLogin(login);
|
|
750
713
|
if (!existing) throw new StoreError(404, `用户 ${login} 不存在`);
|
|
751
714
|
const ts = now();
|
|
752
|
-
const db =
|
|
753
|
-
db.
|
|
754
|
-
`UPDATE users SET
|
|
715
|
+
const db = getDB();
|
|
716
|
+
await db.run(
|
|
717
|
+
`UPDATE {{users}} SET
|
|
755
718
|
email = COALESCE(?, email),
|
|
756
719
|
display_name = COALESCE(?, display_name),
|
|
757
720
|
is_admin = COALESCE(?, is_admin),
|
|
758
721
|
is_active = COALESCE(?, is_active),
|
|
759
722
|
kind = COALESCE(?, kind),
|
|
760
723
|
updated_at = ?
|
|
761
|
-
WHERE login =
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
724
|
+
WHERE login = ?`,
|
|
725
|
+
[
|
|
726
|
+
patch.email !== undefined ? patch.email : null,
|
|
727
|
+
patch.display_name !== undefined ? patch.display_name : null,
|
|
728
|
+
patch.is_admin !== undefined ? (patch.is_admin ? 1 : 0) : null,
|
|
729
|
+
patch.is_active !== undefined ? (patch.is_active ? 1 : 0) : null,
|
|
730
|
+
patch.kind ?? null,
|
|
731
|
+
ts,
|
|
732
|
+
login
|
|
733
|
+
]
|
|
770
734
|
);
|
|
771
|
-
return getUserByLogin(login)!;
|
|
735
|
+
return (await getUserByLogin(login))!;
|
|
772
736
|
}
|
|
773
737
|
|
|
774
738
|
export async function setUserPassword(login: string, newPassword: string): Promise<void> {
|
|
775
739
|
if (newPassword.length < 8) throw new StoreError(400, "密码至少 8 位");
|
|
776
740
|
if (newPassword.length > 200) throw new StoreError(400, "密码过长(≤200)");
|
|
777
|
-
const existing = getUserByLogin(login);
|
|
741
|
+
const existing = await getUserByLogin(login);
|
|
778
742
|
if (!existing) throw new StoreError(404, `用户 ${login} 不存在`);
|
|
779
743
|
const ts = now();
|
|
780
744
|
const hash = await hashPassword(newPassword);
|
|
781
|
-
|
|
782
|
-
.query("UPDATE users SET password_hash = ?, updated_at = ? WHERE login = ?")
|
|
783
|
-
.run(hash, ts, login);
|
|
745
|
+
await getDB().run("UPDATE {{users}} SET password_hash = ?, updated_at = ? WHERE login = ?", [hash, ts, login]);
|
|
784
746
|
}
|
|
785
747
|
|
|
786
|
-
export function countAdmins(): number {
|
|
787
|
-
const row =
|
|
788
|
-
return row
|
|
748
|
+
export async function countAdmins(): Promise<number> {
|
|
749
|
+
const row = await getDB().get<{ n: number }>("SELECT COUNT(*) AS n FROM {{users}} WHERE is_admin = 1");
|
|
750
|
+
return row!.n;
|
|
789
751
|
}
|
|
790
752
|
|
|
791
753
|
export interface PatRow {
|
|
@@ -914,7 +876,7 @@ function ctEqualBuf(a: Uint8Array, b: Uint8Array): boolean {
|
|
|
914
876
|
export async function createPat(input: CreatePatInput): Promise<CreatePatResult> {
|
|
915
877
|
const login = input.user_login.trim();
|
|
916
878
|
const name = input.name.trim();
|
|
917
|
-
const user = getUserByLogin(login);
|
|
879
|
+
const user = await getUserByLogin(login);
|
|
918
880
|
if (!user) throw new StoreError(404, `用户 ${login} 不存在`);
|
|
919
881
|
if (!PAT_NAME_RE.test(name)) throw new StoreError(400, "token 名称含非法字符或长度不在 1-64 内");
|
|
920
882
|
const scopes = input.scopes ?? ["read", "write"];
|
|
@@ -934,27 +896,26 @@ export async function createPat(input: CreatePatInput): Promise<CreatePatResult>
|
|
|
934
896
|
const tokenHash = sha256Hex(salt + plaintext);
|
|
935
897
|
const lastEight = plaintext.slice(-8);
|
|
936
898
|
const ts = now();
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
.run(login, name, salt, tokenHash, lastEight, JSON.stringify(scopes), JSON.stringify(ipAllowlist), expiresAt, ts);
|
|
945
|
-
const row = getPat(Number(info.lastInsertRowid));
|
|
899
|
+
const info = await getDB().run(
|
|
900
|
+
`INSERT INTO {{personal_access_tokens}}
|
|
901
|
+
(user_login, name, salt, token_hash, token_last_eight, scopes, ip_allowlist, expires_at, created_at)
|
|
902
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
903
|
+
[login, name, salt, tokenHash, lastEight, JSON.stringify(scopes), JSON.stringify(ipAllowlist), expiresAt, ts]
|
|
904
|
+
);
|
|
905
|
+
const row = await getPat(info.insertId);
|
|
946
906
|
if (!row) throw new StoreError(500, "PAT 创建后无法读取");
|
|
947
907
|
return { row, plaintext };
|
|
948
908
|
}
|
|
949
909
|
|
|
950
|
-
export function getPat(id: number): PatRow | null {
|
|
951
|
-
return (
|
|
910
|
+
export async function getPat(id: number): Promise<PatRow | null> {
|
|
911
|
+
return (await getDB().get<PatRow>("SELECT * FROM {{personal_access_tokens}} WHERE id = ?", [id])) ?? null;
|
|
952
912
|
}
|
|
953
913
|
|
|
954
|
-
export function listPatsForUser(login: string): PatRow[] {
|
|
955
|
-
return
|
|
956
|
-
|
|
957
|
-
|
|
914
|
+
export async function listPatsForUser(login: string): Promise<PatRow[]> {
|
|
915
|
+
return await getDB().all<PatRow>(
|
|
916
|
+
"SELECT * FROM {{personal_access_tokens}} WHERE user_login = ? ORDER BY created_at DESC",
|
|
917
|
+
[login]
|
|
918
|
+
);
|
|
958
919
|
}
|
|
959
920
|
|
|
960
921
|
export interface PatWithUser extends PatRow {
|
|
@@ -963,44 +924,39 @@ export interface PatWithUser extends PatRow {
|
|
|
963
924
|
user_is_active: number | null;
|
|
964
925
|
}
|
|
965
926
|
|
|
966
|
-
export function listAllPatsWithUsers(): PatWithUser[] {
|
|
967
|
-
return
|
|
968
|
-
.
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
)
|
|
974
|
-
.all() as PatWithUser[];
|
|
927
|
+
export async function listAllPatsWithUsers(): Promise<PatWithUser[]> {
|
|
928
|
+
return await getDB().all<PatWithUser>(
|
|
929
|
+
`SELECT p.*, u.kind AS user_kind, u.is_admin AS user_is_admin, u.is_active AS user_is_active
|
|
930
|
+
FROM {{personal_access_tokens}} p
|
|
931
|
+
LEFT JOIN {{users}} u ON u.login = p.user_login
|
|
932
|
+
ORDER BY p.created_at DESC`
|
|
933
|
+
);
|
|
975
934
|
}
|
|
976
935
|
|
|
977
|
-
export function revokePat(id: number, login: string): void {
|
|
978
|
-
const row = getPat(id);
|
|
936
|
+
export async function revokePat(id: number, login: string): Promise<void> {
|
|
937
|
+
const row = await getPat(id);
|
|
979
938
|
if (!row) throw new StoreError(404, "token 不存在");
|
|
980
939
|
if (row.user_login !== login) throw new StoreError(403, "无权操作他人 token");
|
|
981
940
|
if (row.revoked_at) return;
|
|
982
|
-
|
|
983
|
-
.query("UPDATE personal_access_tokens SET revoked_at = ? WHERE id = ?")
|
|
984
|
-
.run(now(), id);
|
|
941
|
+
await getDB().run("UPDATE {{personal_access_tokens}} SET revoked_at = ? WHERE id = ?", [now(), id]);
|
|
985
942
|
}
|
|
986
943
|
|
|
987
944
|
// Admin override: revoke any token regardless of owner. Caller MUST check
|
|
988
945
|
// ctx.user.is_admin === 1 before invoking.
|
|
989
|
-
export function revokePatAsAdmin(id: number): void {
|
|
990
|
-
const row = getPat(id);
|
|
946
|
+
export async function revokePatAsAdmin(id: number): Promise<void> {
|
|
947
|
+
const row = await getPat(id);
|
|
991
948
|
if (!row) throw new StoreError(404, "token 不存在");
|
|
992
949
|
if (row.revoked_at) return;
|
|
993
|
-
|
|
994
|
-
.query("UPDATE personal_access_tokens SET revoked_at = ? WHERE id = ?")
|
|
995
|
-
.run(now(), id);
|
|
950
|
+
await getDB().run("UPDATE {{personal_access_tokens}} SET revoked_at = ? WHERE id = ?", [now(), id]);
|
|
996
951
|
}
|
|
997
952
|
|
|
998
953
|
export async function verifyPat(rawToken: string, ip?: string | null): Promise<UserRow | null> {
|
|
999
954
|
if (!/^[0-9a-f]{40}$/.test(rawToken)) return null;
|
|
1000
955
|
const lastEight = rawToken.slice(-8);
|
|
1001
|
-
const candidates =
|
|
1002
|
-
|
|
1003
|
-
|
|
956
|
+
const candidates = await getDB().all<PatRow>(
|
|
957
|
+
"SELECT * FROM {{personal_access_tokens}} WHERE token_last_eight = ?",
|
|
958
|
+
[lastEight]
|
|
959
|
+
);
|
|
1004
960
|
const nowMs = Date.now();
|
|
1005
961
|
for (const row of candidates) {
|
|
1006
962
|
if (row.revoked_at) continue;
|
|
@@ -1009,11 +965,9 @@ export async function verifyPat(rawToken: string, ip?: string | null): Promise<U
|
|
|
1009
965
|
if (!ctEqualBuf(Buffer.from(expected, "hex"), Buffer.from(row.token_hash, "hex"))) continue;
|
|
1010
966
|
const allowlist = parsePatIpAllowlist(row.ip_allowlist ?? "[]");
|
|
1011
967
|
if (!ipAllowedBy(ip, allowlist)) continue;
|
|
1012
|
-
const user = getUserByLogin(row.user_login);
|
|
968
|
+
const user = await getUserByLogin(row.user_login);
|
|
1013
969
|
if (!user || !user.is_active) return null;
|
|
1014
|
-
|
|
1015
|
-
.query("UPDATE personal_access_tokens SET last_used_at = ? WHERE id = ?")
|
|
1016
|
-
.run(now(), row.id);
|
|
970
|
+
await getDB().run("UPDATE {{personal_access_tokens}} SET last_used_at = ? WHERE id = ?", [now(), row.id]);
|
|
1017
971
|
return user;
|
|
1018
972
|
}
|
|
1019
973
|
return null;
|
|
@@ -1036,28 +990,29 @@ export interface ProjectMemberWithUser extends ProjectMembership {
|
|
|
1036
990
|
user_is_active: number;
|
|
1037
991
|
}
|
|
1038
992
|
|
|
1039
|
-
export function listProjectMembersWithUsers(projectId: number): ProjectMemberWithUser[] {
|
|
1040
|
-
return
|
|
1041
|
-
.
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
.all(projectId) as ProjectMemberWithUser[];
|
|
993
|
+
export async function listProjectMembersWithUsers(projectId: number): Promise<ProjectMemberWithUser[]> {
|
|
994
|
+
return await getDB().all<ProjectMemberWithUser>(
|
|
995
|
+
`SELECT m.*, u.kind AS user_kind, u.display_name AS user_display_name, u.is_active AS user_is_active
|
|
996
|
+
FROM {{project_members}} m JOIN {{users}} u ON u.login = m.user_login
|
|
997
|
+
WHERE m.project_id = ?
|
|
998
|
+
ORDER BY CASE m.role WHEN 'admin' THEN 0 WHEN 'writer' THEN 1 ELSE 2 END, m.created_at ASC`,
|
|
999
|
+
[projectId]
|
|
1000
|
+
);
|
|
1048
1001
|
}
|
|
1049
1002
|
|
|
1050
|
-
export function listMembershipsForUser(userLogin: string): ProjectMembership[] {
|
|
1051
|
-
return
|
|
1052
|
-
|
|
1053
|
-
|
|
1003
|
+
export async function listMembershipsForUser(userLogin: string): Promise<ProjectMembership[]> {
|
|
1004
|
+
return await getDB().all<ProjectMembership>(
|
|
1005
|
+
"SELECT * FROM {{project_members}} WHERE user_login = ? ORDER BY created_at ASC",
|
|
1006
|
+
[userLogin]
|
|
1007
|
+
);
|
|
1054
1008
|
}
|
|
1055
1009
|
|
|
1056
|
-
export function getProjectMembership(projectId: number, userLogin: string): ProjectMembership | null {
|
|
1010
|
+
export async function getProjectMembership(projectId: number, userLogin: string): Promise<ProjectMembership | null> {
|
|
1057
1011
|
return (
|
|
1058
|
-
(
|
|
1059
|
-
|
|
1060
|
-
|
|
1012
|
+
(await getDB().get<ProjectMembership>(
|
|
1013
|
+
"SELECT * FROM {{project_members}} WHERE project_id = ? AND user_login = ?",
|
|
1014
|
+
[projectId, userLogin]
|
|
1015
|
+
)) ?? null
|
|
1061
1016
|
);
|
|
1062
1017
|
}
|
|
1063
1018
|
|
|
@@ -1065,66 +1020,71 @@ export function getProjectMembership(projectId: number, userLogin: string): Proj
|
|
|
1065
1020
|
// "admin" (the highest project role) so caller's canWrite/canAdmin work without
|
|
1066
1021
|
// a separate code path. Caller still needs to check user.is_admin separately
|
|
1067
1022
|
// for site-wide admin powers (user management, etc.).
|
|
1068
|
-
export function getRoleOnProject(projectId: number, user: { login: string; is_admin: number } | null): ProjectRole | null {
|
|
1023
|
+
export async function getRoleOnProject(projectId: number, user: { login: string; is_admin: number } | null): Promise<ProjectRole | null> {
|
|
1069
1024
|
if (!user) return null;
|
|
1070
1025
|
if (user.is_admin === 1) return "admin";
|
|
1071
|
-
const m = getProjectMembership(projectId, user.login);
|
|
1026
|
+
const m = await getProjectMembership(projectId, user.login);
|
|
1072
1027
|
return m?.role ?? null;
|
|
1073
1028
|
}
|
|
1074
1029
|
|
|
1075
|
-
export function canWriteProject(projectId: number, user: { login: string; is_admin: number } | null): boolean {
|
|
1076
|
-
const r = getRoleOnProject(projectId, user);
|
|
1030
|
+
export async function canWriteProject(projectId: number, user: { login: string; is_admin: number } | null): Promise<boolean> {
|
|
1031
|
+
const r = await getRoleOnProject(projectId, user);
|
|
1077
1032
|
return r !== null && ROLE_RANK[r] >= ROLE_RANK.writer;
|
|
1078
1033
|
}
|
|
1079
1034
|
|
|
1080
|
-
export function canAdminProject(projectId: number, user: { login: string; is_admin: number } | null): boolean {
|
|
1081
|
-
const r = getRoleOnProject(projectId, user);
|
|
1035
|
+
export async function canAdminProject(projectId: number, user: { login: string; is_admin: number } | null): Promise<boolean> {
|
|
1036
|
+
const r = await getRoleOnProject(projectId, user);
|
|
1082
1037
|
return r === "admin";
|
|
1083
1038
|
}
|
|
1084
1039
|
|
|
1085
|
-
export function addProjectMember(projectId: number, userLogin: string, role: ProjectRole): ProjectMembership {
|
|
1040
|
+
export async function addProjectMember(projectId: number, userLogin: string, role: ProjectRole): Promise<ProjectMembership> {
|
|
1086
1041
|
const login = userLogin.trim();
|
|
1087
|
-
if (!getUserByLogin(login)) throw new StoreError(404, `用户 ${login} 不存在`);
|
|
1042
|
+
if (!(await getUserByLogin(login))) throw new StoreError(404, `用户 ${login} 不存在`);
|
|
1088
1043
|
const ts = now();
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
)
|
|
1093
|
-
return getProjectMembership(projectId, login)!;
|
|
1044
|
+
await getDB().run(
|
|
1045
|
+
"INSERT OR IGNORE INTO {{project_members}} (project_id, user_login, role, created_at) VALUES (?, ?, ?, ?)",
|
|
1046
|
+
[projectId, login, role, ts]
|
|
1047
|
+
);
|
|
1048
|
+
return (await getProjectMembership(projectId, login))!;
|
|
1094
1049
|
}
|
|
1095
1050
|
|
|
1096
|
-
export function setProjectMemberRole(projectId: number, userLogin: string, role: ProjectRole): void {
|
|
1097
|
-
const m = getProjectMembership(projectId, userLogin);
|
|
1051
|
+
export async function setProjectMemberRole(projectId: number, userLogin: string, role: ProjectRole): Promise<void> {
|
|
1052
|
+
const m = await getProjectMembership(projectId, userLogin);
|
|
1098
1053
|
if (!m) throw new StoreError(404, `用户 ${userLogin} 不是该项目成员`);
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1054
|
+
await getDB().run(
|
|
1055
|
+
"UPDATE {{project_members}} SET role = ? WHERE project_id = ? AND user_login = ?",
|
|
1056
|
+
[role, projectId, userLogin]
|
|
1057
|
+
);
|
|
1102
1058
|
}
|
|
1103
1059
|
|
|
1104
|
-
export function removeProjectMember(projectId: number, userLogin: string): void {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1060
|
+
export async function removeProjectMember(projectId: number, userLogin: string): Promise<void> {
|
|
1061
|
+
await getDB().run(
|
|
1062
|
+
"DELETE FROM {{project_members}} WHERE project_id = ? AND user_login = ?",
|
|
1063
|
+
[projectId, userLogin]
|
|
1064
|
+
);
|
|
1108
1065
|
}
|
|
1109
1066
|
|
|
1110
|
-
export function countProjectAdmins(projectId: number): number {
|
|
1111
|
-
const row =
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1067
|
+
export async function countProjectAdmins(projectId: number): Promise<number> {
|
|
1068
|
+
const row = await getDB().get<{ n: number }>(
|
|
1069
|
+
"SELECT COUNT(*) AS n FROM {{project_members}} WHERE project_id = ? AND role = 'admin'",
|
|
1070
|
+
[projectId]
|
|
1071
|
+
);
|
|
1072
|
+
return row!.n;
|
|
1115
1073
|
}
|
|
1116
1074
|
|
|
1117
1075
|
// Idempotent bootstrap: if the project has zero members, add `login` as admin.
|
|
1118
1076
|
// Called from project-create flow + lazily from project settings page when a
|
|
1119
1077
|
// site-admin first opens it on an old (pre-RBAC) project.
|
|
1120
|
-
export function ensureProjectBootstrapAdmin(projectId: number, login: string): void {
|
|
1121
|
-
const existing =
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
if (
|
|
1078
|
+
export async function ensureProjectBootstrapAdmin(projectId: number, login: string): Promise<void> {
|
|
1079
|
+
const existing = await getDB().get<{ n: number }>(
|
|
1080
|
+
"SELECT COUNT(*) AS n FROM {{project_members}} WHERE project_id = ?",
|
|
1081
|
+
[projectId]
|
|
1082
|
+
);
|
|
1083
|
+
if (existing!.n > 0) return;
|
|
1084
|
+
if (!(await getUserByLogin(login))) return;
|
|
1126
1085
|
const ts = now();
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1086
|
+
await getDB().run(
|
|
1087
|
+
"INSERT OR IGNORE INTO {{project_members}} (project_id, user_login, role, created_at) VALUES (?, ?, 'admin', ?)",
|
|
1088
|
+
[projectId, login, ts]
|
|
1089
|
+
);
|
|
1130
1090
|
}
|