mirror of
https://gitlab.w-hs.de/an14051/backerup-website.git
synced 2026-07-27 17:35:29 +00:00
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:
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* lib/db.ts
|
||||
* Singleton libSQL (SQLite) client. Call getDb() anywhere on the server.
|
||||
*
|
||||
* WAL journal mode is set for better concurrency.
|
||||
* On first boot, existing meta.json / users.json are imported and renamed
|
||||
* to *.migrated so the one-time import never runs twice.
|
||||
*/
|
||||
import { createClient, type Client } from '@libsql/client'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data')
|
||||
const DB_URL = `file:${path.join(DATA_DIR, 'backerup.db')}`
|
||||
|
||||
let _client: Client | null = null
|
||||
|
||||
export function getDb(): Client {
|
||||
if (_client) return _client
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
_client = createClient({ url: DB_URL })
|
||||
// Fire-and-forget: migrations run before first real query via ensureReady()
|
||||
return _client
|
||||
}
|
||||
|
||||
/** Call once at startup (or await before first query if needed). */
|
||||
let _ready: Promise<void> | null = null
|
||||
|
||||
export function ensureReady(): Promise<void> {
|
||||
if (_ready) return _ready
|
||||
_ready = (async () => {
|
||||
const db = getDb()
|
||||
await applyMigrations(db)
|
||||
await importLegacyJson(db)
|
||||
})()
|
||||
return _ready
|
||||
}
|
||||
|
||||
// ── Schema ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function applyMigrations(db: Client): Promise<void> {
|
||||
await db.executeMultiple(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
token TEXT PRIMARY KEY,
|
||||
original_name TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
size INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
uploaded_at TEXT NOT NULL,
|
||||
description TEXT,
|
||||
group_name TEXT,
|
||||
container_name TEXT,
|
||||
image TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
// ── One-time JSON → SQLite import ─────────────────────────────────────────────
|
||||
|
||||
async function importLegacyJson(db: Client): Promise<void> {
|
||||
const metaPath = path.join(DATA_DIR, 'meta.json')
|
||||
if (fs.existsSync(metaPath)) {
|
||||
try {
|
||||
const { rows } = await db.execute('SELECT COUNT(*) as cnt FROM files')
|
||||
if ((rows[0].cnt as number) === 0) {
|
||||
type OldRecord = {
|
||||
token: string; originalName: string; displayName?: string; size: number
|
||||
type: string; uploadedAt: string; description?: string; group?: string
|
||||
containerName?: string; image?: string
|
||||
}
|
||||
const records = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as OldRecord[]
|
||||
for (const r of records) {
|
||||
await db.execute({
|
||||
sql: `INSERT OR IGNORE INTO files
|
||||
(token,original_name,display_name,size,type,uploaded_at,description,group_name,container_name,image)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
args: [r.token, r.originalName, r.displayName ?? null, r.size, r.type,
|
||||
r.uploadedAt, r.description ?? null, r.group ?? null,
|
||||
r.containerName ?? null, r.image ?? null],
|
||||
})
|
||||
}
|
||||
fs.renameSync(metaPath, metaPath + '.migrated')
|
||||
console.log(`[db] Imported ${records.length} file records from meta.json`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[db] Failed to import meta.json:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const usersPath = path.join(DATA_DIR, 'users.json')
|
||||
if (fs.existsSync(usersPath)) {
|
||||
try {
|
||||
const { rows } = await db.execute('SELECT COUNT(*) as cnt FROM users')
|
||||
if ((rows[0].cnt as number) === 0) {
|
||||
type OldUser = {
|
||||
id: string; username: string; passwordHash: string; salt: string
|
||||
role: string; createdAt: string
|
||||
}
|
||||
const users = JSON.parse(fs.readFileSync(usersPath, 'utf-8')) as OldUser[]
|
||||
for (const u of users) {
|
||||
await db.execute({
|
||||
sql: `INSERT OR IGNORE INTO users (id,username,password_hash,salt,role,created_at)
|
||||
VALUES (?,?,?,?,?,?)`,
|
||||
args: [u.id, u.username, u.passwordHash, u.salt, u.role, u.createdAt],
|
||||
})
|
||||
}
|
||||
fs.renameSync(usersPath, usersPath + '.migrated')
|
||||
console.log(`[db] Imported ${users.length} users from users.json`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[db] Failed to import users.json:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-26
@@ -1,5 +1,6 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { getDb, ensureReady } from './db'
|
||||
|
||||
export type FileType = 'backerup' | 'config'
|
||||
|
||||
@@ -17,51 +18,77 @@ export interface FileRecord {
|
||||
}
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data')
|
||||
const META_FILE = path.join(DATA_DIR, 'meta.json')
|
||||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||
|
||||
async function ensureDirs() {
|
||||
await fs.mkdir(UPLOADS_DIR, { recursive: true })
|
||||
type DbRow = Record<string, unknown>
|
||||
|
||||
function rowToRecord(row: DbRow): FileRecord {
|
||||
return {
|
||||
token: row.token as string,
|
||||
originalName: row.original_name as string,
|
||||
displayName: (row.display_name as string | null) ?? undefined,
|
||||
size: row.size as number,
|
||||
type: row.type as FileType,
|
||||
uploadedAt: row.uploaded_at as string,
|
||||
description: (row.description as string | null) ?? undefined,
|
||||
group: (row.group_name as string | null) ?? undefined,
|
||||
containerName: (row.container_name as string | null) ?? undefined,
|
||||
image: (row.image as string | null) ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMeta(): Promise<FileRecord[]> {
|
||||
try {
|
||||
const text = await fs.readFile(META_FILE, 'utf-8')
|
||||
return JSON.parse(text) as FileRecord[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
await ensureReady()
|
||||
const { rows } = await getDb().execute(
|
||||
'SELECT * FROM files ORDER BY uploaded_at DESC',
|
||||
)
|
||||
return rows.map(rowToRecord)
|
||||
}
|
||||
|
||||
export async function listGroups(): Promise<string[]> {
|
||||
const records = await readMeta()
|
||||
const groups = new Set<string>()
|
||||
for (const r of records) {
|
||||
if (r.group) groups.add(r.group)
|
||||
}
|
||||
return Array.from(groups).sort()
|
||||
}
|
||||
|
||||
async function writeMeta(records: FileRecord[]): Promise<void> {
|
||||
await ensureDirs()
|
||||
await fs.writeFile(META_FILE, JSON.stringify(records, null, 2), 'utf-8')
|
||||
await ensureReady()
|
||||
const { rows } = await getDb().execute(
|
||||
'SELECT DISTINCT group_name FROM files WHERE group_name IS NOT NULL ORDER BY group_name',
|
||||
)
|
||||
return rows.map((r) => r.group_name as string)
|
||||
}
|
||||
|
||||
export async function addFile(record: FileRecord, buffer: Buffer): Promise<void> {
|
||||
await ensureDirs()
|
||||
await ensureReady()
|
||||
await fs.mkdir(UPLOADS_DIR, { recursive: true })
|
||||
const dir = path.join(UPLOADS_DIR, record.token)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(path.join(dir, record.originalName), buffer)
|
||||
const records = await readMeta()
|
||||
records.unshift(record)
|
||||
await writeMeta(records)
|
||||
|
||||
await getDb().execute({
|
||||
sql: `INSERT INTO files
|
||||
(token,original_name,display_name,size,type,uploaded_at,description,group_name,container_name,image)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
args: [
|
||||
record.token,
|
||||
record.originalName,
|
||||
record.displayName ?? null,
|
||||
record.size,
|
||||
record.type,
|
||||
record.uploadedAt,
|
||||
record.description ?? null,
|
||||
record.group ?? null,
|
||||
record.containerName ?? null,
|
||||
record.image ?? null,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export async function getFile(token: string): Promise<FileRecord | undefined> {
|
||||
const records = await readMeta()
|
||||
return records.find((r) => r.token === token)
|
||||
await ensureReady()
|
||||
const { rows } = await getDb().execute({
|
||||
sql: 'SELECT * FROM files WHERE token = ?',
|
||||
args: [token],
|
||||
})
|
||||
return rows[0] ? rowToRecord(rows[0]) : undefined
|
||||
}
|
||||
|
||||
export function getUploadPath(token: string, originalName: string): string {
|
||||
return path.join(UPLOADS_DIR, token, originalName)
|
||||
}
|
||||
|
||||
|
||||
+77
-46
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user