mirror: pure-PHP registry server; add GitHub Actions cross-build (win amd64/aarch64)

- mirror/index.php: pure-PHP front controller (upload/index/variants/pdm download)
- drop Node server.js
- .github/workflows/release.yml: build pmm/pdm on ubuntu for
  windows-amd64 (x86_64-w64-mingw32-gcc), linux-amd64 (gcc), linux-aarch64
  (aarch64-linux-gnu-gcc); upload artifacts; on tag v* -> GitHub Release
这个提交包含在:
JGZYES
2026-08-30 18:28:32 +08:00
父节点 6d0b99d550
当前提交 58c6c8b527
修改 4 个文件,包含 209 行新增223 行删除
+65
查看文件
@@ -0,0 +1,65 @@
name: build
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- target: windows-amd64
cc: x86_64-w64-mingw32-gcc
ext: .exe
pkg: mingw-w64
- target: linux-amd64
cc: gcc
ext: ''
pkg: ''
- target: linux-aarch64
cc: aarch64-linux-gnu-gcc
ext: ''
pkg: gcc-aarch64-linux-gnu
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install toolchain
if: ${{ matrix.pkg != '' }}
run: |
sudo apt-get update
sudo apt-get install -y ${{ matrix.pkg }}
- name: Build pmm
run: |
mkdir -p out
${{ matrix.cc }} -O2 -Wall -static -std=c11 -o out/pmm${{ matrix.ext }} \
src/main.c src/json.c src/ini.c src/pmm.c src/http.c src/repo.c src/install.c \
src/sha256.c src/sha1.c src/mirrors.c src/pdm.c
- name: Build pdm
run: |
${{ matrix.cc }} -O2 -Wall -static -std=c11 -o out/pdm${{ matrix.ext }} \
src/pdm_main.c src/pdm.c src/pmm.c src/sha256.c src/install.c src/http.c \
src/json.c src/ini.c src/mirrors.c src/sha1.c
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: out/*
if-no-files-found: error
- name: Release (tagged)
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: |
out/pmm${{ matrix.ext }}
out/pdm${{ matrix.ext }}
generate_release_notes: true
name: PMM ${{ github.ref_name }}
+8 -7
查看文件
@@ -104,23 +104,24 @@ priority = 20
## 镜像站(mirror/ 网页版) ## 镜像站(mirror/ 网页版)
`mirror/` 目录是一个零依赖的 Node.js 镜像站,提供网页浏览、搜索、按平台筛选、 `mirror/` 目录是一个**纯 PHP** 镜像站(zero-dependency:仅需 PHP 8 的 json/标准扩展),
上传包并自动生成 registry 索引。 提供网页浏览、搜索、按平台筛选、上传包并自动生成 registry 索引。
**默认站点:`https://pmm.parlz.com/mirror`**(上传生成的下载链接和 `mirror.ini` **默认站点:`https://pmm.parlz.com/mirror`**(上传生成的下载链接和 `mirror.ini`
都指向它)。本地开发/测试可用 `PMM_BASE_URL` 覆盖: 都指向它)。本地开发/测试可用 `PMM_BASE_URL` 覆盖:
```sh ```sh
cd mirror && node server.js # 生产默认 https://pmm.parlz.com/mirror cd mirror
PMM_BASE_URL=http://127.0.0.1:8080 node server.js # 本地调试 PMM_BASE_URL=http://127.0.0.1:8080 php -S 0.0.0.0:8080 index.php # 本地调试
PMM_PORT=9000 node server.js # 仅改监听端口 php -S 0.0.0.0:8080 index.php # 生产默认 https://pmm.parlz.com/mirror
``` ```
Apache/Nginx 部署时把所有请求路由到 `index.php` 前端控制器即可。)
- 网页 `/`:浏览/搜索包、按 Windows/Linux/macOS 筛选、复制安装命令 - 网页 `/`:浏览/搜索包、按 Windows/Linux/macOS 筛选、复制安装命令
- `POST /upload`:上传 `.pdm`/`.deb`/`.exe`/`.dmg`/`.tar.gz` 等,自动算 sha256/sha1 - `POST /upload`:上传 `.pdm`/`.deb`/`.exe`/`.dmg`/`.tar.gz` 等,自动算 sha256/sha1
- 上传带 `x-pkg-name``x-pkg-version``x-pkg-os``x-pkg-desc` 头(或表单包名) - 上传带 `x-pkg-name``x-pkg-version``x-pkg-os``x-pkg-desc` 头(或表单包名)
会写 registry。**包文件存到 `packages/<包>/<版本>.pdm`,元数据 `packages/<包>/<版本>.json` 会写 registry。**包文件存到 `packages/<包>/<版本>-<平台>.pdm`,元数据
`<包>.json` 是 latest 指针(含 `versions` 列表)**;不再有独立的 `files/` 目录 `packages/<包>/<版本>-<平台>.json``<包>.json` 是 latest 指针(含 `variants` 列表)**。
- `GET /packages.json`registry 聚合索引(只列 latest,供 `pmm install` 无版本时用) - `GET /packages.json`registry 聚合索引(只列 latest,供 `pmm install` 无版本时用)
- `GET /mirror.ini`:可直接复制到 `~/.pmm/mirror.ini` 的镜像配置 - `GET /mirror.ini`:可直接复制到 `~/.pmm/mirror.ini` 的镜像配置
- 目录约定:`packages/<包>.json`latest+ `packages/<包>/<版本>.pdm|.json` - 目录约定:`packages/<包>.json`latest+ `packages/<包>/<版本>.pdm|.json`
+136
查看文件
@@ -0,0 +1,136 @@
<?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);
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') 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';
-216
查看文件
@@ -1,216 +0,0 @@
// PMM 镜像站(网页版,Node.js 无依赖)
//
// 用自带的 Node 运行,或把这些静态文件放到任意 web 服务器(nginx/静态托管)上。
//
// node server.js // 默认 http://localhost:8080
// PMM_PORT=9000 node server.js // 自定义端口
//
// 目录约定(见 README):
// packages/ registry 索引(packages.json 聚合 + <pkg>.json 单包元数据)
// files/ 实际发布文件(deb/exe/dmg/pdm/tar.gz ...
// mirror.ini 提供给 pmm 的镜像配置(server.js 会生成)
//
// 网页 / /index.html 浏览器浏览、搜索、按平台筛选
// /api/packages.json registry 索引
// /api/packages/<pkg>.json 单包元数据
// /packages.json 同 /api/packages.json(让 pmm 直接用 / 也行)
// /files/<name> 文件下载
// /mirror.ini 可直接复制到 ~/.pmm/mirror.ini
// /upload POST 上传(multipart 或 raw
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const ROOT = __dirname;
const PKG_DIR = path.join(ROOT, 'packages');
const PORT = process.env.PMM_PORT ? parseInt(process.env.PMM_PORT, 10) : 8080;
const HOST = process.env.PMM_HOST || '127.0.0.1';
/* Default public base for generated download URLs / mirror.ini.
* Override with PMM_BASE_URL (e.g. http://127.0.0.1:8080 for local testing). */
const BASE_URL = (process.env.PMM_BASE_URL || 'https://pmm.parlz.com/mirror').replace(/\/+$/, '');
function ensureDirs() {
if (!fs.existsSync(PKG_DIR)) fs.mkdirSync(PKG_DIR, { recursive: true });
}
/* ---- parse the <pkg>.json latest pointers into an index ---- */
function loadIndex() {
const index = { generated: new Date().toISOString(), packages: [] };
if (!fs.existsSync(PKG_DIR)) return index;
for (const f of fs.readdirSync(PKG_DIR)) {
if (!f.endsWith('.json')) continue; /* <pkg>.json only (list, not pkg/ dir) */
if (fs.statSync(path.join(PKG_DIR, f)).isDirectory()) continue;
try {
const meta = JSON.parse(fs.readFileSync(path.join(PKG_DIR, f), 'utf8'));
if (!meta.variants) continue;
index.packages.push(meta);
} catch (e) { /* skip corrupt */ }
}
index.packages.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return index;
}
/* ---- helpers ---- */
function sha256(p) { return crypto.createHash('sha256').update(p).digest('hex'); }
function sha1(p) { return crypto.createHash('sha1').update(p).digest('hex'); }
function baseUrl() {
return BASE_URL;
}
/* ---- upload a file into packages/<pkg>/<ver>.pdm, then write registry metadata ---- */
function handleUpload(req, res) {
let body = [];
req.on('data', c => body.push(c));
req.on('end', () => {
body = Buffer.concat(body);
let fname = null;
const ct = req.headers['content-type'] || '';
if (/multipart\/form-data/.test(ct)) {
const b = body.toString('latin1');
const disp = /filename="([^"]+)"/.exec(b);
fname = disp ? disp[1] : null;
const marker = `\r\n\r\n`;
const idx = b.indexOf(marker);
body = idx >= 0 ? Buffer.from(b.substring(idx + marker.length, b.length - 4 || b.length), 'latin1') : body;
} else {
fname = req.headers['x-file-name'] || urlQuery(req.url, 'file') || 'file.pdm';
}
if (!fname) { res.writeHead(400); return res.end('no filename'); }
fname = path.basename(fname.replace(/[\\/]/g, '/'));
const pkg = (req.headers['x-pkg-name'] || urlQuery(req.url, 'pkg') || '').trim();
let ver = (req.headers['x-pkg-version'] || urlQuery(req.url, 'ver') || '').trim();
if (!pkg) { res.writeHead(400); return res.end('no x-pkg-name'); }
if (!ver) ver = sha256(body).slice(0, 8);
/* store packages/<pkg>/<ver>-<os>.pdm (os lets one package carry multiple platforms) */
const dir = path.join(PKG_DIR, pkg);
fs.mkdirSync(dir, { recursive: true });
const os = (req.headers['x-pkg-os'] || urlQuery(req.url, 'os') || 'any').trim();
const storeName = `${ver}-${os}.pdm`;
const outPath = path.join(dir, storeName);
fs.writeFileSync(outPath, body);
const info = {
size: body.length,
sha256: sha256(body),
sha1: sha1(body),
file: storeName,
url: `${baseUrl()}/packages/${encodeURIComponent(pkg)}/${encodeURIComponent(storeName)}`,
os,
description: (req.headers['x-pkg-desc'] || urlQuery(req.url, 'desc') || `Uploaded via PMM mirror (${baseUrl()})`).trim(),
};
writeRegistryEntry(pkg, ver, info);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, file: storeName, version: ver, size: info.size,
sha256: info.sha256, sha1: info.sha1, pkg, url: info.url, mirror: baseUrl() }));
});
}
function writeRegistryEntry(pkg, ver, info) {
const meta = {
name: pkg, version: ver,
file: info.file, url: info.url, sha256: info.sha256,
os: info.os || 'any',
description: info.description || `Uploaded via PMM mirror (${baseUrl()})`,
};
/* per-version-per-os metadata: packages/<pkg>/<ver>-<os>.json */
fs.writeFileSync(path.join(PKG_DIR, pkg, `${ver}-${info.os}.json`), JSON.stringify(meta, null, 2));
/* latest pointer: packages/<pkg>.json with a versions list */
const latestPath = path.join(PKG_DIR, pkg + '.json');
let last = {};
if (fs.existsSync(latestPath)) {
try { last = JSON.parse(fs.readFileSync(latestPath, 'utf8')); } catch (e) { last = {}; }
}
/* variants: one entry per (version, os) so the client can pick by platform */
let variants = Array.isArray(last.variants) ? last.variants : [];
variants = variants.filter(v => !(v.version === ver && v.os === (info.os || 'any')));
variants.push({ version: ver, os: info.os || 'any', file: info.file, url: info.url,
sha256: info.sha256, description: info.description || '' });
const latest = Object.assign({}, meta, { variants });
fs.writeFileSync(latestPath, JSON.stringify(latest, null, 2));
console.log(` [registry] ${pkg} @ ${ver} (os=${meta.os}) -> packages/${pkg}/${ver}-${meta.os}.pdm`);
}
function urlQuery(url, key) {
const q = url.split('?')[1] || '';
for (const kv of q.split('&')) {
const [k, v] = kv.split('=');
if (k === key) return decodeURIComponent(v || '');
}
return null;
}
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json',
'.ini': 'text/plain', '.txt': 'text/plain', '.css': 'text/css', '.pdm': 'application/octet-stream' };
function serveStatic(res, p) {
const ext = path.extname(p);
fs.readFile(p, (err, data) => {
if (err) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream',
'Content-Length': data.length }); /* lets the client show total size */
res.end(data);
});
}
function generateMirrorIni() {
return `# 由 PMM 镜像站 ${baseUrl()} 自动生成
# 复制到 ~/.pmm/mirror.ini(或 mirror.conf)即可用 pmm install 从本镜像安装
[main]
registry = ${baseUrl()}/packages
priority = 1
`;
}
const server = http.createServer((req, res) => {
const u = new URL(req.url, baseUrl());
const pathname = decodeURIComponent(u.pathname);
// CORS for browser-based tools
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
if (req.method === 'POST' && pathname === '/upload') return handleUpload(req, res);
if (pathname === '/' || pathname === '/index.html') return serveStatic(res, path.join(ROOT, 'index.html'));
if (pathname === '/packages.json' || pathname === '/api/packages.json')
return serveJson(res, loadIndex());
if (pathname === '/mirror.ini' || pathname === '/mirror.conf') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
return res.end(generateMirrorIni());
}
const pm = /^\/api\/packages\/([^/]+)\.json$/.exec(pathname) || /^\/packages\/([^/]+)\.json$/.exec(pathname);
if (pm) {
const f = path.join(PKG_DIR, pm[1] + '.json');
if (fs.existsSync(f)) return serveStatic(res, f);
res.writeHead(404); return res.end('not found');
}
// versioned metadata / package file:
// /packages/<pkg>/<ver>-<os>.json -> packages/<pkg>/<ver>-<os>.json
// /packages/<pkg>/<ver>-<os>.pdm -> packages/<pkg>/<ver>-<os>.pdm (download)
const pv = /^\/api\/packages\/([^/]+)\/([^/]+)\.(json|pdm)$/.exec(pathname) ||
/^\/packages\/([^/]+)\/([^/]+)\.(json|pdm)$/.exec(pathname);
if (pv) {
const ext = '.' + pv[3];
const f = path.join(PKG_DIR, pv[1], pv[2] + ext);
if (fs.existsSync(f)) return serveStatic(res, f);
res.writeHead(404); return res.end('not found');
}
if (pathname === '/api/health') { res.writeHead(200); return res.end('ok'); }
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
});
function serveJson(res, obj) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj, null, 2)); }
ensureDirs();
console.log(`PMM mirror (web) serving ${ROOT}`);
console.log(` web UI: ${baseUrl()}/`);
console.log(` registry: ${baseUrl()}/packages.json`);
console.log(` mirror.ini: ${baseUrl()}/mirror.ini`);
server.listen(PORT, HOST);