175 lines
7.8 KiB
JavaScript
175 lines
7.8 KiB
JavaScript
import { createHash, randomBytes, randomUUID, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
|
import { mkdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
|
|
const scrypt = promisify(scryptCallback);
|
|
|
|
export class GameDatabaseError extends Error {
|
|
constructor(message, status = 400, code = "bad_request") {
|
|
super(message);
|
|
this.name = "GameDatabaseError";
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function validateCredentials(username, password) {
|
|
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
|
throw new GameDatabaseError("Username must be 3-20 letters, numbers, or underscores.");
|
|
}
|
|
if (typeof password !== "string" || password.length < 8) {
|
|
throw new GameDatabaseError("Password must be at least 8 characters.");
|
|
}
|
|
if (password.length > 128) throw new GameDatabaseError("Password must be 128 characters or fewer.");
|
|
}
|
|
|
|
function tokenHash(token) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
async function derivePassword(password, salt) {
|
|
return Buffer.from(await scrypt(password, salt, 64));
|
|
}
|
|
|
|
function publicAccount(row) {
|
|
return { id: row.id, username: row.username, createdAt: Number(row.created_at) };
|
|
}
|
|
|
|
export function openGameDatabase(options = {}) {
|
|
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
|
|
const databasePath = path.resolve(options.databasePath ?? path.join(dataDir, "game.db"));
|
|
const sessionTtlDays = Math.max(1, Number(options.sessionTtlDays ?? process.env.SESSION_TTL_DAYS ?? 30));
|
|
mkdirSync(path.dirname(databasePath), { recursive: true });
|
|
const database = new DatabaseSync(databasePath);
|
|
database.exec("PRAGMA journal_mode = WAL");
|
|
database.exec("PRAGMA foreign_keys = ON");
|
|
database.exec("PRAGMA busy_timeout = 5000");
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
id TEXT PRIMARY KEY, username TEXT NOT NULL, username_key TEXT NOT NULL UNIQUE,
|
|
password_salt TEXT NOT NULL, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
) STRICT;
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token_hash TEXT PRIMARY KEY,
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL
|
|
) STRICT;
|
|
CREATE INDEX IF NOT EXISTS sessions_account_id_idx ON sessions(account_id);
|
|
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
|
|
CREATE TABLE IF NOT EXISTS cloud_saves (
|
|
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
|
revision INTEGER NOT NULL, data_json TEXT NOT NULL, updated_at INTEGER NOT NULL
|
|
) STRICT;
|
|
`);
|
|
|
|
const statements = {
|
|
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
|
|
insertAccount: database.prepare("INSERT INTO accounts (id, username, username_key, password_salt, password_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)"),
|
|
insertSession: database.prepare("INSERT INTO sessions (token_hash, account_id, created_at, expires_at) VALUES (?, ?, ?, ?)"),
|
|
sessionAccount: database.prepare(`
|
|
SELECT accounts.id, accounts.username, accounts.created_at, sessions.expires_at
|
|
FROM sessions JOIN accounts ON accounts.id = sessions.account_id
|
|
WHERE sessions.token_hash = ?
|
|
`),
|
|
deleteSession: database.prepare("DELETE FROM sessions WHERE token_hash = ?"),
|
|
deleteExpiredSessions: database.prepare("DELETE FROM sessions WHERE expires_at <= ?"),
|
|
cloudSave: database.prepare("SELECT revision, data_json, updated_at FROM cloud_saves WHERE account_id = ?"),
|
|
upsertCloudSave: database.prepare(`
|
|
INSERT INTO cloud_saves (account_id, revision, data_json, updated_at) VALUES (?, 1, ?, ?)
|
|
ON CONFLICT(account_id) DO UPDATE SET revision = cloud_saves.revision + 1,
|
|
data_json = excluded.data_json, updated_at = excluded.updated_at
|
|
RETURNING revision, updated_at
|
|
`),
|
|
};
|
|
|
|
function issueSession(accountId) {
|
|
const token = randomBytes(32).toString("base64url");
|
|
const createdAt = Date.now();
|
|
const expiresAt = createdAt + sessionTtlDays * 24 * 60 * 60 * 1000;
|
|
statements.insertSession.run(tokenHash(token), accountId, createdAt, expiresAt);
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
async function register(usernameInput, password) {
|
|
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
|
|
validateCredentials(username, password);
|
|
const usernameKey = username.toLowerCase();
|
|
if (statements.accountByUsername.get(usernameKey)) {
|
|
throw new GameDatabaseError("That account already exists.", 409, "account_exists");
|
|
}
|
|
const salt = randomBytes(16);
|
|
const digest = await derivePassword(password, salt);
|
|
const account = { id: `account-${randomUUID()}`, username, createdAt: Date.now() };
|
|
try {
|
|
statements.insertAccount.run(account.id, username, usernameKey, salt.toString("base64"), digest.toString("base64"), account.createdAt);
|
|
} catch (error) {
|
|
if (String(error?.message).includes("UNIQUE constraint failed")) {
|
|
throw new GameDatabaseError("That account already exists.", 409, "account_exists");
|
|
}
|
|
throw error;
|
|
}
|
|
return { account, ...issueSession(account.id) };
|
|
}
|
|
|
|
async function login(usernameInput, password) {
|
|
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
|
|
if (!username || typeof password !== "string" || !password) {
|
|
throw new GameDatabaseError("Enter your username and password.", 400, "missing_credentials");
|
|
}
|
|
const row = statements.accountByUsername.get(username.toLowerCase());
|
|
if (!row) throw new GameDatabaseError("Incorrect username or password.", 401, "invalid_credentials");
|
|
const actual = await derivePassword(password, Buffer.from(row.password_salt, "base64"));
|
|
const expected = Buffer.from(row.password_hash, "base64");
|
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
throw new GameDatabaseError("Incorrect username or password.", 401, "invalid_credentials");
|
|
}
|
|
return { account: publicAccount(row), ...issueSession(row.id) };
|
|
}
|
|
|
|
function authenticate(token) {
|
|
if (typeof token !== "string" || token.length < 20) {
|
|
throw new GameDatabaseError("Authentication required.", 401, "authentication_required");
|
|
}
|
|
const hash = tokenHash(token);
|
|
const row = statements.sessionAccount.get(hash);
|
|
if (!row || Number(row.expires_at) <= Date.now()) {
|
|
if (row) statements.deleteSession.run(hash);
|
|
throw new GameDatabaseError("Your session has expired. Please sign in again.", 401, "session_expired");
|
|
}
|
|
return publicAccount(row);
|
|
}
|
|
|
|
function getCloudSave(accountId) {
|
|
const row = statements.cloudSave.get(accountId);
|
|
if (!row) return { revision: 0, updatedAt: null, data: { version: 1, characters: [] } };
|
|
try {
|
|
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data: JSON.parse(row.data_json) };
|
|
} catch {
|
|
throw new GameDatabaseError("The cloud save is unreadable.", 500, "cloud_save_corrupt");
|
|
}
|
|
}
|
|
|
|
function putCloudSave(accountId, data) {
|
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
throw new GameDatabaseError("Cloud save data must be an object.");
|
|
}
|
|
const now = Date.now();
|
|
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
|
|
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
|
}
|
|
|
|
return {
|
|
databasePath,
|
|
register,
|
|
login,
|
|
authenticate,
|
|
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
|
getCloudSave,
|
|
putCloudSave,
|
|
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
|
close: () => database.close(),
|
|
};
|
|
}
|