ework-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/ework-web.js +2 -0
- package/package.json +61 -0
- package/src/attachments.ts +54 -0
- package/src/auth.ts +218 -0
- package/src/build.ts +17 -0
- package/src/config.ts +178 -0
- package/src/db.ts +106 -0
- package/src/fileview.ts +617 -0
- package/src/giteaApi.ts +333 -0
- package/src/index.ts +1310 -0
- package/src/logger.ts +44 -0
- package/src/opencode.ts +340 -0
- package/src/ratelimit.ts +35 -0
- package/src/reactions.ts +31 -0
- package/src/render/components.ts +66 -0
- package/src/render/layout.ts +240 -0
- package/src/render/markdown.ts +106 -0
- package/src/schema.sql +178 -0
- package/src/static/app.js +571 -0
- package/src/static/favicon.svg +6 -0
- package/src/static/file.js +103 -0
- package/src/static/session.js +380 -0
- package/src/static/tts.js +101 -0
- package/src/store.ts +1020 -0
- package/src/translate.ts +166 -0
- package/src/views/adminTokens.ts +122 -0
- package/src/views/home.ts +109 -0
- package/src/views/issueList.ts +89 -0
- package/src/views/issueNew.ts +35 -0
- package/src/views/issueThread.ts +179 -0
- package/src/views/issues.ts +66 -0
- package/src/views/projectMembers.ts +146 -0
- package/src/views/projectUpstreams.ts +145 -0
- package/src/views/sessionLog.ts +709 -0
- package/src/views/settings.ts +67 -0
- package/src/views/tokens.ts +146 -0
- package/src/views/ttsBackends.ts +98 -0
- package/src/views/users.ts +163 -0
- package/src/views/webhooks.ts +166 -0
- package/src/webhooks.ts +634 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { BUILD_ID } from "../build";
|
|
2
|
+
export interface LayoutProps {
|
|
3
|
+
title: string;
|
|
4
|
+
issueTitle: string;
|
|
5
|
+
repoPath: string;
|
|
6
|
+
issueNumber: number;
|
|
7
|
+
state: string;
|
|
8
|
+
totalComments: number;
|
|
9
|
+
descriptionHtml: string;
|
|
10
|
+
descriptionCollapsed: boolean;
|
|
11
|
+
bodyClass?: string;
|
|
12
|
+
writesEnabled?: boolean;
|
|
13
|
+
operatorLogin?: string;
|
|
14
|
+
upstreamWebUrl?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const THEME_CSS = `
|
|
18
|
+
:root{
|
|
19
|
+
--bg:#ffffff;--bg-muted:#f5f5f5;--bg-elev:#ffffff;--border:#e1e4e8;--text:#24292e;--text-muted:#6a737d;
|
|
20
|
+
--accent:#2185d0;--green:#2da44e;--header-bg:#2c3338;--header-text:#e6edf3;
|
|
21
|
+
--human:#0969da;--bot:#2da44e;--system:#8957e5;--code-bg:#f6f8fa;
|
|
22
|
+
}
|
|
23
|
+
@media (prefers-color-scheme:dark){:root{
|
|
24
|
+
--bg:#1b1b1b;--bg-muted:#232323;--bg-elev:#262626;--border:#373737;--text:#e6e6e6;--text-muted:#9a9a9a;
|
|
25
|
+
--accent:#4493f8;--green:#3fb950;--header-bg:#161616;--header-text:#e6edf3;
|
|
26
|
+
--human:#4493f8;--bot:#3fb950;--system:#bc8cff;--code-bg:#0d1117;
|
|
27
|
+
}}
|
|
28
|
+
*{box-sizing:border-box}
|
|
29
|
+
mark.hl{background:#ffe082;color:#b71c1c;font-weight:700;border-radius:2px;padding:0 2px}
|
|
30
|
+
@media (prefers-color-scheme:dark){mark.hl{background:#5d4037;color:#ff8a80}}
|
|
31
|
+
.fsnip{color:var(--text-muted);font-size:13px;margin-top:.2rem;line-height:1.4;overflow-wrap:anywhere}
|
|
32
|
+
html,body{margin:0;padding:0;background:var(--bg);color:var(--text);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,"PingFang SC","Microsoft YaHei",sans-serif}
|
|
33
|
+
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
|
34
|
+
header.topbar{background:var(--header-bg);color:var(--header-text);padding:.55rem 1rem;display:flex;align-items:center;gap:.6rem;font-size:13px;flex-wrap:wrap}
|
|
35
|
+
header.topbar .repo{opacity:.85}
|
|
36
|
+
header.topbar .num{opacity:.7}
|
|
37
|
+
.state-badge{font-size:12px;padding:.1rem .5rem;border-radius:10px;font-weight:600}
|
|
38
|
+
.state-open{background:#1a7f37;color:#fff}.state-closed{background:#8250df;color:#fff}
|
|
39
|
+
.meta-bar{display:flex;flex-direction:column;gap:.3rem;padding:.7rem max(1rem,calc((100% - 900px)/2));border-bottom:1px solid var(--border);background:var(--bg-muted)}
|
|
40
|
+
.meta-bar h1{font-size:18px;margin:0;font-weight:600;overflow-wrap:anywhere;word-break:break-word}
|
|
41
|
+
.meta-status{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;font-size:13px;color:var(--text-muted)}
|
|
42
|
+
.count{color:var(--text-muted);font-size:12px;white-space:nowrap}
|
|
43
|
+
#list{padding:.5rem .6rem 4rem;max-width:900px;margin:0 auto}
|
|
44
|
+
.sentinel{height:1px}
|
|
45
|
+
.loader,#loadOlderWrap{display:flex;justify-content:center;padding:1rem;color:var(--text-muted);font-size:13px}
|
|
46
|
+
#loadOlder{background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;padding:.5rem 1.2rem;color:var(--text);cursor:pointer;font-size:13px}
|
|
47
|
+
#loadOlder:hover{border-color:var(--accent)}
|
|
48
|
+
.item{padding:.7rem .2rem;position:relative}
|
|
49
|
+
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;overflow:hidden;min-width:0}
|
|
50
|
+
.card-h{display:flex;align-items:center;gap:.5rem;padding:.4rem .7rem;background:var(--bg-muted);font-size:13px;flex-wrap:wrap}
|
|
51
|
+
.card-h .who{font-weight:600;color:var(--text)}
|
|
52
|
+
.tag{font-size:11px;font-weight:600;padding:.05rem .4rem;border-radius:4px;line-height:1.5}
|
|
53
|
+
.tag-human{background:color-mix(in srgb,var(--human) 18%,transparent);color:var(--human)}
|
|
54
|
+
.tag-bot{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
|
|
55
|
+
.tag-system{background:color-mix(in srgb,var(--system) 18%,transparent);color:var(--system)}
|
|
56
|
+
.when{color:var(--text-muted);font-size:12px}
|
|
57
|
+
.clink{background:none;border:none;cursor:pointer;font-size:13px;color:var(--text-muted);padding:0 .2rem;opacity:.6;line-height:1.5}
|
|
58
|
+
.clink:hover{opacity:1;color:var(--accent)}
|
|
59
|
+
.clink.done{color:var(--green);opacity:1}
|
|
60
|
+
.card-actions{margin-left:auto;display:inline-flex;gap:.3rem;align-items:center}
|
|
61
|
+
.ttsbtn{background:none;border:none;cursor:pointer;font-size:13px;color:var(--text-muted);padding:0 .2rem;opacity:.6;line-height:1.5}
|
|
62
|
+
.ttsbtn:hover{opacity:1;color:var(--accent)}
|
|
63
|
+
.ttsbtn.playing{color:var(--green);opacity:1}
|
|
64
|
+
.ttsstop{background:none;border:none;cursor:pointer;font-size:13px;color:var(--text-muted);padding:0 .2rem;line-height:1.5}
|
|
65
|
+
.ttsstop:hover{color:var(--accent)}
|
|
66
|
+
.cbtn,.tbtn{background:none;border:none;cursor:pointer;font-size:13px;color:var(--text-muted);padding:0 .2rem;opacity:.6;line-height:1.5}
|
|
67
|
+
.cbtn:hover,.tbtn:hover{opacity:1;color:var(--accent)}
|
|
68
|
+
.clink:hover{opacity:1;color:var(--accent)}
|
|
69
|
+
.clink.done{color:var(--green);opacity:1}
|
|
70
|
+
.rx{display:inline-flex;gap:.3rem;align-items:center;margin-left:.2rem;flex-wrap:wrap}
|
|
71
|
+
.rxc{display:inline-flex;align-items:center;gap:.15rem;font-size:12px;background:var(--bg-muted);border:1px solid var(--border);border-radius:10px;padding:0 .4rem;line-height:1.6}
|
|
72
|
+
.rxn{font-size:11px;color:var(--text-muted)}
|
|
73
|
+
.card-b{padding:.6rem .8rem;overflow-wrap:anywhere;word-break:break-word}
|
|
74
|
+
.card-b pre{background:var(--code-bg);padding:.7rem;border-radius:6px;overflow:auto;font-size:12.5px}
|
|
75
|
+
.card-b code{background:var(--code-bg);padding:.1em .35em;border-radius:4px;font-size:12.5px}
|
|
76
|
+
.card-b pre code{background:none;padding:0}
|
|
77
|
+
.card-b img,.desc img{max-width:100%;height:auto}
|
|
78
|
+
.card-b table,.desc table{border-collapse:collapse;margin:.6rem 0;font-size:13px}
|
|
79
|
+
.card-b th,.card-b td,.desc th,.desc td{border:1px solid var(--border);padding:.35rem .55rem;text-align:left;vertical-align:top}
|
|
80
|
+
.card-b th,.desc th{background:var(--bg-muted);font-weight:600}
|
|
81
|
+
.item-system .card{border-style:dashed;opacity:.86}
|
|
82
|
+
.item-system .card-b{font-size:13px;color:var(--text-muted)}
|
|
83
|
+
.hidden{display:none!important}
|
|
84
|
+
.new-marker{background:color-mix(in srgb,var(--green) 12%,transparent);transition:background 4s ease}
|
|
85
|
+
.composer{display:flex;flex-direction:column;gap:.5rem;padding:.7rem 1rem;max-width:900px;margin:0 auto .9rem}
|
|
86
|
+
.composer textarea{width:100%;resize:vertical;min-height:6em;max-height:18em;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.6rem .7rem;font:inherit;font-size:15px;line-height:1.5}
|
|
87
|
+
.composer .submit-col{display:flex;flex-direction:row;gap:.5rem;justify-content:flex-end;align-items:center}
|
|
88
|
+
.composer .upload-btn{background:var(--bg-muted);color:var(--text-muted);border:1px solid var(--border);border-radius:8px;padding:.4rem .6rem;font-size:18px;line-height:1;cursor:pointer;position:relative;overflow:hidden}
|
|
89
|
+
.composer .upload-btn input{position:absolute;inset:0;opacity:0;cursor:pointer}
|
|
90
|
+
.composer textarea:focus{outline:none;border-color:var(--accent)}
|
|
91
|
+
.composer button{flex-shrink:0;background:var(--green);color:#fff;border:none;border-radius:8px;padding:.55rem 1.1rem;font:600 13px system-ui,sans-serif;cursor:pointer}
|
|
92
|
+
.composer button:disabled{opacity:.5;cursor:default}
|
|
93
|
+
.composer .btn-close{background:var(--bg-muted);color:var(--text);border:1px solid var(--border);margin-right:auto}
|
|
94
|
+
.composer .btn-close:hover{border-color:var(--accent)}
|
|
95
|
+
.composer .btn-close.armed{background:#d23f31;color:#fff;border-color:#d23f31}
|
|
96
|
+
.composer-ro{max-width:900px;margin:0 auto .9rem;padding:.7rem 1rem;color:var(--text-muted);font-size:13px;text-align:center;border:1px dashed var(--border);border-radius:8px}
|
|
97
|
+
.tabs{display:flex;gap:.4rem;padding:.6rem 1rem;border-bottom:1px solid var(--border);max-width:900px;margin:0 auto}
|
|
98
|
+
.tab{padding:.4rem 1rem;border-radius:6px;font-size:14px;color:var(--text-muted)}
|
|
99
|
+
.tab.active{background:var(--bg-muted);color:var(--text);font-weight:600}
|
|
100
|
+
.desc-wrap{max-width:900px;margin:0 auto;padding:.5rem 1rem}
|
|
101
|
+
.desc-actions{margin-bottom:.4rem;display:flex;gap:.3rem;justify-content:flex-end}
|
|
102
|
+
.desc{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:.6rem .8rem;overflow:hidden;overflow-wrap:anywhere;word-break:break-word}
|
|
103
|
+
.desc.collapsed{max-height:8.5em;position:relative}
|
|
104
|
+
.desc.collapsed::after{content:"";position:absolute;inset:auto 0 0 0;height:2.6em;background:linear-gradient(transparent,var(--bg-elev))}
|
|
105
|
+
.desc-toggle{margin-top:.4rem;background:none;border:none;color:var(--accent);font-size:13px;cursor:pointer;padding:.2rem 0}
|
|
106
|
+
.upstream-link{font-size:12px;color:var(--accent);opacity:.85}
|
|
107
|
+
.upstream-link:hover{opacity:1;text-decoration:underline}
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
export function renderLayout(props: LayoutProps, inner: string, initialItems: string): string {
|
|
111
|
+
const stateClass = props.state === "closed" ? "state-closed" : "state-open";
|
|
112
|
+
const stateLabel = props.state === "closed" ? "Closed" : "Open";
|
|
113
|
+
const isClosed = props.state === "closed";
|
|
114
|
+
const toggleAction = isClosed ? "reopen" : "close";
|
|
115
|
+
const toggleLabel = isClosed ? "评论并重新打开" : "评论并关闭";
|
|
116
|
+
const toggleTitle = isClosed ? "发评论并重新打开工单" : "发评论并关闭工单";
|
|
117
|
+
const [repoOwner, repoName] = props.repoPath.split("/");
|
|
118
|
+
const repoIssuesHref = `/${encodeURIComponent(repoOwner ?? "")}/${encodeURIComponent(repoName ?? "")}/issues`;
|
|
119
|
+
const op = props.operatorLogin ?? "operator";
|
|
120
|
+
return `<!doctype html>
|
|
121
|
+
<html lang="zh">
|
|
122
|
+
<head>
|
|
123
|
+
<meta charset="utf-8">
|
|
124
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
125
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
126
|
+
<title>${escapeHtml(props.title)}</title>
|
|
127
|
+
<link rel="stylesheet" href="/static/highlight.css">
|
|
128
|
+
<style>${THEME_CSS}</style>
|
|
129
|
+
</head>
|
|
130
|
+
<body class="${props.bodyClass ?? ""}">
|
|
131
|
+
<header class="topbar">
|
|
132
|
+
<a href="/" style="color:var(--header-text)">🏠</a>
|
|
133
|
+
<span style="opacity:.5">/</span>
|
|
134
|
+
<a href="${escapeAttr(repoIssuesHref)}" style="color:var(--header-text)">${escapeHtml(props.repoPath)}</a>
|
|
135
|
+
<span class="num">#${props.issueNumber}</span>
|
|
136
|
+
</header>
|
|
137
|
+
<div class="meta-bar">
|
|
138
|
+
<h1>${escapeHtml(props.issueTitle)}</h1>
|
|
139
|
+
<div class="meta-status">
|
|
140
|
+
<span class="state-badge ${stateClass}">${stateLabel}</span>
|
|
141
|
+
<span class="count" id="count">…</span>
|
|
142
|
+
${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
${props.descriptionHtml.trim() ? `<div class="desc-wrap">
|
|
146
|
+
<div class="desc-actions">${actionBarHTML({ copy: true, link: true, translate: true, tts: true })}</div>
|
|
147
|
+
<div class="desc${props.descriptionCollapsed ? " collapsed" : ""}" id="issueDesc">${props.descriptionHtml}</div>
|
|
148
|
+
${props.descriptionCollapsed ? `<button type="button" class="desc-toggle" id="descToggle">显示详情 ▾</button>` : ""}
|
|
149
|
+
</div>` : ""}
|
|
150
|
+
${props.writesEnabled !== false
|
|
151
|
+
? `<form id="composer" class="composer">
|
|
152
|
+
<textarea id="composerInput" rows="5" placeholder="写评论…(Ctrl/⌘+Enter 发送,以 ${escapeHtml(op)} 身份;可粘贴/选择图片或任意文件上传)"></textarea>
|
|
153
|
+
<div class="submit-col">
|
|
154
|
+
<button type="button" id="composerClose" class="btn-close" data-action="${toggleAction}" title="${toggleTitle}">${toggleLabel}</button>
|
|
155
|
+
<label class="upload-btn" title="上传图片/附件/文件">📎<input type="file" id="composerFile" multiple></label>
|
|
156
|
+
<button type="submit" id="composerSubmit">发送</button>
|
|
157
|
+
</div>
|
|
158
|
+
</form>`
|
|
159
|
+
: `<div class="composer-ro">📝 只读模式:评论 / 上传未启用(WORK_WRITES_ENABLED=false)</div>`}
|
|
160
|
+
<main id="list">
|
|
161
|
+
<div id="items">${initialItems}</div>
|
|
162
|
+
<div class="sentinel" id="sentinel"></div>
|
|
163
|
+
<div id="loadOlderWrap"><button id="loadOlder">加载更早</button></div>
|
|
164
|
+
<div class="loader hidden" id="loader">加载中…</div>
|
|
165
|
+
</main>
|
|
166
|
+
<script type="application/json" id="initial-data">${inner}</script>
|
|
167
|
+
<script src="/static/tts.js?v=${BUILD_ID}" defer></script>
|
|
168
|
+
<script src="/static/app.js?v=${BUILD_ID}" defer></script>
|
|
169
|
+
</body>
|
|
170
|
+
</html>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function escapeHtml(s: string): string {
|
|
174
|
+
return (s ?? "")
|
|
175
|
+
.replace(/&/g, "&")
|
|
176
|
+
.replace(/</g, "<")
|
|
177
|
+
.replace(/>/g, ">")
|
|
178
|
+
.replace(/"/g, """)
|
|
179
|
+
.replace(/'/g, "'");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function escapeAttr(s: string): string {
|
|
183
|
+
return escapeHtml(s);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function containsCI(haystack: string, needle: string): boolean {
|
|
187
|
+
if (!needle) return false;
|
|
188
|
+
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function highlightAll(text: string, needle: string): string {
|
|
192
|
+
const esc = escapeHtml(text);
|
|
193
|
+
if (!needle) return esc;
|
|
194
|
+
const n = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
195
|
+
return esc.replace(new RegExp(`(${n})`, "gi"), '<mark class="hl">$1</mark>');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function snipHighlight(text: string, needle: string, radius = 50): string {
|
|
199
|
+
if (!needle) return "";
|
|
200
|
+
const flat = text.replace(/\s+/g, " ");
|
|
201
|
+
const lo = flat.toLowerCase();
|
|
202
|
+
const n = needle.toLowerCase();
|
|
203
|
+
const idx = lo.indexOf(n);
|
|
204
|
+
if (idx < 0) return "";
|
|
205
|
+
const start = Math.max(0, idx - radius);
|
|
206
|
+
const end = Math.min(flat.length, idx + n.length + radius);
|
|
207
|
+
const prefix = start > 0 ? "…" : "";
|
|
208
|
+
const suffix = end < flat.length ? "…" : "";
|
|
209
|
+
return `${prefix}${escapeHtml(flat.slice(start, idx))}<mark class="hl">${escapeHtml(flat.slice(idx, idx + n.length))}</mark>${escapeHtml(flat.slice(idx + n.length, end))}${suffix}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function tabNavHTML(active: "projects" | "issues" | "sessions" | "me", user?: { login: string; is_admin: number | boolean }): string {
|
|
213
|
+
const p = active === "projects" ? " active" : "";
|
|
214
|
+
const i = active === "issues" ? " active" : "";
|
|
215
|
+
const s = active === "sessions" ? " active" : "";
|
|
216
|
+
const m = active === "me" ? " active" : "";
|
|
217
|
+
const userTab = user
|
|
218
|
+
? `<a class="tab${m}" href="/me" title="${escapeAttr(user.login)}">👤 ${escapeHtml(user.login)}</a>`
|
|
219
|
+
: "";
|
|
220
|
+
return `<nav class="tabs"><a class="tab${p}" href="/projects">项目</a><a class="tab${i}" href="/issues">Issues</a><a class="tab${s}" href="/sessions">会话</a><a class="tab" href="/settings" style="margin-left:auto">⚙️ 设置</a>${userTab}</nav>`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface ActionBarOpts {
|
|
224
|
+
cid?: string;
|
|
225
|
+
copy?: boolean;
|
|
226
|
+
link?: boolean;
|
|
227
|
+
translate?: boolean;
|
|
228
|
+
tts?: boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function actionBarHTML(o: ActionBarOpts): string {
|
|
232
|
+
const d = o.cid != null ? ` data-cid="${escapeAttr(o.cid)}"` : "";
|
|
233
|
+
const cpy = o.copy ? `<button type="button" class="cbtn"${d} title="复制">📋</button>` : "";
|
|
234
|
+
const lnk = o.link ? `<button type="button" class="clink"${d} title="复制楼层链接">🔗</button>` : "";
|
|
235
|
+
const tr = o.translate ? `<button type="button" class="tbtn"${d} title="翻译">翻译</button>` : "";
|
|
236
|
+
const tts = o.tts
|
|
237
|
+
? `<button type="button" class="ttsstop"${d} title="停止朗读">⏹</button><button type="button" class="ttsbtn"${d} title="朗读">🔊</button>`
|
|
238
|
+
: "";
|
|
239
|
+
return cpy + lnk + tr + tts;
|
|
240
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Marked } from "marked";
|
|
2
|
+
import { markedHighlight } from "marked-highlight";
|
|
3
|
+
import hljs from "highlight.js";
|
|
4
|
+
import createDOMPurify from "dompurify";
|
|
5
|
+
import { JSDOM } from "jsdom";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { resolve as resolvePath } from "path";
|
|
8
|
+
|
|
9
|
+
const marked = new Marked(
|
|
10
|
+
markedHighlight({
|
|
11
|
+
emptyLangClass: "hljs",
|
|
12
|
+
langPrefix: "hljs language-",
|
|
13
|
+
highlight(code, lang) {
|
|
14
|
+
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
|
|
15
|
+
try {
|
|
16
|
+
return hljs.highlight(code, { language }).value;
|
|
17
|
+
} catch {
|
|
18
|
+
return code;
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
marked.setOptions({ gfm: true, breaks: true });
|
|
25
|
+
|
|
26
|
+
// XSS boundary: marked passes raw HTML through by default, so any body with
|
|
27
|
+
// <img onerror>/<script>/javascript: is stored XSS. Sanitize runs before
|
|
28
|
+
// linkify; the injected <a> tags have fixed hrefs over controlled tokens.
|
|
29
|
+
const purify = createDOMPurify(new JSDOM("").window);
|
|
30
|
+
const PURIFY_OPTS = {
|
|
31
|
+
FORBID_TAGS: ["style", "form", "input", "button", "textarea", "select", "iframe", "object", "embed", "link", "meta"],
|
|
32
|
+
FORBID_ATTR: ["style", "onerror", "onload", "onclick", "onmouseover", "onfocus", "onblur"],
|
|
33
|
+
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|\.\/|\.\.\/|#)/i,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function renderMarkdown(body: string, baseDir = ""): string {
|
|
37
|
+
const dirty = marked.parse(body ?? "");
|
|
38
|
+
const html = typeof dirty === "string" ? dirty : "";
|
|
39
|
+
const clean = purify.sanitize(html, PURIFY_OPTS) as unknown as string;
|
|
40
|
+
return linkifyAbsPaths(linkifySessionIDs(clean), baseDir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Linkify ses_ IDs in text nodes only — skips tag interiors (don't corrupt
|
|
44
|
+
// hrefs) and existing <a> text (don't double-wrap). Runs post-sanitize with a
|
|
45
|
+
// fixed /sessions/ href over an alphanumeric token: never re-sanitize it out.
|
|
46
|
+
export function linkifySessionIDs(html: string): string {
|
|
47
|
+
let out = "";
|
|
48
|
+
let inA = false;
|
|
49
|
+
const re = /(<[^>]*>)|([^<]+)/g;
|
|
50
|
+
let m: RegExpExecArray | null;
|
|
51
|
+
while ((m = re.exec(html)) !== null) {
|
|
52
|
+
if (m[1] !== undefined) {
|
|
53
|
+
const tag = m[1];
|
|
54
|
+
if (/^<a[\s>]/i.test(tag)) inA = true;
|
|
55
|
+
else if (/^<\/a[\s>]/i.test(tag)) inA = false;
|
|
56
|
+
out += tag;
|
|
57
|
+
} else if (m[2] !== undefined) {
|
|
58
|
+
out += inA
|
|
59
|
+
? m[2]
|
|
60
|
+
: m[2].replace(/ses_[0-9A-Za-z]{8,}/g, (id) => `<a href="/sessions/${id}">${id}</a>`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Linkify paths in text nodes — three forms:
|
|
67
|
+
// - home: ~/foo/bar → expands ~ to os.homedir()
|
|
68
|
+
// - absolute: /foo/bar (≥2 segments, existing behavior)
|
|
69
|
+
// - relative: foo/bar.ext → resolved against `baseDir` (e.g. session workdir)
|
|
70
|
+
// `pre` is a leading boundary (start, or non-word/non-slash/non-colon) so URLs
|
|
71
|
+
// (http://host/path, ":" excluded) and emails aren't split. Relative paths need
|
|
72
|
+
// a "." (extension hint) to avoid linkifying prose like "foo/bar". Runs
|
|
73
|
+
// post-sanitize; the /file endpoint re-validates against fileRoots on click.
|
|
74
|
+
const PATH_RE = /(^|[^\w\/:])((?:~\/[\w.\-]+(?:\/[\w.\-]+)*\/?)|(?:\/[\w.\-]+(?:\/[\w.\-]+)+\/?)|(?:[\w.\-]+(?:\/[\w.\-]+)+\/?))/g;
|
|
75
|
+
|
|
76
|
+
function resolveToken(token: string, baseDir: string): string | null {
|
|
77
|
+
if (token.startsWith("~")) return homedir() + token.slice(1);
|
|
78
|
+
if (token.startsWith("/")) return token;
|
|
79
|
+
if (!baseDir || !token.includes(".")) return null;
|
|
80
|
+
return resolvePath(baseDir, token);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function linkifyAbsPaths(html: string, baseDir = ""): string {
|
|
84
|
+
let out = "";
|
|
85
|
+
let inA = false;
|
|
86
|
+
const re = /(<[^>]*>)|([^<]+)/g;
|
|
87
|
+
let m: RegExpExecArray | null;
|
|
88
|
+
while ((m = re.exec(html)) !== null) {
|
|
89
|
+
if (m[1] !== undefined) {
|
|
90
|
+
const tag = m[1];
|
|
91
|
+
if (/^<a[\s>]/i.test(tag)) inA = true;
|
|
92
|
+
else if (/^<\/a[\s>]/i.test(tag)) inA = false;
|
|
93
|
+
out += tag;
|
|
94
|
+
} else if (m[2] !== undefined) {
|
|
95
|
+
out += inA
|
|
96
|
+
? m[2]
|
|
97
|
+
: m[2].replace(PATH_RE, (_full, pre: string, token: string) => {
|
|
98
|
+
const resolved = resolveToken(token, baseDir);
|
|
99
|
+
return resolved
|
|
100
|
+
? `${pre}<a href="/file?path=${encodeURIComponent(resolved)}">${token}</a>`
|
|
101
|
+
: `${pre}${token}`;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
package/src/schema.sql
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
-- ework schema. Applied idempotently on boot (IF NOT EXISTS everywhere).
|
|
2
|
+
-- See db.ts for PRAGMA setup (WAL + foreign_keys = ON).
|
|
3
|
+
|
|
4
|
+
-- login stays PRIMARY KEY intentionally. Renaming users is not supported;
|
|
5
|
+
-- swap to INTEGER user_id + UNIQUE(login) if that ever changes.
|
|
6
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
7
|
+
login TEXT PRIMARY KEY,
|
|
8
|
+
kind TEXT NOT NULL DEFAULT 'human'
|
|
9
|
+
CHECK (kind IN ('human','bot','system')),
|
|
10
|
+
display_name TEXT,
|
|
11
|
+
password_hash TEXT,
|
|
12
|
+
email TEXT,
|
|
13
|
+
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
15
|
+
created_at TEXT NOT NULL,
|
|
16
|
+
updated_at TEXT NOT NULL DEFAULT ''
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
owner TEXT NOT NULL,
|
|
22
|
+
name TEXT NOT NULL,
|
|
23
|
+
description TEXT NOT NULL DEFAULT '',
|
|
24
|
+
-- JSON array of upstream Git URLs (any platform: Gitea, GitHub, GitLab, ...).
|
|
25
|
+
-- First entry is the default; daemon reads it via webhook payload's
|
|
26
|
+
-- repository.clone_url so it knows where AI should `git clone` from.
|
|
27
|
+
-- Empty array = no upstream bound (project is purely a tracker).
|
|
28
|
+
upstream_urls TEXT NOT NULL DEFAULT '[]',
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
updated_at TEXT NOT NULL,
|
|
31
|
+
UNIQUE (owner, name)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS issues (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
37
|
+
number INTEGER NOT NULL,
|
|
38
|
+
title TEXT NOT NULL,
|
|
39
|
+
body TEXT NOT NULL DEFAULT '',
|
|
40
|
+
state TEXT NOT NULL DEFAULT 'open'
|
|
41
|
+
CHECK (state IN ('open','closed')),
|
|
42
|
+
author TEXT NOT NULL REFERENCES users(login),
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
updated_at TEXT NOT NULL,
|
|
45
|
+
UNIQUE (project_id, number)
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS issues_project_state_updated
|
|
48
|
+
ON issues (project_id, state, updated_at DESC);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS issues_state_updated
|
|
50
|
+
ON issues (state, updated_at DESC);
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
53
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
54
|
+
issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
|
55
|
+
author TEXT NOT NULL REFERENCES users(login),
|
|
56
|
+
body TEXT NOT NULL,
|
|
57
|
+
created_at TEXT NOT NULL,
|
|
58
|
+
updated_at TEXT NOT NULL DEFAULT ''
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS comments_issue_created
|
|
61
|
+
ON comments (issue_id, created_at);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS labels (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
66
|
+
name TEXT NOT NULL,
|
|
67
|
+
color TEXT NOT NULL DEFAULT '#888888',
|
|
68
|
+
UNIQUE (project_id, name)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS issue_labels (
|
|
72
|
+
issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
|
73
|
+
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
|
74
|
+
PRIMARY KEY (issue_id, label_id)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS reactions (
|
|
78
|
+
comment_id INTEGER NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
|
79
|
+
user_login TEXT NOT NULL REFERENCES users(login),
|
|
80
|
+
content TEXT NOT NULL,
|
|
81
|
+
PRIMARY KEY (comment_id, user_login, content)
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS reactions_comment
|
|
84
|
+
ON reactions (comment_id);
|
|
85
|
+
|
|
86
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
87
|
+
uuid TEXT PRIMARY KEY,
|
|
88
|
+
issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
|
89
|
+
filename TEXT NOT NULL,
|
|
90
|
+
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
91
|
+
size INTEGER NOT NULL,
|
|
92
|
+
blob_path TEXT NOT NULL,
|
|
93
|
+
uploaded_by TEXT NOT NULL REFERENCES users(login),
|
|
94
|
+
created_at TEXT NOT NULL
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS attachments_issue
|
|
97
|
+
ON attachments (issue_id);
|
|
98
|
+
|
|
99
|
+
-- Webhooks (Gitea-compatible). Scoped per-project so different repos can fan out
|
|
100
|
+
-- to different downstream consumers. `events` is a JSON array of event types
|
|
101
|
+
-- ('issues', 'issue_comment', 'push', ...). ework v1 emits only the first two.
|
|
102
|
+
CREATE TABLE IF NOT EXISTS webhooks (
|
|
103
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
105
|
+
url TEXT NOT NULL,
|
|
106
|
+
secret TEXT NOT NULL DEFAULT '',
|
|
107
|
+
content_type TEXT NOT NULL DEFAULT 'application/json',
|
|
108
|
+
-- JSON array of event names: e.g. '["issues","issue_comment"]'.
|
|
109
|
+
events TEXT NOT NULL DEFAULT '["issues","issue_comment"]',
|
|
110
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
111
|
+
created_at TEXT NOT NULL,
|
|
112
|
+
updated_at TEXT NOT NULL
|
|
113
|
+
);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS webhooks_project
|
|
115
|
+
ON webhooks (project_id);
|
|
116
|
+
|
|
117
|
+
-- Delivery history. One row per attempt. Retries append new rows (don't overwrite),
|
|
118
|
+
-- so a failed webhook shows the full retry trail.
|
|
119
|
+
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
120
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
121
|
+
webhook_id INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
|
|
122
|
+
event TEXT NOT NULL,
|
|
123
|
+
delivery_uuid TEXT NOT NULL,
|
|
124
|
+
payload TEXT NOT NULL,
|
|
125
|
+
response_status INTEGER,
|
|
126
|
+
response_body TEXT,
|
|
127
|
+
duration_ms INTEGER,
|
|
128
|
+
error TEXT,
|
|
129
|
+
created_at TEXT NOT NULL
|
|
130
|
+
);
|
|
131
|
+
CREATE INDEX IF NOT EXISTS deliveries_webhook
|
|
132
|
+
ON webhook_deliveries (webhook_id);
|
|
133
|
+
CREATE INDEX IF NOT EXISTS deliveries_created
|
|
134
|
+
ON webhook_deliveries (created_at DESC);
|
|
135
|
+
|
|
136
|
+
-- Personal Access Tokens (Gitea-aligned). Hashed with per-token salt so the
|
|
137
|
+
-- DB leak doesn't reveal tokens; last_eight enables indexed lookup without
|
|
138
|
+
-- storing the plaintext. `scopes` is stored but not yet enforced — every PAT
|
|
139
|
+
-- inherits the user's full perms in v1; granularity lands with project_members.
|
|
140
|
+
CREATE TABLE IF NOT EXISTS personal_access_tokens (
|
|
141
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
142
|
+
user_login TEXT NOT NULL REFERENCES users(login) ON DELETE CASCADE,
|
|
143
|
+
name TEXT NOT NULL,
|
|
144
|
+
salt TEXT NOT NULL,
|
|
145
|
+
token_hash TEXT NOT NULL,
|
|
146
|
+
token_last_eight TEXT NOT NULL,
|
|
147
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
148
|
+
-- JSON array of CIDR strings (IPv4). Empty = no restriction. Validated in
|
|
149
|
+
-- store.ts createPat; verifyPat checks the request IP against this list.
|
|
150
|
+
ip_allowlist TEXT NOT NULL DEFAULT '[]',
|
|
151
|
+
expires_at TEXT,
|
|
152
|
+
last_used_at TEXT,
|
|
153
|
+
created_at TEXT NOT NULL,
|
|
154
|
+
revoked_at TEXT
|
|
155
|
+
);
|
|
156
|
+
CREATE INDEX IF NOT EXISTS pat_user
|
|
157
|
+
ON personal_access_tokens (user_login);
|
|
158
|
+
CREATE INDEX IF NOT EXISTS pat_last_eight
|
|
159
|
+
ON personal_access_tokens (token_last_eight);
|
|
160
|
+
|
|
161
|
+
-- Per-project RBAC. Roles follow Gitea semantics:
|
|
162
|
+
-- reader: can read issues + comments (currently no-op since all authed users
|
|
163
|
+
-- can read; reserved for future private projects)
|
|
164
|
+
-- writer: + create issues, comment, close/reopen, upload attachments, react
|
|
165
|
+
-- admin: + manage project webhooks + manage project members
|
|
166
|
+
-- Site-admins (users.is_admin=1) bypass all checks. PAT scope enforcement also
|
|
167
|
+
-- routes through here: a write-scoped PAT can only write where the owning user
|
|
168
|
+
-- has writer+ role on the target project.
|
|
169
|
+
CREATE TABLE IF NOT EXISTS project_members (
|
|
170
|
+
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
171
|
+
user_login TEXT NOT NULL REFERENCES users(login) ON DELETE CASCADE,
|
|
172
|
+
role TEXT NOT NULL DEFAULT 'writer'
|
|
173
|
+
CHECK (role IN ('reader','writer','admin')),
|
|
174
|
+
created_at TEXT NOT NULL,
|
|
175
|
+
PRIMARY KEY (project_id, user_login)
|
|
176
|
+
);
|
|
177
|
+
CREATE INDEX IF NOT EXISTS project_members_user
|
|
178
|
+
ON project_members (user_login);
|