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 +1 -1
- package/src/config.ts +8 -0
- package/src/db.ts +18 -1
- package/src/giteaApi.ts +7 -1
- package/src/index.ts +47 -7
- package/src/ratelimit.ts +4 -0
- package/src/schema-mysql.sql +1 -0
- package/src/schema.sql +3 -0
- package/src/store.ts +39 -2
- package/src/views/home.ts +1 -1
- package/src/views/issues.ts +6 -1
- package/src/views/projectMembers.ts +15 -0
package/package.json
CHANGED
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":
|
|
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
|
-
|
|
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
package/src/schema-mysql.sql
CHANGED
|
@@ -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(
|
|
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("")
|
package/src/views/issues.ts
CHANGED
|
@@ -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({
|
|
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}
|