ework-web 0.10.101 → 0.10.103

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.101",
3
+ "version": "0.10.103",
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/index.ts CHANGED
@@ -208,7 +208,6 @@ const SEC_HEADERS: Record<string, string> = {
208
208
  "x-content-type-options": "nosniff",
209
209
  "x-frame-options": "DENY",
210
210
  "referrer-policy": "same-origin",
211
- "permissions-policy": "()",
212
211
  };
213
212
 
214
213
  function buildCsp(cfg: Config): string {
@@ -1828,11 +1827,19 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1828
1827
  const [, owner, repo, numStr] = cp;
1829
1828
  if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
1830
1829
  const number = Number(numStr);
1830
+ const isFormSubmit = (req.headers.get("content-type") || "").includes("application/x-www-form-urlencoded");
1831
1831
  try {
1832
- const payload = (await req.json().catch(() => ({}))) as { body?: unknown; close?: unknown; reopen?: unknown };
1833
- const body = typeof payload.body === "string" ? payload.body : "";
1832
+ const payload = isFormSubmit
1833
+ ? ((await req.formData().catch(() => new FormData())) as FormData)
1834
+ : ((await req.json().catch(() => ({}))) as { body?: unknown; close?: unknown; reopen?: unknown });
1835
+ const body = typeof (payload as FormData).get === "function"
1836
+ ? String((payload as FormData).get("body") ?? "")
1837
+ : typeof (payload as { body?: unknown }).body === "string"
1838
+ ? ((payload as { body: string }).body)
1839
+ : "";
1834
1840
  const hasBody = body.trim().length > 0;
1835
- const wantsStateChange = payload.close === true || payload.reopen === true;
1841
+ const jsonPayload = isFormSubmit ? null : (payload as { close?: unknown; reopen?: unknown });
1842
+ const wantsStateChange = (jsonPayload?.close ?? jsonPayload?.reopen) === true;
1836
1843
  if (!hasBody && !wantsStateChange) return json({ error: "body required" }, 400);
1837
1844
  if (body.length > 65536) return json({ error: "body too long" }, 413);
1838
1845
  const project = await getProject(owner, repo);
@@ -1855,11 +1862,11 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1855
1862
  }
1856
1863
  let closed = false;
1857
1864
  let reopened = false;
1858
- if (payload.close === true) {
1865
+ if (jsonPayload?.close === true) {
1859
1866
  await setIssueState(issue.id, "closed");
1860
1867
  closed = true;
1861
1868
  void emitIssueEvent(project.id, issue.id, "closed", url.origin);
1862
- } else if (payload.reopen === true) {
1869
+ } else if (jsonPayload?.reopen === true) {
1863
1870
  await setIssueState(issue.id, "open");
1864
1871
  reopened = true;
1865
1872
  void emitIssueEvent(project.id, issue.id, "reopened", url.origin);
@@ -1867,8 +1874,17 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1867
1874
  if (view) {
1868
1875
  void emitCommentEvent(project.id, issue.id, view.id, url.origin);
1869
1876
  }
1877
+ if (isFormSubmit) {
1878
+ return new Response(null, {
1879
+ status: 303,
1880
+ headers: { location: `/${owner}/${repo}/issues/${number}${view ? `#comment-${view.id}` : ""}` },
1881
+ });
1882
+ }
1870
1883
  return json({ comment: view, closed, reopened });
1871
1884
  } catch (e) {
1885
+ if (isFormSubmit) {
1886
+ return new Response(null, { status: 303, headers: { location: `/${owner}/${repo}/issues/${number}` } });
1887
+ }
1872
1888
  return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
1873
1889
  }
1874
1890
  }
@@ -263,8 +263,8 @@ ${props.descriptionHtml.trim() ? `<div class="desc-wrap">
263
263
  ${props.descriptionCollapsed ? `<button type="button" class="desc-toggle" id="descToggle">显示详情 ▾</button>` : ""}
264
264
  </div>` : ""}
265
265
  ${props.writesEnabled !== false
266
- ? `<form id="composer" class="composer">
267
- <textarea id="composerInput" rows="5" placeholder="写评论…(Ctrl/⌘+Enter 发送,以 ${escapeHtml(op)} 身份;可粘贴/选择图片或任意文件上传)"></textarea>
266
+ ? `<form id="composer" class="composer" method="post" action="/api/${encodeURIComponent(repoOwner ?? "")}/${encodeURIComponent(repoName ?? "")}/issues/${props.issueNumber}/comment">
267
+ <textarea id="composerInput" name="body" rows="5" placeholder="写评论…(Ctrl/⌘+Enter 发送,以 ${escapeHtml(op)} 身份;可粘贴/选择图片或任意文件上传)"></textarea>
268
268
  <div class="submit-col">
269
269
  <button type="button" id="composerClose" class="btn-close" data-action="${toggleAction}" title="${toggleTitle}">${toggleLabel}</button>
270
270
  <label class="upload-btn" title="上传图片/附件/文件">📎<input type="file" id="composerFile" multiple></label>
package/src/static/app.js CHANGED
@@ -6,6 +6,16 @@
6
6
  const initialEl = document.getElementById("initial-data");
7
7
  const P = initialEl ? JSON.parse(initialEl.textContent) : null;
8
8
  if (!P) return;
9
+ const earlyForm = document.getElementById("composer");
10
+ if (earlyForm) {
11
+ earlyForm.addEventListener(
12
+ "submit",
13
+ (e) => {
14
+ if (!e.defaultPrevented) e.preventDefault();
15
+ },
16
+ { capture: true }
17
+ );
18
+ }
9
19
  const MAX_DOM = 300;
10
20
  const POLL_MS = 5000;
11
21
  const NEW_FADE_MS = 4500;
package/src/store.ts CHANGED
@@ -627,6 +627,20 @@ export async function getIssueByUpstreamNumber(projectId: number, upstreamNumber
627
627
  );
628
628
  }
629
629
 
630
+ export async function linkIssueToUpstream(issueId: number, upstreamNumber: number): Promise<boolean> {
631
+ try {
632
+ const res = await getDB().run(
633
+ "UPDATE {{issues}} SET upstream_issue_number = ?, updated_at = ? WHERE id = ? AND upstream_issue_number IS NULL",
634
+ [upstreamNumber, now(), issueId]
635
+ );
636
+ return res.changes > 0;
637
+ } catch {
638
+ // Unique index (project_id, upstream_issue_number): another row already
639
+ // holds this mapping — leave it alone, the existing winner stays linked.
640
+ return false;
641
+ }
642
+ }
643
+
630
644
  export async function getCommentByUpstreamId(upstreamCommentId: number): Promise<CommentRow | null> {
631
645
  return await getDB().get<CommentRow>(
632
646
  "SELECT * FROM {{comments}} WHERE upstream_comment_id = ?",
@@ -3,7 +3,9 @@ import {
3
3
  type ProjectRow,
4
4
  type IssueRow,
5
5
  getProjectById,
6
+ getIssue,
6
7
  getIssueByUpstreamNumber,
8
+ linkIssueToUpstream,
7
9
  getCommentByUpstreamId,
8
10
  createIssue,
9
11
  ensureUser,
@@ -110,6 +112,22 @@ export class UpstreamSync {
110
112
  }
111
113
 
112
114
  private async importIssue(gi: GiteaIssue, emit: boolean): Promise<void> {
115
+ const body = gi.body ?? "";
116
+ // Issues ework-mirror created upstream reference their origin issue by
117
+ // number in the footer. Linking instead of importing prevents the echo
118
+ // loop (local → mirror → GitHub → poll → twin) while keeping comment and
119
+ // state sync flowing to the original.
120
+ const mirrored = body.includes("<!-- ework-mirror -->")
121
+ ? body.match(/Mirrored from ework issue #(\d+)/)
122
+ : null;
123
+ if (mirrored?.[1]) {
124
+ const origin = await getIssue(this.project.id, Number(mirrored[1]));
125
+ if (origin) {
126
+ await linkIssueToUpstream(origin.id, gi.number);
127
+ return;
128
+ }
129
+ }
130
+ if (body.includes("<!-- ework-mirror -->")) return;
113
131
  await ensureUser(gi.user?.login ?? "upstream", fromGithubBot(gi.user?.login) ? "bot" : "human");
114
132
  await createIssue(
115
133
  this.project.id,
@@ -82,7 +82,7 @@ function syncCardHtml(project: ProjectRow, sync: UpstreamSyncRow | null): string
82
82
  ${statusHtml}
83
83
  <div class="form-grid">
84
84
  <div><label for="s-base">上游地址(Gitea 根地址,如 http://host:3000)</label>
85
- <input id="s-base" name="base_url" type="url" placeholder="http://192.168.10.96:3300" value="${escapeAttr(baseUrl)}" required></div>
85
+ <input id="s-base" name="base_url" type="url" placeholder="http://192.168.1.100:3300" value="${escapeAttr(baseUrl)}" required></div>
86
86
  <div><label for="s-owner">上游 owner</label>
87
87
  <input id="s-owner" name="upstream_owner" value="${escapeAttr(sync?.upstream_owner ?? project.owner)}" required></div>
88
88
  <div><label for="s-repo">上游 repo</label>