254 lines
7.4 KiB
TypeScript
254 lines
7.4 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import crypto from "crypto";
|
|
|
|
const DATA_DIR = process.env.NEXREDIRECT_DATA_DIR || path.join(process.cwd(), "data");
|
|
const DB_PATH = path.join(DATA_DIR, "nexredirect.db");
|
|
|
|
let _db: Database.Database | null = null;
|
|
|
|
export function getDb(): Database.Database {
|
|
if (_db) return _db;
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
const db = new Database(DB_PATH);
|
|
db.pragma("journal_mode = WAL");
|
|
db.pragma("foreign_keys = ON");
|
|
ensureSchema(db);
|
|
_db = db;
|
|
return db;
|
|
}
|
|
|
|
function ensureSchema(db: Database.Database) {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'admin',
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS domain_groups (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
target_url TEXT NOT NULL,
|
|
redirect_code INTEGER NOT NULL DEFAULT 302,
|
|
created_by INTEGER REFERENCES users(id),
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS domains (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
domain TEXT UNIQUE NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
target_url TEXT,
|
|
group_id INTEGER REFERENCES domain_groups(id) ON DELETE SET NULL,
|
|
redirect_code INTEGER NOT NULL DEFAULT 302,
|
|
preserve_path INTEGER NOT NULL DEFAULT 1,
|
|
include_www INTEGER NOT NULL DEFAULT 1,
|
|
created_by INTEGER REFERENCES users(id),
|
|
created_at INTEGER NOT NULL,
|
|
verified_at INTEGER
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_domains_status ON domains(status);
|
|
|
|
CREATE TABLE IF NOT EXISTS hits (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
ts INTEGER NOT NULL,
|
|
ip_hash TEXT NOT NULL,
|
|
country TEXT,
|
|
user_agent TEXT,
|
|
referer TEXT,
|
|
path TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_hits_domain_ts ON hits(domain_id, ts);
|
|
CREATE INDEX IF NOT EXISTS idx_hits_ts ON hits(ts);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
token_hash TEXT UNIQUE NOT NULL,
|
|
scopes TEXT NOT NULL,
|
|
created_by INTEGER REFERENCES users(id),
|
|
created_at INTEGER NOT NULL,
|
|
last_used_at INTEGER,
|
|
revoked_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS update_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
from_version TEXT,
|
|
to_version TEXT,
|
|
ts INTEGER NOT NULL,
|
|
status TEXT NOT NULL,
|
|
log TEXT
|
|
);
|
|
`);
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
user_id INTEGER,
|
|
user_email TEXT,
|
|
action TEXT NOT NULL,
|
|
target_type TEXT,
|
|
target_id TEXT,
|
|
details TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
|
|
`);
|
|
|
|
runMigrations(db);
|
|
}
|
|
|
|
export function logAudit(entry: {
|
|
user_id?: number | null;
|
|
user_email?: string | null;
|
|
action: string;
|
|
target_type?: string;
|
|
target_id?: string | number;
|
|
details?: unknown;
|
|
}) {
|
|
try {
|
|
getDb().prepare(`INSERT INTO audit_log (ts, user_id, user_email, action, target_type, target_id, details) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
|
Date.now(),
|
|
entry.user_id ?? null,
|
|
entry.user_email ?? null,
|
|
entry.action,
|
|
entry.target_type ?? null,
|
|
entry.target_id !== undefined ? String(entry.target_id) : null,
|
|
entry.details === undefined ? null : JSON.stringify(entry.details),
|
|
);
|
|
} catch {
|
|
// never block the main flow on audit failure
|
|
}
|
|
}
|
|
|
|
function getSettingDirect(db: Database.Database, key: string): string | null {
|
|
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | undefined;
|
|
return row?.value ?? null;
|
|
}
|
|
function setSettingDirect(db: Database.Database, key: string, value: string) {
|
|
db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
|
|
}
|
|
|
|
function hasColumn(db: Database.Database, table: string, col: string): boolean {
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
|
return cols.some((c) => c.name === col);
|
|
}
|
|
|
|
function runMigrations(db: Database.Database) {
|
|
// m_301_to_302: switch existing 301-redirects to 302 so browser-cache stops eating hits.
|
|
if (getSettingDirect(db, "m_301_to_302") !== "done") {
|
|
db.prepare("UPDATE domains SET redirect_code = 302 WHERE redirect_code = 301").run();
|
|
db.prepare("UPDATE domain_groups SET redirect_code = 302 WHERE redirect_code = 301").run();
|
|
setSettingDirect(db, "m_301_to_302", "done");
|
|
}
|
|
|
|
// sunset_config column: self-healing — always check schema regardless of setting flag.
|
|
if (!hasColumn(db, "domains", "sunset_config")) {
|
|
db.exec("ALTER TABLE domains ADD COLUMN sunset_config TEXT");
|
|
}
|
|
setSettingDirect(db, "m_sunset_column", "done");
|
|
}
|
|
|
|
export function getSetting(key: string): string | null {
|
|
const row = getDb().prepare("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | undefined;
|
|
return row?.value ?? null;
|
|
}
|
|
|
|
export function setSetting(key: string, value: string) {
|
|
getDb()
|
|
.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
.run(key, value);
|
|
}
|
|
|
|
export function isSetupComplete(): boolean {
|
|
return getSetting("setup_complete") === "true";
|
|
}
|
|
|
|
export function getDailySalt(): string {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const stored = getSetting("daily_ip_salt");
|
|
if (stored) {
|
|
const [date, salt] = stored.split("|");
|
|
if (date === today) return salt;
|
|
}
|
|
const salt = crypto.randomBytes(16).toString("hex");
|
|
setSetting("daily_ip_salt", `${today}|${salt}`);
|
|
return salt;
|
|
}
|
|
|
|
export function hashIp(ip: string): string {
|
|
return crypto.createHash("sha256").update(ip + getDailySalt()).digest("hex");
|
|
}
|
|
|
|
export type SunsetConfig = {
|
|
enabled: boolean;
|
|
title?: string;
|
|
message?: string;
|
|
button_label?: string;
|
|
sunset_date?: string;
|
|
};
|
|
|
|
export type DomainRow = {
|
|
id: number;
|
|
domain: string;
|
|
status: "pending" | "active" | "error";
|
|
target_url: string | null;
|
|
group_id: number | null;
|
|
redirect_code: number;
|
|
preserve_path: number;
|
|
include_www: number;
|
|
created_by: number | null;
|
|
created_at: number;
|
|
verified_at: number | null;
|
|
sunset_config: string | null;
|
|
};
|
|
|
|
export function parseSunset(row: { sunset_config?: string | null }): SunsetConfig | null {
|
|
if (!row.sunset_config) return null;
|
|
try {
|
|
const parsed = JSON.parse(row.sunset_config) as SunsetConfig;
|
|
return parsed.enabled ? parsed : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type DomainGroupRow = {
|
|
id: number;
|
|
name: string;
|
|
target_url: string;
|
|
redirect_code: number;
|
|
created_by: number | null;
|
|
created_at: number;
|
|
};
|
|
|
|
export type UserRow = {
|
|
id: number;
|
|
email: string;
|
|
username: string | null;
|
|
password_hash: string;
|
|
role: string;
|
|
created_at: number;
|
|
};
|
|
|
|
export type ApiTokenRow = {
|
|
id: number;
|
|
name: string;
|
|
token_hash: string;
|
|
scopes: string;
|
|
created_by: number | null;
|
|
created_at: number;
|
|
last_used_at: number | null;
|
|
revoked_at: number | null;
|
|
};
|