204 lines
7.6 KiB
JavaScript
204 lines
7.6 KiB
JavaScript
const Database = require('better-sqlite3')
|
|
const path = require('path')
|
|
const fs = require('fs')
|
|
const crypto = require('crypto')
|
|
|
|
const dataDir = path.join(process.cwd(), 'data')
|
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
|
|
const dbPath = path.join(dataDir, 'rational_lock.db')
|
|
const db = new Database(dbPath)
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS rl_users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS rl_sessions (
|
|
token TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS rl_user_data (
|
|
username TEXT PRIMARY KEY,
|
|
items_json TEXT NOT NULL DEFAULT '[]',
|
|
applications_json TEXT NOT NULL DEFAULT '[]',
|
|
draft_json TEXT,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_rl_sessions_user ON rl_sessions(username);
|
|
CREATE INDEX IF NOT EXISTS idx_rl_sessions_exp ON rl_sessions(expires_at);
|
|
`)
|
|
|
|
const now = () => Date.now()
|
|
const SESSION_MAX_AGE = 1 * 24 * 3600 * 1000
|
|
const COOKIE_NAME = 'rational_lock_sid'
|
|
const DEFAULT_USER = { username: 'admin_rl', password: process.env.LOCK_DEFAULT_PASSWORD || '' }
|
|
|
|
const parseCookie = cookieStr => {
|
|
const out = {}
|
|
String(cookieStr || '').split(/;\s*/).forEach(p => {
|
|
const idx = p.indexOf('=')
|
|
if (idx > 0) out[p.slice(0, idx)] = decodeURIComponent(p.slice(idx + 1))
|
|
})
|
|
return out
|
|
}
|
|
|
|
const ensureDefaultUser = () => {
|
|
const row = db.prepare('SELECT username FROM rl_users WHERE username = ?').get(DEFAULT_USER.username)
|
|
if (!row) {
|
|
db.prepare('INSERT INTO rl_users (username, password, created_at) VALUES (?, ?, ?)').run(DEFAULT_USER.username, DEFAULT_USER.password, now())
|
|
}
|
|
}
|
|
|
|
const cleanupSessions = () => {
|
|
db.prepare('DELETE FROM rl_sessions WHERE expires_at <= ?').run(now())
|
|
}
|
|
|
|
const readDataOf = username => {
|
|
const row = db.prepare('SELECT items_json, applications_json, draft_json FROM rl_user_data WHERE username = ?').get(username)
|
|
if (!row) return { db: { items: [], applications: [] }, draft: null }
|
|
let items = []
|
|
let applications = []
|
|
let draft = null
|
|
try { items = JSON.parse(row.items_json || '[]') } catch {}
|
|
try { applications = JSON.parse(row.applications_json || '[]') } catch {}
|
|
try { draft = row.draft_json ? JSON.parse(row.draft_json) : null } catch {}
|
|
return {
|
|
db: {
|
|
items: Array.isArray(items) ? items : [],
|
|
applications: Array.isArray(applications) ? applications : []
|
|
},
|
|
draft: draft && typeof draft === 'object' ? draft : null
|
|
}
|
|
}
|
|
|
|
const writeDataOf = (username, data) => {
|
|
const dbData = data && data.db && typeof data.db === 'object' ? data.db : {}
|
|
const items = Array.isArray(dbData.items) ? dbData.items : []
|
|
const applications = Array.isArray(dbData.applications) ? dbData.applications : []
|
|
const draft = data && data.draft && typeof data.draft === 'object' ? data.draft : null
|
|
db.prepare(`
|
|
INSERT INTO rl_user_data (username, items_json, applications_json, draft_json, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(username) DO UPDATE SET
|
|
items_json = excluded.items_json,
|
|
applications_json = excluded.applications_json,
|
|
draft_json = excluded.draft_json,
|
|
updated_at = excluded.updated_at
|
|
`).run(
|
|
username,
|
|
JSON.stringify(items),
|
|
JSON.stringify(applications),
|
|
draft ? JSON.stringify(draft) : null,
|
|
now()
|
|
)
|
|
}
|
|
|
|
const getSessionUser = req => {
|
|
cleanupSessions()
|
|
const cookies = parseCookie(req.headers.cookie || '')
|
|
const token = String(cookies[COOKIE_NAME] || '')
|
|
if (!token) return ''
|
|
const row = db.prepare('SELECT username, expires_at FROM rl_sessions WHERE token = ?').get(token)
|
|
if (!row) return ''
|
|
if (Number(row.expires_at) <= now()) {
|
|
db.prepare('DELETE FROM rl_sessions WHERE token = ?').run(token)
|
|
return ''
|
|
}
|
|
return String(row.username || '')
|
|
}
|
|
|
|
const requireAuth = (req, res, next) => {
|
|
const username = getSessionUser(req)
|
|
if (!username) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
|
req.rlUser = username
|
|
next()
|
|
}
|
|
|
|
const bindRoutes = app => {
|
|
ensureDefaultUser()
|
|
|
|
app.get('/api/rational_lock/health', (req, res) => {
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
app.get('/api/rational_lock/auth/local_creds', (req, res) => {
|
|
try {
|
|
const os = require('os')
|
|
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
|
|
const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
|
|
const usersRoot = path.join(path.parse(userProfile).root, 'Users')
|
|
try {
|
|
if (fs.existsSync(usersRoot)) {
|
|
fs.readdirSync(usersRoot).forEach(name => {
|
|
const dir = path.join(usersRoot, name)
|
|
if (dir !== userProfile && fs.existsSync(dir)) candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
|
|
})
|
|
}
|
|
} catch {}
|
|
let content = null
|
|
for (const p of candidates) { if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break } }
|
|
if (!content) return res.json({ ok: true, creds: null })
|
|
const creds = {}
|
|
content.split(/\r?\n/).forEach(line => {
|
|
const t = line.trim()
|
|
if (!t || t.startsWith('#')) return
|
|
const idx = t.indexOf('=')
|
|
if (idx > 0) creds[t.slice(0, idx).trim()] = t.slice(idx + 1).trim()
|
|
})
|
|
const serverToken = String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '')
|
|
if (serverToken) {
|
|
const clientToken = String(req.query.token || '')
|
|
if (clientToken !== serverToken) return res.json({ ok: true, creds: null })
|
|
}
|
|
const u = creds.RL_USERNAME || ''
|
|
const p = creds.RL_PASSWORD || ''
|
|
if (!u && !p) return res.json({ ok: true, creds: null })
|
|
res.json({ ok: true, creds: { username: u, password: p } })
|
|
} catch { res.json({ ok: true, creds: null }) }
|
|
})
|
|
|
|
app.post('/api/rational_lock/auth/login', (req, res) => {
|
|
const username = String((req.body && req.body.username) || '').trim()
|
|
const password = String((req.body && req.body.password) || '')
|
|
if (!username || !password) return res.status(400).json({ ok: false, error: 'invalid_credentials' })
|
|
const row = db.prepare('SELECT username, password FROM rl_users WHERE username = ?').get(username)
|
|
if (!row || String(row.password || '') !== password) return res.status(401).json({ ok: false, error: 'invalid_credentials' })
|
|
const token = crypto.randomBytes(24).toString('hex')
|
|
const createdAt = now()
|
|
const expiresAt = createdAt + SESSION_MAX_AGE
|
|
db.prepare('INSERT INTO rl_sessions (token, username, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, username, createdAt, expiresAt)
|
|
res.cookie(COOKIE_NAME, token, { httpOnly: true, sameSite: 'lax', maxAge: SESSION_MAX_AGE })
|
|
res.json({ ok: true, username })
|
|
})
|
|
|
|
app.post('/api/rational_lock/auth/logout', (req, res) => {
|
|
const cookies = parseCookie(req.headers.cookie || '')
|
|
const token = String(cookies[COOKIE_NAME] || '')
|
|
if (token) db.prepare('DELETE FROM rl_sessions WHERE token = ?').run(token)
|
|
res.cookie(COOKIE_NAME, '', { httpOnly: true, sameSite: 'lax', maxAge: 0 })
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
app.get('/api/rational_lock/auth/me', (req, res) => {
|
|
const username = getSessionUser(req)
|
|
if (!username) return res.status(401).json({ ok: false, error: 'unauthorized' })
|
|
res.json({ ok: true, username })
|
|
})
|
|
|
|
app.get('/api/rational_lock/data', requireAuth, (req, res) => {
|
|
const payload = readDataOf(req.rlUser)
|
|
res.json({ ok: true, ...payload })
|
|
})
|
|
|
|
app.put('/api/rational_lock/data', requireAuth, (req, res) => {
|
|
writeDataOf(req.rlUser, req.body || {})
|
|
res.json({ ok: true })
|
|
})
|
|
}
|
|
|
|
module.exports = { bindRoutes }
|