This commit is contained in:
Bilal Teke 2026-04-20 20:35:43 +02:00
parent cc7a77f21b
commit c7fd939f41
19 changed files with 155 additions and 6999 deletions

View file

@ -11,13 +11,9 @@ NEXTAUTH_SECRET=your-generated-secret-key
# Data directory for persistent storage (config, backups, database)
DATA_DIR=./data
# Admin username for login
ADMIN_USERNAME=admin
# Admin password hash (generated with bcryptjs)
# To generate a hash, run: node -e "console.log(require('bcryptjs').hashSync('your-password', 10))"
# Example hash for password 'admin': $2a$10$your-bcrypt-hash-here
ADMIN_PASSWORD_HASH=$2a$10$your-bcrypt-hash-here
# SQLite database file (should be under DATA_DIR)
DATABASE_FILE=database.db
# Optional: Polling interval for status checks (in milliseconds)
# STATUS_POLL_INTERVAL=30000

View file

@ -1,68 +1,49 @@
# Multi-stage build for Next.js standalone output
FROM node:20-alpine AS base
# Verwende ein robustes Debian-basiertes Node-Image für native Module
FROM node:20-slim AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN \
if [ -f package-lock.json ]; then npm ci; \
else echo "Lockfile not found." && exit 1; \
fi
RUN npm ci
# Rebuild the source code only when needed
# Build source code
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Kopiere gebaute App und node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/src ./src
COPY --from=builder /app/data ./data
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Create data directory for persistent storage
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
# Erstelle Datenverzeichnis für SQLite, falls nicht vorhanden
RUN mkdir -p /app/data
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]
CMD ["npx", "next", "start"]

View file

@ -46,17 +46,11 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge
npm install
```
### Schritt 1.1: Prisma Client generieren
```bash
npm run prisma:generate
```
### Schritt 1.1: Datenbank wird automatisch erstellt
### Schritt 1.2: Datenbank-Schema anwenden
Es ist **keine** externe Datenbank und kein Setup-Script nötig. Die App verwendet eine lokale SQLite-Datenbank (`better-sqlite3`). Die Datenbankdatei wird beim ersten Start automatisch unter dem in `.env` definierten `DATA_DIR` angelegt (Standard: `./data/database.db`).
```bash
npm run prisma:db-push
```
### Schritt 2: Umgebungsvariablen konfigurieren
@ -71,29 +65,15 @@ Bearbeite `.env` und setze die erforderlichen Variablen:
```env
PORT=3000
HOSTNAME=0.0.0.0
# NextAuth Secret (generieren mit: openssl rand -base64 32)
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
# SQLite database URL für persistenten Benutzerzugriff
DATABASE_URL=file:./dev.db
# Admin-Anmeldedaten (optional, solange noch kein erster Benutzer angelegt wurde)
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=<siehe unten>
DATA_DIR=./data
DATABASE_FILE=database.db
```
### Schritt 3: Admin-Passwort-Hash generieren
Um den Passwort-Hash zu generieren, führe aus:
### Schritt 3: First-Run-Setup (Admin-Benutzer anlegen)
```bash
node scripts/generate-password-hash.js
```
Das Script fordert dich auf, ein Passwort einzugeben und gibt einen bcryptjs-Hash aus.
Kopiere den Hash in deine `.env` Datei als `ADMIN_PASSWORD_HASH`.
Beim ersten Start ist `/setup` verfügbar, solange noch kein Benutzer existiert. Dort kannst du den ersten Admin-Benutzer anlegen. Danach ist `/setup` gesperrt und du nutzt `/login`.
### Schritt 4: Entwicklungsserver starten
@ -103,12 +83,16 @@ npm run dev
Der Server läuft unter `http://localhost:3000`
## Login & Admin-Zugang
1. Navigiere zu `http://localhost:3000/setup`, wenn noch kein Administrator angelegt wurde.
2. Erstelle den ersten Admin-Benutzer.
1. Navigiere zu `http://localhost:3000/setup`, wenn noch **kein** Benutzer existiert.
2. Lege den ersten Admin-Benutzer an (Benutzername und Passwort werden in der Datenbank gespeichert, Passwort wird mit bcryptjs gehasht).
3. Danach melde dich unter `http://localhost:3000/login` an.
4. Nach erfolgreichem Login wird zu `/admin` weitergeleitet
4. Nach erfolgreichem Login wirst du zu `/admin` weitergeleitet.
## Speicherort der Datenbank
Die SQLite-Datenbankdatei liegt standardmäßig unter `./data/database.db` (relativ zum Projektverzeichnis oder `/app/data/database.db` im Docker-Container). Der Pfad kann über `DATA_DIR` und `DATABASE_FILE` in der `.env` angepasst werden.
## Admin-Interface
@ -168,9 +152,6 @@ npm run lint # ESLint ausführen
| `PORT` | Port für den Server | `3000` |
| `HOSTNAME` | Server-Hostname | `0.0.0.0` |
| `NEXTAUTH_SECRET` | Secret für JWT-Signing | `openssl rand -base64 32` |
| `DATABASE_URL` | SQLite-Verbindungs-URL für Prisma | `file:./dev.db` |
| `ADMIN_USERNAME` | Admin-Benutzername | `admin` |
| `ADMIN_PASSWORD_HASH` | Bcryptjs-Hash des Admin-Passworts | `$2a$10$...` |
## Sicherheit
@ -220,7 +201,6 @@ docker-compose up --build
docker build -t homelab-dashboard .
docker run -p 3000:3000 \
-e NEXTAUTH_SECRET=your-secret \
-e DATABASE_URL=file:/app/data/database.db \
-e DATA_DIR=/app/data \
-v $(pwd)/data:/app/data \
homelab-dashboard
@ -257,7 +237,6 @@ docker-compose down
5. **Environment Variables**:
- **NEXTAUTH_SECRET**: Dein generierter Secret (openssl rand -base64 32)
- **DATABASE_URL**: file:/app/data/database.db
- **DATA_DIR**: /app/data
- **NODE_ENV**: production
@ -287,7 +266,6 @@ services:
- PORT=3000
- HOSTNAME=0.0.0.0
- NEXTAUTH_SECRET=your-nextauth-secret
- DATABASE_URL=file:/app/data/database.db
- DATA_DIR=/app/data
volumes:
- /mnt/user/appdata/homelab-dashboard/data:/app/data
@ -298,7 +276,6 @@ services:
- **Container startet nicht**: Logs prüfen mit `docker-compose logs`
- **Daten nicht persistent**: Volume-Mapping überprüfen
- **Authentifizierung fehlt**: NEXTAUTH_SECRET setzen
- **Datenbank-Fehler**: DATABASE_URL und DATA_DIR prüfen
## Konfiguration

View file

@ -1,3 +1,4 @@
export const runtime = "nodejs";
import NextAuth from 'next-auth';
import { authOptions } from '@/auth';

View file

@ -1,3 +1,4 @@
export const runtime = "nodejs";
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { createBackup, listBackups, restoreBackup } from '@/src/lib/config/backup-config';

View file

@ -1,5 +1,5 @@
export const runtime = "nodejs";
import { NextResponse } from 'next/server';
import { hash } from 'bcryptjs';
import { z } from 'zod';
import { createInitialAdmin, hasAnyUser } from '@/src/lib/db/user';
@ -42,10 +42,8 @@ export async function POST(request: Request) {
);
}
const passwordHash = await hash(result.data.password, 10);
try {
await createInitialAdmin(result.data.username, passwordHash);
await createInitialAdmin(result.data.username, result.data.password);
return NextResponse.json({ message: 'Administrator wurde erfolgreich angelegt.' }, { status: 201 });
} catch (error) {
if (error instanceof Error && error.message === 'INITIAL_ADMIN_ALREADY_EXISTS') {

View file

@ -1,3 +1,4 @@
export const runtime = "nodejs";
import { redirect } from 'next/navigation';
import { hasAnyUser } from '@/src/lib/db/user';
import LoginForm from '@/src/components/LoginForm';
@ -5,17 +6,8 @@ import LoginForm from '@/src/components/LoginForm';
export const dynamic = 'force-dynamic';
export default async function LoginPage() {
let hasUser = false;
try {
hasUser = await hasAnyUser();
} catch (error) {
console.warn('Failed to determine user setup status on /login:', error);
}
if (!hasUser && !process.env.ADMIN_PASSWORD_HASH) {
if (!hasAnyUser()) {
redirect('/setup');
}
return <LoginForm />;
}

View file

@ -1,3 +1,4 @@
export const runtime = "nodejs";
import { redirect } from 'next/navigation';
import { hasAnyUser } from '@/src/lib/db/user';
import SetupForm from '@/src/components/SetupForm';
@ -5,15 +6,8 @@ import SetupForm from '@/src/components/SetupForm';
export const dynamic = 'force-dynamic';
export default async function SetupPage() {
try {
const hasUser = await hasAnyUser();
if (hasUser) {
redirect('/login');
}
} catch (error) {
console.warn('Failed to determine user setup status on /setup:', error);
if (hasAnyUser()) {
redirect('/login');
}
return <SetupForm />;
}

59
auth.ts
View file

@ -14,58 +14,27 @@ export const authOptions: NextAuthOptions = {
if (!credentials?.username || !credentials?.password) {
return null;
}
const username = credentials.username.trim();
const password = credentials.password;
if (process.env.DATABASE_URL) {
try {
const dbUser = await getUserByUsername(username);
if (dbUser) {
const isPasswordValid = await compare(password, dbUser.passwordHash);
if (!isPasswordValid) {
return null;
}
return {
id: String(dbUser.id),
name: username,
email: `${username}@homelab.local`,
username,
};
}
if (await hasAnyUser()) {
try {
const dbUser = getUserByUsername(username);
if (dbUser) {
const isPasswordValid = await compare(password, dbUser.password_hash);
if (!isPasswordValid) {
return null;
}
} catch (error) {
console.error('Database authentication error:', error);
return {
id: String(dbUser.id),
name: username,
email: `${username}@homelab.local`,
username,
};
}
if (hasAnyUser()) {
return null;
}
}
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH;
if (!adminPasswordHash || username !== adminUsername) {
return null;
}
try {
const isPasswordValid = await compare(password, adminPasswordHash);
if (!isPasswordValid) {
return null;
}
return {
id: '1',
name: 'Admin',
email: 'admin@homelab.local',
username: adminUsername,
};
} catch (error) {
console.error('Password comparison error:', error);
console.error('Database authentication error:', error);
return null;
}
},

View file

@ -1,6 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
const nextConfig = {};
module.exports = nextConfig

6788
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,16 +2,17 @@
"name": "homelab-dashboard",
"version": "0.1.0",
"private": true,
"engines": {
"node": "^20.0.0 || ^22.0.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint app src lib --ext .ts,.tsx",
"prisma:generate": "prisma generate",
"prisma:db-push": "prisma db push"
"lint": "eslint app src lib --ext .ts,.tsx"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"better-sqlite3": "^9.0.0",
"bcryptjs": "^3.0.3",
"next": "^16.2.3",
"next-auth": "^4.24.14",
@ -29,7 +30,6 @@
"eslint": "^8.56.0",
"eslint-config-next": "^15.0.3",
"postcss": "^8.4.32",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}

View file

@ -1,16 +1 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
role String
createdAt DateTime @default(now())
}

1
src/lib/db-init.ts Normal file
View file

@ -0,0 +1 @@
import './db'; // just import to ensure DB is initialized

26
src/lib/db.ts Normal file
View file

@ -0,0 +1,26 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config/paths';
const DB_FILE = path.join(DATA_DIR, 'database.db');
// Ensure DATA_DIR exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
// Open or create the database
export const db = new Database(DB_FILE);
// Initialize users table if not exists
const init = db.prepare(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
init.run();

View file

@ -1,12 +1 @@
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma = globalThis.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}

View file

@ -1,40 +1,34 @@
import { prisma } from './prisma';
import { db } from '../db';
import bcrypt from 'bcryptjs';
export async function getUserByUsername(username: string) {
return prisma.user.findUnique({
where: { username },
});
export interface User {
id: number;
username: string;
password_hash: string;
role: string;
created_at: string;
}
export async function createUser(username: string, passwordHash: string, role = 'admin') {
return prisma.user.create({
data: {
username,
passwordHash,
role,
},
});
export function getUserByUsername(username: string): User | undefined {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
export async function hasAnyUser() {
const count = await prisma.user.count();
return count > 0;
export function hasAnyUser(): boolean {
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
return row.count > 0;
}
export async function createInitialAdmin(username: string, passwordHash: string) {
return prisma.$transaction(async (tx: any) => {
const existingUsers = await tx.user.count();
if (existingUsers > 0) {
throw new Error('INITIAL_ADMIN_ALREADY_EXISTS');
}
return tx.user.create({
data: {
username,
passwordHash,
role: 'admin',
},
});
});
export async function createUser(username: string, password: string, role = 'admin'): Promise<User> {
const password_hash = await bcrypt.hash(password, 10);
const stmt = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
stmt.run(username, password_hash, role);
return getUserByUsername(username)!;
}
export async function createInitialAdmin(username: string, password: string): Promise<User> {
if (hasAnyUser()) {
throw new Error('INITIAL_ADMIN_ALREADY_EXISTS');
}
return createUser(username, password, 'admin');
}

33
src/lib/users.ts Normal file
View file

@ -0,0 +1,33 @@
import { db } from './db';
import bcrypt from 'bcryptjs';
export interface User {
id: number;
username: string;
password_hash: string;
role: string;
created_at: string;
}
export function getUserByUsername(username: string): User | undefined {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
export function hasAnyUser(): boolean {
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
return row.count > 0;
}
export async function createUser(username: string, password: string, role = 'admin'): Promise<User> {
const password_hash = await bcrypt.hash(password, 10);
const stmt = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)');
const info = stmt.run(username, password_hash, role);
return getUserByUsername(username)!;
}
export async function verifyUser(username: string, password: string): Promise<User | null> {
const user = getUserByUsername(username);
if (!user) return null;
const valid = await bcrypt.compare(password, user.password_hash);
return valid ? user : null;
}

7
types/user.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
export interface User {
id: number;
username: string;
password_hash: string;
role: string;
created_at: string;
}