ework-web 0.10.35 → 0.10.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.35",
3
+ "version": "0.10.36",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -93,6 +93,13 @@ export const configSchema = z.object({
93
93
  if (!Number.isFinite(n) || n < 1 || n > 64) return 6;
94
94
  return Math.floor(n);
95
95
  }, z.number().int().min(1).max(64)),
96
+ // Space-separated list of origins (e.g. "https://ework-web.taobao.net http://ework-web.taobao.net")
97
+ // injected into CSP form-action + connect-src. For reverse-proxy/gateway deployments
98
+ // where the browser sees a different scheme/host than the app's 'self'.
99
+ publicOrigins: z
100
+ .string()
101
+ .default("")
102
+ .transform((s) => s.split(/\s+/).filter(Boolean)),
96
103
  });
97
104
 
98
105
  export type Config = z.infer<typeof configSchema>;
@@ -188,6 +195,7 @@ export async function loadConfig(): Promise<Config> {
188
195
  defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
189
196
  autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
190
197
  webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
198
+ publicOrigins: process.env.WORK_PUBLIC_ORIGINS ?? "",
191
199
  });
192
200
  }
193
201
 
package/src/db.ts CHANGED
@@ -301,7 +301,8 @@ function translateForMysql(sql: string): string {
301
301
  return sql
302
302
  .replace(/INSERT OR IGNORE INTO/g, "INSERT IGNORE INTO")
303
303
  .replace(/ON CONFLICT\((\w+)\) DO UPDATE SET/g, "ON DUPLICATE KEY UPDATE")
304
- .replace(/excluded\.(\w+)/g, "VALUES($1)");
304
+ .replace(/excluded\.(\w+)/g, "VALUES($1)")
305
+ .replace(/LIKE \? ESCAPE '\\'/g, "LIKE ?");
305
306
  }
306
307
 
307
308
  async function migrateMysqlSurrogateId(pool: Pool): Promise<void> {
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,
@@ -185,13 +185,20 @@ async function autoWireAllProjects(origin: string): Promise<void> {
185
185
  void autoWireAllProjects(`http://${cfg.host}:${cfg.port}`);
186
186
 
187
187
  const SEC_HEADERS: Record<string, string> = {
188
- "content-security-policy": `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`,
188
+ "content-security-policy": buildCsp(cfg),
189
189
  "x-content-type-options": "nosniff",
190
190
  "x-frame-options": "DENY",
191
191
  "referrer-policy": "same-origin",
192
192
  "permissions-policy": "()",
193
193
  };
194
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
+
195
202
  const hlCss = loadHighlightCss();
196
203
 
197
204
  function loadHighlightCss(): string {
@@ -483,14 +490,18 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
483
490
  if (req.method === "POST") {
484
491
  const form = await req.formData().catch(() => new FormData());
485
492
  const next = sanitizeNext(String(form.get("next") ?? "/"));
486
- if (!rateLimit(`login:${ip}`, 5, 5 / (15 * 60))) {
487
- return html(loginHTML(next, "尝试过多,15 分钟后再试", cfg), 429);
488
- }
489
493
  const login = String(form.get("login") ?? "").trim();
490
494
  const password = String(form.get("password") ?? "");
491
495
  const token = String(form.get("token") ?? "").trim();
492
496
 
493
- // Token wins over login/password if both supplied (migration path).
497
+ const rlKey = `login:${ip}:${login || token || "?"}`;
498
+ if (!rateLimit(rlKey, 5, 5 / (15 * 60))) {
499
+ return new Response(loginHTML(next, "尝试过多,15 分钟后再试", cfg), {
500
+ status: 429,
501
+ headers: { "content-type": "text/html; charset=utf-8", "retry-after": "900" },
502
+ });
503
+ }
504
+
494
505
  let resolvedLogin: string | null = null;
495
506
  if (token && token === cfg.authToken) {
496
507
  resolvedLogin = cfg.operatorLogin;
@@ -508,6 +519,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
508
519
  }
509
520
 
510
521
  if (resolvedLogin) {
522
+ clearRateLimit(rlKey);
511
523
  const setCookie = await makeAuthCookieHeader(cfg, resolvedLogin);
512
524
  return new Response(null, {
513
525
  status: 302,
package/src/ratelimit.ts CHANGED
@@ -33,3 +33,7 @@ export function clientIp(req: Request): string {
33
33
  if (xff) return (xff.split(",")[0] ?? "").trim();
34
34
  return req.headers.get("x-real-ip") ?? "unknown";
35
35
  }
36
+
37
+ export function clearRateLimit(id: string): void {
38
+ buckets.delete(id);
39
+ }