mirror: drop web UI + upload; keep only static package repo (packages/, serve via any static server)

这个提交包含在:
JGZYES
2026-08-30 18:47:06 +08:00
父节点 a3538504d5
当前提交 b41a6d01f1
修改 4 个文件,包含 21 行新增343 行删除
+15 -19
查看文件
@@ -102,32 +102,28 @@ priority = 20
- 下载文件时也会把镜像的 `download` 前缀按优先级排在原始 URL 之前逐个尝试。
- 每家的 `priority` 相同则按出现顺序;`pmm mirror use <名>` 可强制优先用某个镜像。
## 镜像mirror/ 网页版
## 镜像仓库mirror/ 静态目录
`mirror/` 目录是一个**纯 PHP** 镜像站(zero-dependency:仅需 PHP 8 的 json/标准扩展),
提供网页浏览、搜索、按平台筛选、上传包并自动生成 registry 索引。
**默认站点:`https://pmm.parlz.com/mirror`**(上传生成的下载链接和 `mirror.ini`
都指向它)。本地开发/测试可用 `PMM_BASE_URL` 覆盖:
`mirror/` 现在只做一件事:**静态包仓库**(无网页、无上传),把 `packages/` 目录用
任意静态文件服务器直接对外提供即可:
```sh
cd mirror
PMM_BASE_URL=http://127.0.0.1:8080 php -S 0.0.0.0:8080 index.php # 本地调试
php -S 0.0.0.0:8080 index.php # 生产默认 https://pmm.parlz.com/mirror
python -m http.server 8080 # 或 nginx/apache 直接指向 mirror/ 目录
```
Apache/Nginx 部署时把所有请求路由到 `index.php` 前端控制器即可。)
- 网页 `/`:浏览/搜索包、按 Windows/Linux/macOS 筛选、复制安装命令
- `POST /upload`:上传 `.pdm`/`.deb`/`.exe`/`.dmg`/`.tar.gz` 等,自动算 sha256/sha1
- 上传带 `x-pkg-name``x-pkg-version``x-pkg-os``x-pkg-desc` 头(或表单包名)
会写 registry。**包文件存到 `packages/<包>/<版本>-<平台>.pdm`,元数据
`packages/<包>/<版本>-<平台>.json``<包>.json` 是 latest 指针(含 `variants` 列表)**。
- `GET /packages.json`registry 聚合索引(只列 latest,供 `pmm install` 无版本时用)
- `GET /mirror.ini`:可直接复制到 `~/.pmm/mirror.ini` 的镜像配置
- 目录约定:`packages/<包>.json`latest+ `packages/<包>/<版本>.pdm|.json`
- 目录约定:`packages/<包>.json`latest,含 `variants`)、
`packages/<包>/<版本>-<平台>.pdm`(包文件)、`packages/<包>/<版本>-<平台>.json`(元数据)。
- 客户端把 `~/.pmm/mirror.ini` 指到该目录的 `packages`
```ini
[main]
registry = http://<host>:8080/packages # 生产 https://pmm.parlz.com/mirror/packages
priority = 1
```
然后 `pmm install nodejs`(自动识平台)即可从这个静态仓库下载安装。
- `mirror.ini` 也可从此目录按需生成(`registry` 指向 `/packages`)。
`mirror.ini` 指向这个镜像后,`pmm install <包>` 装 latest,也支持
**Python/pip 风格的版本限定**
`pmm install <包>` 装 latest,也支持 **Python/pip 风格的版本限定**
```sh
pmm install nodejs # latest
-172
查看文件
@@ -1,172 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PMM 镜像站</title>
<style>
:root { --bg:#0f172a; --card:#1e293b; --acc:#38bdf8; --txt:#e2e8f0; --muted:#94a3b8; --pos:#4ade80; --neg:#f87171; }
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Microsoft YaHei",sans-serif;
background:var(--bg); color:var(--txt); }
header { padding:22px 28px; border-bottom:1px solid #334155; display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
header h1 { font-size:20px; margin:0; display:flex; align-items:center; gap:10px; }
header h1 .dot { width:12px; height:12px; border-radius:50%; background:var(--acc); box-shadow:0 0 12px var(--acc); }
.crumb { color:var(--muted); font-size:14px; }
main { padding:20px 28px; max-width:1080px; margin:0 auto; }
.toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-bottom:20px; }
input,select,button { padding:9px 12px; border-radius:8px; border:1px solid #334155; background:var(--card); color:var(--txt); font-size:14px; }
input:focus,select:focus { outline:2px solid var(--acc); border-color:transparent; }
button.primary { background:var(--acc); border:none; color:#022c3b; font-weight:600; cursor:pointer; }
button.primary:hover { filter:brightness(1.1); }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:16px; }
.card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; transition:.15s; }
.card:hover { border-color:var(--acc); transform:translateY(-2px); }
.card .name { font-weight:700; font-size:16px; display:flex; justify-content:space-between; align-items:center; gap:8px; }
.tag { font-size:12px; padding:2px 8px; border-radius:20px; background:#0f172a; color:var(--muted); border:1px solid #334155; }
.tag.win { color:#60a5fa; } .tag.lin { color:#4ade80; } .tag.mac { color:#f472b6; } .tag.any { color:var(--muted); }
.card .desc { color:var(--muted); font-size:13px; margin:8px 0; min-height:36px; }
.card .meta { font-size:12px; color:var(--muted); display:flex; justify-content:space-between; }
.card .actions { margin-top:12px; display:flex; gap:8px; }
.card a.btn { text-decoration:none; font-size:13px; padding:6px 10px; border-radius:7px; }
.a-ok { background:#14532d; color:#bbf7d0; } .a-mut { background:#1e293b; border:1px solid #334155; color:var(--txt); }
.hint { color:var(--muted); font-size:13px; }
code { background:#0f172a; border:1px solid #334155; padding:1px 5px; border-radius:5px; font-size:12px; }
.upload { margin:22px 0; background:var(--card); border:1px dashed #475569; border-radius:12px; padding:18px; }
.upload h3 { margin:0 0 10px; font-size:15px; }
.upload .row { display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.upload .row label { font-size:13px; color:var(--muted); }
.error { color:var(--neg); }
.success { color:var(--pos); }
footer { color:var(--muted); font-size:12px; text-align:center; padding:26px; }
</style>
</head>
<body>
<header>
<h1><span class="dot"></span>PMM 镜像站</h1>
<span class="crumb" id="mirror-url"></span>
</header>
<main>
<div class="toolbar">
<input id="q" placeholder="搜索包名 / 描述..." autocomplete="off">
<select id="os">
<option value="all">全部平台</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="macos">macOS</option>
<option value="any">跨平台</option>
</select>
<span class="hint" id="count"></span>
</div>
<div class="upload">
<h3>上传包(.pdm / .deb / .exe / .dmg / .tar.gz ...</h3>
<div class="row">
<label>包名 <input id="u-pkg" placeholder="myapp" size="18"></label>
<label>版本 <input id="u-ver" placeholder="1.0.0" size="10"></label>
<label>文件 <input type="file" id="u-file" required></label>
<button class="primary" id="u-go">上传并索引</button>
</div>
<div class="hint" id="u-status"></div>
</div>
<div class="grid" id="grid"></div>
<br>
<div class="hint" style="text-align:center">
客户端使用:<code>pmm mirror use main</code><code>pmm install &lt;包名&gt;</code>
或复制本页 <a href="/mirror.ini" style="color:var(--acc)">mirror.ini</a><code>~/.pmm/</code>
</div>
</main>
<footer>ParlzPackageManger · 镜像站</footer>
<script>
const SELF = location.origin;
document.getElementById('mirror-url').textContent = SELF;
let ALL = [];
async function load() {
try {
const r = await fetch(SELF + '/api/packages.json');
const j = await r.json();
ALL = j.packages || [];
render();
} catch (e) { console.warn('packages.json:%o', e); }
}
function platformOf(m) {
const osv = (m.os || '').toLowerCase();
if (osv === 'all' || osv === 'any' || !osv) return 'any';
if (/win/.test(osv)) return 'windows';
if (/mac|darwin/.test(osv)) return 'macos';
return 'linux';
}
function render() {
const q = document.getElementById('q').value.trim().toLowerCase();
const osf = document.getElementById('os').value;
const list = ALL.filter(m => {
const match = !q || (m.name||'').toLowerCase().includes(q) || (m.description||'').toLowerCase().includes(q);
const okOs = osf === 'all' || platformOf(m) === osf || platformOf(m) === 'any';
return match && okOs;
});
document.getElementById('count').textContent = `${list.length} / ${ALL.length}`;
const grid = document.getElementById('grid');
grid.innerHTML = '';
const tagCls = { windows:'win', linux:'lin', macos:'mac', any:'any' };
for (const m of list) {
const p = platformOf(m);
const name = m.name || m.file || '?';
const ver = m.version || '';
const file = m.file || '';
const size = m.size ? fmtSize(m.size) : '';
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class="name"><span>${esc(name)}</span><span class="tag ${tagCls[p]||''}">${p}</span></div>
<div class="desc">${esc(m.description || '暂无描述')}</div>
<div class="meta"><span>v${esc(ver)}</span><span>${size}</span></div>
<div class="actions">
<a class="btn a-ok" href="${esc(m.url||'#')}" download>下载</a>
<a class="btn a-mut" href="#" data-copy="${esc('pmm install ' + name)}">复制安装命令</a>
</div>`;
card.querySelector('[data-copy]').addEventListener('click', e => {
e.preventDefault();
navigator.clipboard && navigator.clipboard.writeText(e.target.getAttribute('data-copy'));
e.target.textContent = '已复制';
setTimeout(()=>e.target.textContent='复制安装命令',1200);
});
grid.appendChild(card);
}
}
function fmtSize(n) {
if (n >= 1048576) return (n/1048576).toFixed(1) + ' MB';
if (n >= 1024) return (n/1024).toFixed(1) + ' KB';
return n + ' B';
}
function esc(s){ return String(s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
document.getElementById('q').addEventListener('input', render);
document.getElementById('os').addEventListener('change', render);
document.getElementById('u-go').addEventListener('click', async () => {
const pkg = document.getElementById('u-pkg').value.trim();
const ver = document.getElementById('u-ver').value.trim();
const f = document.getElementById('u-file').files[0];
const status = document.getElementById('u-status');
if (!f) { status.textContent = '请选择文件'; return; }
status.textContent = '上传中...'; status.className='hint';
const fd = new FormData();
fd.append('file', f);
const hdr = {};
if (pkg) hdr['x-pkg-name'] = pkg;
if (ver) hdr['x-pkg-version'] = ver;
try {
const r = await fetch(SELF + '/upload', { method:'POST', body: fd, headers: hdr });
const j = await r.json();
status.textContent = `完成:${j.file}sha256 ${(j.sha256||'').slice(0,16)}…)` + (j.pkg?`,注册包 ${j.pkg}`:'');
status.className='success';
load();
} catch (e) { status.textContent='上传失败 '+e; status.className='error'; }
});
load();
</script>
</body>
</html>
-150
查看文件
@@ -1,150 +0,0 @@
<?php
/**
* PMM mirror – pure-PHP registry server (drop-in for the Node server.js).
*
* Run with the PHP built-in server:
* php -S 0.0.0.0:8080 -t . # uses this file as router for *.php; add router:
* php -S 0.0.0.0:8080 index.php # simplest: router = this file
* On Apache/Nginx point all requests to this file (front controller).
*
* Endpoints (identical to the Node version):
* GET / -> browser UI (index.html)
* GET /api/packages.json, /packages.json -> index
* GET /mirror.ini -> client mirror config
* GET /packages/<pkg>.json -> latest pointer
* GET /packages/<pkg>/<ver>-<os>.json|.pdm -> per-variant metadata / download
* POST /upload -> upload a file, write registry variants
*
* Storage: packages/<pkg>/<ver>-<os>.pdm, packages/<pkg>/<ver>-<os>.json,
* packages/<pkg>.json (latest + variants).
*/
$ROOT = __DIR__;
$PKG = $ROOT . '/packages';
$BASE = preg_replace('#/+\z#', '', getenv('PMM_BASE_URL') ?: 'https://pmm.parlz.com/mirror');
function base_url() { global $BASE; return $BASE; }
function pkgs_dir() { global $PKG; if (!is_dir($PKG)) mkdir($PKG, 0777, true); return $PKG; }
function send_json($data, $code = 200) {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
}
function send_file($path, $ctype = 'application/octet-stream', $code = 200) {
if (!is_file($path)) { http_response_code(404); echo 'not found'; exit; }
http_response_code($code);
header('Content-Type: ' . $ctype);
header('Access-Control-Allow-Origin: *');
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
function load_index() {
$out = ['generated' => gmdate('c'), 'packages' => []];
foreach (glob(pkgs_dir() . '/*.json') as $f) {
if (is_dir($f)) continue;
$m = json_decode(file_get_contents($f), true);
if (!is_array($m) || empty($m['variants'])) continue;
$out['packages'][] = $m;
}
usort($out['packages'], fn($a, $b) => strcmp($a['name'] ?? '', $b['name'] ?? ''));
return $out;
}
function send_index() { send_json(['generated' => gmdate('c'), 'packages' => load_index()['packages']]); }
function mirror_ini() {
return "# 由 PMM 镜像站 " . base_url() . " 自动生成\n"
. "# 复制到 ~/.pmm/mirror.ini 即可 pmm install\n"
. "[main]\n"
. "registry = " . base_url() . "/packages\n"
. "priority = 1\n";
}
function handle_upload() {
$body = file_get_contents('php://input');
if ($body === false) { http_response_code(400); echo 'no body'; exit; }
$H = fn($k) => trim($_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] ?? '');
$pkg = $H('X-PKG-NAME');
$ver = $H('X-PKG-VERSION');
$os = $H('X-PKG-OS') ?: 'any';
$desc = $H('X-PKG-DESC') ?: ("Uploaded via PMM mirror (" . base_url() . ")");
if ($pkg === '') { http_response_code(400); echo 'no x-pkg-name'; exit; }
if ($ver === '') $ver = substr(hash('sha256', $body), 0, 8);
$dir = pkgs_dir() . '/' . $pkg;
if (!is_dir($dir)) mkdir($dir, 0777, true);
$store = $ver . '-' . $os . '.pdm';
file_put_contents($dir . '/' . $store, $body);
$meta = [
'name' => $pkg, 'version' => $ver, 'file' => $store,
'url' => base_url() . '/packages/' . rawurlencode($pkg) . '/' . rawurlencode($store),
'sha256' => hash('sha256', $body), 'os' => $os, 'description' => $desc,
];
file_put_contents($dir . '/' . $ver . '-' . $os . '.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// latest pointer + variants
$latestPath = pkgs_dir() . '/' . $pkg . '.json';
$last = [];
if (is_file($latestPath)) { $last = json_decode(file_get_contents($latestPath), true) ?: []; }
$variants = is_array($last['variants'] ?? null) ? $last['variants'] : [];
$variants = array_values(array_filter($variants, fn($v) => !($v['version'] === $ver && $v['os'] === $os)));
$variants[] = ['version' => $ver, 'os' => $os, 'file' => $store, 'url' => $meta['url'],
'sha256' => $meta['sha256'], 'description' => $desc];
$latest = $meta + ['variants' => $variants];
file_put_contents($latestPath, json_encode($latest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
send_json(['ok' => true, 'file' => $store, 'version' => $ver, 'size' => strlen($body),
'sha256' => $meta['sha256'], 'pkg' => $pkg, 'url' => $meta['url'], 'mirror' => base_url()]);
}
function serve_single_json($rel) {
$f = pkgs_dir() . '/' . $rel;
if (is_file($f)) send_file($f, 'application/json');
http_response_code(404); echo 'not found'; exit;
}
// ------- router -------
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: *');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { http_response_code(204); exit; }
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$uri = rawurldecode($uri);
/* strip the deployment sub-path (e.g. "/mirror" when the site is at
* https://host/mirror/index.php) so the router works under a subdirectory too.
* Only strip when the remainder starts with '/' or is empty — so "/mirror.ini"
* is NOT mistaken for the "/mirror" prefix. */
$basePath = parse_url(base_url(), PHP_URL_PATH) ?: '';
if ($basePath && $basePath !== '/' && strpos($uri, $basePath) === 0) {
$rest = substr($uri, strlen($basePath));
if ($rest === '' || $rest[0] === '/') {
$uri = ($rest === '') ? '/' : $rest;
}
}
if (preg_match('#^/upload$#', $uri)) {
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') { handle_upload(); }
else { http_response_code(405); echo 'method not allowed'; exit; }
}
if ($uri === '/' || $uri === '/index.html' || $uri === '/index.php') {
send_file($ROOT . '/index.html', 'text/html; charset=utf-8');
}
if ($uri === '/packages.json' || $uri === '/api/packages.json') send_index();
if ($uri === '/mirror.ini' || $uri === '/mirror.conf') { header('Content-Type: text/plain'); echo mirror_ini(); exit; }
// /api/packages/<pkg>.json or /packages/<pkg>.json
if (preg_match('#^/(?:api/)?packages/([^/]+)\.json$#', $uri, $m)) serve_single_json($m[1] . '.json');
// /packages/<pkg>/<ver>-<os>.json|.pdm
if (preg_match('#^/(?:api/)?packages/([^/]+)/([^/]+)\.(json|pdm)$#', $uri, $m)) {
$f = pkgs_dir() . '/' . $m[1] . '/' . $m[2] . '.' . $m[3];
send_file($f, ($m[3] === 'json') ? 'application/json' : 'application/octet-stream');
}
if ($uri === '/api/health') { echo 'ok'; exit; }
http_response_code(404); echo 'not found';
+6 -2
查看文件
@@ -99,8 +99,12 @@ char *http_get(const char *url, int *status) {
#include <windows.h>
static unsigned long long now_ms(void) { return (unsigned long long)GetTickCount64(); }
#else
#include <time.h>
static unsigned long long now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (unsigned long long)ts.tv_sec * 1000 + (unsigned long long)ts.tv_nsec / 1000000; }
#include <sys/time.h>
static unsigned long long now_ms(void) {
struct timeval tv;
gettimeofday(&tv, NULL);
return (unsigned long long)tv.tv_sec * 1000ULL + (unsigned long long)tv.tv_usec / 1000ULL;
}
#endif
/* human size: B / KB / MB / GB with 1 decimal */