Files
Toolbox/src/server/rational_lock.js
T

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 }