596 lines
25 KiB
JavaScript
596 lines
25 KiB
JavaScript
const fs = require('fs')
|
|
const path = require('path')
|
|
const crypto = require('crypto')
|
|
const { getDB } = require('./weekly_db')
|
|
const { enc, dec, hashPassword, encryptBuffer, sealDatabase } = require('./weekly_crypto')
|
|
|
|
const dbPath = path.join(process.cwd(), 'data', 'weekly.db')
|
|
const sealPath = path.join(process.cwd(), 'data', 'weekly.enc')
|
|
|
|
let idx = { reports: [], events: [], reviews: [] }
|
|
let sealTimer = null
|
|
|
|
const rebuildIndex = () => {
|
|
try {
|
|
const db = getDB()
|
|
if (db && db.open === false) return
|
|
const rs = db.prepare(`SELECT * FROM reports ORDER BY id ASC`).all()
|
|
const es = db.prepare(`SELECT * FROM events ORDER BY id ASC`).all()
|
|
const rv = db.prepare(`SELECT * FROM event_reviews ORDER BY id ASC`).all()
|
|
idx.reports = rs.map(r => ({ id:r.id, week_id:r.week_id, title:dec(r.title_enc,r.title_iv,r.title_tag), summary:dec(r.summary_enc,r.summary_iv,r.summary_tag), content:dec(r.content_enc,r.content_iv,r.content_tag) }))
|
|
idx.events = es.map(e => ({ id:e.id, report_id:e.report_id, title:dec(e.title_enc,e.title_iv,e.title_tag), content:dec(e.content_enc,e.content_iv,e.content_tag), status_code:e.status_code }))
|
|
idx.reviews = rv.map(v => ({ id:v.id, event_id:v.event_id, review:dec(v.review_enc,v.review_iv,v.review_tag) }))
|
|
} catch (e) {
|
|
const msg = String(e && (e.message || e) || '')
|
|
if (msg.includes('Database not initialized')) return
|
|
if (msg.includes('database connection is not open')) return
|
|
console.error('rebuildIndex failed', e)
|
|
}
|
|
}
|
|
|
|
const scheduleMaintain = () => {
|
|
try { setTimeout(() => { try { rebuildIndex() } catch {} }, 0) } catch {}
|
|
try {
|
|
if (sealTimer) clearTimeout(sealTimer)
|
|
sealTimer = setTimeout(() => {
|
|
sealTimer = null
|
|
try { sealDatabase(dbPath, sealPath) } catch {}
|
|
}, 1500)
|
|
} catch {}
|
|
}
|
|
|
|
const search = (q) => {
|
|
q = String(q||'').trim()
|
|
const terms = q ? q.split(/[\s,]+/).filter(Boolean) : []
|
|
const hit = (text) => {
|
|
let s = String(text||'')
|
|
let score = 0
|
|
for (const t of terms) { const re = new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'gi'); const m = s.match(re); if (m) score += m.length }
|
|
return score
|
|
}
|
|
const results = []
|
|
for (const r of idx.reports) { const score = hit(r.title)+hit(r.summary)+hit(r.content); if (score>0) results.push({ type:'report', id:r.id, week_id:r.week_id, score, title:r.title }) }
|
|
for (const e of idx.events) { const score = hit(e.title)+hit(e.content); if (score>0) results.push({ type:'event', id:e.id, report_id:e.report_id, score, title:e.title }) }
|
|
for (const v of idx.reviews) { const score = hit(v.review); if (score>0) results.push({ type:'review', id:v.id, event_id:v.event_id, score, title:v.review.slice(0,50) }) }
|
|
results.sort((a,b)=>b.score-a.score)
|
|
return results
|
|
}
|
|
|
|
const searchAdvanced = (criteria) => {
|
|
const range = criteria.range||{}
|
|
const status = Array.isArray(criteria.status) ? criteria.status : []
|
|
const mime = Array.isArray(criteria.mime) ? criteria.mime : []
|
|
let pool = idx.events.map(e => ({...e}))
|
|
if (status.length) pool = pool.filter(e => status.includes(e.status_code))
|
|
if (range.start || range.end) {
|
|
const ridToWeek = Object.fromEntries(idx.reports.map(r => [r.id, r.week_id]))
|
|
pool = pool.filter(e => {
|
|
const w = ridToWeek[e.report_id]||''
|
|
const y = w
|
|
if (range.start && y < range.start) return false
|
|
if (range.end && y > range.end) return false
|
|
return true
|
|
})
|
|
}
|
|
if (mime.length) {
|
|
const db = getDB()
|
|
const rows = db.prepare(`SELECT DISTINCT event_id FROM attachments`).all()
|
|
const hasAtt = new Set(rows.map(r=>r.event_id))
|
|
pool = pool.filter(e => hasAtt.has(e.id))
|
|
}
|
|
return pool
|
|
}
|
|
|
|
// Helper: Week ID Calculation
|
|
const weekIdForDate = (dateStr) => {
|
|
try {
|
|
const d = new Date(dateStr)
|
|
const y = d.getUTCFullYear()
|
|
const day = d.getUTCDay()
|
|
const diffToMon = (day + 6) % 7
|
|
const mon = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - diffToMon))
|
|
const firstDay = new Date(Date.UTC(mon.getUTCFullYear(), 0, 1))
|
|
const firstDayWeekday = (firstDay.getUTCDay() + 6) % 7
|
|
const days = Math.floor((mon - firstDay) / 86400000) + firstDayWeekday + 1
|
|
const week = Math.ceil(days / 7)
|
|
return `${mon.getUTCFullYear()}-W${String(week).padStart(2,'0')}`
|
|
} catch { return `${new Date().getUTCFullYear()}-W01` }
|
|
}
|
|
|
|
const beijingToLocalISO = (dateStr, hour=23, minute=59) => {
|
|
try {
|
|
const ymd = String(dateStr).slice(0,10)
|
|
const HH = String(hour).padStart(2,'0')
|
|
const MM = String(minute).padStart(2,'0')
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return `${ymd} ${HH}:${MM}`
|
|
return ''
|
|
} catch { return '' }
|
|
}
|
|
|
|
const weeksForYear = (year) => {
|
|
const y = parseInt(year, 10)
|
|
const tz = 'Asia/Shanghai'
|
|
const dateInTZ = (d) => new Date(d)
|
|
const firstThursday = new Date(Date.UTC(y, 0, 4))
|
|
const day = firstThursday.getUTCDay()
|
|
const diffToMon = (day + 6) % 7
|
|
let mon = new Date(Date.UTC(y, 0, 4 - diffToMon))
|
|
const out = []
|
|
let weekNum = 1
|
|
while (weekNum <= 53) {
|
|
const thu = new Date(Date.UTC(mon.getUTCFullYear(), mon.getUTCMonth(), mon.getUTCDate() + 3))
|
|
const isoYear = thu.getUTCFullYear()
|
|
if (isoYear !== y) break
|
|
const sun = new Date(Date.UTC(mon.getUTCFullYear(), mon.getUTCMonth(), mon.getUTCDate() + 6))
|
|
const weekLabel = `${y}-W${String(weekNum).padStart(2,'0')}`
|
|
const startStr = new Intl.DateTimeFormat('zh-CN', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(dateInTZ(mon)).replace(/\//g,'-')
|
|
const endStr = new Intl.DateTimeFormat('zh-CN', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(dateInTZ(sun)).replace(/\//g,'-')
|
|
out.push({ week_id: weekLabel, start: startStr, end: endStr })
|
|
mon = new Date(Date.UTC(mon.getUTCFullYear(), mon.getUTCMonth(), mon.getUTCDate() + 7))
|
|
weekNum++
|
|
}
|
|
|
|
// Optimize: Batch check for report and events existence
|
|
try {
|
|
const db = getDB()
|
|
const reports = db.prepare(`SELECT id, week_id FROM reports WHERE week_id LIKE ?`).all(`${y}-%`)
|
|
const reportMap = new Map()
|
|
reports.forEach(r => reportMap.set(r.week_id, r.id))
|
|
|
|
if (reports.length > 0) {
|
|
const placeholders = reports.map(() => '?').join(',')
|
|
const reportIds = reports.map(r => r.id)
|
|
const eventRows = db.prepare(`SELECT DISTINCT report_id FROM events WHERE report_id IN (${placeholders})`).all(...reportIds)
|
|
const eventSet = new Set(eventRows.map(r => r.report_id))
|
|
|
|
out.forEach(w => {
|
|
const rid = reportMap.get(w.week_id)
|
|
w.has_report = !!rid
|
|
w.has_events = rid ? eventSet.has(rid) : false
|
|
})
|
|
} else {
|
|
out.forEach(w => { w.has_report = false; w.has_events = false })
|
|
}
|
|
} catch (e) {
|
|
console.error('weeksForYear DB error', e)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
const logJSON = (action, details, source='weekly') => {
|
|
try {
|
|
getDB().prepare(`INSERT INTO logs (action, details, source) VALUES (?, ?, ?)`).run(action, JSON.stringify(details), source)
|
|
} catch (e) {
|
|
console.error('Log failed', e)
|
|
}
|
|
}
|
|
|
|
const saveVersion = (type, id, data) => {
|
|
try {
|
|
let s = ''
|
|
try { s = JSON.stringify(data) } catch { s = '' }
|
|
const e = enc(s)
|
|
getDB().prepare(`INSERT INTO versions (subject_type, subject_id, snapshot_enc, snapshot_iv, snapshot_tag) VALUES (?, ?, ?, ?, ?)`).run(type, id, e.ct, e.iv, e.tag)
|
|
} catch (e) {}
|
|
}
|
|
|
|
const ensureReportForWeek = (week_id) => {
|
|
const db = getDB()
|
|
const existing = db.prepare(`SELECT id FROM reports WHERE week_id=? ORDER BY id DESC LIMIT 1`).get(week_id)
|
|
if (existing && existing.id) return existing.id
|
|
|
|
let titleAuto = `${week_id} 周报`
|
|
try {
|
|
const m = String(week_id||'').match(/^(\d{4})-W(\d{2})$/)
|
|
if (m) titleAuto = `${m[1]}-第${parseInt(m[2],10)}周(自动新增)`
|
|
} catch {}
|
|
|
|
const et = enc(titleAuto)
|
|
const es = enc('')
|
|
const ec = enc('这是自动新增的周报。你可以修改。')
|
|
const info = db.prepare(`INSERT INTO reports (week_id, title_enc, title_iv, title_tag, summary_enc, summary_iv, summary_tag, content_enc, content_iv, content_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(week_id, et.ct, et.iv, et.tag, es.ct, es.iv, es.tag, ec.ct, ec.iv, ec.tag)
|
|
const id = info.lastInsertRowid
|
|
saveVersion('report', id, { week_id, title: titleAuto, summary: '', content: '这是自动新增的周报。你可以修改。' })
|
|
scheduleMaintain()
|
|
return id
|
|
}
|
|
|
|
const createReport = (week_id, title, summary, content) => {
|
|
const db = getDB()
|
|
const existing = db.prepare(`SELECT id FROM reports WHERE week_id=?`).get(week_id)
|
|
if (existing) return existing.id
|
|
const et = enc(title||'')
|
|
const es = enc(summary||'')
|
|
const ec = enc(content||'')
|
|
const info = db.prepare(`INSERT INTO reports (week_id, title_enc, title_iv, title_tag, summary_enc, summary_iv, summary_tag, content_enc, content_iv, content_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(week_id, et.ct, et.iv, et.tag, es.ct, es.iv, es.tag, ec.ct, ec.iv, ec.tag)
|
|
const id = info.lastInsertRowid
|
|
saveVersion('report', id, { week_id, title, summary, content })
|
|
logJSON('weekly.report.create', { id, week_id }, 'weekly')
|
|
scheduleMaintain()
|
|
return id
|
|
}
|
|
|
|
const updateReport = (payload) => {
|
|
const id = parseInt(payload.id, 10)
|
|
const old = getDB().prepare(`SELECT * FROM reports WHERE id=?`).get(id)
|
|
if (!old) return
|
|
|
|
const oldTitle = dec(old.title_enc, old.title_iv, old.title_tag)
|
|
const oldSummary = dec(old.summary_enc, old.summary_iv, old.summary_tag)
|
|
const oldContent = dec(old.content_enc, old.content_iv, old.content_tag)
|
|
|
|
const title = payload.title !== undefined ? payload.title : oldTitle
|
|
const summary = payload.summary !== undefined ? payload.summary : oldSummary
|
|
const content = payload.content !== undefined ? payload.content : oldContent
|
|
|
|
const et = enc(title||'')
|
|
const es = enc(summary||'')
|
|
const ec = enc(content||'')
|
|
|
|
getDB().prepare(`UPDATE reports SET title_enc=?, title_iv=?, title_tag=?, summary_enc=?, summary_iv=?, summary_tag=?, content_enc=?, content_iv=?, content_tag=?, updated_at=datetime('now') WHERE id=?`).run(et.ct, et.iv, et.tag, es.ct, es.iv, es.tag, ec.ct, ec.iv, ec.tag, id)
|
|
saveVersion('report', id, { week_id: old.week_id, title, summary, content })
|
|
logJSON('weekly.report.update', { id }, 'weekly')
|
|
scheduleMaintain()
|
|
}
|
|
|
|
const getReportByWeek = (week_id) => {
|
|
const db = getDB()
|
|
const row = db.prepare(`SELECT * FROM reports WHERE week_id=? ORDER BY id DESC LIMIT 1`).get(week_id)
|
|
if (!row) return null
|
|
return {
|
|
id: row.id,
|
|
week_id: row.week_id,
|
|
title: dec(row.title_enc, row.title_iv, row.title_tag),
|
|
summary: dec(row.summary_enc, row.summary_iv, row.summary_tag),
|
|
content: dec(row.content_enc, row.content_iv, row.content_tag),
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at
|
|
}
|
|
}
|
|
|
|
const createEvent = (payload) => {
|
|
const db = getDB()
|
|
const et = enc(payload.title||'')
|
|
const ec = enc(payload.content||'')
|
|
const es = enc(payload.status||'')
|
|
const info = db.prepare(`INSERT INTO events (report_id, title_enc, title_iv, title_tag, content_enc, content_iv, content_tag, status_code, status_enc, status_iv, status_tag, deadline, parent_event_id, completed_at, push_required) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
parseInt(payload.report_id,10), et.ct, et.iv, et.tag, ec.ct, ec.iv, ec.tag, String(payload.status_code||''), es.ct, es.iv, es.tag, payload.deadline||null, payload.parent_event_id ? parseInt(payload.parent_event_id,10) : null, null, payload.push_required ? 1 : 0
|
|
)
|
|
const id = info.lastInsertRowid
|
|
saveVersion('event', id, { title: payload.title, content: payload.content, status_code: payload.status_code, status: payload.status, deadline: payload.deadline, parent_event_id: payload.parent_event_id||null, push_required: payload.push_required ? 1 : 0 })
|
|
logJSON('weekly.event.create', { id }, 'weekly')
|
|
scheduleMaintain()
|
|
return id
|
|
}
|
|
|
|
const updateEvent = (payload) => {
|
|
const id = parseInt(payload.id,10)
|
|
const old = getEventFull(id)
|
|
if (!old) return
|
|
|
|
const title = payload.title !== undefined ? payload.title : old.title
|
|
const content = payload.content !== undefined ? payload.content : old.content
|
|
const status = payload.status !== undefined ? payload.status : old.status
|
|
const statusCode = payload.status_code !== undefined ? payload.status_code : old.status_code
|
|
const deadline = payload.deadline !== undefined ? payload.deadline : old.deadline
|
|
const parentId = payload.parent_event_id !== undefined ? payload.parent_event_id : old.parent_event_id
|
|
const pushRequired = payload.push_required !== undefined ? (payload.push_required ? 1 : 0) : (old.push_required ? 1 : 0)
|
|
|
|
const eTitle = enc(title||'')
|
|
const eContent = enc(content||'')
|
|
const eStatus = enc(status||'')
|
|
|
|
getDB().prepare(`UPDATE events SET title_enc=?, title_iv=?, title_tag=?, content_enc=?, content_iv=?, content_tag=?, status_code=?, status_enc=?, status_iv=?, status_tag=?, deadline=?, parent_event_id=?, push_required=?, updated_at=datetime('now') WHERE id=?`).run(
|
|
eTitle.ct, eTitle.iv, eTitle.tag, eContent.ct, eContent.iv, eContent.tag, String(statusCode||''), eStatus.ct, eStatus.iv, eStatus.tag, deadline||null, parentId ? parseInt(parentId,10) : null, pushRequired, id
|
|
)
|
|
saveVersion('event', id, { title, content, status_code: statusCode, status, deadline, parent_event_id: parentId, push_required: pushRequired })
|
|
logJSON('weekly.event.update', { id }, 'weekly')
|
|
scheduleMaintain()
|
|
}
|
|
|
|
const deleteEvent = (id) => {
|
|
const eid = parseInt(id,10)
|
|
const db = getDB()
|
|
db.prepare(`DELETE FROM event_reviews WHERE event_id=?`).run(eid)
|
|
db.prepare(`DELETE FROM attachments WHERE event_id=?`).run(eid)
|
|
try { db.prepare(`DELETE FROM event_external WHERE event_id=?`).run(eid) } catch (e) {}
|
|
db.prepare(`DELETE FROM events WHERE id=?`).run(eid)
|
|
logJSON('weekly.event.delete', { id:eid }, 'weekly')
|
|
scheduleMaintain()
|
|
}
|
|
|
|
const listEvents = (report_id) => {
|
|
const rows = getDB().prepare(`SELECT id, report_id, title_enc, title_iv, title_tag, content_enc, content_iv, content_tag, status_code, deadline, parent_event_id, completed_at, push_required, created_at, updated_at FROM events WHERE report_id=? ORDER BY id ASC`).all(parseInt(report_id,10))
|
|
return rows.map(r => ({
|
|
id: r.id,
|
|
report_id: r.report_id,
|
|
title: dec(r.title_enc, r.title_iv, r.title_tag),
|
|
content: dec(r.content_enc, r.content_iv, r.content_tag),
|
|
status_code: r.status_code,
|
|
status: r.status_code,
|
|
deadline: r.deadline,
|
|
parent_event_id: r.parent_event_id,
|
|
completed_at: r.completed_at,
|
|
push_required: !!r.push_required,
|
|
created_at: r.created_at,
|
|
updated_at: r.updated_at
|
|
}))
|
|
}
|
|
|
|
const getEventFull = (id) => {
|
|
const row = getDB().prepare(`SELECT e.*, r.week_id FROM events e JOIN reports r ON e.report_id=r.id WHERE e.id=?`).get(parseInt(id,10))
|
|
if (!row) return null
|
|
return {
|
|
id: row.id,
|
|
week_id: row.week_id,
|
|
title: dec(row.title_enc, row.title_iv, row.title_tag),
|
|
content: dec(row.content_enc, row.content_iv, row.content_tag),
|
|
status_code: row.status_code,
|
|
deadline: row.deadline,
|
|
push_required: !!row.push_required
|
|
}
|
|
}
|
|
|
|
const addReview = (event_id, reviewText) => {
|
|
const e = enc(reviewText||'')
|
|
const info = getDB().prepare(`INSERT INTO event_reviews (event_id, review_enc, review_iv, review_tag) VALUES (?, ?, ?, ?)`).run(parseInt(event_id,10), e.ct, e.iv, e.tag)
|
|
const id = info.lastInsertRowid
|
|
saveVersion('event_review', id, { event_id, review: reviewText })
|
|
logJSON('weekly.review.create', { id }, 'weekly')
|
|
scheduleMaintain()
|
|
return id
|
|
}
|
|
|
|
const listReviews = (event_id) => {
|
|
const rows = getDB().prepare(`SELECT * FROM event_reviews WHERE event_id=? ORDER BY id ASC`).all(parseInt(event_id,10))
|
|
return rows.map(r => ({ id:r.id, event_id:r.event_id, review: dec(r.review_enc, r.review_iv, r.review_tag), created_at:r.created_at }))
|
|
}
|
|
|
|
const listReviewsByEventIds = (eventIds) => {
|
|
const ids = Array.isArray(eventIds) ? eventIds.map(v => parseInt(v, 10)).filter(Number.isFinite) : []
|
|
if (!ids.length) return {}
|
|
const placeholders = ids.map(() => '?').join(',')
|
|
const rows = getDB().prepare(`SELECT * FROM event_reviews WHERE event_id IN (${placeholders}) ORDER BY event_id ASC, id ASC`).all(...ids)
|
|
const out = {}
|
|
ids.forEach(id => { out[id] = [] })
|
|
rows.forEach(r => {
|
|
const key = r.event_id
|
|
if (!out[key]) out[key] = []
|
|
out[key].push({ id: r.id, event_id: r.event_id, review: dec(r.review_enc, r.review_iv, r.review_tag), created_at: r.created_at })
|
|
})
|
|
return out
|
|
}
|
|
|
|
const attachmentsDir = path.join(process.cwd(), 'uploads', 'weekly', 'attachments')
|
|
if (!fs.existsSync(attachmentsDir)) fs.mkdirSync(attachmentsDir, { recursive: true })
|
|
|
|
const saveAttachmentChunk = (event_id, filename, mime, buffer) => {
|
|
const originalName = String(filename||'file.bin')
|
|
const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g,'_')
|
|
const efn = enc(originalName)
|
|
const emime = enc(String(mime||'application/octet-stream'))
|
|
|
|
const { ct, iv, tag } = encryptBuffer(buffer)
|
|
|
|
const outName = `${Date.now()}-${Math.random().toString(36).slice(2)}-${safeName}`
|
|
const full = path.join(attachmentsDir, outName)
|
|
fs.writeFileSync(full, Buffer.concat([iv, tag, ct]))
|
|
|
|
const estore = enc(full)
|
|
const info = getDB().prepare(`INSERT INTO attachments (event_id, filename_enc, filename_iv, filename_tag, mime_enc, mime_iv, mime_tag, size, chunks, store_enc, store_iv, store_tag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
parseInt(event_id,10), efn.ct, efn.iv, efn.tag, emime.ct, emime.iv, emime.tag, buffer.length, 1, estore.ct, estore.iv, estore.tag
|
|
)
|
|
const id = info.lastInsertRowid
|
|
logJSON('weekly.attachment.save', { id, event_id, originalName, safeName, mime, size: buffer.length, path: full }, 'weekly')
|
|
return id
|
|
}
|
|
|
|
const listAttachments = (event_id) => {
|
|
const rows = getDB().prepare(`SELECT * FROM attachments WHERE event_id=? ORDER BY id ASC`).all(parseInt(event_id,10))
|
|
return rows.map(r => ({ id:r.id, event_id:r.event_id, filename: dec(r.filename_enc, r.filename_iv, r.filename_tag), mime: dec(r.mime_enc, r.mime_iv, r.mime_tag), size: r.size, created_at: r.created_at }))
|
|
}
|
|
|
|
const listAttachmentsByEventIds = (eventIds) => {
|
|
const ids = Array.isArray(eventIds) ? eventIds.map(v => parseInt(v, 10)).filter(Number.isFinite) : []
|
|
if (!ids.length) return {}
|
|
const placeholders = ids.map(() => '?').join(',')
|
|
const rows = getDB().prepare(`SELECT * FROM attachments WHERE event_id IN (${placeholders}) ORDER BY event_id ASC, id ASC`).all(...ids)
|
|
const out = {}
|
|
ids.forEach(id => { out[id] = [] })
|
|
rows.forEach(r => {
|
|
const key = r.event_id
|
|
if (!out[key]) out[key] = []
|
|
out[key].push({ id: r.id, event_id: r.event_id, filename: dec(r.filename_enc, r.filename_iv, r.filename_tag), mime: dec(r.mime_enc, r.mime_iv, r.mime_tag), size: r.size, created_at: r.created_at })
|
|
})
|
|
return out
|
|
}
|
|
|
|
const ensureDefaultUser = () => {
|
|
const db = getDB()
|
|
const c = db.prepare(`SELECT COUNT(1) AS c FROM users`).get().c
|
|
if (c === 0) {
|
|
const salt = crypto.randomBytes(16).toString('hex')
|
|
const defaultPass = process.env.WEEKLY_DEFAULT_PASSWORD || process.env.LOCK_DEFAULT_PASSWORD || ''
|
|
const hash = hashPassword(defaultPass, salt)
|
|
db.prepare(`INSERT INTO users (username, pass_salt, pass_hash) VALUES (?, ?, ?)`).run('admin_wy', salt, hash)
|
|
logJSON('weekly.user.init', { username: 'admin_wy' }, 'weekly')
|
|
}
|
|
}
|
|
|
|
const login = (username, password) => {
|
|
const row = getDB().prepare(`SELECT * FROM users WHERE username=?`).get(String(username||''))
|
|
if (!row) return { ok:false, error:'用户不存在' }
|
|
const hash = hashPassword(password, row.pass_salt)
|
|
if (hash !== row.pass_hash) return { ok:false, error:'密码错误' }
|
|
return { ok:true, user:{ id: row.id, username: row.username } }
|
|
}
|
|
|
|
const changePassword = (username, oldPass, newPass) => {
|
|
const db = getDB()
|
|
const row = db.prepare(`SELECT * FROM users WHERE username=?`).get(String(username||''))
|
|
if (!row) return { ok:false, error:'用户不存在' }
|
|
const oldHash = hashPassword(oldPass, row.pass_salt)
|
|
if (oldHash !== row.pass_hash) return { ok:false, error:'旧密码错误' }
|
|
const salt = crypto.randomBytes(16).toString('hex')
|
|
const hash = hashPassword(newPass, salt)
|
|
db.prepare(`UPDATE users SET pass_salt=?, pass_hash=?, updated_at=datetime('now') WHERE id=?`).run(salt, hash, row.id)
|
|
logJSON('weekly.pass.change', { username }, 'weekly')
|
|
return { ok:true }
|
|
}
|
|
|
|
const decryptAttachmentBuffer = (row, rawBuffer) => {
|
|
if (!row) return null
|
|
const filename = dec(row.filename_enc, row.filename_iv, row.filename_tag)
|
|
const mime = dec(row.mime_enc, row.mime_iv, row.mime_tag)
|
|
const buf = Buffer.isBuffer(rawBuffer) ? rawBuffer : Buffer.alloc(0)
|
|
if (buf.length < 28) return null
|
|
const iv = buf.subarray(0, 12)
|
|
const tag = buf.subarray(12, 28)
|
|
const ct = buf.subarray(28)
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', require('./weekly_crypto').getKey(), iv)
|
|
decipher.setAuthTag(tag)
|
|
const p1 = decipher.update(ct)
|
|
const p2 = decipher.final()
|
|
const data = Buffer.concat([p1, p2])
|
|
return { filename, mime, data }
|
|
}
|
|
|
|
const getAttachmentData = (id) => {
|
|
const row = getDB().prepare(`SELECT * FROM attachments WHERE id=?`).get(parseInt(id,10))
|
|
if (!row) return null
|
|
const storePath = dec(row.store_enc, row.store_iv, row.store_tag)
|
|
if (!fs.existsSync(storePath)) return null
|
|
const buf = fs.readFileSync(storePath)
|
|
return decryptAttachmentBuffer(row, buf)
|
|
}
|
|
|
|
const getAttachmentDataByStoredPath = (storedPath, rawBuffer) => {
|
|
const expected = path.resolve(String(storedPath || ''))
|
|
if (!expected) return null
|
|
const rows = getDB().prepare(`SELECT * FROM attachments ORDER BY id DESC`).all()
|
|
for (const row of rows) {
|
|
const current = path.resolve(dec(row.store_enc, row.store_iv, row.store_tag) || '')
|
|
if (!current || current !== expected) continue
|
|
const fileBuf = Buffer.isBuffer(rawBuffer) ? rawBuffer : (fs.existsSync(current) ? fs.readFileSync(current) : null)
|
|
if (!fileBuf) return null
|
|
return decryptAttachmentBuffer(row, fileBuf)
|
|
}
|
|
return null
|
|
}
|
|
|
|
const deleteAttachment = (id) => {
|
|
const row = getDB().prepare(`SELECT * FROM attachments WHERE id=?`).get(parseInt(id,10))
|
|
if (row) {
|
|
const storePath = dec(row.store_enc, row.store_iv, row.store_tag)
|
|
try { if (fs.existsSync(storePath)) fs.unlinkSync(storePath) } catch {}
|
|
getDB().prepare(`DELETE FROM attachments WHERE id=?`).run(row.id)
|
|
logJSON('weekly.attachment.delete', { id: row.id, event_id: row.event_id }, 'weekly')
|
|
}
|
|
}
|
|
|
|
const getRelations = (event_id) => {
|
|
const rows = getDB().prepare(`SELECT * FROM event_relations WHERE event_id=? OR target_event_id=?`).all(parseInt(event_id,10), parseInt(event_id,10))
|
|
return rows.map(r => ({
|
|
id: r.id,
|
|
event_id: r.event_id,
|
|
target_event_id: r.target_event_id,
|
|
relation_type: r.relation_type,
|
|
created_at: r.created_at
|
|
}))
|
|
}
|
|
|
|
const addRelation = (event_id, target_event_id, type) => {
|
|
const info = getDB().prepare(`INSERT INTO event_relations (event_id, target_event_id, relation_type) VALUES (?, ?, ?)`).run(parseInt(event_id,10), parseInt(target_event_id,10), String(type||'related'))
|
|
return info.lastInsertRowid
|
|
}
|
|
|
|
const deleteRelation = (id) => {
|
|
getDB().prepare(`DELETE FROM event_relations WHERE id=?`).run(parseInt(id,10))
|
|
}
|
|
|
|
const getVersions = (type, id) => {
|
|
const rows = getDB().prepare(`SELECT id, created_at FROM versions WHERE subject_type=? AND subject_id=? ORDER BY id DESC`).all(type, parseInt(id,10))
|
|
return rows
|
|
}
|
|
|
|
const getVersionDiff = (type, id, v1, v2) => {
|
|
const db = getDB()
|
|
const r1 = db.prepare(`SELECT snapshot_enc, snapshot_iv, snapshot_tag FROM versions WHERE id=?`).get(parseInt(v1,10))
|
|
const r2 = db.prepare(`SELECT snapshot_enc, snapshot_iv, snapshot_tag FROM versions WHERE id=?`).get(parseInt(v2,10))
|
|
if (!r1 || !r2) return null
|
|
const d1 = JSON.parse(dec(r1.snapshot_enc, r1.snapshot_iv, r1.snapshot_tag) || '{}')
|
|
const d2 = JSON.parse(dec(r2.snapshot_enc, r2.snapshot_iv, r2.snapshot_tag) || '{}')
|
|
// Simple diff
|
|
const diff = {}
|
|
const allKeys = new Set([...Object.keys(d1), ...Object.keys(d2)])
|
|
for (const k of allKeys) {
|
|
if (JSON.stringify(d1[k]) !== JSON.stringify(d2[k])) {
|
|
diff[k] = { old: d1[k], new: d2[k] }
|
|
}
|
|
}
|
|
return diff
|
|
}
|
|
|
|
const getReminders = () => {
|
|
const now = new Date()
|
|
const db = getDB()
|
|
// Upcoming deadlines in 7 days
|
|
const upcoming = []
|
|
const events = db.prepare(`SELECT * FROM events WHERE deadline IS NOT NULL AND status_code!='DONE'`).all()
|
|
for (const e of events) {
|
|
const d = new Date(e.deadline)
|
|
if (isNaN(d.getTime())) continue
|
|
const diff = (d - now) / (1000 * 3600 * 24)
|
|
if (diff >= -1 && diff <= 7) {
|
|
upcoming.push({
|
|
id: e.id,
|
|
report_id: e.report_id,
|
|
title: dec(e.title_enc, e.title_iv, e.title_tag),
|
|
deadline: e.deadline,
|
|
days: Math.ceil(diff)
|
|
})
|
|
}
|
|
}
|
|
return upcoming.sort((a,b) => a.days - b.days)
|
|
}
|
|
|
|
module.exports = {
|
|
weekIdForDate,
|
|
beijingToLocalISO,
|
|
weeksForYear,
|
|
logJSON,
|
|
saveVersion,
|
|
ensureReportForWeek,
|
|
createReport,
|
|
updateReport,
|
|
getReportByWeek,
|
|
createEvent,
|
|
updateEvent,
|
|
deleteEvent,
|
|
listEvents,
|
|
getEventFull,
|
|
addReview,
|
|
listReviews,
|
|
listReviewsByEventIds,
|
|
listAttachments,
|
|
listAttachmentsByEventIds,
|
|
saveAttachmentChunk,
|
|
getAttachmentData,
|
|
getAttachmentDataByStoredPath,
|
|
deleteAttachment,
|
|
ensureDefaultUser,
|
|
login,
|
|
changePassword,
|
|
rebuildIndex,
|
|
scheduleMaintain,
|
|
search,
|
|
searchAdvanced,
|
|
getRelations,
|
|
addRelation,
|
|
deleteRelation,
|
|
getVersions,
|
|
getVersionDiff,
|
|
getReminders
|
|
}
|