From 4ac36cd2ebdfde8d2e28d1e3e3bf8f97bc1cf36c Mon Sep 17 00:00:00 2001 From: LiHaohua Date: Tue, 28 Jul 2026 16:42:51 +0800 Subject: [PATCH] fix(portal): make "My packages" actually find the caller's packages "My packages" derived ownership solely by parsing the deb Maintainer field for a GitHub noreply address, so every package published with a real email (fxcam, calendar, ...) was invisible to its uploader; fresh submissions were also invisible until merge + index rebuild, and the in-page index cache never expired, so even then a reload was required. - resolve ownership from the published owners.json (aggregated from the release manifests' authoritative uploaded_by; generated by the packages repo Pages build), keeping the noreply fallback for legacy packages - new GET /api/my-submissions: recent publish PRs of the caller (portal PRs matched by title, czdev PRs by author) so just-uploaded packages show up immediately as "under review" / "merged, index rebuilding" - index/owners caches now expire after 30s, are cache-busted with ?t=, and are dropped after a successful submit - worker submit/unpublish pre-checks and the upload preview also use owners.json instead of guessing from Maintainer - the Owner column shows @login when known (falls back to the email) Co-authored-by: Cursor --- site/app.js | 109 +++++++++++++++++++++++++++++++++++++------ site/i18n/en.json | 32 +++++++++---- site/i18n/ja.json | 32 +++++++++---- site/i18n/zh-CN.json | 32 +++++++++---- site/index.html | 2 +- worker/src/index.js | 82 +++++++++++++++++++++++++++++--- 6 files changed, 245 insertions(+), 44 deletions(-) diff --git a/site/app.js b/site/app.js index 0417cdd..aed3bda 100644 --- a/site/app.js +++ b/site/app.js @@ -189,11 +189,25 @@ async function init() { } } +// The index/owners caches expire quickly (and are dropped entirely after a +// successful submit) so "My packages" reflects a fresh publish without a full +// page reload; the ?t= cache-buster defeats stale CDN/browser copies. +const INDEX_TTL_MS = 30 * 1000; +let indexFetchedAt = 0; +let ownersCache = null; + +function invalidateIndexCache() { + publishedIndex = null; + ownersCache = null; + indexFetchedAt = 0; +} + async function loadIndex() { - if (publishedIndex) return publishedIndex; + if (publishedIndex && Date.now() - indexFetchedAt < INDEX_TTL_MS) return publishedIndex; publishedIndex = new Map(); + indexFetchedAt = Date.now(); try { - const text = await (await fetch(INDEX_URL)).text(); + const text = await (await fetch(`${INDEX_URL}?t=${Date.now()}`)).text(); for (const para of text.split(/\n\n+/)) { const f = {}; for (const line of para.split("\n")) { @@ -208,6 +222,44 @@ async function loadIndex() { return publishedIndex; } +/** + * Authoritative ownership map published alongside the APT index: + * { "": { "": "" } }, aggregated + * from the release manifests' `uploaded_by`. Legacy packages predating the + * ownership record are absent — callers fall back to loginFromNoreply. + */ +async function loadOwners() { + if (ownersCache && Date.now() - indexFetchedAt < INDEX_TTL_MS) return ownersCache; + ownersCache = {}; + try { + const url = INDEX_URL.replace(/dists\/.*$/, "owners.json"); + const r = await fetch(`${url}?t=${Date.now()}`); + if (r.ok) ownersCache = await r.json(); + } catch { /* fall back to Maintainer-derived ownership */ } + return ownersCache; +} + +/** Owner login of a published package: owners.json first, noreply fallback. */ +function packageOwner(owners, pkg, entries) { + const versions = owners && owners[pkg]; + if (versions) { + for (const v of Object.keys(versions)) { + const login = String(versions[v] || "").toLowerCase(); + if (login) return login; + } + } + return loginFromNoreply((entries && entries[0] && entries[0].Maintainer) || ""); +} + +/** Recent publish PRs of mine (open = under review, merged = index rebuilding). */ +async function loadMySubmissions() { + try { + const r = await fetch("/api/my-submissions"); + if (r.ok) return await r.json(); + } catch { /* portal API unreachable — published list still renders */ } + return []; +} + /* --------------------------------- tabs ---------------------------------- */ // Each tab is a distinct URL hash (#/upload, #/mine) so a page refresh keeps // the user on the same tab and the browser back/forward buttons work. @@ -321,15 +373,15 @@ async function renderPreview() { : `${t("preview.needLogin")}`; // 版本 / 包名占用预检(所有权按上传者 GitHub 账号先到先得) - const idx = await loadIndex(); + const [idx, owners] = await Promise.all([loadIndex(), loadOwners()]); const entries = idx.get(c.Package) || []; let verState = "", verOk = true; if (!entries.length) { verState = `${me ? t("preview.newPkg", { login: me.login }) : t("preview.newPkgAnon")}`; } else { - // 前端只能读到线上索引里的 Maintainer,尽力从 noreply 地址反推 owner; - // 服务端会按记录的 uploaded_by 权威复核。 - const ownerLogin = loginFromNoreply(entries[0].Maintainer || ""); + // owners.json 是发布侧聚合的权威归属(uploaded_by);老包缺记录时退回 + // 从 noreply Maintainer 反推。服务端会按 uploaded_by 权威复核。 + const ownerLogin = packageOwner(owners, c.Package, entries); const owned = me && (me.is_admin || (ownerLogin && ownerLogin === me.login.toLowerCase())); const latest = entries.map((e) => e.Version).sort(compareDebVersions).pop(); if (ownerLogin && !owned) { @@ -497,6 +549,9 @@ $("submit-btn").addEventListener("click", async () => { const r = await fetch("/api/submit", { method: "POST", body }); const data = await r.json(); if (!r.ok) throw new Error(data.detail || data.error || `HTTP ${r.status}`); + // Drop the cached index/owners so "My packages" immediately shows this + // submission (as a pending row) instead of a stale snapshot. + invalidateIndexCache(); say("ok", t("upload.submitOk", { message: data.message, actions: data.actions_url, @@ -516,24 +571,52 @@ async function renderMine() { // Show a spinner while the (possibly slow) APT index fetch is in flight, so // an empty table never looks like a broken page. rows.innerHTML = `
${t("mine.loading")}
`; - const idx = await loadIndex(); + const [idx, owners, submissions] = await Promise.all([loadIndex(), loadOwners(), loadMySubmissions()]); + const mine = []; for (const [name, entries] of idx) { + // Ownership is per-package (first-come): resolve once from owners.json, + // falling back to a noreply Maintainer for legacy packages. + const ownerLogin = packageOwner(owners, name, entries); + if (!(me.is_admin || (ownerLogin && ownerLogin === me.login.toLowerCase()))) continue; for (const e of entries) { - const ownerLogin = loginFromNoreply(e.Maintainer || ""); - if (me.is_admin || (ownerLogin && ownerLogin === me.login.toLowerCase())) { - mine.push({ name, ...e, email: extractEmail(e.Maintainer || "").toLowerCase() }); - } + mine.push({ name, ...e, owner: ownerLogin, email: extractEmail(e.Maintainer || "").toLowerCase() }); } } - if (!mine.length) { + + // Submissions not yet in the published index: open PR = under review; + // merged PR = published but the index/CDN is still rebuilding (~minutes). + const pending = submissions.filter((sub) => { + if (sub.state !== "open" && sub.state !== "merged") return false; + return !(idx.get(sub.package) || []).some((e) => e.Version === sub.version); + }); + + if (!mine.length && !pending.length) { rows.innerHTML = `${t("mine.empty")}`; return; } rows.innerHTML = ""; + for (const sub of pending) { + const tr = document.createElement("tr"); + const status = sub.state === "open" ? t("mine.statusReview") : t("mine.statusPublishing"); + tr.innerHTML = `${sub.package}${sub.version}@${sub.submitter}`; + const actions = tr.lastElementChild; + const badge = document.createElement("span"); + badge.className = "muted"; + badge.textContent = status; + actions.appendChild(badge); + const a = document.createElement("a"); + a.className = "dl-btn"; + a.textContent = t("mine.viewPr"); + a.href = sub.url; + a.target = "_blank"; + a.rel = "noopener"; + actions.appendChild(a); + rows.appendChild(tr); + } for (const p of mine.sort((a, b) => a.name.localeCompare(b.name) || compareDebVersions(a.Version, b.Version))) { const tr = document.createElement("tr"); - tr.innerHTML = `${p.name}${p.Version}${p.email}`; + tr.innerHTML = `${p.name}${p.Version}${p.owner ? "@" + p.owner : p.email}`; const actions = tr.lastElementChild; const dl = debDownloadUrl(p); if (dl) { diff --git a/site/i18n/en.json b/site/i18n/en.json index 0279f3a..2965b4a 100644 --- a/site/i18n/en.json +++ b/site/i18n/en.json @@ -1,13 +1,26 @@ { - "meta": { "title": "Developer Center · CardputerZero AppStore" }, - "language": { "label": "Language" }, - "header": { "title": "CardputerZero Developer Center", "logout": "Sign out", "admin": " (admin)" }, - "boot": { "loading": "Loading…" }, + "meta": { + "title": "Developer Center · CardputerZero AppStore" + }, + "language": { + "label": "Language" + }, + "header": { + "title": "CardputerZero Developer Center", + "logout": "Sign out", + "admin": " (admin)" + }, + "boot": { + "loading": "Loading…" + }, "login": { "intro": "Sign in with GitHub to upload and manage AppStore packages.
Package names are first-come, first-served: whoever first uploads a name owns it under their GitHub account,
and only the original uploader (or an admin) can update or unpublish that name afterwards.
", "button": "Sign in with GitHub" }, - "tabs": { "upload": "Upload package", "mine": "My packages" }, + "tabs": { + "upload": "Upload package", + "mine": "My packages" + }, "upload": { "dropHtml": "Drag a .deb here, or click to choose a file
Parsed locally as soon as you pick it — nothing is uploaded yet", "chosen": "Selected {name} ({size} MB) — click to replace", @@ -62,16 +75,19 @@ "mine": { "colPackage": "Package", "colVersion": "Version", - "colMaintainer": "Maintainer", + "colMaintainer": "Owner", "loading": "Loading your packages…", "empty": "No published packages found that belong to you", "download": "Download .deb", "unpublish": "Unpublish", "submitted": "Submitted", "sizeUnknown": "size unknown", - "note": "The list comes from the live APT index and only shows packages that belong to you. You can download published .deb files directly; unpublishing opens a removal PR.", + "note": "The list combines the live APT index with your recent publish PRs and only shows packages that belong to you. A fresh submission first appears as \"under review\"; after merge the index takes a few minutes to rebuild. You can download published .deb files directly; unpublishing opens a removal PR.", "confirmUnpublish": "Unpublish {name} {version}? This opens a removal PR.", - "unpublishFailed": "Unpublish failed: {msg}" + "unpublishFailed": "Unpublish failed: {msg}", + "statusReview": "Under review", + "statusPublishing": "Merged, index rebuilding…", + "viewPr": "View PR" }, "decompress": { "loadFailed": "Could not load the {what} decompression module (used only for local preview). Please refresh and retry; you can still submit — the server completes validation." diff --git a/site/i18n/ja.json b/site/i18n/ja.json index e7867d3..ccc60eb 100644 --- a/site/i18n/ja.json +++ b/site/i18n/ja.json @@ -1,13 +1,26 @@ { - "meta": { "title": "開発者センター · CardputerZero AppStore" }, - "language": { "label": "言語" }, - "header": { "title": "CardputerZero 開発者センター", "logout": "ログアウト", "admin": "(管理者)" }, - "boot": { "loading": "読み込み中…" }, + "meta": { + "title": "開発者センター · CardputerZero AppStore" + }, + "language": { + "label": "言語" + }, + "header": { + "title": "CardputerZero 開発者センター", + "logout": "ログアウト", + "admin": "(管理者)" + }, + "boot": { + "loading": "読み込み中…" + }, "login": { "intro": "AppStore パッケージのアップロード・管理には GitHub ログインが必要です。
パッケージ名は先着順です。最初にアップロードした人の GitHub アカウントに帰属し、
以降は元のアップロード者(または管理者)だけが更新・公開停止できます。
", "button": "GitHub でログイン" }, - "tabs": { "upload": "パッケージをアップロード", "mine": "マイパッケージ" }, + "tabs": { + "upload": "パッケージをアップロード", + "mine": "マイパッケージ" + }, "upload": { "dropHtml": ".deb をここにドラッグ、またはクリックしてファイルを選択
選択するとすぐにローカルで解析します(まだアップロードされません)", "chosen": "選択済み {name}({size} MB)— クリックで変更", @@ -62,16 +75,19 @@ "mine": { "colPackage": "パッケージ", "colVersion": "バージョン", - "colMaintainer": "Maintainer", + "colMaintainer": "所有者", "loading": "マイパッケージを読み込み中…", "empty": "あなたに帰属する公開済みパッケージは見つかりませんでした", "download": ".deb をダウンロード", "unpublish": "公開停止", "submitted": "送信済み", "sizeUnknown": "サイズ不明", - "note": "一覧はオンラインの APT インデックスから取得し、あなたに帰属するパッケージのみ表示します。公開済みの .deb は直接ダウンロードでき、公開停止は削除 PR を作成します。", + "note": "一覧はオンラインの APT インデックスと最近の公開 PR から取得し、あなたに帰属するパッケージのみ表示します。提出直後は「審査中」と表示され、マージ後のインデックス更新には数分かかります。公開済みの .deb は直接ダウンロードでき、公開停止は削除 PR を作成します。", "confirmUnpublish": "{name} {version} を公開停止しますか?削除 PR を作成します。", - "unpublishFailed": "公開停止に失敗しました:{msg}" + "unpublishFailed": "公開停止に失敗しました:{msg}", + "statusReview": "審査中", + "statusPublishing": "マージ済み、インデックス更新中…", + "viewPr": "PR を見る" }, "decompress": { "loadFailed": "{what} の解凍モジュールを読み込めませんでした(ローカルプレビュー用)。更新して再試行してください。そのまま送信もでき、サーバー側で検証します。" diff --git a/site/i18n/zh-CN.json b/site/i18n/zh-CN.json index 6b67c61..533668a 100644 --- a/site/i18n/zh-CN.json +++ b/site/i18n/zh-CN.json @@ -1,13 +1,26 @@ { - "meta": { "title": "开发者中心 · CardputerZero AppStore" }, - "language": { "label": "语言" }, - "header": { "title": "CardputerZero 开发者中心", "logout": "退出", "admin": "(管理员)" }, - "boot": { "loading": "加载中…" }, + "meta": { + "title": "开发者中心 · CardputerZero AppStore" + }, + "language": { + "label": "语言" + }, + "header": { + "title": "CardputerZero 开发者中心", + "logout": "退出", + "admin": "(管理员)" + }, + "boot": { + "loading": "加载中…" + }, "login": { "intro": "上传 / 管理 AppStore 软件包需要 GitHub 登录。
包名先到先得:谁先上传某个包名,就归属于其 GitHub 账号,
之后同名包只有原上传者(或管理员)可以更新和下架。
", "button": "使用 GitHub 登录" }, - "tabs": { "upload": "上传软件包", "mine": "我的软件包" }, + "tabs": { + "upload": "上传软件包", + "mine": "我的软件包" + }, "upload": { "dropHtml": "拖拽 .deb 到这里,或点击选择文件
选择后立即在本地解析,不会上传", "chosen": "已选择 {name}({size} MB)— 点击可更换", @@ -62,16 +75,19 @@ "mine": { "colPackage": "包名", "colVersion": "版本", - "colMaintainer": "Maintainer", + "colMaintainer": "归属", "loading": "正在读取你的软件包…", "empty": "没有找到属于你的已发布软件包", "download": "下载 .deb", "unpublish": "下架", "submitted": "已提交", "sizeUnknown": "大小未知", - "note": "列表来自线上 APT 索引,只显示归属于你的包。可直接下载已发布的 .deb;下架会生成移除 PR。", + "note": "列表来自线上 APT 索引与你最近的发布 PR,只显示归属于你的包。刚提交的包会先显示为「审核中」,合并后索引更新约需几分钟。可直接下载已发布的 .deb;下架会生成移除 PR。", "confirmUnpublish": "确认下架 {name} {version}?将生成移除 PR。", - "unpublishFailed": "下架失败:{msg}" + "unpublishFailed": "下架失败:{msg}", + "statusReview": "审核中", + "statusPublishing": "已合并,索引更新中…", + "viewPr": "查看 PR" }, "decompress": { "loadFailed": "无法加载 {what} 解压组件(仅用于本地预览),请刷新重试;仍可直接提交,服务器会完成校验" diff --git a/site/index.html b/site/index.html index 67e2248..099c6dc 100644 --- a/site/index.html +++ b/site/index.html @@ -230,7 +230,7 @@

CardputerZero 开发者中心