ensureScan 原本只在每日自动扫描被调用,/api/packages 未接线, 导致部署重启后 rawCache 为空、登录拉包返回空列表且无扫描动作, 必须手动点「重新扫描」。现在 /api/packages 首次访问懒触发扫描, 返回 scanning 状态供前端轮询,登录后即可自动扫描。
700 lines
25 KiB
JavaScript
700 lines
25 KiB
JavaScript
/**
|
||
* Verdaccio 缓存可视化管理面板 — 后端服务
|
||
* Express + better-sqlite3,§4.2 REST API
|
||
*
|
||
* 环境变量:
|
||
* VERDACCIO_STORAGE Verdaccio 缓存目录(优先于 UI 设置)
|
||
* PORT 监听端口(默认 3000)
|
||
* DATA_DIR SQLite 数据目录(默认 backend/data)
|
||
* VCM_USERNAME 面板登录账号(默认 admin)
|
||
* VCM_PASSWORD 面板登录密码(默认 666666,Docker 部署务必通过 compose 覆盖)
|
||
* UV_THREADPOOL_SIZE libuv IO 线程池大小(默认 4 → 8:扫描/stat/readFile 并发更高,NAS 四核安全)
|
||
*/
|
||
// 必须在 libuv 线程池首次使用前设置:扫描大量目录/文件时默认 4 线程是主要瓶颈
|
||
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'
|
||
import express from 'express'
|
||
import path from 'node:path'
|
||
import fs from 'node:fs'
|
||
import { randomUUID } from 'node:crypto'
|
||
import { fileURLToPath } from 'node:url'
|
||
import {
|
||
getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored,
|
||
addLog, getLogs,
|
||
} from './db.js'
|
||
import {
|
||
resolveStorageDir, isValidStorage, validateStorage, scanStorage, scanStorageAsync,
|
||
demoPackages, demoSummary, clearPkgCache,
|
||
} from './scanner.js'
|
||
import { cleanPackage, deleteSingleVersion } from './clean.js'
|
||
import semverCompare from './semver.js'
|
||
import { syncState, runSyncLatest } from './sync-latest.js'
|
||
import {
|
||
seedIfNeeded, countPending, getPkgList, addPkg, editPkg, removePkg,
|
||
getConfig, saveConfig, startPkgListDaemon, updateFromAllSources, dedupPkgList,
|
||
} from './pkglist.js'
|
||
import { pkgPullState, runPkgListPull } from './pkglist-pull.js'
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||
const app = express()
|
||
app.use(express.json({ limit: '1mb' }))
|
||
|
||
const PORT = Number(process.env.PORT || 3000)
|
||
const DEFAULT_VERSION_THRESHOLD = 15
|
||
const DEFAULT_SIZE_THRESHOLD = 500
|
||
|
||
// —— 当前生效的 storage 目录(每次请求动态解析,UI 保存后立即生效) ——
|
||
function currentStorage() {
|
||
return resolveStorageDir()
|
||
}
|
||
|
||
function isDemoMode() {
|
||
return process.env.VCM_DEMO === '1' || !isValidStorage(currentStorage())
|
||
}
|
||
|
||
// —— 扫描结果缓存:真实目录全量扫描 IO 重,缓存避免每次请求/刷新页面重扫 ——
|
||
let rawCache = null
|
||
let rawCacheAt = null
|
||
|
||
// —— 异步扫描状态(后台重扫时供前端轮询进度) ——
|
||
const scanState = {
|
||
scanning: false, done: false, packages: 0, bytes: 0, tgzBytes: 0, diskBytes: null,
|
||
startedAt: null, finishedAt: null, error: null,
|
||
}
|
||
|
||
/** 磁盘占用(跨平台):递归统计目录内所有文件字节和
|
||
* 原实现 execSync('du -sk') 依赖 Linux du,Windows 下不可用;
|
||
* 改为 Node 原生递归统计,语义与「文件字节和」一致,避免外部命令差异 */
|
||
async function diskBytesOf(dir) {
|
||
let total = 0
|
||
async function walk(d) {
|
||
let entries
|
||
try {
|
||
entries = await fs.promises.readdir(d, { withFileTypes: true })
|
||
} catch {
|
||
return
|
||
}
|
||
await Promise.all(entries.map(async e => {
|
||
if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === '.DS_Store') return
|
||
const full = path.join(d, e.name)
|
||
if (e.isDirectory()) {
|
||
await walk(full)
|
||
} else {
|
||
try {
|
||
const st = await fs.promises.stat(full)
|
||
total += st.size
|
||
} catch {}
|
||
}
|
||
}))
|
||
}
|
||
try {
|
||
await walk(dir)
|
||
return total
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function runScanAsync() {
|
||
scanState.scanning = true
|
||
scanState.done = false
|
||
scanState.error = null
|
||
scanState.packages = 0
|
||
scanState.bytes = 0
|
||
scanState.tgzBytes = 0
|
||
scanState.diskBytes = null
|
||
scanState.startedAt = new Date().toISOString()
|
||
const storage = currentStorage()
|
||
console.log(`[scan] 开始扫描存储目录: ${storage}`)
|
||
try {
|
||
// 分片异步扫描:期间 /api/scan/progress 等请求仍可被响应
|
||
rawCache = await scanStorageAsync(storage, (p) => {
|
||
scanState.packages = p.packages
|
||
scanState.bytes = p.bytes // 目录内所有文件字节和
|
||
scanState.tgzBytes = p.tgzBytes || 0 // 仅 .tgz
|
||
})
|
||
rawCacheAt = new Date().toISOString()
|
||
scanState.done = true
|
||
scanState.finishedAt = rawCacheAt
|
||
scanState.diskBytes = await diskBytesOf(storage) // 磁盘占用(跨平台字节和)
|
||
console.log('[scan] 完成 ' + JSON.stringify({
|
||
storage,
|
||
packages: rawCache.length,
|
||
tgzBytes: scanState.tgzBytes,
|
||
bytes: scanState.bytes,
|
||
diskBytes: scanState.diskBytes,
|
||
}))
|
||
} catch (e) {
|
||
scanState.error = e.message
|
||
console.error('[scan] 扫描失败:', e.message)
|
||
} finally {
|
||
scanState.scanning = false
|
||
}
|
||
}
|
||
|
||
// 确保扫描已启动(缓存缺失时懒触发,不阻塞当前请求)
|
||
function ensureScan() {
|
||
if (isDemoMode()) return
|
||
if (rawCache || scanState.scanning) return
|
||
runScanAsync()
|
||
}
|
||
|
||
function getRawPackages() {
|
||
return rawCache || []
|
||
}
|
||
|
||
function invalidateScan() {
|
||
rawCache = null
|
||
rawCacheAt = null
|
||
clearPkgCache() // storage 目录切换 / 清理动作后废弃增量缓存,避免旧目录条目残留
|
||
}
|
||
|
||
// 构建包列表(统一状态计算 + 排序 + 忽略标记)
|
||
function buildPackages(keyword = '') {
|
||
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
|
||
const ignoredRows = getIgnoredRows()
|
||
const ignoredSet = new Set(ignoredRows.map(r => r.pkg_name))
|
||
const ignoredAtMap = Object.fromEntries(ignoredRows.map(r => [r.pkg_name, (r.ignored_at || '').slice(0, 10)]))
|
||
const raw = getRawPackages() || []
|
||
|
||
let list = raw.map(p => {
|
||
let status
|
||
if (p.status === 'broken') status = 'broken'
|
||
else if (ignoredSet.has(p.name)) status = 'ignored'
|
||
else status = p.versions > threshold ? 'warn' : 'ok'
|
||
return {
|
||
name: p.name,
|
||
versions: p.versions,
|
||
size: p.size,
|
||
lastUpdated: p.lastUpdated,
|
||
visits: p.visits || 0,
|
||
distTags: p.distTags || {},
|
||
status,
|
||
ignoredAt: ignoredAtMap[p.name] || null,
|
||
description: p.description || null,
|
||
homepage: p.homepage || null,
|
||
// 列表不携带 versionsDetail(包多时响应巨大拖慢加载),详情走 GET /api/packages/:name
|
||
}
|
||
})
|
||
|
||
// 检索:不区分大小写子串,匹配包名(版本号匹配由前端本地完成,避免列表携带明细)
|
||
const kw = keyword.trim().toLowerCase()
|
||
if (kw) {
|
||
list = list.filter(p => p.name.toLowerCase().includes(kw) || String(p.size).includes(kw))
|
||
}
|
||
|
||
// 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序
|
||
const rank = { warn: 0, ok: 1, ignored: 2, broken: 3 }
|
||
list.sort((a, b) => {
|
||
if (rank[a.status] !== rank[b.status]) return rank[a.status] - rank[b.status]
|
||
return b.versions - a.versions
|
||
})
|
||
return list
|
||
}
|
||
|
||
// —— 登录鉴权:内置账号(Docker 部署通过 VCM_USERNAME/VCM_PASSWORD 环境变量覆盖) ——
|
||
const AUTH_USERNAME = process.env.VCM_USERNAME || 'admin'
|
||
const AUTH_PASSWORD = process.env.VCM_PASSWORD || '666666'
|
||
const SESSION_TTL_MS = 12 * 3600_000 // 会话有效期 12 小时,防长期 token 泄漏
|
||
const LOGIN_LOCK = { failures: 3, windowMs: 5 * 60_000, lockMs: 15 * 60_000 } // 3 次失败锁 15 分钟
|
||
const sessions = new Map() // token -> { name, at, exp };内存态,进程重启后需重新登录
|
||
const loginFail = new Map() // ip -> { count, firstAt, lockedUntil }
|
||
|
||
// 登录限速:滑动窗口内失败达阈值即锁定(按客户端 IP)
|
||
function loginGuard(ip) {
|
||
const now = Date.now()
|
||
const rec = loginFail.get(ip)
|
||
if (!rec) return { locked: false }
|
||
if (rec.lockedUntil && now < rec.lockedUntil) {
|
||
return { locked: true, retryAfter: Math.ceil((rec.lockedUntil - now) / 1000) }
|
||
}
|
||
if (rec.firstAt && now - rec.firstAt > LOGIN_LOCK.windowMs) {
|
||
loginFail.delete(ip) // 窗口过期重置
|
||
return { locked: false }
|
||
}
|
||
return { locked: false }
|
||
}
|
||
|
||
function recordLoginFail(ip) {
|
||
const now = Date.now()
|
||
let rec = loginFail.get(ip)
|
||
if (!rec || now - rec.firstAt > LOGIN_LOCK.windowMs) rec = { count: 0, firstAt: now, lockedUntil: null }
|
||
rec.count += 1
|
||
if (rec.count >= LOGIN_LOCK.failures) {
|
||
rec.lockedUntil = now + LOGIN_LOCK.lockMs
|
||
console.warn(`[auth] IP ${ip} 登录失败 ${rec.count} 次,锁定 ${LOGIN_LOCK.lockMs / 60000} 分钟`)
|
||
}
|
||
loginFail.set(ip, rec)
|
||
}
|
||
|
||
function recordLoginOk(ip) {
|
||
loginFail.delete(ip)
|
||
}
|
||
|
||
app.post('/api/login', (req, res) => {
|
||
const { username, password } = req.body || {}
|
||
const ip = req.ip || req.socket.remoteAddress || 'unknown'
|
||
const guard = loginGuard(ip)
|
||
if (guard.locked) {
|
||
return res.status(429).json({ ok: false, message: `尝试过于频繁,请 ${guard.retryAfter} 秒后再试` })
|
||
}
|
||
if (String(username || '') !== AUTH_USERNAME || String(password || '') !== AUTH_PASSWORD) {
|
||
recordLoginFail(ip)
|
||
const rec = loginFail.get(ip)
|
||
const left = rec && rec.lockedUntil ? Math.ceil((rec.lockedUntil - Date.now()) / 1000) : 0
|
||
return res.status(401).json({
|
||
ok: false,
|
||
message: left > 0 ? `用户名或密码错误,已触发 ${left} 秒锁定` : '用户名或密码错误',
|
||
})
|
||
}
|
||
recordLoginOk(ip)
|
||
const token = randomUUID()
|
||
const now = Date.now()
|
||
sessions.set(token, { name: AUTH_USERNAME, at: now, exp: now + SESSION_TTL_MS })
|
||
res.json({ ok: true, token, user: { name: AUTH_USERNAME } })
|
||
})
|
||
|
||
app.post('/api/logout', (req, res) => {
|
||
const auth = req.headers.authorization || ''
|
||
if (auth.startsWith('Bearer ')) sessions.delete(auth.slice(7))
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// 保护全部 /api/*(/login、/logout 除外):未登录或会话过期返回 401,前端据此跳转登录页
|
||
app.use('/api', (req, res, next) => {
|
||
if (req.path === '/login' || req.path === '/logout') return next()
|
||
const auth = req.headers.authorization || ''
|
||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
|
||
const sess = token ? sessions.get(token) : null
|
||
if (!sess) {
|
||
return res.status(401).json({ ok: false, message: '未登录或会话已失效' })
|
||
}
|
||
if (Date.now() > sess.exp) {
|
||
sessions.delete(token) // 过期 token 立即作废
|
||
return res.status(401).json({ ok: false, message: '登录已过期,请重新登录' })
|
||
}
|
||
next()
|
||
})
|
||
|
||
// —— API:阈值 ——
|
||
// spec §4.2:GET/POST /api/threshold(版本阈值)
|
||
app.get('/api/threshold', (req, res) => {
|
||
res.json({ versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)) })
|
||
})
|
||
app.post('/api/threshold', (req, res) => {
|
||
// 兼容 versionThreshold / value 两种字段名
|
||
const v = clampInt(req.body.versionThreshold ?? req.body.value, 1, 100)
|
||
setSetting('version_threshold', v)
|
||
res.json({ ok: true, versionThreshold: v })
|
||
})
|
||
|
||
// 扩展:GET/POST /api/thresholds(版本 + 大小 + 单包例外)
|
||
app.get('/api/thresholds', (req, res) => {
|
||
let exceptions = []
|
||
try { exceptions = JSON.parse(getSetting('exceptions', '[]')) } catch { exceptions = [] }
|
||
res.json({
|
||
versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
|
||
sizeThreshold: Number(getSetting('size_threshold', DEFAULT_SIZE_THRESHOLD)),
|
||
exceptions,
|
||
})
|
||
})
|
||
app.post('/api/thresholds', (req, res) => {
|
||
const { versionThreshold, sizeThreshold, exceptions } = req.body || {}
|
||
setSetting('version_threshold', clampInt(versionThreshold, 1, 100))
|
||
setSetting('size_threshold', clampInt(sizeThreshold, 1, 10000))
|
||
const ex = Array.isArray(exceptions)
|
||
? exceptions.map(e => ({
|
||
pkgName: String(e.pkgName || '').trim(),
|
||
versionThreshold: clampInt(e.versionThreshold, 1, 100),
|
||
sizeThreshold: clampInt(e.sizeThreshold, 1, 10000),
|
||
})).filter(e => e.pkgName)
|
||
: []
|
||
setSetting('exceptions', JSON.stringify(ex))
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// —— API:忽略 ——
|
||
app.get('/api/ignored', (req, res) => {
|
||
res.json(getIgnored())
|
||
})
|
||
app.post('/api/ignore', (req, res) => {
|
||
const { pkgName } = req.body || {}
|
||
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
|
||
addIgnored(String(pkgName))
|
||
addLog({ action: 'ignore', pkgName: String(pkgName) })
|
||
res.json({ ok: true })
|
||
})
|
||
app.delete('/api/ignore/:pkgName', (req, res) => {
|
||
const pkgName = decodeURIComponent(req.params.pkgName)
|
||
removeIgnored(pkgName)
|
||
addLog({ action: 'unignore', pkgName })
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// —— API:包列表(扫描) ——
|
||
app.get('/api/packages', (req, res) => {
|
||
const demo = isDemoMode()
|
||
const storage = currentStorage()
|
||
const validation = demo ? { ok: true, message: 'demo' } : validateStorage(storage)
|
||
ensureScan() // 懒触发:登录后首次拉包自动启动扫描(rawCache 缺失时),返回 scanning 状态供前端轮询
|
||
const packages = buildPackages(req.query.keyword || '') // 先构建(填充缓存)
|
||
res.json({
|
||
demo,
|
||
storageDir: storage || null,
|
||
storageValid: validation.ok,
|
||
storageMessage: validation.message,
|
||
threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
|
||
scannedAt: demo ? new Date().toISOString() : (rawCacheAt || null),
|
||
scanning: scanState.scanning,
|
||
scanPackages: scanState.packages,
|
||
scanBytes: scanState.bytes,
|
||
totalBytes: demo ? null : (scanState.diskBytes ?? scanState.bytes), // 磁盘占用(跨平台字节和)
|
||
packages,
|
||
...(demo ? demoSummary() : {}),
|
||
})
|
||
})
|
||
|
||
// —— API:单包详情(含版本明细,抽屉打开时按需加载)
|
||
// 只读现有扫描缓存,绝不触发同步扫描(缓存被失效时直接 404,
|
||
// 前端用列表数据兜底 + 提示,避免打开抽屉卡几十秒) ——
|
||
app.get('/api/packages/:pkgName', (req, res) => {
|
||
const name = decodeURIComponent(req.params.pkgName)
|
||
const raw = isDemoMode() ? demoPackages() : (rawCache || null)
|
||
const p = raw ? raw.find(x => x.name === name) : null
|
||
if (!p) return res.status(404).json({ ok: false, message: '包不存在或正在扫描中' })
|
||
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
|
||
const ignoredSet = new Set(getIgnoredRows().map(r => r.pkg_name))
|
||
let status
|
||
if (p.status === 'broken') status = 'broken'
|
||
else if (ignoredSet.has(p.name)) status = 'ignored'
|
||
else status = p.versions > threshold ? 'warn' : 'ok'
|
||
res.json({ ok: true, pkg: { ...p, status } })
|
||
})
|
||
|
||
// —— API:手动重新扫描缓存目录(异步,扫描期间前端轮询进度) ——
|
||
app.post('/api/scan', (req, res) => {
|
||
invalidateScan()
|
||
const demo = isDemoMode()
|
||
if (demo) {
|
||
const scannedAt = new Date().toISOString()
|
||
return res.json({ ok: true, demo, scanning: false, scannedAt })
|
||
}
|
||
if (!scanState.scanning) runScanAsync() // 后台启动,不阻塞响应
|
||
res.json({
|
||
ok: true,
|
||
demo: false,
|
||
scanning: scanState.scanning,
|
||
startedAt: scanState.startedAt,
|
||
packages: scanState.packages,
|
||
bytes: scanState.bytes,
|
||
})
|
||
})
|
||
|
||
// —— API:扫描进度(重扫/首扫期间由前端轮询) ——
|
||
app.get('/api/scan/progress', (req, res) => {
|
||
res.json({
|
||
scanning: scanState.scanning,
|
||
done: scanState.done,
|
||
packages: scanState.packages,
|
||
bytes: scanState.bytes,
|
||
tgzBytes: scanState.tgzBytes,
|
||
diskBytes: scanState.diskBytes,
|
||
error: scanState.error,
|
||
startedAt: scanState.startedAt,
|
||
finishedAt: scanState.finishedAt,
|
||
})
|
||
})
|
||
|
||
// —— API:一键拉取存量包最新版本(异步,期间前端轮询进度) ——
|
||
app.post('/api/sync/latest', (req, res) => {
|
||
if (isDemoMode()) {
|
||
return res.json({ ok: true, demo: true, running: false, message: '演示模式:未配置 Verdaccio 缓存目录,不支持拉取' })
|
||
}
|
||
if (!syncState.running) {
|
||
runSyncLatest({
|
||
addLog,
|
||
invalidateScan,
|
||
})
|
||
}
|
||
res.json({
|
||
ok: true,
|
||
demo: false,
|
||
running: syncState.running,
|
||
total: syncState.total,
|
||
startedAt: syncState.startedAt,
|
||
})
|
||
})
|
||
|
||
// —— API:拉取进度(拉取期间由前端轮询) ——
|
||
app.get('/api/sync/progress', (req, res) => {
|
||
res.json({
|
||
running: syncState.running,
|
||
done: syncState.done,
|
||
total: syncState.total,
|
||
processed: syncState.processed,
|
||
success: syncState.success,
|
||
skipped: syncState.skipped,
|
||
failed: syncState.failed,
|
||
failedList: syncState.failedList,
|
||
current: syncState.current,
|
||
error: syncState.error,
|
||
startedAt: syncState.startedAt,
|
||
finishedAt: syncState.finishedAt,
|
||
})
|
||
})
|
||
|
||
// —— API:npm 包清单(热门包缓存扩充) -----
|
||
// 清单维护:查看 / 新增 / 编辑 / 删除(含 valid 性校验与操作日志)
|
||
app.get('/api/pkglist', (req, res) => {
|
||
res.json({ ok: true, ...getPkgList() })
|
||
})
|
||
app.post('/api/pkglist', (req, res) => {
|
||
const r = addPkg(req.body || {})
|
||
if (!r.ok) return res.status(400).json(r)
|
||
res.json({ ok: true, ...getPkgList() })
|
||
})
|
||
app.put('/api/pkglist/:id', (req, res) => {
|
||
const r = editPkg(req.params.id, req.body || {})
|
||
if (!r.ok) return res.status(400).json(r)
|
||
res.json({ ok: true, ...getPkgList() })
|
||
})
|
||
app.delete('/api/pkglist/:id', (req, res) => {
|
||
const r = removePkg(req.params.id)
|
||
if (!r.ok) return res.status(400).json(r)
|
||
res.json({ ok: true, ...getPkgList() })
|
||
})
|
||
|
||
// —— API:清单手动去重(将 storage 已缓存但清单仍为 pending 的条目置 done,避免重复拉取) ——
|
||
app.post('/api/pkglist/dedup', (req, res) => {
|
||
const r = dedupPkgList()
|
||
res.json({ ok: true, ...r, ...getPkgList() })
|
||
})
|
||
|
||
// —— API:按清单手动拉取(同步启动后台任务,不阻塞;失败自动重试 3 次;清单失效时提示) ——
|
||
app.post('/api/pkglist/pull', (req, res) => {
|
||
if (isDemoMode()) {
|
||
return res.json({ ok: true, demo: true, running: false, message: '演示模式:未配置 Verdaccio 缓存目录,不支持拉取' })
|
||
}
|
||
// 清单失效(全部条目已成功拉取)→ 当前无可用 npm 包
|
||
if (countPending() === 0) {
|
||
return res.json({ ok: true, active: false, running: false, message: '当前无可用npm包' })
|
||
}
|
||
const r = runPkgListPull({ addLog, invalidateScan, source: 'manual' })
|
||
res.json({
|
||
ok: true, demo: false,
|
||
running: r.started ? true : pkgPullState.running,
|
||
active: r.active,
|
||
pendingCount: r.pendingCount,
|
||
startedAt: pkgPullState.startedAt,
|
||
})
|
||
})
|
||
|
||
// —— API:按清单拉取进度(拉取期间由前端轮询;含重试轮次与失败明细) ——
|
||
app.get('/api/pkglist/progress', (req, res) => {
|
||
res.json({
|
||
running: pkgPullState.running,
|
||
done: pkgPullState.done,
|
||
total: pkgPullState.total,
|
||
processed: pkgPullState.processed,
|
||
success: pkgPullState.success,
|
||
failed: pkgPullState.failed,
|
||
failedList: pkgPullState.failedList,
|
||
round: pkgPullState.round,
|
||
current: pkgPullState.current,
|
||
source: pkgPullState.source,
|
||
error: pkgPullState.error,
|
||
startedAt: pkgPullState.startedAt,
|
||
finishedAt: pkgPullState.finishedAt,
|
||
})
|
||
})
|
||
|
||
// —— API:清单定时更新 / 定时拉取配置(右上角用户名下拉入口读写) ——
|
||
app.get('/api/pkglist/config', (req, res) => {
|
||
res.json({ ok: true, config: getConfig() })
|
||
})
|
||
app.post('/api/pkglist/config', (req, res) => {
|
||
const r = saveConfig(req.body || {})
|
||
if (!r.ok) return res.status(400).json(r)
|
||
res.json({ ok: true, config: r.config })
|
||
})
|
||
|
||
// —— API:清理 ——
|
||
app.post('/api/clean', (req, res) => {
|
||
const { pkgName, keep } = req.body || {}
|
||
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
|
||
if (isDemoMode()) {
|
||
return demoClean(res, pkgName, clampInt(keep, 1, 999))
|
||
}
|
||
const storage = currentStorage()
|
||
const result = cleanPackage(storage, pkgName, clampInt(keep, 1, 999))
|
||
if (result.ok) {
|
||
addLog({
|
||
pkgName,
|
||
deletedVersions: result.deletedVersions,
|
||
deletedCount: result.deletedCount,
|
||
freedSpace: result.freedSpace,
|
||
})
|
||
invalidateScan()
|
||
}
|
||
res.json(result)
|
||
})
|
||
|
||
// —— API:单版本删除 ——
|
||
app.delete('/api/packages/:pkgName/versions/:version', (req, res) => {
|
||
const { pkgName, version } = req.params
|
||
if (isDemoMode()) {
|
||
return demoDeleteVersion(res, pkgName, version)
|
||
}
|
||
const storage = currentStorage()
|
||
const result = deleteSingleVersion(storage, pkgName, version)
|
||
if (result.ok) {
|
||
addLog({
|
||
pkgName,
|
||
deletedVersions: version,
|
||
deletedCount: 1,
|
||
freedSpace: result.freedSpace,
|
||
})
|
||
invalidateScan()
|
||
}
|
||
res.json(result)
|
||
})
|
||
|
||
// —— API:日志 ——
|
||
app.get('/api/logs', (req, res) => {
|
||
res.json(getLogs(50))
|
||
})
|
||
|
||
// —— API:storage 配置(UI 设置页) ——
|
||
app.get('/api/storage', (req, res) => {
|
||
const storage = currentStorage()
|
||
const demo = isDemoMode()
|
||
res.json({
|
||
storageDir: storage,
|
||
envDir: process.env.VERDACCIO_STORAGE || null,
|
||
demo,
|
||
validation: demo ? { ok: true, message: 'demo' } : validateStorage(storage),
|
||
})
|
||
})
|
||
app.post('/api/storage', (req, res) => {
|
||
const { storageDir } = req.body || {}
|
||
if (!storageDir) return res.status(400).json({ ok: false, message: '缺少 storageDir' })
|
||
const v = validateStorage(String(storageDir))
|
||
if (!v.ok) return res.status(400).json(v)
|
||
setSetting('storage_dir', String(storageDir))
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// —— 生产:托管前端 dist ——
|
||
const distDir = path.join(__dirname, '..', 'frontend', 'dist')
|
||
if (fs.existsSync(distDir)) {
|
||
app.use(express.static(distDir))
|
||
app.get('*', (req, res) => {
|
||
if (req.path.startsWith('/api/')) return res.status(404).json({ ok: false, message: 'not found' })
|
||
res.sendFile(path.join(distDir, 'index.html'))
|
||
})
|
||
}
|
||
|
||
app.listen(PORT, () => {
|
||
const storage = resolveStorageDir()
|
||
const mode = isDemoMode() ? 'demo' : 'storage'
|
||
if (!process.env.VCM_PASSWORD) {
|
||
console.warn(
|
||
'[auth] ⚠ 未设置 VCM_PASSWORD 环境变量,正在使用默认口令 666666。' +
|
||
'生产部署请务必通过 docker-compose 的环境变量覆盖,否则面板可被任意登录!',
|
||
)
|
||
}
|
||
console.log(`[verdaccio-manager] listening on :${PORT} (mode=${mode})`)
|
||
if (mode === 'storage') console.log(`[verdaccio-manager] storage: ${storage}`)
|
||
else console.log('[verdaccio-manager] 未配置 VERDACCIO_STORAGE,运行于演示模式(设置 VCM_DEMO=0 + 真实目录可关闭)')
|
||
scheduleDailyScan()
|
||
// —— npm 包清单:初始化内置热门包 + 启动定时守护(定时更新收录 / 定时按清单拉取) ——
|
||
try {
|
||
seedIfNeeded()
|
||
startPkgListDaemon({
|
||
// 定时更新清单:内置热门池 ∪ npm-search 榜单,按包名去重后补入(pending)
|
||
runAutoUpdate() {
|
||
updateFromAllSources().then(({ added, rankSource }) => {
|
||
addLog({
|
||
action: 'list-auto',
|
||
pkgName: null,
|
||
deletedCount: added,
|
||
deletedVersions: JSON.stringify({
|
||
note: rankSource ? `定时收录热门 npm 包(榜单源 ${rankSource})` : '定时收录热门 npm 包(npm-search 榜单不可用,仅内置池)',
|
||
}),
|
||
})
|
||
})
|
||
},
|
||
// 定时拉取:清单已失效(无 pending)时自动停止,直到清单写入新包
|
||
runAutoPull() {
|
||
if (isDemoMode()) return
|
||
runPkgListPull({ addLog, invalidateScan, source: 'auto' })
|
||
},
|
||
})
|
||
} catch (e) {
|
||
console.error('[pkglist] 初始化失败:', e.message)
|
||
}
|
||
})
|
||
|
||
// 清理登录限速表:超过窗口 + 锁定期的记录不再保留(防 Map 无限增长)
|
||
setInterval(() => {
|
||
const now = Date.now()
|
||
for (const [ip, rec] of loginFail) {
|
||
const stale = rec.lockedUntil
|
||
? now > rec.lockedUntil
|
||
: now - rec.firstAt > LOGIN_LOCK.windowMs
|
||
if (stale) loginFail.delete(ip)
|
||
}
|
||
}, 10 * 60_000).unref()
|
||
|
||
// —— 每天 00:00(服务器本地时区)自动重扫缓存,逻辑同 POST /api/scan ——
|
||
function scheduleDailyScan() {
|
||
const now = new Date()
|
||
const next = new Date(now)
|
||
next.setHours(24, 0, 0, 0) // 下个本地零点
|
||
const wait = next.getTime() - now.getTime()
|
||
setTimeout(() => {
|
||
try {
|
||
invalidateScan()
|
||
ensureScan() // 后台异步重扫,不阻塞事件循环
|
||
console.log(`[auto-scan] ${new Date().toISOString()} 每日自动扫描已启动`)
|
||
} catch (e) {
|
||
console.error('[auto-scan] 每日自动扫描失败:', e.message)
|
||
}
|
||
scheduleDailyScan() // 安排次日零点
|
||
}, wait)
|
||
}
|
||
|
||
function clampInt(v, min, max) {
|
||
const n = Number(v)
|
||
if (Number.isNaN(n)) return min
|
||
return Math.min(max, Math.max(min, Math.round(n)))
|
||
}
|
||
|
||
// —— demo 模式下的清理/删除(内存态) ——
|
||
const demoMemory = { logs: [] }
|
||
function demoClean(res, pkgName, keep) {
|
||
const p = demoPackages().find(x => x.name === pkgName)
|
||
if (!p) return res.json({ ok: false, message: '包不存在' })
|
||
const sorted = [...p.versionsDetail].sort((a, b) => semverCompare(a.version, b.version))
|
||
const toDel = sorted.slice(0, Math.max(0, sorted.length - keep))
|
||
if (!toDel.length) return res.json({ ok: false, message: '无需清理' })
|
||
const freed = toDel.reduce((s, v) => s + v.size, 0)
|
||
const result = {
|
||
ok: true,
|
||
pkgName,
|
||
deletedVersions: toDel.map(v => v.version).join(', '),
|
||
deletedCount: toDel.length,
|
||
freedSpace: `${freed.toFixed(1)} MB`,
|
||
}
|
||
// demo 日志并入 logs 表,保持一致
|
||
addLog({ pkgName, deletedVersions: result.deletedVersions, deletedCount: result.deletedCount, freedSpace: result.freedSpace })
|
||
res.json(result)
|
||
}
|
||
|
||
function demoDeleteVersion(res, pkgName, version) {
|
||
const p = demoPackages().find(x => x.name === pkgName)
|
||
const v = p?.versionsDetail?.find(x => x.version === version)
|
||
if (!v) return res.json({ ok: false, message: '版本不存在' })
|
||
addLog({ pkgName, deletedVersions: version, deletedCount: 1, freedSpace: `${v.size.toFixed(1)} MB` })
|
||
res.json({ ok: true, freedSpace: `${v.size.toFixed(1)} MB` })
|
||
}
|