ework-web 0.10.34 → 0.10.36

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.34",
3
+ "version": "0.10.36",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -93,6 +93,13 @@ export const configSchema = z.object({
93
93
  if (!Number.isFinite(n) || n < 1 || n > 64) return 6;
94
94
  return Math.floor(n);
95
95
  }, z.number().int().min(1).max(64)),
96
+ // Space-separated list of origins (e.g. "https://ework-web.taobao.net http://ework-web.taobao.net")
97
+ // injected into CSP form-action + connect-src. For reverse-proxy/gateway deployments
98
+ // where the browser sees a different scheme/host than the app's 'self'.
99
+ publicOrigins: z
100
+ .string()
101
+ .default("")
102
+ .transform((s) => s.split(/\s+/).filter(Boolean)),
96
103
  });
97
104
 
98
105
  export type Config = z.infer<typeof configSchema>;
@@ -188,6 +195,7 @@ export async function loadConfig(): Promise<Config> {
188
195
  defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
189
196
  autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
190
197
  webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
198
+ publicOrigins: process.env.WORK_PUBLIC_ORIGINS ?? "",
191
199
  });
192
200
  }
193
201
 
package/src/db.ts CHANGED
@@ -117,6 +117,9 @@ function migrateProjectsTable(db: Database): void {
117
117
  if (!have.has("model")) {
118
118
  db.exec(applyPrefix("ALTER TABLE {{projects}} ADD COLUMN model TEXT NOT NULL DEFAULT ''"));
119
119
  }
120
+ if (!have.has("visibility")) {
121
+ db.exec(applyPrefix("ALTER TABLE {{projects}} ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'"));
122
+ }
120
123
  }
121
124
 
122
125
  function migrateIssuesTable(db: Database): void {
@@ -298,7 +301,8 @@ function translateForMysql(sql: string): string {
298
301
  return sql
299
302
  .replace(/INSERT OR IGNORE INTO/g, "INSERT IGNORE INTO")
300
303
  .replace(/ON CONFLICT\((\w+)\) DO UPDATE SET/g, "ON DUPLICATE KEY UPDATE")
301
- .replace(/excluded\.(\w+)/g, "VALUES($1)");
304
+ .replace(/excluded\.(\w+)/g, "VALUES($1)")
305
+ .replace(/LIKE \? ESCAPE '\\'/g, "LIKE ?");
302
306
  }
303
307
 
304
308
  async function migrateMysqlSurrogateId(pool: Pool): Promise<void> {
@@ -338,6 +342,18 @@ async function migrateMysqlSurrogateId(pool: Pool): Promise<void> {
338
342
  }
339
343
  }
340
344
 
345
+ async function migrateMysqlProjectsVisibility(pool: Pool): Promise<void> {
346
+ const [cols] = await pool.query(
347
+ applyPrefix("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{{projects}}' AND COLUMN_NAME = 'visibility'")
348
+ );
349
+ if (Array.isArray(cols) && cols.length > 0) return;
350
+ try {
351
+ await pool.query(applyPrefix("ALTER TABLE {{projects}} ADD COLUMN visibility VARCHAR(16) NOT NULL DEFAULT 'public'"));
352
+ } catch (e) {
353
+ console.warn("[db] MySQL projects visibility column add failed:", (e as Error).message);
354
+ }
355
+ }
356
+
341
357
  class MysqlDriver implements AsyncDatabase {
342
358
  readonly dialect = "mysql" as const;
343
359
  private readonly pool: Pool;
@@ -381,6 +397,7 @@ class MysqlDriver implements AsyncDatabase {
381
397
  }
382
398
  }
383
399
  await migrateMysqlSurrogateId(pool);
400
+ await migrateMysqlProjectsVisibility(pool);
384
401
  }
385
402
  return new MysqlDriver(pool);
386
403
  }
package/src/giteaApi.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  removeReaction,
32
32
  listReactionsFor,
33
33
  canWriteProject,
34
+ canReadProject,
34
35
  type UserRow,
35
36
  } from "./store";
36
37
  import {
@@ -122,7 +123,7 @@ export async function handleGiteaApi(
122
123
  const limitRaw = Number(url.searchParams.get("limit") ?? 50);
123
124
  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 200) : 50;
124
125
  try {
125
- const rows = await listAllIssues({ q, state, limit });
126
+ const rows = await listAllIssues({ q, state, limit, viewerLogin: user.login, viewerIsAdmin: user.is_admin === 1 });
126
127
  const body = [];
127
128
  for (const row of rows) {
128
129
  const project = await getProject(row.project_owner, row.project_name);
@@ -144,6 +145,7 @@ export async function handleGiteaApi(
144
145
  if (!(owner && repo)) return giteaError(404, "not found");
145
146
  const project = await getProject(owner, repo);
146
147
  if (!project) return giteaError(404, "repository not found");
148
+ if (!(await canReadProject(project.id, user))) return giteaError(404, "repository not found");
147
149
  return { status: 200, body: buildRepository(project, origin) };
148
150
  }
149
151
 
@@ -187,6 +189,7 @@ export async function handleGiteaApi(
187
189
  if (!issue) return giteaError(404, "issue not found");
188
190
 
189
191
  if (req.method === "GET") {
192
+ if (!(await canReadProject(project.id, user))) return giteaError(404, "repository not found");
190
193
  return { status: 200, body: buildIssuePayload(issue, project, await countComments(issue.id), origin) };
191
194
  }
192
195
  if (req.method === "PATCH") {
@@ -227,6 +230,7 @@ export async function handleGiteaApi(
227
230
  if (!issue) return giteaError(404, "issue not found");
228
231
 
229
232
  if (req.method === "GET") {
233
+ if (!(await canReadProject(project.id, user))) return giteaError(404, "repository not found");
230
234
  const comments = await listCommentsForIssue(issue.id);
231
235
  return {
232
236
  status: 200,
@@ -269,6 +273,7 @@ export async function handleGiteaApi(
269
273
  try {
270
274
  const project = await getProject(owner, repo);
271
275
  if (!project) return giteaError(404, "repository not found");
276
+ if (!(await canReadProject(project.id, user))) return giteaError(404, "repository not found");
272
277
 
273
278
  if (req.method === "GET") {
274
279
  const comment = await getComment(cid);
@@ -306,6 +311,7 @@ export async function handleGiteaApi(
306
311
  try {
307
312
  const project = await getProject(owner, repo);
308
313
  if (!project) return giteaError(404, "repository not found");
314
+ if (!(await canReadProject(project.id, user))) return giteaError(404, "repository not found");
309
315
 
310
316
  if (req.method === "GET") {
311
317
  return { status: 200, body: await reactionsList(cid, origin) };
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import { buildAdminTokensPage } from "./views/adminTokens";
27
27
  import { buildSessionList, buildSessionView, renderNewMessages, renderBatchHTML } from "./views/sessionLog";
28
28
  import { buildFileView, FileViewError, readFileSince, serveRawFile } from "./fileview";
29
29
  import { translateText, translateTextStream, TranslateError } from "./translate";
30
- import { rateLimit } from "./ratelimit";
30
+ import { rateLimit, clearRateLimit } from "./ratelimit";
31
31
  import {
32
32
  StoreError,
33
33
  getProject,
@@ -53,6 +53,8 @@ import {
53
53
  listAllPatsWithUsers,
54
54
  canWriteProject,
55
55
  canAdminProject,
56
+ canReadProject,
57
+ updateProjectVisibility,
56
58
  ensureProjectBootstrapAdmin,
57
59
  addProjectMember,
58
60
  setProjectMemberRole,
@@ -183,13 +185,20 @@ async function autoWireAllProjects(origin: string): Promise<void> {
183
185
  void autoWireAllProjects(`http://${cfg.host}:${cfg.port}`);
184
186
 
185
187
  const SEC_HEADERS: Record<string, string> = {
186
- "content-security-policy": `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`,
188
+ "content-security-policy": buildCsp(cfg),
187
189
  "x-content-type-options": "nosniff",
188
190
  "x-frame-options": "DENY",
189
191
  "referrer-policy": "same-origin",
190
192
  "permissions-policy": "()",
191
193
  };
192
194
 
195
+ function buildCsp(cfg: Config): string {
196
+ const origins = cfg.publicOrigins.join(" ");
197
+ const formAction = origins ? `'self' ${origins}` : "'self'";
198
+ const connectSrc = origins ? `'self' ${origins}` : "'self'";
199
+ return `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ${connectSrc}; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action ${formAction}`;
200
+ }
201
+
193
202
  const hlCss = loadHighlightCss();
194
203
 
195
204
  function loadHighlightCss(): string {
@@ -359,6 +368,7 @@ const REPO_WEBHOOKS_RE = /^\/([^/]+)\/([^/]+)\/settings\/webhooks$/;
359
368
  const REPO_MEMBERS_RE = /^\/([^/]+)\/([^/]+)\/settings\/members$/;
360
369
  const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/(role|remove)$/;
361
370
  const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
371
+ const REPO_VISIBILITY_RE = /^\/([^/]+)\/([^/]+)\/settings\/visibility$/;
362
372
  const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
363
373
  const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
364
374
  const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
@@ -480,14 +490,18 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
480
490
  if (req.method === "POST") {
481
491
  const form = await req.formData().catch(() => new FormData());
482
492
  const next = sanitizeNext(String(form.get("next") ?? "/"));
483
- if (!rateLimit(`login:${ip}`, 5, 5 / (15 * 60))) {
484
- return html(loginHTML(next, "尝试过多,15 分钟后再试", cfg), 429);
485
- }
486
493
  const login = String(form.get("login") ?? "").trim();
487
494
  const password = String(form.get("password") ?? "");
488
495
  const token = String(form.get("token") ?? "").trim();
489
496
 
490
- // Token wins over login/password if both supplied (migration path).
497
+ const rlKey = `login:${ip}:${login || token || "?"}`;
498
+ if (!rateLimit(rlKey, 5, 5 / (15 * 60))) {
499
+ return new Response(loginHTML(next, "尝试过多,15 分钟后再试", cfg), {
500
+ status: 429,
501
+ headers: { "content-type": "text/html; charset=utf-8", "retry-after": "900" },
502
+ });
503
+ }
504
+
491
505
  let resolvedLogin: string | null = null;
492
506
  if (token && token === cfg.authToken) {
493
507
  resolvedLogin = cfg.operatorLogin;
@@ -505,6 +519,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
505
519
  }
506
520
 
507
521
  if (resolvedLogin) {
522
+ clearRateLimit(rlKey);
508
523
  const setCookie = await makeAuthCookieHeader(cfg, resolvedLogin);
509
524
  return new Response(null, {
510
525
  status: 302,
@@ -613,7 +628,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
613
628
  const q = url.searchParams.get("q")?.trim() ?? "";
614
629
  const label = url.searchParams.get("label")?.trim() ?? "";
615
630
  try {
616
- return html(await buildIssuesFeed(state, q, label));
631
+ return html(await buildIssuesFeed(state, q, label, ctx.user));
617
632
  } catch (e) {
618
633
  return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
619
634
  }
@@ -1731,6 +1746,25 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1731
1746
  }
1732
1747
  }
1733
1748
 
1749
+ const visMatch = url.pathname.match(REPO_VISIBILITY_RE);
1750
+ if (visMatch) {
1751
+ const [, owner, repo] = visMatch;
1752
+ if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
1753
+ const project = await getProject(owner, repo);
1754
+ if (!project) return html(errorPage("项目不存在", ""), 404);
1755
+ const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
1756
+ if (!(await canAdminProject(project.id, ctx.user))) {
1757
+ return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1758
+ }
1759
+ const fd = await req.formData();
1760
+ const visibility = fd.get("visibility");
1761
+ if (visibility !== "public" && visibility !== "private") {
1762
+ return Response.redirect(`${back}?err=${encodeURIComponent("无效的可见性")}`, 303);
1763
+ }
1764
+ await updateProjectVisibility(project.id, visibility);
1765
+ return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`可见性已更新为 ${visibility === "public" ? "公开" : "私有"}`)}`, 303);
1766
+ }
1767
+
1734
1768
  const whAction = url.pathname.match(WH_ACTION_RE);
1735
1769
  if (whAction) {
1736
1770
  const [, idStr, action] = whAction;
@@ -1900,6 +1934,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1900
1934
  if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
1901
1935
  const number = Number(numStr);
1902
1936
  try {
1937
+ const project = await getProject(owner, repo);
1938
+ if (!project) return html(errorPage("404", "项目不存在"), 404);
1939
+ if (!(await canReadProject(project.id, ctx.user))) return html(errorPage("404", "项目不存在"), 404);
1903
1940
  const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
1904
1941
  return html(body);
1905
1942
  } catch (e) {
@@ -1912,6 +1949,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1912
1949
  if (list) {
1913
1950
  const [, owner, repo] = list;
1914
1951
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
1952
+ const project = await getProject(owner, repo);
1953
+ if (!project) return html(errorPage("404", "项目不存在"), 404);
1954
+ if (!(await canReadProject(project.id, ctx.user))) return html(errorPage("404", "项目不存在"), 404);
1915
1955
  const state = parseState(url.searchParams.get("state"));
1916
1956
  const q = url.searchParams.get("q")?.trim() ?? "";
1917
1957
  const label = url.searchParams.get("label")?.trim() ?? "";
package/src/ratelimit.ts CHANGED
@@ -33,3 +33,7 @@ export function clientIp(req: Request): string {
33
33
  if (xff) return (xff.split(",")[0] ?? "").trim();
34
34
  return req.headers.get("x-real-ip") ?? "unknown";
35
35
  }
36
+
37
+ export function clearRateLimit(id: string): void {
38
+ buckets.delete(id);
39
+ }
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS {{projects}} (
28
28
  description VARCHAR(2048) NOT NULL DEFAULT '',
29
29
  upstream_urls VARCHAR(4096) NOT NULL DEFAULT '[]',
30
30
  model VARCHAR(128) NOT NULL DEFAULT '',
31
+ visibility VARCHAR(16) NOT NULL DEFAULT 'public',
31
32
  created_at VARCHAR(40) NOT NULL,
32
33
  updated_at VARCHAR(40) NOT NULL,
33
34
  UNIQUE (owner, name)
package/src/schema.sql CHANGED
@@ -30,6 +30,9 @@ CREATE TABLE IF NOT EXISTS {{projects}} (
30
30
  -- `opencode run --model <X>` by ework-daemon. Empty = inherit global
31
31
  -- defaultModel from the config table.
32
32
  model TEXT NOT NULL DEFAULT '',
33
+ -- 'public' = any logged-in user can read; 'private' = requires project
34
+ -- membership (reader+). Enforced by canReadProject().
35
+ visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','private')),
33
36
  created_at TEXT NOT NULL,
34
37
  updated_at TEXT NOT NULL,
35
38
  UNIQUE (owner, name)
package/src/store.ts CHANGED
@@ -36,6 +36,7 @@ export interface ProjectRow {
36
36
  description: string;
37
37
  upstream_urls: string;
38
38
  model: string;
39
+ visibility: string;
39
40
  created_at: string;
40
41
  updated_at: string;
41
42
  }
@@ -175,15 +176,27 @@ export interface ProjectWithCounts extends ProjectRow {
175
176
  total_count: number;
176
177
  }
177
178
 
178
- export async function listProjectsWithCounts(): Promise<ProjectWithCounts[]> {
179
+ export async function listProjectsWithCounts(
180
+ viewer?: { login: string; is_admin: number } | null,
181
+ ): Promise<ProjectWithCounts[]> {
182
+ const isAdmin = viewer?.is_admin === 1;
183
+ const login = viewer?.login;
184
+ const visibilityFilter = isAdmin
185
+ ? ""
186
+ : login
187
+ ? ` AND (p.visibility != 'private' OR EXISTS (SELECT 1 FROM {{project_members}} pm WHERE pm.project_id = p.id AND pm.user_login = ?))`
188
+ : ` AND p.visibility != 'private'`;
189
+ const args = !isAdmin && login ? [login] : [];
179
190
  return await getDB().all<ProjectWithCounts>(
180
191
  `SELECT p.*,
181
192
  COALESCE(SUM(CASE WHEN i.state = 'open' THEN 1 ELSE 0 END), 0) AS open_count,
182
193
  COUNT(i.id) AS total_count
183
194
  FROM {{projects}} p
184
195
  LEFT JOIN {{issues}} i ON i.project_id = p.id
196
+ WHERE 1=1${visibilityFilter}
185
197
  GROUP BY p.id
186
- ORDER BY p.updated_at DESC`
198
+ ORDER BY p.updated_at DESC`,
199
+ args,
187
200
  );
188
201
  }
189
202
 
@@ -211,6 +224,10 @@ export async function touchProject(projectId: number): Promise<void> {
211
224
  await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [now(), projectId]);
212
225
  }
213
226
 
227
+ export async function updateProjectVisibility(projectId: number, visibility: "public" | "private"): Promise<void> {
228
+ await getDB().run("UPDATE {{projects}} SET visibility = ?, updated_at = ? WHERE id = ?", [visibility, now(), projectId]);
229
+ }
230
+
214
231
  const MAX_UPSTREAM_URLS = 10;
215
232
  const UPSTREAM_URL_RE = /^(https?|ssh|git):\/\/[^\s]+$/i;
216
233
  const GIT_SCP_RE = /^[A-Za-z0-9_./-]+@[A-Za-z0-9._-]+:.+$/;
@@ -332,6 +349,8 @@ export interface ListIssuesOpts {
332
349
  q?: string;
333
350
  label?: string;
334
351
  limit?: number;
352
+ viewerLogin?: string;
353
+ viewerIsAdmin?: boolean;
335
354
  }
336
355
 
337
356
  export async function listIssues(projectId: number, opts: ListIssuesOpts = {}): Promise<IssueWithMeta[]> {
@@ -385,6 +404,14 @@ export async function listAllIssues(opts: ListIssuesOpts = {}): Promise<IssueWit
385
404
  sql += " AND EXISTS (SELECT 1 FROM {{issue_labels}} il JOIN {{labels}} l ON l.id = il.label_id WHERE il.issue_id = i.id AND l.name = ?)";
386
405
  args.push(label);
387
406
  }
407
+ if (!opts.viewerIsAdmin) {
408
+ sql += " AND (p.visibility != 'private'";
409
+ if (opts.viewerLogin) {
410
+ sql += " OR EXISTS (SELECT 1 FROM {{project_members}} pm WHERE pm.project_id = p.id AND pm.user_login = ?)";
411
+ args.push(opts.viewerLogin);
412
+ }
413
+ sql += ")";
414
+ }
388
415
  sql += " ORDER BY i.updated_at DESC LIMIT ?";
389
416
  args.push(limit);
390
417
  return await getDB().all<IssueWithMeta>(sql, args);
@@ -1208,6 +1235,16 @@ export async function canAdminProject(projectId: number, user: { login: string;
1208
1235
  return r === "admin";
1209
1236
  }
1210
1237
 
1238
+ export async function canReadProject(projectId: number, user: { login: string; is_admin: number } | null): Promise<boolean> {
1239
+ if (!user) return false;
1240
+ if (user.is_admin === 1) return true;
1241
+ const project = await getProjectById(projectId);
1242
+ if (!project) return false;
1243
+ if (project.visibility !== "private") return true;
1244
+ const r = await getRoleOnProject(projectId, user);
1245
+ return r !== null;
1246
+ }
1247
+
1211
1248
  export async function addProjectMember(projectId: number, userLogin: string, role: ProjectRole): Promise<ProjectMembership> {
1212
1249
  const login = userLogin.trim();
1213
1250
  if (!(await getUserByLogin(login))) throw new StoreError(404, `用户 ${login} 不存在`);
package/src/views/home.ts CHANGED
@@ -33,7 +33,7 @@ export async function buildHome(
33
33
  viewer: UserRow | null,
34
34
  flash: { kind: "ok" | "err"; msg: string } | null = null
35
35
  ): Promise<string> {
36
- const projects = await listProjectsWithCounts();
36
+ const projects = await listProjectsWithCounts(viewer);
37
37
  const cards = await Promise.all(projects.map((p) => projectCard(p, viewer)));
38
38
  const list = projects.length
39
39
  ? cards.join("")
@@ -26,8 +26,13 @@ export async function buildIssuesFeed(
26
26
  state: "open" | "closed" | "all",
27
27
  q: string,
28
28
  label: string = "",
29
+ viewer?: { login: string; is_admin: number } | null,
29
30
  ): Promise<string> {
30
- const issues = await listAllIssues({ state, q, label, limit: FEED_PAGE_SIZE });
31
+ const issues = await listAllIssues({
32
+ state, q, label, limit: FEED_PAGE_SIZE,
33
+ viewerLogin: viewer?.login,
34
+ viewerIsAdmin: viewer?.is_admin === 1,
35
+ });
31
36
  const labelMap = await listLabelsForIssues(issues.map((it) => it.id));
32
37
  const matchesFirst = q
33
38
  ? [...issues].sort((a, b) => Number(containsCI(b.title, q)) - Number(containsCI(a.title, q)))
@@ -107,6 +107,21 @@ ${tabNavHTML("projects")}
107
107
  ${projectSettingsTabsHTML(project.owner, project.name, "members")}
108
108
  ${flashHtml}
109
109
 
110
+ <form class="card" method="POST" action="${escapeAttr(`/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/visibility`)}}">
111
+ <h2>可见性</h2>
112
+ <div class="form-grid">
113
+ <div>
114
+ <label for="vis">项目可见性</label>
115
+ <select id="vis" name="visibility">
116
+ <option value="public"${project.visibility === "public" ? " selected" : ""}>🌐 公开 — 所有登录用户可见</option>
117
+ <option value="private"${project.visibility === "private" ? " selected" : ""}>🔒 私有 — 仅项目成员可见</option>
118
+ </select>
119
+ </div>
120
+ </div>
121
+ <div class="hint">公开 = 任何登录用户都能查看 issue 和评论;私有 = 需要项目成员(reader+)权限。Bot 用户已自动获得 writer 角色,不受影响。</div>
122
+ <button class="primary" type="submit">保存</button>
123
+ </form>
124
+
110
125
  <div class="card">
111
126
  <h2>当前成员(${members.length})</h2>
112
127
  ${rowsHtml}