cx-nexredirect/lib/db.ts

167 lines
4.5 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 301,
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 301,
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
);
`);
}
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 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;
};
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;
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;
};