diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fe364c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/README.md b/README.md index 7e60773..1b727d5 100644 --- a/README.md +++ b/README.md @@ -104,23 +104,24 @@ priority = 20 ## 镜像站(mirror/ 网页版) -`mirror/` 目录是一个零依赖的 Node.js 镜像站,提供网页浏览、搜索、按平台筛选、 -上传包并自动生成 registry 索引。 +`mirror/` 目录是一个**纯 PHP** 镜像站(zero-dependency:仅需 PHP 8 的 json/标准扩展), +提供网页浏览、搜索、按平台筛选、上传包并自动生成 registry 索引。 **默认站点:`https://pmm.parlz.com/mirror`**(上传生成的下载链接和 `mirror.ini` 都指向它)。本地开发/测试可用 `PMM_BASE_URL` 覆盖: ```sh -cd mirror && node server.js # 生产默认 https://pmm.parlz.com/mirror -PMM_BASE_URL=http://127.0.0.1:8080 node server.js # 本地调试 -PMM_PORT=9000 node server.js # 仅改监听端口 +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 ``` +(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 指针(含 `versions` 列表)**;不再有独立的 `files/` 目录。 + 会写 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` diff --git a/mirror/index.php b/mirror/index.php new file mode 100644 index 0000000..5632997 --- /dev/null +++ b/mirror/index.php @@ -0,0 +1,136 @@ + browser UI (index.html) + * GET /api/packages.json, /packages.json -> index + * GET /mirror.ini -> client mirror config + * GET /packages/.json -> latest pointer + * GET /packages//-.json|.pdm -> per-variant metadata / download + * POST /upload -> upload a file, write registry variants + * + * Storage: packages//-.pdm, packages//-.json, + * packages/.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/.json or /packages/.json +if (preg_match('#^/(?:api/)?packages/([^/]+)\.json$#', $uri, $m)) serve_single_json($m[1] . '.json'); +// /packages//-.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'; diff --git a/mirror/server.js b/mirror/server.js deleted file mode 100644 index d943704..0000000 --- a/mirror/server.js +++ /dev/null @@ -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 聚合 + .json 单包元数据) -// files/ 实际发布文件(deb/exe/dmg/pdm/tar.gz ...) -// mirror.ini 提供给 pmm 的镜像配置(server.js 会生成) -// -// 网页 / /index.html 浏览器浏览、搜索、按平台筛选 -// /api/packages.json registry 索引 -// /api/packages/.json 单包元数据 -// /packages.json 同 /api/packages.json(让 pmm 直接用 / 也行) -// /files/ 文件下载 -// /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 .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; /* .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//.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//-.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//-.json */ - fs.writeFileSync(path.join(PKG_DIR, pkg, `${ver}-${info.os}.json`), JSON.stringify(meta, null, 2)); - /* latest pointer: packages/.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//-.json -> packages//-.json - // /packages//-.pdm -> packages//-.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);