Add auth bootstrap and database safeguards
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
||||
const requestedOutput = process.argv[2];
|
||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
|
||||
const databaseName = basename(databasePath).replace(/\.[^.]+$/, '');
|
||||
const outputPath = resolve(
|
||||
requestedOutput || `./data/backups/${databaseName}-${timestamp}.json`
|
||||
);
|
||||
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
const database = await connect(databasePath);
|
||||
const tableRows = await (
|
||||
await database.prepare(`
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY rowid
|
||||
`)
|
||||
).all();
|
||||
|
||||
const tables = [];
|
||||
let totalRows = 0;
|
||||
for (const tableRow of tableRows) {
|
||||
const name = String(tableRow.name);
|
||||
const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all();
|
||||
tables.push({ name, rowCount: rows.length, rows });
|
||||
totalRows += rows.length;
|
||||
}
|
||||
|
||||
const backup = {
|
||||
format: 'shd-build-database-backup',
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
sourceDatabase: basename(databasePath),
|
||||
totalRows,
|
||||
tables
|
||||
};
|
||||
await writeFile(outputPath, `${JSON.stringify(backup, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx'
|
||||
});
|
||||
|
||||
console.log(`Backed up ${totalRows} rows from ${tables.length} tables.`);
|
||||
console.log(`Backup: ${outputPath}`);
|
||||
+13
-1
@@ -1,10 +1,17 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { access, mkdir, readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { initializeDefaultUsers } from './default-users.mjs';
|
||||
|
||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
|
||||
const schemaPath = resolve('./database/schema.sql');
|
||||
let databaseAlreadyExisted = true;
|
||||
try {
|
||||
await access(databasePath);
|
||||
} catch {
|
||||
databaseAlreadyExisted = false;
|
||||
}
|
||||
|
||||
await mkdir(dirname(databasePath), { recursive: true });
|
||||
|
||||
@@ -22,3 +29,8 @@ for (const statement of statements) {
|
||||
}
|
||||
|
||||
console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
|
||||
|
||||
if (!databaseAlreadyExisted) {
|
||||
console.log('New database detected; creating default users.');
|
||||
await initializeDefaultUsers(database);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const backupArgument = process.argv[2];
|
||||
if (!backupArgument) {
|
||||
console.error('Usage: npm run db:restore -- <backup-file>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const backupPath = resolve(backupArgument);
|
||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
||||
const backup = JSON.parse(await readFile(backupPath, 'utf8'));
|
||||
if (backup?.format !== 'shd-build-database-backup' || backup?.version !== 1 || !Array.isArray(backup.tables)) {
|
||||
throw new Error('The selected file is not a supported SHD.build database backup.');
|
||||
}
|
||||
|
||||
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
|
||||
const database = await connect(databasePath);
|
||||
|
||||
// Restores are deliberately limited to empty databases. This prevents an
|
||||
// accidental merge from overwriting contributor-entered records.
|
||||
for (const table of backup.tables) {
|
||||
const existing = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`)
|
||||
).get();
|
||||
if (Number(existing?.count) > 0) {
|
||||
throw new Error(
|
||||
`Restore refused: table "${table.name}" is not empty. Use a new migrated database.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let restoredRows = 0;
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
for (const table of backup.tables) {
|
||||
for (const row of table.rows) {
|
||||
const columns = Object.keys(row);
|
||||
if (!columns.length) continue;
|
||||
const columnSql = columns.map(quoteIdentifier).join(', ');
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const insert = await database.prepare(
|
||||
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`
|
||||
);
|
||||
await insert.run(...columns.map((column) => row[column]));
|
||||
restoredRows++;
|
||||
}
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Restored ${restoredRows} rows into ${databasePath}.`);
|
||||
console.log(`Source backup: ${backupPath}`);
|
||||
@@ -0,0 +1,92 @@
|
||||
import { argon2id, hash } from 'argon2';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
export const defaultUsers = [
|
||||
{
|
||||
username: 'Aewyn',
|
||||
role: 'user',
|
||||
passwordEnvironmentKey: 'DEFAULT_ADMIN_PASSWORD'
|
||||
},
|
||||
{
|
||||
username: 'Quartermaster',
|
||||
role: 'user',
|
||||
passwordEnvironmentKey: 'DEFAULT_USER_PASSWORD'
|
||||
}
|
||||
];
|
||||
|
||||
function temporaryPassword() {
|
||||
// 120 bits of entropy encoded without punctuation that is awkward to paste.
|
||||
return randomBytes(15).toString('base64url');
|
||||
}
|
||||
|
||||
async function secureHash(password) {
|
||||
return hash(password, {
|
||||
type: argon2id,
|
||||
memoryCost: 19_456,
|
||||
timeCost: 2,
|
||||
parallelism: 1
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates missing default users. With resetPasswords enabled, existing default
|
||||
* users keep their IDs and related records but receive new password hashes.
|
||||
*/
|
||||
export async function initializeDefaultUsers(database, options = {}) {
|
||||
const resetPasswords = options.resetPasswords === true;
|
||||
const findUser = await database.prepare(
|
||||
'SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE'
|
||||
);
|
||||
const insertUser = await database.prepare(
|
||||
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
const updateCredential = await database.prepare(
|
||||
'UPDATE users SET password_hash = ?, role = ? WHERE id = ?'
|
||||
);
|
||||
const credentials = [];
|
||||
|
||||
for (const definition of defaultUsers) {
|
||||
const existing = await findUser.get(definition.username);
|
||||
if (existing && !resetPasswords) {
|
||||
console.log(`${definition.username}: already exists (${existing.role}); left unchanged`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const configuredPassword = process.env[definition.passwordEnvironmentKey]?.trim();
|
||||
const password = configuredPassword || temporaryPassword();
|
||||
if (password.length < 12 || password.length > 128) {
|
||||
throw new Error(`${definition.passwordEnvironmentKey} must be between 12 and 128 characters.`);
|
||||
}
|
||||
const passwordHash = await secureHash(password);
|
||||
|
||||
if (existing) {
|
||||
await updateCredential.run(passwordHash, String(existing.role), existing.id);
|
||||
console.log(`${definition.username}: credentials reset; role and related data preserved`);
|
||||
} else {
|
||||
await insertUser.run(
|
||||
randomUUID(),
|
||||
definition.username,
|
||||
passwordHash,
|
||||
definition.role,
|
||||
new Date().toISOString()
|
||||
);
|
||||
console.log(`${definition.username}: default account created`);
|
||||
}
|
||||
credentials.push({
|
||||
username: definition.username,
|
||||
role: existing ? String(existing.role) : definition.role,
|
||||
password,
|
||||
generated: !configuredPassword
|
||||
});
|
||||
}
|
||||
|
||||
if (credentials.length) {
|
||||
console.log('\nDefault user credentials (shown once):');
|
||||
for (const credential of credentials) {
|
||||
console.log(`${credential.username} (${credential.role}): ${credential.password}${credential.generated ? ' [generated]' : ' [from environment]'}`);
|
||||
}
|
||||
console.log('Change generated passwords after the first sign-in.\n');
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
@@ -1,48 +1,10 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { argon2id, hash } from 'argon2';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { initializeDefaultUsers } from './default-users.mjs';
|
||||
|
||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
|
||||
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
||||
const database = await connect(databasePath);
|
||||
const resetPasswords = process.argv.includes('--reset-passwords');
|
||||
|
||||
const users = [
|
||||
{ username: 'Aewyn', role: 'admin' },
|
||||
{ username: 'Quartermaster', role: 'user' }
|
||||
];
|
||||
|
||||
const findUser = await database.prepare(
|
||||
'SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE'
|
||||
);
|
||||
const insertUser = await database.prepare(
|
||||
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
|
||||
for (const user of users) {
|
||||
const existing = await findUser.get(user.username);
|
||||
|
||||
if (existing) {
|
||||
console.log(`${user.username}: already exists (${existing.role}); left unchanged`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 120 bits of entropy encoded without punctuation that is awkward to paste.
|
||||
const temporaryPassword = randomBytes(15).toString('base64url');
|
||||
const passwordHash = await hash(temporaryPassword, {
|
||||
type: argon2id,
|
||||
memoryCost: 19_456,
|
||||
timeCost: 2,
|
||||
parallelism: 1
|
||||
});
|
||||
|
||||
await insertUser.run(
|
||||
randomUUID(),
|
||||
user.username,
|
||||
passwordHash,
|
||||
user.role,
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
console.log(`${user.username} (${user.role}) temporary password: ${temporaryPassword}`);
|
||||
}
|
||||
await initializeDefaultUsers(database, { resetPasswords });
|
||||
|
||||
Reference in New Issue
Block a user