Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 96 additions & 13 deletions site/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -208,6 +222,44 @@ async function loadIndex() {
return publishedIndex;
}

/**
* Authoritative ownership map published alongside the APT index:
* { "<package>": { "<version>": "<github login, lowercase>" } }, 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.
Expand Down Expand Up @@ -321,15 +373,15 @@ async function renderPreview() {
: `<span class="lv-danger">${t("preview.needLogin")}</span>`;

// 版本 / 包名占用预检(所有权按上传者 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 = `<span class="lv-pass">${me ? t("preview.newPkg", { login: me.login }) : t("preview.newPkgAnon")}</span>`;
} 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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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 = `<tr><td colspan="4"><div class="loading"><span class="spinner"></span>${t("mine.loading")}</div></td></tr>`;
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 = `<tr><td colspan="4" class="muted">${t("mine.empty")}</td></tr>`;
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 = `<td>${sub.package}</td><td>${sub.version}</td><td>@${sub.submitter}</td><td class="row-actions"></td>`;
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 = `<td>${p.name}</td><td>${p.Version}</td><td>${p.email}</td><td class="row-actions"></td>`;
tr.innerHTML = `<td>${p.name}</td><td>${p.Version}</td><td>${p.owner ? "@" + p.owner : p.email}</td><td class="row-actions"></td>`;
const actions = tr.lastElementChild;
const dl = debDownloadUrl(p);
if (dl) {
Expand Down
32 changes: 24 additions & 8 deletions site/i18n/en.json
Original file line number Diff line number Diff line change
@@ -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.<br><span class=\"muted\">Package names are first-come, first-served: whoever first uploads a name owns it under their GitHub account,<br>and only the original uploader (or an admin) can update or unpublish that name afterwards.</span>",
"button": "Sign in with GitHub"
},
"tabs": { "upload": "Upload package", "mine": "My packages" },
"tabs": {
"upload": "Upload package",
"mine": "My packages"
},
"upload": {
"dropHtml": "Drag a <b>.deb</b> here, or click to choose a file<br><span class=\"muted\">Parsed locally as soon as you pick it — nothing is uploaded yet</span>",
"chosen": "Selected <b>{name}</b> ({size} MB) — click to replace",
Expand Down Expand Up @@ -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."
Expand Down
32 changes: 24 additions & 8 deletions site/i18n/ja.json
Original file line number Diff line number Diff line change
@@ -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 ログインが必要です。<br><span class=\"muted\">パッケージ名は先着順です。最初にアップロードした人の GitHub アカウントに帰属し、<br>以降は元のアップロード者(または管理者)だけが更新・公開停止できます。</span>",
"button": "GitHub でログイン"
},
"tabs": { "upload": "パッケージをアップロード", "mine": "マイパッケージ" },
"tabs": {
"upload": "パッケージをアップロード",
"mine": "マイパッケージ"
},
"upload": {
"dropHtml": "<b>.deb</b> をここにドラッグ、またはクリックしてファイルを選択<br><span class=\"muted\">選択するとすぐにローカルで解析します(まだアップロードされません)</span>",
"chosen": "選択済み <b>{name}</b>({size} MB)— クリックで変更",
Expand Down Expand Up @@ -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} の解凍モジュールを読み込めませんでした(ローカルプレビュー用)。更新して再試行してください。そのまま送信もでき、サーバー側で検証します。"
Expand Down
32 changes: 24 additions & 8 deletions site/i18n/zh-CN.json
Original file line number Diff line number Diff line change
@@ -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 登录。<br><span class=\"muted\">包名先到先得:谁先上传某个包名,就归属于其 GitHub 账号,<br>之后同名包只有原上传者(或管理员)可以更新和下架。</span>",
"button": "使用 GitHub 登录"
},
"tabs": { "upload": "上传软件包", "mine": "我的软件包" },
"tabs": {
"upload": "上传软件包",
"mine": "我的软件包"
},
"upload": {
"dropHtml": "拖拽 <b>.deb</b> 到这里,或点击选择文件<br><span class=\"muted\">选择后立即在本地解析,不会上传</span>",
"chosen": "已选择 <b>{name}</b>({size} MB)— 点击可更换",
Expand Down Expand Up @@ -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} 解压组件(仅用于本地预览),请刷新重试;仍可直接提交,服务器会完成校验"
Expand Down
2 changes: 1 addition & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ <h1 data-i18n="header.title">CardputerZero 开发者中心</h1>
<!-- 我的软件包 -->
<div class="panel hidden" id="mine-panel">
<table>
<thead><tr><th data-i18n="mine.colPackage">包名</th><th data-i18n="mine.colVersion">版本</th><th data-i18n="mine.colMaintainer">Maintainer</th><th></th></tr></thead>
<thead><tr><th data-i18n="mine.colPackage">包名</th><th data-i18n="mine.colVersion">版本</th><th data-i18n="mine.colMaintainer">归属</th><th></th></tr></thead>
<tbody id="mine-rows"><tr><td colspan="4"><div class="loading"><span class="spinner"></span><span data-i18n="mine.loading">正在读取你的软件包…</span></div></td></tr></tbody>
</table>
<p class="muted" data-i18n="mine.note">列表来自线上 APT 索引,只显示归属于你的包。可直接下载已发布的 .deb;下架会生成移除 PR。</p>
Expand Down
Loading
Loading