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
package/src/logger.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
type Level = "debug" | "info" | "warn" | "error"
|
|
2
|
+
|
|
3
|
+
const LEVELS: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 }
|
|
4
|
+
|
|
5
|
+
function threshold(): number {
|
|
6
|
+
const configured = (process.env.WORK_LOG_LEVEL || "info").toLowerCase()
|
|
7
|
+
return LEVELS[configured as Level] ?? LEVELS.info
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function emit(level: Level, msg: string, fields?: Record<string, unknown>): void {
|
|
11
|
+
if (LEVELS[level] < threshold()) return
|
|
12
|
+
const payload: Record<string, unknown> = {
|
|
13
|
+
t: new Date().toISOString(),
|
|
14
|
+
level,
|
|
15
|
+
msg,
|
|
16
|
+
}
|
|
17
|
+
if (fields) {
|
|
18
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
19
|
+
if (v instanceof Error) payload[k] = { name: v.name, message: v.message, stack: v.stack }
|
|
20
|
+
else if (v !== undefined) payload[k] = v
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const line = JSON.stringify(payload)
|
|
24
|
+
if (level === "error" || level === "warn") process.stderr.write(line + "\n")
|
|
25
|
+
else process.stdout.write(line + "\n")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const log = {
|
|
29
|
+
debug: (msg: string, fields?: Record<string, unknown>) => emit("debug", msg, fields),
|
|
30
|
+
info: (msg: string, fields?: Record<string, unknown>) => emit("info", msg, fields),
|
|
31
|
+
warn: (msg: string, fields?: Record<string, unknown>) => emit("warn", msg, fields),
|
|
32
|
+
error: (msg: string, fields?: Record<string, unknown>) => emit("error", msg, fields),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const startTime = Date.now()
|
|
36
|
+
|
|
37
|
+
export function uptimeSeconds(): number {
|
|
38
|
+
return Math.floor((Date.now() - startTime) / 1000)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const VERSION = "0.1.0"
|
|
42
|
+
export function version(): string {
|
|
43
|
+
return VERSION
|
|
44
|
+
}
|
package/src/opencode.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { Config } from "./config";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { openSync, closeSync, readFileSync, unlinkSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
// OpenCode session client — READ-ONLY. Transcript detail goes through the
|
|
8
|
+
// `opencode export` CLI (stable, no schema coupling); the session LIST reads the
|
|
9
|
+
// DB directly (read-only bun:sqlite) because `opencode session list` filters out
|
|
10
|
+
// recent sessions by project/version — a global SELECT is the only way to list all.
|
|
11
|
+
// Editing (step 2) is deferred to an isolated Docker opencode.
|
|
12
|
+
// NOTE: `opencode export` truncates at 64KB when stdout is a pipe, so we redirect
|
|
13
|
+
// it to a temp file (same workaround oclog uses).
|
|
14
|
+
|
|
15
|
+
export class OpencodeError extends Error {
|
|
16
|
+
status: number;
|
|
17
|
+
constructor(message: string, status: number) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "OpencodeError";
|
|
20
|
+
this.status = status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SessionListItem {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
created: number;
|
|
28
|
+
updated: number;
|
|
29
|
+
directory?: string;
|
|
30
|
+
peakTokens?: number;
|
|
31
|
+
msgCount?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SessionInfo {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
directory: string;
|
|
38
|
+
version: string;
|
|
39
|
+
time: { created?: number; updated?: number };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ToolState {
|
|
43
|
+
input?: unknown;
|
|
44
|
+
output?: unknown;
|
|
45
|
+
status?: string;
|
|
46
|
+
title?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MessagePart {
|
|
50
|
+
type: string;
|
|
51
|
+
text?: string;
|
|
52
|
+
tool?: string;
|
|
53
|
+
state?: ToolState;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MessageTokens {
|
|
57
|
+
total?: number;
|
|
58
|
+
input?: number;
|
|
59
|
+
output?: number;
|
|
60
|
+
reasoning?: number;
|
|
61
|
+
cache?: { read?: number; write?: number };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MessageInfo {
|
|
65
|
+
role: string;
|
|
66
|
+
id: string;
|
|
67
|
+
agent?: string;
|
|
68
|
+
modelID?: string;
|
|
69
|
+
time?: { created?: number };
|
|
70
|
+
tokens?: MessageTokens;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SessionMessage {
|
|
74
|
+
info: MessageInfo;
|
|
75
|
+
parts: MessagePart[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SessionExport {
|
|
79
|
+
info: SessionInfo;
|
|
80
|
+
messages: SessionMessage[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class OpencodeClient {
|
|
84
|
+
private readonly bin: string;
|
|
85
|
+
private readonly dbPath: string;
|
|
86
|
+
private readonly timeoutMs: number;
|
|
87
|
+
private readonly maxBytes = 50 * 1024 * 1024;
|
|
88
|
+
|
|
89
|
+
constructor(cfg: Config) {
|
|
90
|
+
this.bin = cfg.opencodeBin;
|
|
91
|
+
this.dbPath = cfg.opencodeDbPath;
|
|
92
|
+
this.timeoutMs = cfg.upstreamTimeoutMs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Global session list via read-only SQLite SELECT. `opencode session list` (CLI)
|
|
96
|
+
// filters out recent sessions by project/version, so only a direct DB read lists
|
|
97
|
+
// all. WAL mode: a read-only connection never blocks opencode's writes.
|
|
98
|
+
async listSessions(limit: number): Promise<SessionListItem[]> {
|
|
99
|
+
let db: Database;
|
|
100
|
+
try {
|
|
101
|
+
db = new Database(this.dbPath, { readonly: true });
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
104
|
+
throw new OpencodeError(`cannot open opencode DB (${this.dbPath}): ${msg}`, 502);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const rows = db
|
|
108
|
+
.prepare(
|
|
109
|
+
"SELECT s.id AS id, s.title AS title, s.time_created AS created, s.time_updated AS updated, s.directory AS directory, " +
|
|
110
|
+
"m.peak AS peakTokens, m.calls AS msgCount " +
|
|
111
|
+
"FROM session s LEFT JOIN (" +
|
|
112
|
+
"SELECT session_id, MAX(CAST(json_extract(data,'$.tokens.input') AS INT) + CAST(json_extract(data,'$.tokens.cache.read') AS INT) + CAST(json_extract(data,'$.tokens.cache.write') AS INT)) AS peak, " +
|
|
113
|
+
"COUNT(*) AS calls FROM message WHERE json_extract(data,'$.tokens.input') > 0 GROUP BY session_id" +
|
|
114
|
+
") m ON m.session_id = s.id " +
|
|
115
|
+
"WHERE s.time_archived IS NULL ORDER BY s.time_updated DESC LIMIT ?"
|
|
116
|
+
)
|
|
117
|
+
.all(limit) as Array<{ id: unknown; title: unknown; created: unknown; updated: unknown; directory: unknown; peakTokens: unknown; msgCount: unknown }>;
|
|
118
|
+
return rows
|
|
119
|
+
.map((r) => {
|
|
120
|
+
const id = typeof r.id === "string" ? r.id : "";
|
|
121
|
+
if (!id) return null;
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
title: typeof r.title === "string" && r.title ? r.title : "(untitled)",
|
|
125
|
+
created: typeof r.created === "number" ? r.created : 0,
|
|
126
|
+
updated: typeof r.updated === "number" ? r.updated : 0,
|
|
127
|
+
directory: typeof r.directory === "string" ? r.directory : undefined,
|
|
128
|
+
peakTokens: typeof r.peakTokens === "number" && r.peakTokens > 0 ? r.peakTokens : undefined,
|
|
129
|
+
msgCount: typeof r.msgCount === "number" && r.msgCount > 0 ? r.msgCount : undefined,
|
|
130
|
+
} as SessionListItem;
|
|
131
|
+
})
|
|
132
|
+
.filter((x): x is SessionListItem => x !== null);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
135
|
+
throw new OpencodeError(`session list query failed: ${msg}`, 502);
|
|
136
|
+
} finally {
|
|
137
|
+
db.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async exportSession(id: string): Promise<SessionExport> {
|
|
142
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
143
|
+
throw new OpencodeError(`bad session id: ${id}`, 400);
|
|
144
|
+
}
|
|
145
|
+
const raw = await this.runJSON(["export", id]);
|
|
146
|
+
const exp = parseSessionExport(raw);
|
|
147
|
+
if (!exp) throw new OpencodeError(`malformed export for ${id}`, 502);
|
|
148
|
+
return exp;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async exportSessionRaw(id: string): Promise<string> {
|
|
152
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
153
|
+
throw new OpencodeError(`bad session id: ${id}`, 400);
|
|
154
|
+
}
|
|
155
|
+
const { stdout, code } = await this.run(["export", id]);
|
|
156
|
+
if (code !== 0) {
|
|
157
|
+
throw new OpencodeError(`opencode export ${id} → exit ${code}`, 502);
|
|
158
|
+
}
|
|
159
|
+
return stdout;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- subprocess plumbing ---
|
|
163
|
+
|
|
164
|
+
private async runJSON(args: string[]): Promise<unknown> {
|
|
165
|
+
const { stdout, code, stderr } = await this.run(args);
|
|
166
|
+
if (code !== 0) {
|
|
167
|
+
const why = stderr.trim() || `exit ${code}`;
|
|
168
|
+
const status = /not found|no such/i.test(why) ? 404 : 502;
|
|
169
|
+
throw new OpencodeError(`opencode ${args.join(" ")} failed: ${why}`, status);
|
|
170
|
+
}
|
|
171
|
+
const text = stdout.trim();
|
|
172
|
+
if (!text) return null;
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(text);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new OpencodeError(`opencode ${args.join(" ")}: non-JSON output`, 502);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async run(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
181
|
+
// stdout → temp file: `opencode export` truncates at 64KB when stdout is a
|
|
182
|
+
// pipe (kernel pipe buffer). stderr is small (progress lines) so a pipe is fine.
|
|
183
|
+
const tmp = join(tmpdir(), `ocweb-${process.pid}-${Math.random().toString(36).slice(2)}.json`);
|
|
184
|
+
const fd = openSync(tmp, "w");
|
|
185
|
+
const proc = Bun.spawn([this.bin, ...args], {
|
|
186
|
+
stdout: fd,
|
|
187
|
+
stderr: "pipe",
|
|
188
|
+
env: process.env,
|
|
189
|
+
});
|
|
190
|
+
// Kill the process if it runs too long, so a hung CLI can't stall a request.
|
|
191
|
+
const killer = setTimeout(() => {
|
|
192
|
+
try { proc.kill(); } catch { /* already exited */ }
|
|
193
|
+
}, this.timeoutMs);
|
|
194
|
+
let stderr = "";
|
|
195
|
+
let code: number | null = null;
|
|
196
|
+
try {
|
|
197
|
+
stderr = await readCapped(proc.stderr, 64 * 1024);
|
|
198
|
+
code = await proc.exited;
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(killer);
|
|
201
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
202
|
+
}
|
|
203
|
+
let stdout = "";
|
|
204
|
+
if (code === 0) {
|
|
205
|
+
try {
|
|
206
|
+
stdout = readFileSync(tmp, "utf-8");
|
|
207
|
+
} catch { /* file vanished — treat as empty */ }
|
|
208
|
+
if (stdout.length > this.maxBytes) {
|
|
209
|
+
try { unlinkSync(tmp); } catch { /* best-effort */ }
|
|
210
|
+
throw new OpencodeError(`opencode output exceeded ${this.maxBytes} bytes`, 413);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
try { unlinkSync(tmp); } catch { /* best-effort */ }
|
|
214
|
+
return { stdout, stderr, code };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function readCapped(stream: ReadableStream<Uint8Array> | null, maxBytes: number): Promise<string> {
|
|
219
|
+
if (!stream) return "";
|
|
220
|
+
const reader = stream.getReader();
|
|
221
|
+
const chunks: Uint8Array[] = [];
|
|
222
|
+
let total = 0;
|
|
223
|
+
for (;;) {
|
|
224
|
+
const { done, value } = await reader.read();
|
|
225
|
+
if (done) break;
|
|
226
|
+
if (value) {
|
|
227
|
+
total += value.byteLength;
|
|
228
|
+
if (total > maxBytes) {
|
|
229
|
+
try { await reader.cancel(); } catch { /* ignore */ }
|
|
230
|
+
throw new OpencodeError(`opencode output exceeded ${maxBytes} bytes`, 413);
|
|
231
|
+
}
|
|
232
|
+
chunks.push(value);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function asString(v: unknown): string {
|
|
239
|
+
return typeof v === "string" ? v : "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseSessionExport(v: unknown): SessionExport | null {
|
|
243
|
+
if (!v || typeof v !== "object") return null;
|
|
244
|
+
const o = v as Record<string, unknown>;
|
|
245
|
+
const infoRaw = o.info;
|
|
246
|
+
const msgsRaw = o.messages;
|
|
247
|
+
if (!infoRaw || typeof infoRaw !== "object") return null;
|
|
248
|
+
const info = parseSessionInfo(infoRaw);
|
|
249
|
+
if (!info) return null;
|
|
250
|
+
const messages = Array.isArray(msgsRaw) ? msgsRaw.map(parseMessage).filter((x): x is SessionMessage => x !== null) : [];
|
|
251
|
+
return { info, messages };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseSessionInfo(v: unknown): SessionInfo | null {
|
|
255
|
+
if (!v || typeof v !== "object") return null;
|
|
256
|
+
const o = v as Record<string, unknown>;
|
|
257
|
+
const id = typeof o.id === "string" ? o.id : "";
|
|
258
|
+
if (!id) return null;
|
|
259
|
+
const timeRaw = o.time && typeof o.time === "object" ? (o.time as Record<string, unknown>) : {};
|
|
260
|
+
return {
|
|
261
|
+
id,
|
|
262
|
+
title: asString(o.title) || "(untitled)",
|
|
263
|
+
directory: asString(o.directory),
|
|
264
|
+
version: asString(o.version),
|
|
265
|
+
time: {
|
|
266
|
+
created: typeof timeRaw.created === "number" ? timeRaw.created : undefined,
|
|
267
|
+
updated: typeof timeRaw.updated === "number" ? timeRaw.updated : undefined,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseMessage(v: unknown): SessionMessage | null {
|
|
273
|
+
if (!v || typeof v !== "object") return null;
|
|
274
|
+
const o = v as Record<string, unknown>;
|
|
275
|
+
const infoRaw = o.info && typeof o.info === "object" ? o.info : null;
|
|
276
|
+
if (!infoRaw) return null;
|
|
277
|
+
const info = parseMessageInfo(infoRaw);
|
|
278
|
+
if (!info) return null;
|
|
279
|
+
const partsRaw = Array.isArray(o.parts) ? o.parts : [];
|
|
280
|
+
const parts = partsRaw.map(parsePart).filter((x): x is MessagePart => x !== null);
|
|
281
|
+
return { info, parts };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseMessageInfo(v: unknown): MessageInfo | null {
|
|
285
|
+
if (!v || typeof v !== "object") return null;
|
|
286
|
+
const o = v as Record<string, unknown>;
|
|
287
|
+
const id = typeof o.id === "string" ? o.id : "";
|
|
288
|
+
const role = typeof o.role === "string" ? o.role : "";
|
|
289
|
+
if (!id || !role) return null;
|
|
290
|
+
const modelRaw = o.model && typeof o.model === "object" ? (o.model as Record<string, unknown>) : null;
|
|
291
|
+
const timeRaw = o.time && typeof o.time === "object" ? (o.time as Record<string, unknown>) : null;
|
|
292
|
+
return {
|
|
293
|
+
role,
|
|
294
|
+
id,
|
|
295
|
+
agent: typeof o.agent === "string" ? o.agent : undefined,
|
|
296
|
+
modelID: modelRaw && typeof modelRaw.modelID === "string" ? modelRaw.modelID : undefined,
|
|
297
|
+
time: timeRaw && typeof timeRaw.created === "number" ? { created: timeRaw.created } : undefined,
|
|
298
|
+
tokens: parseTokens(o.tokens),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function parseTokens(v: unknown): MessageTokens | undefined {
|
|
303
|
+
if (!v || typeof v !== "object") return undefined;
|
|
304
|
+
const o = v as Record<string, unknown>;
|
|
305
|
+
const num = (k: string): number | undefined =>
|
|
306
|
+
typeof o[k] === "number" && Number.isFinite(o[k] as number) ? (o[k] as number) : undefined;
|
|
307
|
+
const t: MessageTokens = {};
|
|
308
|
+
if (num("total") !== undefined) t.total = num("total");
|
|
309
|
+
if (num("input") !== undefined) t.input = num("input");
|
|
310
|
+
if (num("output") !== undefined) t.output = num("output");
|
|
311
|
+
if (num("reasoning") !== undefined) t.reasoning = num("reasoning");
|
|
312
|
+
const c = o.cache;
|
|
313
|
+
if (c && typeof c === "object") {
|
|
314
|
+
const co = c as Record<string, unknown>;
|
|
315
|
+
const cr = typeof co.read === "number" && Number.isFinite(co.read as number) ? (co.read as number) : undefined;
|
|
316
|
+
const cw = typeof co.write === "number" && Number.isFinite(co.write as number) ? (co.write as number) : undefined;
|
|
317
|
+
if (cr !== undefined || cw !== undefined) t.cache = { read: cr, write: cw };
|
|
318
|
+
}
|
|
319
|
+
return Object.keys(t).length ? t : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parsePart(v: unknown): MessagePart | null {
|
|
323
|
+
if (!v || typeof v !== "object") return null;
|
|
324
|
+
const o = v as Record<string, unknown>;
|
|
325
|
+
const type = typeof o.type === "string" ? o.type : "";
|
|
326
|
+
if (!type) return null;
|
|
327
|
+
const part: MessagePart = { type };
|
|
328
|
+
if (typeof o.text === "string") part.text = o.text;
|
|
329
|
+
if (typeof o.tool === "string") part.tool = o.tool;
|
|
330
|
+
if (o.state && typeof o.state === "object") {
|
|
331
|
+
const s = o.state as Record<string, unknown>;
|
|
332
|
+
const state: ToolState = {};
|
|
333
|
+
if (s.input !== undefined) state.input = s.input;
|
|
334
|
+
if (s.output !== undefined) state.output = s.output;
|
|
335
|
+
if (typeof s.status === "string") state.status = s.status;
|
|
336
|
+
if (typeof s.title === "string") state.title = s.title;
|
|
337
|
+
part.state = state;
|
|
338
|
+
}
|
|
339
|
+
return part;
|
|
340
|
+
}
|
package/src/ratelimit.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
interface Bucket {
|
|
2
|
+
tokens: number;
|
|
3
|
+
last: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const buckets = new Map<string, Bucket>();
|
|
7
|
+
const IDLE_TTL_MS = 60 * 60 * 1000;
|
|
8
|
+
let lastSweep = 0;
|
|
9
|
+
|
|
10
|
+
// Token-bucket limiter (H3). Returns true when a token was consumed (allow), false
|
|
11
|
+
// when the bucket is empty (caller answers 429). Guards /login against brute force
|
|
12
|
+
// and /api/* against abuse; idle buckets are swept so memory stays bounded.
|
|
13
|
+
export function rateLimit(id: string, capacity: number, refillPerSec: number): boolean {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
if (now - lastSweep > 5 * 60 * 1000) {
|
|
16
|
+
lastSweep = now;
|
|
17
|
+
for (const [k, v] of buckets) if (now - v.last > IDLE_TTL_MS) buckets.delete(k);
|
|
18
|
+
}
|
|
19
|
+
let b = buckets.get(id);
|
|
20
|
+
if (!b) {
|
|
21
|
+
b = { tokens: capacity, last: now };
|
|
22
|
+
buckets.set(id, b);
|
|
23
|
+
}
|
|
24
|
+
b.tokens = Math.min(capacity, b.tokens + ((now - b.last) / 1000) * refillPerSec);
|
|
25
|
+
b.last = now;
|
|
26
|
+
if (b.tokens < 1) return false;
|
|
27
|
+
b.tokens -= 1;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clientIp(req: Request): string {
|
|
32
|
+
const xff = req.headers.get("x-forwarded-for");
|
|
33
|
+
if (xff) return (xff.split(",")[0] ?? "").trim();
|
|
34
|
+
return req.headers.get("x-real-ip") ?? "unknown";
|
|
35
|
+
}
|
package/src/reactions.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { listReactionsFor } from "./store";
|
|
2
|
+
import type { CommentView } from "./render/components";
|
|
3
|
+
|
|
4
|
+
export const REACTION_EMOJI: Record<string, string> = {
|
|
5
|
+
"+1": "👍",
|
|
6
|
+
"-1": "👎",
|
|
7
|
+
eyes: "👀",
|
|
8
|
+
heart: "❤️",
|
|
9
|
+
laugh: "😄",
|
|
10
|
+
hooray: "🎉",
|
|
11
|
+
confused: "😕",
|
|
12
|
+
rocket: "🚀",
|
|
13
|
+
tada: "🎉",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function hydrateReactions(views: CommentView[]): void {
|
|
17
|
+
const ids = views.map((v) => v.id);
|
|
18
|
+
if (ids.length === 0) return;
|
|
19
|
+
const aggs = listReactionsFor(ids);
|
|
20
|
+
const byComment = new Map<number, { e: string; n: number }[]>();
|
|
21
|
+
for (const a of aggs) {
|
|
22
|
+
const emoji = REACTION_EMOJI[a.content] ?? a.content;
|
|
23
|
+
const arr = byComment.get(a.comment_id) ?? [];
|
|
24
|
+
arr.push({ e: emoji, n: a.n });
|
|
25
|
+
byComment.set(a.comment_id, arr);
|
|
26
|
+
}
|
|
27
|
+
for (const v of views) {
|
|
28
|
+
const r = byComment.get(v.id);
|
|
29
|
+
if (r && r.length > 0) v.reactions = r;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { actionBarHTML } from "./layout";
|
|
2
|
+
import type { UserKind } from "../store";
|
|
3
|
+
|
|
4
|
+
export type ActorTag = "human" | "bot" | "system";
|
|
5
|
+
|
|
6
|
+
export function classifyActor(body: string, authorKind?: UserKind | null): ActorTag {
|
|
7
|
+
const b = body ?? "";
|
|
8
|
+
if (b.startsWith("[system]")) return "system";
|
|
9
|
+
if (authorKind === "system") return "system";
|
|
10
|
+
if (authorKind === "bot") return "bot";
|
|
11
|
+
if (b.startsWith("[bot]")) return "bot";
|
|
12
|
+
return "human";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CommentView {
|
|
16
|
+
id: number;
|
|
17
|
+
tag: ActorTag;
|
|
18
|
+
login: string;
|
|
19
|
+
avatar: string;
|
|
20
|
+
created_at: string;
|
|
21
|
+
body_html: string;
|
|
22
|
+
reactions?: { e: string; n: number }[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TAG_LABEL: Record<ActorTag, string> = { human: "👤", bot: "🤖", system: "⚙️" };
|
|
26
|
+
|
|
27
|
+
function esc(s: string): string {
|
|
28
|
+
return (s ?? "")
|
|
29
|
+
.replace(/&/g, "&")
|
|
30
|
+
.replace(/</g, "<")
|
|
31
|
+
.replace(/>/g, ">")
|
|
32
|
+
.replace(/"/g, """);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function relTime(iso: string): string {
|
|
36
|
+
const t = Date.parse(iso);
|
|
37
|
+
if (!Number.isFinite(t)) return "";
|
|
38
|
+
const d = (Date.now() - t) / 1000;
|
|
39
|
+
if (d < 60) return "刚刚";
|
|
40
|
+
if (d < 3600) return Math.floor(d / 60) + "分钟前";
|
|
41
|
+
if (d < 86400) return Math.floor(d / 3600) + "小时前";
|
|
42
|
+
if (d < 86400 * 30) return Math.floor(d / 86400) + "天前";
|
|
43
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Must match app.js cardHTML so client hydration/polling produce identical markup.
|
|
47
|
+
export function renderCommentCard(c: CommentView): string {
|
|
48
|
+
const tag = c.tag || "human";
|
|
49
|
+
const label = TAG_LABEL[tag] || "👤";
|
|
50
|
+
const rx =
|
|
51
|
+
c.reactions && c.reactions.length
|
|
52
|
+
? `<span class="rx">${c.reactions
|
|
53
|
+
.map((r) => `<span class="rxc">${r.e}<span class="rxn">${r.n}</span></span>`)
|
|
54
|
+
.join("")}</span>`
|
|
55
|
+
: "";
|
|
56
|
+
return (
|
|
57
|
+
`<div class="item item-${esc(tag)}" id="comment-${c.id}" data-id="${c.id}">` +
|
|
58
|
+
`<div class="card"><div class="card-h">` +
|
|
59
|
+
`<span class="tag tag-${esc(tag)}">${label} ${esc(tag)}</span>` +
|
|
60
|
+
`<span class="who">${esc(c.login)}</span>` +
|
|
61
|
+
`<span class="when" data-ts="${esc(c.created_at)}" title="${esc(c.created_at)}">${relTime(c.created_at)}</span>` +
|
|
62
|
+
rx +
|
|
63
|
+
`<span class="card-actions">` + actionBarHTML({ cid: String(c.id), copy: true, link: true, translate: true, tts: true }) + `</span>` +
|
|
64
|
+
`</div><div class="card-b">${c.body_html}</div></div></div>`
|
|
65
|
+
);
|
|
66
|
+
}
|