ework-web 0.10.34 → 0.10.35
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/db.ts +16 -0
- package/src/giteaApi.ts +7 -1
- package/src/index.ts +29 -1
- 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/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 {
|
|
@@ -338,6 +341,18 @@ async function migrateMysqlSurrogateId(pool: Pool): Promise<void> {
|
|
|
338
341
|
}
|
|
339
342
|
}
|
|
340
343
|
|
|
344
|
+
async function migrateMysqlProjectsVisibility(pool: Pool): Promise<void> {
|
|
345
|
+
const [cols] = await pool.query(
|
|
346
|
+
applyPrefix("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{{projects}}' AND COLUMN_NAME = 'visibility'")
|
|
347
|
+
);
|
|
348
|
+
if (Array.isArray(cols) && cols.length > 0) return;
|
|
349
|
+
try {
|
|
350
|
+
await pool.query(applyPrefix("ALTER TABLE {{projects}} ADD COLUMN visibility VARCHAR(16) NOT NULL DEFAULT 'public'"));
|
|
351
|
+
} catch (e) {
|
|
352
|
+
console.warn("[db] MySQL projects visibility column add failed:", (e as Error).message);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
341
356
|
class MysqlDriver implements AsyncDatabase {
|
|
342
357
|
readonly dialect = "mysql" as const;
|
|
343
358
|
private readonly pool: Pool;
|
|
@@ -381,6 +396,7 @@ class MysqlDriver implements AsyncDatabase {
|
|
|
381
396
|
}
|
|
382
397
|
}
|
|
383
398
|
await migrateMysqlSurrogateId(pool);
|
|
399
|
+
await migrateMysqlProjectsVisibility(pool);
|
|
384
400
|
}
|
|
385
401
|
return new MysqlDriver(pool);
|
|
386
402
|
}
|
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
|
@@ -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,
|
|
@@ -359,6 +361,7 @@ const REPO_WEBHOOKS_RE = /^\/([^/]+)\/([^/]+)\/settings\/webhooks$/;
|
|
|
359
361
|
const REPO_MEMBERS_RE = /^\/([^/]+)\/([^/]+)\/settings\/members$/;
|
|
360
362
|
const REPO_MEMBER_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/([^/]+)\/(role|remove)$/;
|
|
361
363
|
const REPO_MEMBER_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/members\/add$/;
|
|
364
|
+
const REPO_VISIBILITY_RE = /^\/([^/]+)\/([^/]+)\/settings\/visibility$/;
|
|
362
365
|
const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
|
|
363
366
|
const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
|
|
364
367
|
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
|
|
@@ -613,7 +616,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
613
616
|
const q = url.searchParams.get("q")?.trim() ?? "";
|
|
614
617
|
const label = url.searchParams.get("label")?.trim() ?? "";
|
|
615
618
|
try {
|
|
616
|
-
return html(await buildIssuesFeed(state, q, label));
|
|
619
|
+
return html(await buildIssuesFeed(state, q, label, ctx.user));
|
|
617
620
|
} catch (e) {
|
|
618
621
|
return html(errorPage("加载失败", errMsg(e)), e instanceof StoreError ? e.status : 500);
|
|
619
622
|
}
|
|
@@ -1731,6 +1734,25 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1731
1734
|
}
|
|
1732
1735
|
}
|
|
1733
1736
|
|
|
1737
|
+
const visMatch = url.pathname.match(REPO_VISIBILITY_RE);
|
|
1738
|
+
if (visMatch) {
|
|
1739
|
+
const [, owner, repo] = visMatch;
|
|
1740
|
+
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
1741
|
+
const project = await getProject(owner, repo);
|
|
1742
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
1743
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/members`;
|
|
1744
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
1745
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
1746
|
+
}
|
|
1747
|
+
const fd = await req.formData();
|
|
1748
|
+
const visibility = fd.get("visibility");
|
|
1749
|
+
if (visibility !== "public" && visibility !== "private") {
|
|
1750
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无效的可见性")}`, 303);
|
|
1751
|
+
}
|
|
1752
|
+
await updateProjectVisibility(project.id, visibility);
|
|
1753
|
+
return Response.redirect(`${back}?ok=1&ok_msg=${encodeURIComponent(`可见性已更新为 ${visibility === "public" ? "公开" : "私有"}`)}`, 303);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1734
1756
|
const whAction = url.pathname.match(WH_ACTION_RE);
|
|
1735
1757
|
if (whAction) {
|
|
1736
1758
|
const [, idStr, action] = whAction;
|
|
@@ -1900,6 +1922,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1900
1922
|
if (!(owner && repo && numStr)) return html(errorPage("404", "bad path"), 404);
|
|
1901
1923
|
const number = Number(numStr);
|
|
1902
1924
|
try {
|
|
1925
|
+
const project = await getProject(owner, repo);
|
|
1926
|
+
if (!project) return html(errorPage("404", "项目不存在"), 404);
|
|
1927
|
+
if (!(await canReadProject(project.id, ctx.user))) return html(errorPage("404", "项目不存在"), 404);
|
|
1903
1928
|
const { html: body } = await buildIssueThread(cfg, owner, repo, number, ctx.user?.login);
|
|
1904
1929
|
return html(body);
|
|
1905
1930
|
} catch (e) {
|
|
@@ -1912,6 +1937,9 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1912
1937
|
if (list) {
|
|
1913
1938
|
const [, owner, repo] = list;
|
|
1914
1939
|
if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
|
|
1940
|
+
const project = await getProject(owner, repo);
|
|
1941
|
+
if (!project) return html(errorPage("404", "项目不存在"), 404);
|
|
1942
|
+
if (!(await canReadProject(project.id, ctx.user))) return html(errorPage("404", "项目不存在"), 404);
|
|
1915
1943
|
const state = parseState(url.searchParams.get("state"));
|
|
1916
1944
|
const q = url.searchParams.get("q")?.trim() ?? "";
|
|
1917
1945
|
const label = url.searchParams.get("label")?.trim() ?? "";
|
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}
|