@weibaohui/dsh-git-server 0.1.4 → 0.2.1

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.
@@ -14,6 +14,17 @@ import { markdown, sanitizeHTML } from './markup.js';
14
14
  import { authenticateUserByToken } from './context.js';
15
15
  import * as svc from './gitx/service.js';
16
16
  function requireAuth() { return { authenticateUserByToken }; }
17
+ /** token 认证(非仓库作用域端点用,如通知/我的工单/导入)。 */
18
+ function authUser(c) {
19
+ const header = String(c.req.headers.authorization ?? '');
20
+ const token = /^token (.+)$/.exec(header)?.[1] ?? '';
21
+ const user = token ? authenticateUserByToken(token) : null;
22
+ if (!user) {
23
+ c.JSON(401, { error: 'unauthorized' });
24
+ return null;
25
+ }
26
+ return user;
27
+ }
17
28
  export function registerDshRoutes(m) {
18
29
  // ── token 认证 + 仓库解析(:o/:r) ─────────────────────────────
19
30
  const authRepo = (c, needWrite = false) => {
@@ -74,7 +85,11 @@ export function registerDshRoutes(m) {
74
85
  }
75
86
  }
76
87
  catch { /* no readme */ }
77
- const numCommits = Number((await git.git(dir, 'rev-list', '--count', '--end-of-options', def))?.toString('utf8').trim() || 0);
88
+ let numCommits = 0;
89
+ try {
90
+ numCommits = Number((await git.git(dir, 'rev-list', '--count', '--end-of-options', def))?.toString('utf8').trim() || 0);
91
+ }
92
+ catch { /* 空仓库没有任何提交 */ }
78
93
  const numReleases = db.db().prepare('SELECT COUNT(*) AS c FROM release WHERE repo_id = ?').get(repo.id).c;
79
94
  c.JSONSuccess({
80
95
  defaultBranch: def, branches, tags, readmeHtml,
@@ -96,7 +111,7 @@ export function registerDshRoutes(m) {
96
111
  });
97
112
  c.JSONSuccess(rows);
98
113
  });
99
- // ── 文件列表(带每项最近提交) ────────────────────────────────
114
+ // ── 文件列表(带每项最近提交;一次 git log 批量解析,不再每条目起子进程) ──
100
115
  m.get('/api/dsh/repos/:o/:r/tree', async (c) => {
101
116
  const ar = authRepo(c);
102
117
  if (!ar)
@@ -106,21 +121,31 @@ export function registerDshRoutes(m) {
106
121
  const p = c.Query('path');
107
122
  const tree = await git.lsTree(dir, ref, p || '');
108
123
  if (!tree) {
124
+ // 空仓库(默认分支尚无提交)按空目录返回,而不是 404
125
+ const anyCommit = await git.getCommit(dir, ar.repo.default_branch || conf.defaultBranch);
126
+ if (!anyCommit) {
127
+ c.JSONSuccess({ entries: [] });
128
+ return;
129
+ }
109
130
  c.JSON(404, { error: 'not found' });
110
131
  return;
111
132
  }
112
- const entries = [];
113
- for (const e of tree.entries) {
114
- const epath = p ? p + '/' + e.name : e.name;
115
- let size = 0;
116
- if (e.type === 'blob')
117
- size = Number((await git.git(dir, 'cat-file', '-s', e.sha))?.toString('utf8').trim() || 0);
118
- const out = (await git.git(dir, 'log', '-1', '--pretty=format:%h%x1f%s%x1f%ct%x1f%an', '--end-of-options', ref, '--', epath))?.toString('utf8') ?? '';
119
- const [lsha, lmsg, ldate, lauthor] = out.split('\x1f');
120
- entries.push({
121
- name: e.name, type: e.type, path: epath, size,
122
- last: lsha ? { sha: lsha, msg: (lmsg || '').slice(0, 90), date: Number(ldate) * 1000, author: lauthor || '' } : null,
123
- });
133
+ const entries = tree.entries.map((e) => ({
134
+ name: e.name, type: e.type, path: p ? p + '/' + e.name : e.name,
135
+ size: e.type === 'blob' ? e.size : 0,
136
+ last: null,
137
+ }));
138
+ const lastMap = await git.lastCommitsForPaths(dir, ref, entries.map((e) => e.path));
139
+ for (const e of entries) {
140
+ let last = lastMap.get(e.path) ?? null;
141
+ if (!last) {
142
+ // 批量遍历上限之外的陈旧条目:单条兜底(很少见)
143
+ const out = (await git.gitOK(dir, 'log', '-1', '--pretty=format:%h%x1f%s%x1f%ct%x1f%an', '--end-of-options', ref, '--', e.path))?.toString('utf8') ?? '';
144
+ const [lsha, lmsg, ldate, lauthor] = out.split('\x1f');
145
+ if (lsha)
146
+ last = { sha: lsha, msg: (lmsg || '').slice(0, 90), date: Number(ldate) * 1000, author: lauthor || '' };
147
+ }
148
+ e.last = last;
124
149
  }
125
150
  c.JSONSuccess({ entries });
126
151
  });
@@ -169,6 +194,10 @@ export function registerDshRoutes(m) {
169
194
  return;
170
195
  }
171
196
  try {
197
+ // 先算 diff/patch(分支不存在等失败不落任何行,避免幽灵工单)
198
+ const headDir = repo.RepoPath();
199
+ const mergeBase = (await git.mergeBase(headDir, base, head)) ?? '';
200
+ const patch = await git.git(headDir, 'diff', '--full-index', '--binary', '--end-of-options', mergeBase || base, head);
172
201
  const { issueAction, ActionType } = await import('./db/actions.js');
173
202
  const index = db.maxIssueIndex(repo.id) + 1;
174
203
  const now = Math.floor(Date.now() / 1000);
@@ -185,9 +214,6 @@ export function registerDshRoutes(m) {
185
214
  db.db().prepare('UPDATE issue SET milestone_id = ?, assignee_id = ? WHERE id = ?')
186
215
  .run(Number(body.milestone) || 0, au ? au.id : 0, issueID);
187
216
  }
188
- const headDir = repo.RepoPath();
189
- const mergeBase = (await git.mergeBase(headDir, base, head)) ?? '';
190
- const patch = await git.git(headDir, 'diff', '--full-index', '--binary', '--end-of-options', mergeBase || base, head);
191
217
  const patchDir = path.join(conf.appDataPath, 'patches', String(repo.id));
192
218
  fs.mkdirSync(patchDir, { recursive: true });
193
219
  fs.writeFileSync(path.join(patchDir, `${index}.patch`), patch);
@@ -198,23 +224,63 @@ export function registerDshRoutes(m) {
198
224
  db.refreshIssueCounts(repo.id);
199
225
  const issue = db.getIssueByID(issueID);
200
226
  await issueAction(ActionType.CREATE_PULL_REQUEST, user, repo, issue);
227
+ const { markIssueUnread } = await import('./notify.js');
228
+ markIssueUnread(issue, user.id, { mentionContent: String(body.body ?? ''), assigned: true });
201
229
  c.JSONSuccess({ ok: true, index });
202
230
  }
203
231
  catch (e) {
204
232
  c.JSON(500, { error: String(e?.message ?? e).slice(0, 200) });
205
233
  }
206
234
  });
207
- // ── 仓库信息(设置页用) ─────────────────────────────────────
235
+ // ── 仓库信息(设置页用;镜像仓带上游地址与同步时间) ────────────
208
236
  m.get('/api/dsh/repos/:o/:r', async (c) => {
209
237
  const ar = authRepo(c);
210
238
  if (!ar)
211
239
  return;
240
+ let mirror = null;
241
+ if (ar.repo.is_mirror) {
242
+ const { mirrorAddress } = await import('./mirror.js');
243
+ const row = db.db().prepare('SELECT updated_unix, next_update_unix FROM mirror WHERE repo_id = ?').get(ar.repo.id);
244
+ mirror = {
245
+ address: mirrorAddress(ar.repo.RepoPath()),
246
+ updatedAt: (row?.updated_unix ?? 0) * 1000,
247
+ nextAt: (row?.next_update_unix ?? 0) * 1000,
248
+ };
249
+ }
212
250
  c.JSONSuccess({
213
251
  name: ar.repo.name, owner: ar.repo.OwnerName(),
214
252
  description: ar.repo.description || '', private: !!ar.repo.is_private,
215
253
  defaultBranch: ar.repo.default_branch || conf.defaultBranch,
254
+ isMirror: !!ar.repo.is_mirror, mirror,
216
255
  });
217
256
  });
257
+ // ── 镜像:立即同步(拉取上游更新) ──────────────────────────────
258
+ m.post('/api/dsh/repos/:o/:r/mirror-sync', async (c) => {
259
+ const ar = authRepo(c, true);
260
+ if (!ar)
261
+ return;
262
+ if (!ar.repo.is_mirror) {
263
+ c.JSON(422, { error: '不是镜像仓库' });
264
+ return;
265
+ }
266
+ try {
267
+ const { syncMirror } = await import('./mirror.js');
268
+ await syncMirror(ar.repo.id, ar.user.id);
269
+ const row = db.db().prepare('SELECT updated_unix, next_update_unix FROM mirror WHERE repo_id = ?').get(ar.repo.id);
270
+ c.JSONSuccess({ ok: true, updatedAt: (row?.updated_unix ?? 0) * 1000, nextAt: (row?.next_update_unix ?? 0) * 1000 });
271
+ }
272
+ catch (e) {
273
+ c.JSON(500, { error: '同步失败:' + String(e?.message ?? e).slice(0, 160) });
274
+ }
275
+ });
276
+ // ── fork 列表(谁复刻了本仓库) ─────────────────────────────────
277
+ m.get('/api/dsh/repos/:o/:r/forks', async (c) => {
278
+ const ar = authRepo(c);
279
+ if (!ar)
280
+ return;
281
+ const rows = db.db().prepare('SELECT r.name, r.num_stars, r.updated_unix, u.name AS owner FROM repository r JOIN user u ON u.id = r.owner_id WHERE r.fork_id = ? ORDER BY r.id DESC LIMIT 50').all(ar.repo.id);
282
+ c.JSONSuccess(rows.map((r) => ({ name: r.name, owner: r.owner, stars: r.num_stars || 0, updatedAt: (r.updated_unix ?? 0) * 1000 })));
283
+ });
218
284
  // ── 单条 issue(详情页编辑用) ───────────────────────────────
219
285
  m.get('/api/dsh/repos/:o/:r/issues/:idx', async (c) => {
220
286
  const ar = authRepo(c);
@@ -650,10 +716,13 @@ export function registerDshRoutes(m) {
650
716
  sets.push('milestone_id = ?');
651
717
  args.push(Number(body.milestone) || 0);
652
718
  }
719
+ let assigneeChanged = false;
653
720
  if (body.assignee !== undefined) {
654
721
  const au = body.assignee ? db.getUserByUsername(String(body.assignee)) : null;
722
+ const nextID = au ? au.id : 0;
723
+ assigneeChanged = nextID !== (issue.assignee_id ?? 0) && nextID !== 0;
655
724
  sets.push('assignee_id = ?');
656
- args.push(au ? au.id : 0);
725
+ args.push(nextID);
657
726
  }
658
727
  if (sets.length) {
659
728
  sets.push('updated_unix = ?');
@@ -667,6 +736,10 @@ export function registerDshRoutes(m) {
667
736
  }
668
737
  }
669
738
  db.refreshIssueCounts(ar.repo.id);
739
+ if (assigneeChanged || body.body !== undefined) {
740
+ const { markIssueUnread } = await import('./notify.js');
741
+ markIssueUnread(db.getIssueByID(issue.id), ar.user.id, { mentionContent: String(body.body ?? ''), assigned: assigneeChanged });
742
+ }
670
743
  c.JSONSuccess({ ok: true });
671
744
  });
672
745
  // ── 关注 / 星标(toggle + 列表,对齐原版 action/watch|star) ──
@@ -789,7 +862,7 @@ export function registerDshRoutes(m) {
789
862
  const ar = authRepo(c);
790
863
  if (!ar)
791
864
  return;
792
- const rows = db.db().prepare('SELECT id, url, content_type, is_active, events, created_unix FROM webhook WHERE repo_id = ? ORDER BY id').all(ar.repo.id);
865
+ const rows = db.db().prepare('SELECT id, url, content_type, is_active, events, last_status, created_unix FROM webhook WHERE repo_id = ? ORDER BY id').all(ar.repo.id);
793
866
  c.JSONSuccess(rows);
794
867
  });
795
868
  m.post('/api/dsh/repos/:o/:r/hooks', async (c) => {
@@ -834,58 +907,36 @@ export function registerDshRoutes(m) {
834
907
  db.db().prepare('DELETE FROM webhook WHERE id = ? AND repo_id = ?').run(c.ParamsInt64(':id'), ar.repo.id);
835
908
  c.JSONSuccess({ ok: true });
836
909
  });
837
- // ── 部署密钥(settings/keys) ─────────────────────────────────
838
- m.get('/api/dsh/repos/:o/:r/keys', async (c) => {
910
+ // 注:部署密钥(deploy_key)是 SSH 专属概念——本插件有意只走 HTTP(不写宿主
911
+ // ~/.ssh),git 凭据走 user-management,故不提供部署密钥端点/界面。
912
+ // ── 协作者 ────────────────────────────────────────────────────
913
+ m.get('/api/dsh/repos/:o/:r/collaborators', async (c) => {
839
914
  const ar = authRepo(c);
840
915
  if (!ar)
841
916
  return;
842
- const rows = db.db().prepare('SELECT k.id, k.name, k.fingerprint, k.created_unix FROM deploy_key dk JOIN public_key k ON k.id = dk.key_id WHERE dk.repo_id = ? ORDER BY k.id').all(ar.repo.id);
917
+ const rows = db.db().prepare('SELECT u.name, u.full_name, col.mode FROM collaboration col JOIN user u ON u.id = col.user_id WHERE col.repo_id = ?').all(ar.repo.id);
843
918
  c.JSONSuccess(rows);
844
919
  });
845
- m.post('/api/dsh/repos/:o/:r/keys', async (c) => {
920
+ const COLLAB_MODES = { read: 1, write: 2, admin: 3 };
921
+ m.post('/api/dsh/repos/:o/:r/collaborators/:name', async (c) => {
846
922
  const ar = authRepo(c, true);
847
923
  if (!ar)
848
924
  return;
849
- const body = await c.form();
850
- const name = String(body.name ?? '').trim();
851
- const content = String(body.content ?? '').trim();
852
- if (!name || !content) {
853
- c.JSON(422, { error: 'name and content required' });
925
+ const u = db.getUserByUsername(c.Params(':name'));
926
+ if (!u) {
927
+ c.JSON(404, { error: 'user not found' });
854
928
  return;
855
929
  }
856
- let fingerprint = '';
857
- try {
858
- const tmp = path.join(os.tmpdir(), 'dgs-key-' + Date.now());
859
- fs.writeFileSync(tmp, content);
860
- fingerprint = String((await git.git(os.tmpdir(), 'ssh-keygen', '-lf', tmp))?.toString('utf8') || '').trim();
861
- fs.rmSync(tmp, { force: true });
862
- }
863
- catch {
864
- fingerprint = 'ssh-key';
865
- }
866
- const now = Math.floor(Date.now() / 1000);
867
- const info = db.db().prepare('INSERT INTO public_key (owner_id, name, fingerprint, content, created_unix, updated_unix) VALUES (?,?,?,?,?,?)')
868
- .run(ar.user.id, name, fingerprint, content, now, now);
869
- db.db().prepare('INSERT INTO deploy_key (key_id, repo_id, name, fingerprint, mode) VALUES (?,?,?,?,1)')
870
- .run(Number(info.lastInsertRowid), ar.repo.id, name, fingerprint);
871
- c.JSONSuccess({ id: Number(info.lastInsertRowid), name, fingerprint });
872
- });
873
- m.delete('/api/dsh/repos/:o/:r/keys/:id', async (c) => {
874
- const ar = authRepo(c, true);
875
- if (!ar)
876
- return;
877
- db.db().prepare('DELETE FROM deploy_key WHERE key_id = ? AND repo_id = ?').run(c.ParamsInt64(':id'), ar.repo.id);
930
+ const body = await c.form().catch(() => ({}));
931
+ const mode = COLLAB_MODES[String(body.mode ?? 'write')] ?? 2;
932
+ const exists = db.db().prepare('SELECT 1 FROM collaboration WHERE user_id = ? AND repo_id = ?').get(u.id, ar.repo.id);
933
+ if (!exists)
934
+ db.db().prepare('INSERT INTO collaboration (user_id, repo_id, mode) VALUES (?,?,?)').run(u.id, ar.repo.id, mode);
935
+ else
936
+ db.db().prepare('UPDATE collaboration SET mode = ? WHERE user_id = ? AND repo_id = ?').run(mode, u.id, ar.repo.id);
878
937
  c.JSONSuccess({ ok: true });
879
938
  });
880
- // ── 协作者 ────────────────────────────────────────────────────
881
- m.get('/api/dsh/repos/:o/:r/collaborators', async (c) => {
882
- const ar = authRepo(c);
883
- if (!ar)
884
- return;
885
- const rows = db.db().prepare('SELECT u.name, u.full_name, col.mode FROM collaboration col JOIN user u ON u.id = col.user_id WHERE col.repo_id = ?').all(ar.repo.id);
886
- c.JSONSuccess(rows);
887
- });
888
- m.post('/api/dsh/repos/:o/:r/collaborators/:name', async (c) => {
939
+ m.patch('/api/dsh/repos/:o/:r/collaborators/:name', async (c) => {
889
940
  const ar = authRepo(c, true);
890
941
  if (!ar)
891
942
  return;
@@ -894,9 +945,13 @@ export function registerDshRoutes(m) {
894
945
  c.JSON(404, { error: 'user not found' });
895
946
  return;
896
947
  }
897
- const exists = db.db().prepare('SELECT 1 FROM collaboration WHERE user_id = ? AND repo_id = ?').get(u.id, ar.repo.id);
898
- if (!exists)
899
- db.db().prepare('INSERT INTO collaboration (user_id, repo_id, mode) VALUES (?,?,2)').run(u.id, ar.repo.id);
948
+ const body = await c.form();
949
+ const mode = COLLAB_MODES[String(body.mode ?? '')];
950
+ if (!mode) {
951
+ c.JSON(422, { error: 'mode must be read|write|admin' });
952
+ return;
953
+ }
954
+ db.db().prepare('UPDATE collaboration SET mode = ? WHERE user_id = ? AND repo_id = ?').run(mode, u.id, ar.repo.id);
900
955
  c.JSONSuccess({ ok: true });
901
956
  });
902
957
  m.delete('/api/dsh/repos/:o/:r/collaborators/:name', async (c) => {
@@ -932,14 +987,17 @@ export function registerDshRoutes(m) {
932
987
  db.db().prepare(`UPDATE repository SET ${sets.join(', ')} WHERE id = ?`).run(...args, ar.repo.id);
933
988
  c.JSONSuccess({ ok: true });
934
989
  });
935
- // ── 用户 profile(资料+仓库+活动) ─────────────────────────────
990
+ // ── 用户 profile(资料+仓库+活动+关注关系) ─────────────────────
936
991
  m.get('/api/dsh/users/:name', async (c) => {
937
992
  const u = db.getUserByUsername(c.Params(':name'));
938
993
  if (!u) {
939
994
  c.JSON(404, { error: 'user not found' });
940
995
  return;
941
996
  }
942
- const repos = db.listReposByOwner(u.id).filter((r) => !r.is_private || c.IsLogged).map((r) => ({
997
+ // 可选登录:带了有效 token 才计算「我是否关注了他」
998
+ const token = /^token (.+)$/.exec(String(c.req.headers.authorization ?? ''))?.[1] ?? '';
999
+ const viewer = token ? authenticateUserByToken(token) : null;
1000
+ const repos = db.listReposByOwner(u.id).filter((r) => !r.is_private || c.IsLogged || viewer).map((r) => ({
943
1001
  name: r.name, owner: r.OwnerName(), description: r.description, stars: r.num_stars,
944
1002
  updated: r.updated_unix,
945
1003
  }));
@@ -948,11 +1006,36 @@ export function registerDshRoutes(m) {
948
1006
  const following = db.db().prepare('SELECT u.name FROM follow f JOIN user u ON u.id = f.follow_id WHERE f.user_id = ?').all(u.id).map((x) => x.name);
949
1007
  c.JSONSuccess({
950
1008
  name: u.name, fullName: u.full_name || '', email: u.email, isAdmin: u.is_admin === 1,
951
- followers, following, repos, activity: activity.map((a) => ({
1009
+ created: u.created_unix ?? 0,
1010
+ followers, following,
1011
+ isFollowing: viewer ? db.isFollowing(viewer.id, u.id) : false,
1012
+ isSelf: viewer ? viewer.id === u.id : false,
1013
+ repos, activity: activity.map((a) => ({
952
1014
  type: a.op_type, repo: a.owner + '/' + a.repo, ref: a.ref_name, content: a.content, date: a.created_unix,
953
1015
  })),
954
1016
  });
955
1017
  });
1018
+ // ── 关注/取关(toggle) ────────────────────────────────────────
1019
+ m.post('/api/dsh/users/:name/follow', async (c) => {
1020
+ const user = authUser(c);
1021
+ if (!user)
1022
+ return;
1023
+ const target = db.getUserByUsername(c.Params(':name'));
1024
+ if (!target) {
1025
+ c.JSON(404, { error: 'user not found' });
1026
+ return;
1027
+ }
1028
+ if (target.id === user.id) {
1029
+ c.JSON(422, { error: '不能关注自己' });
1030
+ return;
1031
+ }
1032
+ const on = !db.isFollowing(user.id, target.id);
1033
+ if (on)
1034
+ db.followUser(user.id, target.id);
1035
+ else
1036
+ db.unfollowUser(user.id, target.id);
1037
+ c.JSONSuccess({ on, followers: db.db().prepare('SELECT COUNT(*) AS c FROM follow WHERE follow_id = ?').get(target.id).c });
1038
+ });
956
1039
  // ── 组织:我的组织列表 / 组织详情 / 创建 ────────────────────────
957
1040
  m.get('/api/dsh/orgs', async (c) => {
958
1041
  const header = String(c.req.headers.authorization ?? '');
@@ -1190,6 +1273,8 @@ export function registerDshRoutes(m) {
1190
1273
  db.refreshIssueCounts(repo.id);
1191
1274
  const { issueAction, ActionType } = await import('./db/actions.js');
1192
1275
  await issueAction(ActionType.MERGE_PULL_REQUEST, user, repo, issue);
1276
+ const { markIssueUnread } = await import('./notify.js');
1277
+ markIssueUnread(issue, user.id);
1193
1278
  c.JSONSuccess({ ok: true });
1194
1279
  }
1195
1280
  catch (e) {
@@ -1333,6 +1418,7 @@ export function registerDshRoutes(m) {
1333
1418
  return;
1334
1419
  const rows = db.db().prepare('SELECT r.id, r.tag_name, r.title, r.note, r.is_draft, r.is_prerelease, r.created_unix, u.name AS author FROM `release` r LEFT JOIN user u ON u.id = r.publisher_id WHERE r.repo_id = ? ORDER BY r.created_unix DESC').all(ar.repo.id);
1335
1420
  const dir = ar.repo.RepoPath();
1421
+ const attachDir = path.join(conf.appDataPath, 'attachments');
1336
1422
  const out = [];
1337
1423
  for (const r of rows) {
1338
1424
  let behind = 0;
@@ -1342,11 +1428,21 @@ export function registerDshRoutes(m) {
1342
1428
  catch {
1343
1429
  behind = 0;
1344
1430
  }
1431
+ const assets = db.db().prepare('SELECT id, uuid, name, created_unix FROM attachment WHERE release_id = ? ORDER BY id').all(r.id)
1432
+ .map((a) => {
1433
+ let size = 0;
1434
+ try {
1435
+ size = fs.statSync(path.join(attachDir, a.uuid)).size;
1436
+ }
1437
+ catch { /* 文件缺失按 0 */ }
1438
+ return { id: a.id, name: a.name, size, createdAt: a.created_unix };
1439
+ });
1345
1440
  out.push({
1346
1441
  id: r.id, tag: r.tag_name, title: r.title, draft: !!r.is_draft, prerelease: !!r.is_prerelease,
1347
1442
  noteHtml: r.note ? sanitizeHTML(markdown(r.note, conf.subpath + '/', {})) : '',
1348
1443
  noteRaw: r.note || '',
1349
1444
  author: r.author, createdAt: r.created_unix, target: r.target || '', behind,
1445
+ assets,
1350
1446
  });
1351
1447
  }
1352
1448
  c.JSONSuccess(out);
@@ -1412,5 +1508,401 @@ export function registerDshRoutes(m) {
1412
1508
  c.JSON(500, { error: String(e?.message ?? e).slice(0, 200) });
1413
1509
  }
1414
1510
  });
1511
+ // ── 通知(issue_user 未读模型) ─────────────────────────────────
1512
+ m.get('/api/dsh/notifications/count', async (c) => {
1513
+ const user = authUser(c);
1514
+ if (!user)
1515
+ return;
1516
+ const { unreadCount } = await import('./notify.js');
1517
+ c.JSONSuccess({ unread: unreadCount(user.id) });
1518
+ });
1519
+ m.get('/api/dsh/notifications', async (c) => {
1520
+ const user = authUser(c);
1521
+ if (!user)
1522
+ return;
1523
+ const { unreadCount } = await import('./notify.js');
1524
+ const rows = db.db().prepare(`SELECT iu.issue_id, iu.is_read, iu.is_assigned, iu.is_mentioned, iu.is_poster,
1525
+ i."index", i.name AS title, i.is_pull, i.is_closed, i.updated_unix,
1526
+ r.name AS repo_name, ow.name AS owner_name
1527
+ FROM issue_user iu
1528
+ JOIN issue i ON i.id = iu.issue_id
1529
+ JOIN repository r ON r.id = i.repo_id
1530
+ JOIN user ow ON ow.id = r.owner_id
1531
+ WHERE iu.uid = ? AND iu.is_read = 0
1532
+ ORDER BY i.updated_unix DESC LIMIT 100`).all(user.id);
1533
+ c.JSONSuccess({
1534
+ unread: unreadCount(user.id),
1535
+ items: rows.map((r) => ({
1536
+ repoOwner: r.owner_name, repoName: r.repo_name, index: Number(r.index),
1537
+ title: r.title, isPull: !!r.is_pull, isClosed: !!r.is_closed,
1538
+ updatedAt: (r.updated_unix ?? 0) * 1000,
1539
+ reason: r.is_mentioned ? 'mentioned' : r.is_assigned ? 'assigned' : r.is_poster ? 'poster' : 'comment',
1540
+ })),
1541
+ });
1542
+ });
1543
+ m.post('/api/dsh/notifications/read-all', async (c) => {
1544
+ const user = authUser(c);
1545
+ if (!user)
1546
+ return;
1547
+ db.db().prepare('UPDATE issue_user SET is_read = 1 WHERE uid = ?').run(user.id);
1548
+ c.JSONSuccess({ ok: true });
1549
+ });
1550
+ m.post('/api/dsh/repos/:o/:r/issues/:idx/read', async (c) => {
1551
+ const ar = authRepo(c);
1552
+ if (!ar)
1553
+ return;
1554
+ const issue = db.getIssueByIndex(ar.repo.id, c.ParamsInt64(':idx'));
1555
+ if (!issue) {
1556
+ c.JSON(404, { error: 'not found' });
1557
+ return;
1558
+ }
1559
+ const { markIssueRead } = await import('./notify.js');
1560
+ markIssueRead(ar.user.id, issue.id);
1561
+ c.JSONSuccess({ ok: true });
1562
+ });
1563
+ // ── 我的工单/PR 聚合(跨仓库) ──────────────────────────────────
1564
+ m.get('/api/dsh/my/issues', async (c) => {
1565
+ const user = authUser(c);
1566
+ if (!user)
1567
+ return;
1568
+ const isPull = c.Query('type') === 'pulls' ? 1 : 0;
1569
+ const filter = c.Query('filter') || 'all';
1570
+ const where = ['i.is_pull = ?'];
1571
+ const args = [isPull];
1572
+ if (filter === 'created') {
1573
+ where.push('i.poster_id = ?');
1574
+ args.push(user.id);
1575
+ }
1576
+ else if (filter === 'assigned') {
1577
+ where.push('i.assignee_id = ?');
1578
+ args.push(user.id);
1579
+ }
1580
+ else {
1581
+ where.push('(i.poster_id = ? OR i.assignee_id = ?)');
1582
+ args.push(user.id, user.id);
1583
+ }
1584
+ const state = c.Query('state');
1585
+ if (state === 'open' || state === 'closed') {
1586
+ where.push('i.is_closed = ?');
1587
+ args.push(state === 'closed' ? 1 : 0);
1588
+ }
1589
+ const rows = db.db().prepare(`SELECT i.id, i."index", i.name AS title, i.is_closed, i.num_comments, i.updated_unix,
1590
+ r.name AS repo_name, ow.name AS owner_name, pu.name AS poster_name, au.name AS assignee_name
1591
+ FROM issue i
1592
+ JOIN repository r ON r.id = i.repo_id
1593
+ JOIN user ow ON ow.id = r.owner_id
1594
+ LEFT JOIN user pu ON pu.id = i.poster_id
1595
+ LEFT JOIN user au ON au.id = i.assignee_id
1596
+ WHERE ${where.join(' AND ')} ORDER BY i.updated_unix DESC LIMIT 100`).all(...args);
1597
+ c.JSONSuccess(rows.map((r) => ({
1598
+ repoOwner: r.owner_name, repoName: r.repo_name, index: Number(r.index),
1599
+ title: r.title, state: r.is_closed ? 'closed' : 'open', comments: r.num_comments || 0,
1600
+ updatedAt: (r.updated_unix ?? 0) * 1000, author: r.poster_name || '', assignee: r.assignee_name || '',
1601
+ })));
1602
+ });
1603
+ // ── 分支删除(此前 UI 调用的宿主路由不存在,一直 404) ────────────
1604
+ m.delete('/api/dsh/repos/:o/:r/branches/*', async (c) => {
1605
+ const ar = authRepo(c, true);
1606
+ if (!ar)
1607
+ return;
1608
+ const branch = c.Params(':*');
1609
+ if (!branch) {
1610
+ c.JSON(422, { error: 'branch required' });
1611
+ return;
1612
+ }
1613
+ if (branch === (ar.repo.default_branch || conf.defaultBranch)) {
1614
+ c.JSON(422, { error: '默认分支不可删除' });
1615
+ return;
1616
+ }
1617
+ const prot = db.db().prepare('SELECT 1 FROM protect_branch WHERE repo_id = ? AND name = ? AND protected = 1').get(ar.repo.id, branch);
1618
+ if (prot) {
1619
+ c.JSON(403, { error: '分支受保护,不可删除' });
1620
+ return;
1621
+ }
1622
+ const dir = ar.repo.RepoPath();
1623
+ const r = await git.gitOK(dir, 'update-ref', '-d', '--end-of-options', 'refs/heads/' + branch);
1624
+ if (r === null) {
1625
+ c.JSON(500, { error: 'delete failed' });
1626
+ return;
1627
+ }
1628
+ const { deleteBranchAction } = await import('./db/actions.js');
1629
+ await deleteBranchAction(ar.user, ar.repo, branch);
1630
+ c.JSONSuccess({ ok: true });
1631
+ });
1632
+ // ── 分支保护(禁删/禁强推;推送侧由 update 钩子强制) ──────────────
1633
+ m.get('/api/dsh/repos/:o/:r/protections', async (c) => {
1634
+ const ar = authRepo(c);
1635
+ if (!ar)
1636
+ return;
1637
+ const rows = db.db().prepare('SELECT name FROM protect_branch WHERE repo_id = ? AND protected = 1 ORDER BY name').all(ar.repo.id);
1638
+ c.JSONSuccess(rows.map((r) => ({ branch: r.name })));
1639
+ });
1640
+ m.post('/api/dsh/repos/:o/:r/protections', async (c) => {
1641
+ const ar = authRepo(c, true);
1642
+ if (!ar)
1643
+ return;
1644
+ const body = await c.form();
1645
+ const branch = String(body.branch ?? '').trim();
1646
+ if (!branch) {
1647
+ c.JSON(422, { error: 'branch required' });
1648
+ return;
1649
+ }
1650
+ const on = body.protected !== false;
1651
+ db.db().prepare('INSERT INTO protect_branch (repo_id, name, protected) VALUES (?,?,?) ON CONFLICT(repo_id, name) DO UPDATE SET protected = excluded.protected').run(ar.repo.id, branch, on ? 1 : 0);
1652
+ c.JSONSuccess({ ok: true });
1653
+ });
1654
+ // ── PR 评审(最简版:通过/请求修改 + 可附评论) ────────────────────
1655
+ m.get('/api/dsh/repos/:o/:r/pulls/:idx/reviews', async (c) => {
1656
+ const ar = authRepo(c);
1657
+ if (!ar)
1658
+ return;
1659
+ const issue = db.getIssueByIndex(ar.repo.id, c.ParamsInt64(':idx'));
1660
+ if (!issue || !issue.is_pull) {
1661
+ c.JSON(404, { error: 'pull not found' });
1662
+ return;
1663
+ }
1664
+ const rows = db.db().prepare('SELECT pr.reviewer_id, pr.approved, pr.content, pr.created_unix, u.name AS reviewer FROM pr_review pr JOIN user u ON u.id = pr.reviewer_id WHERE pr.issue_id = ? ORDER BY pr.id DESC').all(issue.id);
1665
+ const seen = new Set();
1666
+ const out = [];
1667
+ for (const r of rows) {
1668
+ if (seen.has(r.reviewer_id))
1669
+ continue; // 每人只取最新一条
1670
+ seen.add(r.reviewer_id);
1671
+ out.push({ user: r.reviewer, approved: !!r.approved, content: r.content || '', createdAt: (r.created_unix ?? 0) * 1000 });
1672
+ }
1673
+ c.JSONSuccess(out);
1674
+ });
1675
+ m.post('/api/dsh/repos/:o/:r/pulls/:idx/reviews', async (c) => {
1676
+ const ar = authRepo(c);
1677
+ if (!ar)
1678
+ return;
1679
+ const issue = db.getIssueByIndex(ar.repo.id, c.ParamsInt64(':idx'));
1680
+ if (!issue || !issue.is_pull) {
1681
+ c.JSON(404, { error: 'pull not found' });
1682
+ return;
1683
+ }
1684
+ const body = await c.form();
1685
+ const content = String(body.content ?? '').trim();
1686
+ const now = Math.floor(Date.now() / 1000);
1687
+ db.db().prepare('DELETE FROM pr_review WHERE issue_id = ? AND reviewer_id = ?').run(issue.id, ar.user.id);
1688
+ db.db().prepare('INSERT INTO pr_review (repo_id, issue_id, reviewer_id, approved, content, created_unix) VALUES (?,?,?,?,?,?)')
1689
+ .run(ar.repo.id, issue.id, ar.user.id, body.approved ? 1 : 0, content, now);
1690
+ if (content) {
1691
+ db.db().prepare('INSERT INTO comment (type, poster_id, issue_id, content, created_unix, updated_unix) VALUES (0,?,?,?,?,?)').run(ar.user.id, issue.id, content, now, now);
1692
+ db.db().prepare('UPDATE issue SET num_comments = num_comments + 1 WHERE id = ?').run(issue.id);
1693
+ }
1694
+ const { markIssueUnread } = await import('./notify.js');
1695
+ markIssueUnread(issue, ar.user.id, { mentionContent: content });
1696
+ c.JSONSuccess({ ok: true });
1697
+ });
1698
+ // ── 发版二进制附件 ─────────────────────────────────────────────
1699
+ const attachDir = () => path.join(conf.appDataPath, 'attachments');
1700
+ const MAX_ASSET_BYTES = 25 * 1024 * 1024;
1701
+ m.post('/api/dsh/repos/:o/:r/releases/:id/assets', async (c) => {
1702
+ const ar = authRepo(c, true);
1703
+ if (!ar)
1704
+ return;
1705
+ const rel = db.getReleaseByID(ar.repo.id, c.ParamsInt64(':id'));
1706
+ if (!rel) {
1707
+ c.JSON(404, { error: 'release not found' });
1708
+ return;
1709
+ }
1710
+ const body = await c.form();
1711
+ const name = String(body.name ?? '').trim().replace(/[\\/]/g, '_');
1712
+ if (!name) {
1713
+ c.JSON(422, { error: 'name required' });
1714
+ return;
1715
+ }
1716
+ const buf = Buffer.from(String(body.contentBase64 ?? ''), 'base64');
1717
+ if (buf.length === 0) {
1718
+ c.JSON(422, { error: 'content required' });
1719
+ return;
1720
+ }
1721
+ if (buf.length > MAX_ASSET_BYTES) {
1722
+ c.JSON(413, { error: '附件超过 25MB 上限' });
1723
+ return;
1724
+ }
1725
+ const uuid = db.newUUID();
1726
+ fs.mkdirSync(attachDir(), { recursive: true });
1727
+ fs.writeFileSync(path.join(attachDir(), uuid), buf);
1728
+ const info = db.db().prepare('INSERT INTO attachment (uuid, release_id, name, created_unix) VALUES (?,?,?,?)')
1729
+ .run(uuid, rel.id, name, Math.floor(Date.now() / 1000));
1730
+ c.JSONSuccess({ ok: true, asset: { id: Number(info.lastInsertRowid), name, size: buf.length } });
1731
+ });
1732
+ m.get('/api/dsh/repos/:o/:r/attachments/:assetId', async (c) => {
1733
+ const ar = authRepo(c);
1734
+ if (!ar)
1735
+ return;
1736
+ const a = db.db().prepare('SELECT at.id, at.uuid, at.name FROM attachment at JOIN `release` r ON r.id = at.release_id WHERE at.id = ? AND r.repo_id = ?').get(c.ParamsInt64(':assetId'), ar.repo.id);
1737
+ if (!a) {
1738
+ c.JSON(404, { error: 'not found' });
1739
+ return;
1740
+ }
1741
+ const file = path.join(attachDir(), a.uuid);
1742
+ if (!fs.existsSync(file)) {
1743
+ c.JSON(404, { error: 'file gone' });
1744
+ return;
1745
+ }
1746
+ const buf = fs.readFileSync(file);
1747
+ c.res.setHeader('Content-Type', 'application/octet-stream');
1748
+ c.res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(a.name)}`);
1749
+ c.res.setHeader('Content-Length', String(buf.length));
1750
+ c.res.statusCode = 200;
1751
+ c.res.end(buf);
1752
+ c.rendered = true;
1753
+ });
1754
+ m.delete('/api/dsh/repos/:o/:r/attachments/:assetId', async (c) => {
1755
+ const ar = authRepo(c, true);
1756
+ if (!ar)
1757
+ return;
1758
+ const a = db.db().prepare('SELECT at.id, at.uuid FROM attachment at JOIN `release` r ON r.id = at.release_id WHERE at.id = ? AND r.repo_id = ?').get(c.ParamsInt64(':assetId'), ar.repo.id);
1759
+ if (!a) {
1760
+ c.JSON(404, { error: 'not found' });
1761
+ return;
1762
+ }
1763
+ db.db().prepare('DELETE FROM attachment WHERE id = ?').run(a.id);
1764
+ fs.rmSync(path.join(attachDir(), a.uuid), { force: true });
1765
+ c.JSONSuccess({ ok: true });
1766
+ });
1767
+ // ── 从 URL 导入仓库(可选镜像同步) ──────────────────────────────
1768
+ m.post('/api/dsh/migrate', async (c) => {
1769
+ const user = authUser(c);
1770
+ if (!user)
1771
+ return;
1772
+ const body = await c.form();
1773
+ const cloneAddr = String(body.cloneAddr ?? '').trim();
1774
+ const name = String(body.name ?? '').trim();
1775
+ if (!/^https?:\/\//.test(cloneAddr)) {
1776
+ c.JSON(422, { error: '仅支持 http(s) 克隆地址' });
1777
+ return;
1778
+ }
1779
+ if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name) || name.length > 100) {
1780
+ c.JSON(422, { error: '仓库名非法(字母数字 _.-)' });
1781
+ return;
1782
+ }
1783
+ const orgName = String(body.org ?? '');
1784
+ const owner = orgName ? db.getUserByUsername(orgName) : user;
1785
+ if (!owner) {
1786
+ c.JSON(404, { error: 'org not found' });
1787
+ return;
1788
+ }
1789
+ if (orgName) {
1790
+ const mem = db.db().prepare('SELECT 1 FROM org_user WHERE org_id = ? AND uid = ? AND is_owner = 1').get(owner.id, user.id);
1791
+ if (!mem && user.is_admin !== 1) {
1792
+ c.JSON(403, { error: '需要组织管理员' });
1793
+ return;
1794
+ }
1795
+ }
1796
+ if (db.getRepoByOwnerAndName(owner, name)) {
1797
+ c.JSON(422, { error: '同名仓库已存在' });
1798
+ return;
1799
+ }
1800
+ const isMirror = !!body.mirror;
1801
+ const { createRepositoryRecord } = await import('./repox.js');
1802
+ const repo = await createRepositoryRecord(user, owner, {
1803
+ name, description: String(body.description ?? ''), private: !!body.private, autoInit: false, mirror: isMirror,
1804
+ });
1805
+ const dir = repo.RepoPath();
1806
+ try {
1807
+ fs.rmSync(dir, { recursive: true, force: true });
1808
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
1809
+ await git.git(process.cwd(), 'clone', '--mirror', cloneAddr, dir);
1810
+ svc.createDelegateHooks(dir);
1811
+ await git.updateServerInfo(dir);
1812
+ const def = await git.getDefaultBranch(dir);
1813
+ const { size } = await git.countObjects(dir);
1814
+ db.updateRepoColumns(repo.id, { is_bare: 0, ...(def ? { default_branch: def } : {}), size });
1815
+ if (isMirror) {
1816
+ const now = Math.floor(Date.now() / 1000);
1817
+ db.db().prepare('INSERT INTO mirror (repo_id, interval, enable_prune, updated_unix, next_update_unix) VALUES (?,?,0,?,?)').run(repo.id, 0, now, now);
1818
+ }
1819
+ c.JSONSuccess({ ok: true, owner: owner.name, name: repo.name });
1820
+ }
1821
+ catch (e) {
1822
+ fs.rmSync(dir, { recursive: true, force: true });
1823
+ db.db().prepare('DELETE FROM repository WHERE id = ?').run(repo.id);
1824
+ db.db().prepare('UPDATE user SET num_repos = MAX(num_repos - 1, 0) WHERE id = ?').run(owner.id);
1825
+ c.JSON(500, { error: '克隆失败:' + String(e?.message ?? e).slice(0, 160) });
1826
+ }
1827
+ });
1828
+ // ── 代码搜索(git grep 包装,每文件最多 5 条、总共 100 条封顶) ─────
1829
+ m.get('/api/dsh/repos/:o/:r/search', async (c) => {
1830
+ const ar = authRepo(c);
1831
+ if (!ar)
1832
+ return;
1833
+ const q = c.Query('q').trim();
1834
+ if (!q) {
1835
+ c.JSON(422, { error: 'q required' });
1836
+ return;
1837
+ }
1838
+ const dir = ar.repo.RepoPath();
1839
+ const ref = c.Query('ref') || ar.repo.default_branch || conf.defaultBranch;
1840
+ const resolved = await git.resolveRef(dir, ref);
1841
+ if (!resolved) {
1842
+ c.JSON(404, { error: 'ref not found' });
1843
+ return;
1844
+ }
1845
+ const out = await git.gitOK(dir, 'grep', '-n', '-I', '-i', '-m', '5', '-e', q, '--end-of-options', resolved);
1846
+ const matches = [];
1847
+ const prefix = resolved + ':';
1848
+ if (out) {
1849
+ for (const line of out.toString('utf8').split('\n')) {
1850
+ if (!line.startsWith(prefix))
1851
+ continue;
1852
+ const m2 = /^(.*?):(\d+):(.*)$/.exec(line.slice(prefix.length));
1853
+ if (!m2)
1854
+ continue;
1855
+ matches.push({ path: m2[1], line: Number(m2[2]), text: m2[3].slice(0, 200) });
1856
+ if (matches.length >= 100)
1857
+ break;
1858
+ }
1859
+ }
1860
+ c.JSONSuccess({ matches, truncated: matches.length >= 100 });
1861
+ });
1862
+ // ── webhook 测试投递 + 最近投递记录 ──────────────────────────────
1863
+ m.post('/api/dsh/repos/:o/:r/hooks/:id/test', async (c) => {
1864
+ const ar = authRepo(c, true);
1865
+ if (!ar)
1866
+ return;
1867
+ const hook = db.getWebhookByID(ar.repo.id, c.ParamsInt64(':id'));
1868
+ if (!hook) {
1869
+ c.JSON(404, { error: 'hook not found' });
1870
+ return;
1871
+ }
1872
+ const dir = ar.repo.RepoPath();
1873
+ const def = ar.repo.default_branch || conf.defaultBranch;
1874
+ const commits = await git.commitsByPage(dir, def, 1, 5).catch(() => []);
1875
+ const { deliverWebhook } = await import('./webhook.js');
1876
+ deliverWebhook(hook, 'push', {
1877
+ ref: 'refs/heads/' + def,
1878
+ before: '',
1879
+ after: commits[0]?.id ?? '',
1880
+ commits: commits.map((cm) => ({
1881
+ id: cm.id, message: cm.message, timestamp: cm.committer.when.toISOString(),
1882
+ author: { name: cm.author.name, email: cm.author.email, username: cm.author.name },
1883
+ })),
1884
+ repository: { full_name: ar.repo.FullName(), html_url: ar.repo.HTMLURL(), name: ar.repo.name },
1885
+ pusher: { username: ar.user.name },
1886
+ sender: { username: ar.user.name },
1887
+ }, ar.repo);
1888
+ c.JSONSuccess({ ok: true });
1889
+ });
1890
+ m.get('/api/dsh/repos/:o/:r/hooks/:id/deliveries', async (c) => {
1891
+ const ar = authRepo(c);
1892
+ if (!ar)
1893
+ return;
1894
+ const rows = db.db().prepare('SELECT id, event_type, is_delivered, is_succeed, response_content FROM hook_task WHERE hook_id = ? AND repo_id = ? ORDER BY id DESC LIMIT 10').all(c.ParamsInt64(':id'), ar.repo.id);
1895
+ c.JSONSuccess(rows.map((r) => {
1896
+ let status = 0;
1897
+ let err = '';
1898
+ try {
1899
+ const rc = JSON.parse(r.response_content ?? '{}');
1900
+ status = rc.status ?? 0;
1901
+ err = rc.err ?? '';
1902
+ }
1903
+ catch { /* ignore */ }
1904
+ return { id: r.id, event: r.event_type, delivered: !!r.is_delivered, ok: !!r.is_succeed, status, err };
1905
+ }));
1906
+ });
1415
1907
  }
1416
1908
  //# sourceMappingURL=dshapi.js.map