Refactor database interactions and update footer text

- Removed community sharing and self-hosted features from the features list in page.tsx.
- Updated footer text in layout.tsx to remove the MIT License mention.
- Added @libsql/client dependency to package.json and bun.lock for database management.
- Implemented a new db.ts file to handle SQLite database connections and migrations.
- Refactored user and file management functions in users.ts and store.ts to utilize the new database client.
- Ensured legacy JSON data is imported into the new SQLite database structure on first run.
This commit is contained in:
Anders Böttcher
2026-05-12 16:35:10 +02:00
parent 6e2951734e
commit 33e339bde6
8 changed files with 307 additions and 95 deletions
+77 -46
View File
@@ -1,7 +1,6 @@
import path from 'path'
import fs from 'fs/promises'
import crypto from 'crypto'
import { hashPassword, verifyPassword } from './auth'
import { getDb, ensureReady } from './db'
export type Role = 'admin' | 'user'
@@ -17,41 +16,48 @@ export interface User {
/** User fields safe to expose to the client (no credentials). */
export type PublicUser = Omit<User, 'passwordHash' | 'salt'>
const DATA_DIR = path.join(process.cwd(), 'data')
const USERS_FILE = path.join(DATA_DIR, 'users.json')
type DbRow = Record<string, unknown>
async function readUsers(): Promise<User[]> {
try {
const text = await fs.readFile(USERS_FILE, 'utf-8')
return JSON.parse(text) as User[]
} catch {
return []
function rowToUser(row: DbRow): User {
return {
id: row.id as string,
username: row.username as string,
passwordHash: row.password_hash as string,
salt: row.salt as string,
role: row.role as Role,
createdAt: row.created_at as string,
}
}
async function writeUsers(users: User[]): Promise<void> {
await fs.mkdir(DATA_DIR, { recursive: true })
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8')
}
function toPublic(user: User): PublicUser {
const { passwordHash: _, salt: __, ...pub } = user
return pub
}
export async function listUsers(): Promise<PublicUser[]> {
const users = await readUsers()
return users.map(toPublic)
await ensureReady()
const { rows } = await getDb().execute(
'SELECT * FROM users ORDER BY created_at ASC',
)
return rows.map((r) => toPublic(rowToUser(r)))
}
export async function findUserByUsername(username: string): Promise<User | undefined> {
const users = await readUsers()
return users.find((u) => u.username.toLowerCase() === username.toLowerCase())
await ensureReady()
const { rows } = await getDb().execute({
sql: 'SELECT * FROM users WHERE username = ? COLLATE NOCASE',
args: [username],
})
return rows[0] ? rowToUser(rows[0]) : undefined
}
export async function findUserById(id: string): Promise<User | undefined> {
const users = await readUsers()
return users.find((u) => u.id === id)
await ensureReady()
const { rows } = await getDb().execute({
sql: 'SELECT * FROM users WHERE id = ?',
args: [id],
})
return rows[0] ? rowToUser(rows[0]) : undefined
}
export async function createUser(
@@ -59,14 +65,10 @@ export async function createUser(
password: string,
role: Role,
): Promise<PublicUser> {
await ensureReady()
if (!username.trim()) throw new Error('Username cannot be empty')
if (password.length < 8) throw new Error('Password must be at least 8 characters')
const users = await readUsers()
if (users.some((u) => u.username.toLowerCase() === username.toLowerCase())) {
throw new Error(`Username "${username}" is already taken`)
}
const { hash, salt } = await hashPassword(password)
const user: User = {
id: crypto.randomUUID(),
@@ -76,40 +78,67 @@ export async function createUser(
role,
createdAt: new Date().toISOString(),
}
users.push(user)
await writeUsers(users)
try {
await getDb().execute({
sql: `INSERT INTO users (id,username,password_hash,salt,role,created_at)
VALUES (?,?,?,?,?,?)`,
args: [user.id, user.username, user.passwordHash, user.salt, user.role, user.createdAt],
})
} catch (e: unknown) {
if (String(e).includes('UNIQUE')) {
throw new Error(`Username "${username}" is already taken`)
}
throw e
}
return toPublic(user)
}
export async function deleteUser(id: string): Promise<void> {
const users = await readUsers()
const target = users.find((u) => u.id === id)
if (!target) throw new Error('User not found')
await ensureReady()
const db = getDb()
const remaining = users.filter((u) => u.id !== id)
const remainingAdmins = remaining.filter((u) => u.role === 'admin')
if (target.role === 'admin' && remainingAdmins.length === 0) {
throw new Error('Cannot delete the last admin account')
const { rows: userRows } = await db.execute({
sql: 'SELECT * FROM users WHERE id = ?',
args: [id],
})
if (!userRows[0]) throw new Error('User not found')
const user = rowToUser(userRows[0])
if (user.role === 'admin') {
const { rows: adminRows } = await db.execute(
"SELECT COUNT(*) as cnt FROM users WHERE role = 'admin'",
)
if ((adminRows[0].cnt as number) <= 1) {
throw new Error('Cannot delete the last admin account')
}
}
await writeUsers(remaining)
await db.execute({ sql: 'DELETE FROM users WHERE id = ?', args: [id] })
}
export async function updatePassword(id: string, newPassword: string): Promise<void> {
await ensureReady()
if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
const users = await readUsers()
const idx = users.findIndex((u) => u.id === id)
if (idx === -1) throw new Error('User not found')
const { rows } = await getDb().execute({
sql: 'SELECT id FROM users WHERE id = ?',
args: [id],
})
if (!rows[0]) throw new Error('User not found')
const { hash, salt } = await hashPassword(newPassword)
users[idx].passwordHash = hash
users[idx].salt = salt
await writeUsers(users)
await getDb().execute({
sql: 'UPDATE users SET password_hash = ?, salt = ? WHERE id = ?',
args: [hash, salt, id],
})
}
export async function authenticateUser(username: string, password: string): Promise<User | null> {
const user = await findUserByUsername(username)
if (!user) {
// Run a dummy derivation to keep constant time even when user not found
await hashPassword(password)
await hashPassword(password) // constant-time dummy to prevent enumeration
return null
}
const valid = await verifyPassword(password, user.passwordHash, user.salt)
@@ -117,8 +146,9 @@ export async function authenticateUser(username: string, password: string): Prom
}
export async function countUsers(): Promise<number> {
const users = await readUsers()
return users.length
await ensureReady()
const { rows } = await getDb().execute('SELECT COUNT(*) as cnt FROM users')
return rows[0].cnt as number
}
/**
@@ -138,3 +168,4 @@ export async function seedAdminIfEmpty(): Promise<void> {
console.error('[auth] Failed to seed admin user:', e)
}
}