Files
daywork/server/index.js
T

551 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 实习工作日志 · 多端同步服务端
// 零依赖:node:http + node:crypto + JSON 文件存储(个人规模足够,便于备份)
import http from 'node:http'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data')
const PORT = Number(process.env.PORT || 3780)
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
const ITER = 150000
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据
const NATURAL_KEYS = {
project: 'name',
work_log: 'date',
attendance: 'date',
weekly_note: 'week_start',
setting: 'key'
}
fs.mkdirSync(DATA_DIR, { recursive: true })
// ---------- 存储辅助 ----------
function readJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), 'utf-8'))
} catch {
return fallback
}
}
function writeJson(file, data) {
const tmp = path.join(DATA_DIR, file + '.tmp')
fs.writeFileSync(tmp, JSON.stringify(data))
fs.renameSync(tmp, path.join(DATA_DIR, file))
}
// ---------- 异地备份:可选,配置环境变量后每日备份与注册表变更时自动上传 COS ----------
// COS_SECRET_ID / COS_SECRET_KEY / COS_BUCKET(形如 name-125xxxxxx) / COS_REGION(形如 ap-guangzhou)
const COS_CFG = {
secretId: process.env.COS_SECRET_ID || '',
secretKey: process.env.COS_SECRET_KEY || '',
bucket: process.env.COS_BUCKET || '',
region: process.env.COS_REGION || ''
}
let lastOffsiteBackup = { state: 'disabled', at: '', detail: '' }
function cosEncode(s) {
return encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
}
function cosAuth(method, pathname) {
const start = Math.floor(Date.now() / 1000) - 60
const end = start + 600
const keyTime = start + ';' + end
const signKey = crypto.createHmac('sha1', COS_CFG.secretKey).update(keyTime).digest('hex')
const lowerPath = pathname.toLowerCase()
const httpString = method.toLowerCase() + '\n' + cosEncode(lowerPath) + '\n' + '' + '\n' + 'host=' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + '\n'
const stringToSign = crypto.createHash('sha1').update(httpString).digest('hex') + '\n' + signKey + '\n' + keyTime + '\n' + crypto.createHash('sha1').update('host').digest('hex')
const signature = crypto.createHmac('sha1', signKey).update(stringToSign).digest('hex')
return 'q-sign-algorithm=sha1&q-ak=' + COS_CFG.secretId + '&q-sign-time=' + keyTime + '&q-key-time=' + keyTime + '&q-header-list=host&q-url-param-list=&q-signature=' + signature
}
async function cosPut(key, filePath) {
const body = fs.readFileSync(filePath)
const pathname = '/' + key.split('/').map(cosEncode).join('/')
const auth = cosAuth('put', pathname)
const url = 'https://' + COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com' + pathname
const resp = await fetch(url, {
method: 'PUT',
headers: {
Host: COS_CFG.bucket + '.cos.' + COS_CFG.region + '.myqcloud.com',
Authorization: auth,
'Content-Length': String(body.length)
},
body
})
if (!resp.ok) throw new Error('COS ' + resp.status + ' ' + (await resp.text()).slice(0, 120))
}
async function backupOffsite() {
if (!COS_CFG.secretId || !COS_CFG.secretKey || !COS_CFG.bucket || !COS_CFG.region) return
try {
const day = new Date().toISOString().slice(0, 10)
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const files = [['registry.json', 'registry.json'], ['secret.key', 'secret.key']]
try {
const usersDir = path.join(DATA_DIR, 'users')
if (fs.existsSync(usersDir)) {
for (const f of fs.readdirSync(usersDir)) if (f.endsWith('.json')) files.push(['users/' + f, 'users/' + f])
}
} catch { /* 忽略 */ }
for (const [rel, src] of files) {
const full = path.join(DATA_DIR, src)
if (!fs.existsSync(full)) continue
await cosPut('daywork-backup/' + day + '/' + stamp + '/' + rel, full)
await cosPut('daywork-backup/latest/' + rel, full)
}
lastOffsiteBackup = { state: 'ok', at: new Date().toISOString(), detail: files.length + ' files' }
} catch (e) {
lastOffsiteBackup = { state: 'failed', at: new Date().toISOString(), detail: (e && e.message) || String(e) }
console.error('offsite backup failed:', e && e.message)
}
}
if (!fs.existsSync(path.join(DATA_DIR, 'secret.key'))) {
writeJson('secret.key', { secret: crypto.randomBytes(32).toString('hex') })
}
const SECRET = readJson('secret.key', { secret: 'fallback-secret' }).secret
const registryFile = 'registry.json'
let registry = readJson(registryFile, { users: [] })
// 管理员:xionglin 固定为管理员(存量注册表自动补齐 role 字段)
let adminDirty = false
for (const u of registry.users) {
if (u.username === 'xionglin' && u.role !== 'admin') { u.role = 'admin'; adminDirty = true }
else if (u.username !== 'xionglin' && !u.role) { u.role = 'user'; adminDirty = true }
}
if (adminDirty) saveRegistry()
function isAdminToken(token) {
const auth = verifyToken(token)
if (!auth || !tokenUsable(auth)) return null
const rec = registry.users.find(u => u.id === auth.userId)
if (!rec || rec.role !== 'admin') return null
return rec
}
function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } }
function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') }
function loadUserData(userId) {
const data = readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} })
// 数据纪元:用户数据文件首次创建时间。目录被误删重建后该值改变,客户端据此识别并全量重推自救
if (!data.created_at) data.created_at = new Date().toISOString()
return data
}
function saveUserData(userId, data) {
fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true })
writeJson('users/' + userId + '.json', data)
}
// ---------- 认证 ----------
function pbkdf2Hash(password, salt) {
return crypto.pbkdf2Sync(password, salt, ITER, 32, 'sha256').toString('hex')
}
function makeToken(userId) {
const payload = base64url(JSON.stringify({ userId, exp: Date.now() + TOKEN_TTL_MS, iat: Date.now() }))
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url')
return payload + '.' + sig
}
function verifyToken(token) {
const parts = String(token || '').split('.')
if (parts.length !== 2) return null
const expect = crypto.createHmac('sha256', SECRET).update(parts[0]).digest('base64url')
const a = Buffer.from(parts[1])
const b = Buffer.from(expect)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
try {
const payload = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf-8'))
if (!payload.userId || payload.exp < Date.now()) return null
return payload
} catch {
return null
}
}
// 会话时效:改密 / 管理员重置密码后,签发时间早于 pwd_changed_at 的令牌一律失效
function tokenUsable(payload) {
if (!payload || !payload.userId) return false
const rec = registry.users.find(u => u.id === payload.userId)
if (!rec) return false
if (rec.pwd_changed_at) {
const since = Date.parse(rec.pwd_changed_at)
if (!payload.iat || payload.iat < since) return false
}
return true
}
function base64url(s) {
return Buffer.from(s).toString('base64url')
}
// ---------- SSE 长连接:按用户分组,数据变更时实时通知 ----------
const sseClients = new Map()
// 登录防爆破:IP -> { count, first }(内存态,重启即清)
const loginFails = new Map()
function broadcast(userId, event) {
const set = sseClients.get(userId)
if (!set) return
for (const res of set) {
try { res.write('event: ' + event + '\ndata: {}\n\n') } catch { /* 连接已断开 */ }
}
}
function addSse(userId, res) {
if (!sseClients.has(userId)) sseClients.set(userId, new Set())
sseClients.get(userId).add(res)
}
function removeSse(userId, res) {
const set = sseClients.get(userId)
if (set) { set.delete(res); if (!set.size) sseClients.delete(userId) }
}
// ---------- HTTP 基础 ----------
function json(res, status, data) {
const body = JSON.stringify(data)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
})
res.end(body)
}
function readBody(req, limit = 20 * 1024 * 1024) {
return new Promise((resolve, reject) => {
let size = 0
const chunks = []
req.on('data', c => {
size += c.length
if (size > limit) { reject(new Error('body too large')); req.destroy(); return }
chunks.push(c)
})
req.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}')) }
catch { reject(new Error('invalid json')) }
})
req.on('error', reject)
})
}
// ---------- 静态托管网页应用 ----------
// 容器内为 /app/dist;本机直跑时回退到仓库内的 intern-log/dist
const DIST_CANDIDATES = [path.join(__dirname, 'dist'), path.join(__dirname, '..', 'intern-log', 'dist')]
const DIST_DIR = process.env.DIST_DIR || DIST_CANDIDATES.find(p => fs.existsSync(path.join(p, 'index.html'))) || path.join(__dirname, 'dist')
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.json': 'application/json',
'.webmanifest': 'application/manifest+json',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2'
}
// ---------- 路由 ----------
async function handle(req, res) {
const url = new URL(req.url, 'http://localhost')
const route = url.pathname
if (req.method === 'OPTIONS') { json(res, 204, {}); return }
// 非 API 的 GET 请求:返回网页应用(构建产物)
if (req.method === 'GET' && !route.startsWith('/api')) {
let rel = decodeURIComponent(url.pathname)
if (rel === '/' || rel === '') rel = '/index.html'
const file = path.normalize(path.join(DIST_DIR, rel))
if (!file.startsWith(DIST_DIR)) { json(res, 403, { error: 'forbidden' }); return }
fs.readFile(file, (err, data) => {
if (err) {
fs.readFile(path.join(DIST_DIR, 'index.html'), (e2, d2) => {
if (e2) { json(res, 404, { error: 'not found' }); return }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(d2)
})
return
}
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' })
res.end(data)
})
return
}
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true, backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup }); return }
// SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头)
if (req.method === 'GET' && route === '/api/events') {
const auth = verifyToken(url.searchParams.get('token'))
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*'
})
res.write('event: hello\ndata: {}\n\n')
addSse(auth.userId, res)
const ka = setInterval(() => {
try { res.write(': keepalive\n\n') } catch { /* 忽略 */ }
}, 25000)
req.on('close', () => { clearInterval(ka); removeSse(auth.userId, res) })
return
}
// 修改登录密码(验证旧密码后重置盐与哈希)
if (req.method === 'POST' && route === '/api/auth/password') {
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
const body = await readBody(req)
const rec = registry.users.find(u => u.id === auth.userId)
if (!rec) { json(res, 404, { error: '用户不存在' }); return }
if (pbkdf2Hash(String(body.oldPassword || ''), rec.pwd_salt) !== rec.pwd_hash) { json(res, 400, { error: '旧密码不正确' }); return }
if (String(body.newPassword || '').length < 6) { json(res, 400, { error: '新密码至少 6 位' }); return }
rec.pwd_salt = crypto.randomBytes(16).toString('hex')
rec.pwd_hash = pbkdf2Hash(String(body.newPassword), rec.pwd_salt)
rec.pwd_changed_at = new Date().toISOString() // 吊销该用户全部旧令牌
saveRegistry()
json(res, 200, { ok: true })
return
}
// 删除云端账号:注册表与全部业务数据一并清除(不可恢复,客户端已做二次确认)
if (req.method === 'POST' && route === '/api/auth/delete') {
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
const idx = registry.users.findIndex(u => u.id === auth.userId)
if (idx >= 0) { registry.users.splice(idx, 1); saveRegistry() }
try { fs.rmSync(userFile(auth.userId), { force: true }) } catch { /* 忽略 */ }
for (const sseRes of (sseClients.get(auth.userId) || [])) { try { sseRes.end() } catch { /* 忽略 */ } }
sseClients.delete(auth.userId)
json(res, 200, { ok: true })
return
}
if (req.method === 'POST' && route === '/api/auth/register') {
const body = await readBody(req)
const username = String(body.username || '').trim()
const password = String(body.password || '')
if (username.length < 2 || username.length > 20) { json(res, 400, { error: '用户名需 2 到 20 个字符' }); return }
if (password.length < 6) { json(res, 400, { error: '密码至少 6 位' }); return }
if (registry.users.find(u => u.username === username)) { json(res, 409, { error: '用户名已存在' }); return }
const salt = crypto.randomBytes(16).toString('hex')
const userId = 'cu_' + crypto.randomBytes(6).toString('hex')
registry.users.push({ id: userId, username, role: username === 'xionglin' ? 'admin' : 'user', pwd_hash: pbkdf2Hash(password, salt), pwd_salt: salt, created_at: new Date().toISOString() })
saveRegistry()
json(res, 200, { userId, username, token: makeToken(userId) })
return
}
if (req.method === 'POST' && route === '/api/auth/login') {
const loginIp = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'unknown'
const nowMs = Date.now()
let st = loginFails.get(loginIp)
if (st && nowMs - st.first < 10 * 60 * 1000 && st.count >= 10) { json(res, 429, { error: '尝试次数过多,请 10 分钟后再试' }); return }
const body = await readBody(req)
const username = String(body.username || '').trim()
const rec = registry.users.find(u => u.username === username)
if (!rec) { json(res, 401, { error: '用户不存在' }); return }
if (pbkdf2Hash(String(body.password || ''), rec.pwd_salt) !== rec.pwd_hash) {
const s2 = loginFails.get(loginIp)
loginFails.set(loginIp, !s2 || nowMs - s2.first >= 10 * 60 * 1000 ? { count: 1, first: nowMs } : { count: s2.count + 1, first: s2.first })
json(res, 401, { error: '密码不正确' }); return
}
loginFails.delete(loginIp)
json(res, 200, { userId: rec.id, username: rec.username, token: makeToken(rec.id) })
return
}
if (req.method === 'POST' && route === '/api/sync') {
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
const body = await readBody(req)
const userData = loadUserData(auth.userId)
if (!userData.cursor) userData.cursor = 0
if (!userData.tables) userData.tables = {}
if (!userData.tombstones) userData.tombstones = {}
// 应用推送的变更(新者胜 + 自然键去重)
const incomingTables = body.tables || {}
let changed = 0
const now = new Date().toISOString()
for (const t of TABLES) {
const rows = incomingTables[t] || []
userData.tables[t] = userData.tables[t] || {}
const nk = NATURAL_KEYS[t] || null
for (const row of rows) {
if (!row || !row.uuid) continue
const clean = {}
for (const k of Object.keys(row)) if (k !== 'id') clean[k] = row[k]
// 自然键冲突:另一 uuid 的记录已占用同一自然键
let conflictUuid = null
if (nk) {
const nkVal = String(clean[nk] || '')
if (nkVal) {
for (const u2 of Object.keys(userData.tables[t])) {
if (u2 === row.uuid) continue
if (String(userData.tables[t][u2][nk] || '') === nkVal) { conflictUuid = u2; break }
}
}
}
const existing = userData.tables[t][row.uuid]
if (conflictUuid) {
// 与自然键冲突行比较新者(而非与同 uuid 旧行)
const conflictRow = userData.tables[t][conflictUuid]
const incomingNewer = String(clean.updated_at || '') >= String(conflictRow.updated_at || '')
if (!incomingNewer) {
// 本记录较旧:加墓碑让推送端删除它
userData.tombstones[t] = userData.tombstones[t] || {}
userData.tombstones[t][row.uuid] = now
changed++
continue
}
// 本记录较新:删除冲突副本并加墓碑通知其他端
if (t === 'work_log') {
// 冲突的工作日志被删时,其日志条目改挂到幸存者
for (const iu of Object.keys(userData.tables.log_item || {})) {
const it = userData.tables.log_item[iu]
if (String(it.wuuid || '') === conflictUuid) { it.wuuid = row.uuid; changed++ }
}
}
delete userData.tables[t][conflictUuid]
userData.tombstones[t] = userData.tombstones[t] || {}
userData.tombstones[t][conflictUuid] = now
changed++
}
userData.tables[t][row.uuid] = clean
changed++
}
}
// 应用墓碑
const incomingTombs = body.tombstones || []
for (const tb of incomingTombs) {
if (!TABLES.includes(tb.table) || !tb.uuid) continue
userData.tombstones[tb.table] = userData.tombstones[tb.table] || {}
const prev = userData.tombstones[tb.table][tb.uuid]
if (!prev || tb.deleted_at > prev) {
userData.tombstones[tb.table][tb.uuid] = tb.deleted_at
changed++
}
}
// 墓碑生效:清除被删数据
for (const t of TABLES) {
const tombs = userData.tombstones[t] || {}
for (const [u, deletedAt] of Object.entries(tombs)) {
const row = userData.tables[t][u]
if (row && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
}
}
saveUserData(auth.userId, userData)
if (changed) broadcast(auth.userId, 'change')
// 返回权威全量
const outTables = {}
const outTombs = []
for (const t of TABLES) {
outTables[t] = Object.values(userData.tables[t] || {})
const tombs = userData.tombstones[t] || {}
for (const [u, deletedAt] of Object.entries(tombs)) outTombs.push({ table: t, uuid: u, deleted_at: deletedAt })
}
json(res, 200, { tables: outTables, tombstones: outTombs, serverTime: new Date().toISOString(), dataEpoch: String(userData.created_at || '') })
return
}
// ---------- 用户管理(仅管理员 xionglin) ----------
if (route.startsWith('/api/admin/users')) {
const admin = isAdminToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
if (!admin) { json(res, 403, { error: '需要管理员权限' }); return }
if (req.method === 'GET' && route === '/api/admin/users') {
const list = registry.users.map(u => {
let size = 0
try { size = fs.statSync(userFile(u.id)).size } catch { /* 无数据文件 */ }
return { id: u.id, username: u.username, role: u.role || 'user', created_at: u.created_at, data_bytes: size }
}).sort((x, y) => (x.role === 'admin' ? -1 : 1) - (y.role === 'admin' ? -1 : 1))
json(res, 200, { users: list, me: admin.id })
return
}
if (req.method === 'POST' && route === '/api/admin/users/create') {
const body = await readBody(req)
const username = String(body.username || '').trim()
const password = String(body.password || '')
if (username.length < 2 || username.length > 20) { json(res, 400, { error: '用户名需 2 到 20 个字符' }); return }
if (password.length < 6) { json(res, 400, { error: '密码至少 6 位' }); return }
if (registry.users.find(u => u.username === username)) { json(res, 409, { error: '用户名已存在' }); return }
const salt = crypto.randomBytes(16).toString('hex')
const userId = 'cu_' + crypto.randomBytes(6).toString('hex')
registry.users.push({ id: userId, username, role: username === 'xionglin' ? 'admin' : 'user', pwd_hash: pbkdf2Hash(password, salt), pwd_salt: salt, created_at: new Date().toISOString() })
saveRegistry()
json(res, 200, { ok: true, userId, username })
return
}
if (req.method === 'POST' && route === '/api/admin/users/password') {
const body = await readBody(req)
const rec = registry.users.find(u => u.id === body.userId)
if (!rec) { json(res, 404, { error: '用户不存在' }); return }
if (String(body.newPassword || '').length < 6) { json(res, 400, { error: '密码至少 6 位' }); return }
rec.pwd_salt = crypto.randomBytes(16).toString('hex')
rec.pwd_hash = pbkdf2Hash(String(body.newPassword), rec.pwd_salt)
rec.pwd_changed_at = new Date().toISOString()
saveRegistry()
json(res, 200, { ok: true })
return
}
if (req.method === 'POST' && route === '/api/admin/users/delete') {
const body = await readBody(req)
if (body.userId === admin.id) { json(res, 400, { error: '不能删除自己的账号' }); return }
const idx = registry.users.findIndex(u => u.id === body.userId)
if (idx < 0) { json(res, 404, { error: '用户不存在' }); return }
registry.users.splice(idx, 1)
saveRegistry()
try { fs.rmSync(userFile(body.userId), { force: true }) } catch { /* 忽略 */ }
for (const sseRes of (sseClients.get(body.userId) || [])) { try { sseRes.end() } catch { /* 忽略 */ } }
sseClients.delete(body.userId)
json(res, 200, { ok: true })
return
}
}
json(res, 404, { error: 'not found' })
}
// ---------- 自动备份:服务器是唯一数据源,必须自带兜底 ----------
function doBackup() {
try {
const day = new Date().toISOString().slice(0, 10)
const dir = path.join(DATA_DIR, 'backups', day)
fs.mkdirSync(path.join(dir, 'users'), { recursive: true })
try { fs.copyFileSync(path.join(DATA_DIR, registryFile), path.join(dir, 'registry.json')) } catch { /* 忽略 */ }
try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ }
try {
const usersDir = path.join(DATA_DIR, 'users')
if (fs.existsSync(usersDir)) {
for (const f of fs.readdirSync(usersDir)) {
if (f.endsWith('.json')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
}
}
} catch { /* 忽略 */ }
// 保留最近 14 天
try {
const backupsRoot = path.join(DATA_DIR, 'backups')
const days = fs.readdirSync(backupsRoot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
while (days.length > 14) { const old = days.shift(); try { fs.rmSync(path.join(backupsRoot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
} catch { /* 忽略 */ }
} catch (e) { console.error('backup failed:', e && e.message) }
void backupOffsite()
}
doBackup()
setInterval(doBackup, 24 * 60 * 60 * 1000)
// ---------- HTTP 服务 ----------
const server = http.createServer((req, res) => {
handle(req, res).catch(err => {
json(res, 500, { error: err.message || 'server error' })
})
})
server.listen(PORT, () => {
console.log('internlog sync server listening on ' + PORT)
})