Rewritten to use drizzle instead of prisma
commit c0894548dac5133bd89da5b68684443748fa2559 Author: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Fri Nov 7 18:38:30 2025 +0100 Update config.ts commit 5a4f1159d2123ada0f698a10011c24720bf6ea6f Author: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Fri Nov 7 15:58:13 2025 +0100 first drizzle rewrite
This commit is contained in:
91
README.md
91
README.md
@@ -44,7 +44,7 @@ Caddy Proxy Manager brings a beautiful, intuitive web interface to [Caddy Server
|
||||
- **Hardened by default** – Login throttling, strict session management, HSTS injection
|
||||
- **Admin-first design** – Single admin account with production credential enforcement
|
||||
- **Secure secrets** – API tokens never displayed after initial entry, restrictive file permissions
|
||||
- **Modern stack** – Built on Next.js 16, React 19, and Prisma with TypeScript throughout
|
||||
- **Modern stack** – Built on Next.js 16, React 19, and Drizzle ORM with TypeScript throughout
|
||||
|
||||
---
|
||||
|
||||
@@ -125,7 +125,7 @@ Visit `http://localhost:3000/login` and sign in with your credentials.
|
||||
- **Next.js 16 App Router** – Server components, streaming, and server actions
|
||||
- **Material UI Components** – Responsive design with dark theme
|
||||
- **Direct Caddy Integration** – Generates JSON config and pushes via Caddy Admin API
|
||||
- **Prisma ORM** – Type-safe database access with automatic migrations
|
||||
- **Drizzle ORM** – Type-safe SQLite access with checked-in SQL migrations
|
||||
- **SQLite Database** – Zero-configuration persistence with full ACID compliance
|
||||
- **Cloudflare DNS-01** – Automated wildcard certificate issuance
|
||||
- **bcrypt Authentication** – Industry-standard password hashing for access lists
|
||||
@@ -138,18 +138,37 @@ Visit `http://localhost:3000/login` and sign in with your credentials.
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `ADMIN_USERNAME` | Admin login username | `admin` (dev only) | Yes (production) |
|
||||
| `ADMIN_PASSWORD` | Admin password (min 12 chars with letters & numbers) | `admin` (dev only) | Yes (production) |
|
||||
| `SESSION_SECRET` | 32+ character string for session signing | - | Yes |
|
||||
| `SESSION_SECRET` | Session encryption key (32+ chars) | None | **Yes** |
|
||||
| `ADMIN_USERNAME` | Admin login username | `admin` | **Yes** |
|
||||
| `ADMIN_PASSWORD` | Admin password (see requirements below) | `admin` (dev only) | **Yes** |
|
||||
| `BASE_URL` | Public URL of the dashboard | `http://localhost:3000` | No |
|
||||
| `CADDY_API_URL` | Caddy Admin API endpoint | `http://caddy:2019` | No |
|
||||
| `DATABASE_PATH` | SQLite file path | `/app/data/caddy-proxy-manager.db` | No |
|
||||
| `PRIMARY_DOMAIN` | Default domain for Caddy config | `caddyproxymanager.com` | No |
|
||||
|
||||
**Production Security Requirements:**
|
||||
- `ADMIN_PASSWORD` must be 12+ characters with both letters and numbers
|
||||
- `SESSION_SECRET` must be 32+ characters and not a default value
|
||||
- Default credentials (`admin`/`admin`) are automatically rejected
|
||||
**Production Security Requirements (Strictly Enforced):**
|
||||
|
||||
The application will **fail to start** in production if these requirements are not met:
|
||||
|
||||
- **`SESSION_SECRET`**:
|
||||
- Must be at least 32 characters long
|
||||
- Cannot be a known placeholder value
|
||||
- Generate with: `openssl rand -base64 32`
|
||||
|
||||
- **`ADMIN_USERNAME`**:
|
||||
- Must be set (any value is acceptable, including `admin`)
|
||||
|
||||
- **`ADMIN_PASSWORD`**:
|
||||
- Minimum 12 characters
|
||||
- Must include uppercase letters (A-Z)
|
||||
- Must include lowercase letters (a-z)
|
||||
- Must include numbers (0-9)
|
||||
- Must include special characters (!@#$%^&* etc.)
|
||||
- Cannot be `admin` in production
|
||||
|
||||
**Development Mode:**
|
||||
- Default credentials (`admin`/`admin`) are allowed in development
|
||||
- Set `NODE_ENV=development` to use relaxed validation
|
||||
|
||||
---
|
||||
|
||||
@@ -167,7 +186,7 @@ caddy-proxy-manager/
|
||||
│ ├── models/ # Database models and operations
|
||||
│ ├── caddy/ # Caddy config generation
|
||||
│ └── auth/ # Authentication helpers
|
||||
├── prisma/ # Database schema and migrations
|
||||
├── drizzle/ # Database migrations
|
||||
├── docker/
|
||||
│ ├── web/ # Next.js production Dockerfile
|
||||
│ └── caddy/ # Custom Caddy build (xcaddy + modules)
|
||||
@@ -181,18 +200,56 @@ caddy-proxy-manager/
|
||||
|
||||
We take security seriously. Here's what's built-in:
|
||||
|
||||
- **Session Secret Enforcement** – Production requires strong, unique session secrets
|
||||
- **Credential Validation** – Default credentials rejected; minimum complexity enforced
|
||||
### Authentication & Authorization
|
||||
- **Strict Credential Enforcement** – Application refuses to start in production with weak/default credentials
|
||||
- **Password Complexity** – Enforced minimum 12 chars with uppercase, lowercase, numbers, and special characters
|
||||
- **Session Secret Validation** – 32+ character secrets required with automatic detection of insecure placeholders
|
||||
- **Login Throttling** – IP + username based rate limiting (5 attempts / 5 minutes)
|
||||
- **Admin-Only Mutations** – All configuration changes require admin privileges
|
||||
- **Fail-Fast Validation** – Security checks run at server startup, not at first request
|
||||
|
||||
### Data Protection
|
||||
- **Certificate Protection** – Imported certificates stored with `0600` permissions
|
||||
- **Audit Trail** – Immutable log of all administrative actions
|
||||
- **HSTS Headers** – Strict-Transport-Security automatically applied to managed hosts
|
||||
- **Session Encryption** – All sessions encrypted with validated secrets
|
||||
- **Secret Redaction** – API tokens never rendered back to the browser
|
||||
- **Audit Trail** – Immutable log of all administrative actions
|
||||
|
||||
### Infrastructure Security
|
||||
- **HSTS Headers** – Strict-Transport-Security automatically applied to managed hosts
|
||||
- **Secure Defaults** – All security features enabled by default
|
||||
- **Docker Security** – Minimal attack surface with multi-stage builds
|
||||
- **Privilege Dropping** – Containers run as non-root users
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
**For Production Deployments:**
|
||||
```bash
|
||||
# 1. Generate a secure session secret
|
||||
export SESSION_SECRET=$(openssl rand -base64 32)
|
||||
|
||||
# 2. Create strong admin credentials
|
||||
export ADMIN_USERNAME="admin" # Any username is fine
|
||||
export ADMIN_PASSWORD="Your-Str0ng-P@ssw0rd!" # 12+ chars, mixed case, numbers, special chars
|
||||
|
||||
# 3. Store credentials securely
|
||||
echo "SESSION_SECRET=$SESSION_SECRET" > .env
|
||||
echo "ADMIN_USERNAME=$ADMIN_USERNAME" >> .env
|
||||
echo "ADMIN_PASSWORD=$ADMIN_PASSWORD" >> .env
|
||||
chmod 600 .env # Restrict file permissions
|
||||
```
|
||||
|
||||
**For Development:**
|
||||
```bash
|
||||
# Development mode allows default credentials
|
||||
export NODE_ENV=development
|
||||
npm run dev
|
||||
# Login with admin/admin
|
||||
```
|
||||
|
||||
**Known Limitations:**
|
||||
- Imported certificate keys stored in SQLite without encryption (planned enhancement)
|
||||
- In-memory rate limiting (requires Redis/Memcached for multi-instance deployments)
|
||||
- No 2FA support yet (planned enhancement)
|
||||
|
||||
---
|
||||
|
||||
@@ -223,7 +280,7 @@ To enable automatic SSL certificates with Cloudflare DNS-01 challenges:
|
||||
|
||||
### Development Notes
|
||||
|
||||
- **Database:** Prisma manages schema migrations. Run `npx prisma db push` to sync changes.
|
||||
- **Database:** Drizzle migrations live in `/drizzle`. Run `npm run db:migrate` to apply them to your local SQLite file.
|
||||
- **Caddy Config:** Rebuilt on each mutation and pushed to Caddy Admin API. Errors are surfaced in the UI.
|
||||
- **Rate Limiting:** Kept in-memory per Node process. For horizontal scaling, use Redis/Memcached.
|
||||
- **Authentication:** Currently supports single admin user. Multi-role support requires architecture changes.
|
||||
@@ -234,8 +291,6 @@ To enable automatic SSL certificates with Cloudflare DNS-01 challenges:
|
||||
|
||||
We're actively working on these improvements:
|
||||
|
||||
- [ ] Encrypted storage for imported certificate private keys
|
||||
- [ ] Redis/Memcached integration for distributed rate limiting
|
||||
- [ ] Multi-user support with role-based access control
|
||||
- [ ] Additional DNS providers (Namecheap, Route53, etc.)
|
||||
- [ ] Metrics and monitoring dashboard
|
||||
@@ -287,7 +342,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
||||
- **[Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager)** – The original project
|
||||
- **[Next.js](https://nextjs.org/)** – React framework for production
|
||||
- **[Material UI](https://mui.com/)** – Beautiful React components
|
||||
- **[Prisma](https://www.prisma.io/)** – Next-generation ORM
|
||||
- **[Drizzle ORM](https://orm.drizzle.team/)** – Lightweight SQL migrations and type-safe queries
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import prisma from "@/src/lib/db";
|
||||
import db, { toIso } from "@/src/lib/db";
|
||||
import { requireUser } from "@/src/lib/auth";
|
||||
import OverviewClient from "./OverviewClient";
|
||||
import {
|
||||
accessLists,
|
||||
auditEvents,
|
||||
certificates,
|
||||
deadHosts,
|
||||
proxyHosts,
|
||||
redirectHosts
|
||||
} from "@/src/lib/db/schema";
|
||||
import { count, desc } from "drizzle-orm";
|
||||
|
||||
type StatCard = {
|
||||
label: string;
|
||||
@@ -10,14 +19,19 @@ type StatCard = {
|
||||
};
|
||||
|
||||
async function loadStats(): Promise<StatCard[]> {
|
||||
const [proxyHostsCount, redirectHostsCount, deadHostsCount, certificatesCount, accessListsCount] =
|
||||
const [proxyHostCountResult, redirectHostCountResult, deadHostCountResult, certificateCountResult, accessListCountResult] =
|
||||
await Promise.all([
|
||||
prisma.proxyHost.count(),
|
||||
prisma.redirectHost.count(),
|
||||
prisma.deadHost.count(),
|
||||
prisma.certificate.count(),
|
||||
prisma.accessList.count()
|
||||
db.select({ value: count() }).from(proxyHosts),
|
||||
db.select({ value: count() }).from(redirectHosts),
|
||||
db.select({ value: count() }).from(deadHosts),
|
||||
db.select({ value: count() }).from(certificates),
|
||||
db.select({ value: count() }).from(accessLists)
|
||||
]);
|
||||
const proxyHostsCount = proxyHostCountResult[0]?.value ?? 0;
|
||||
const redirectHostsCount = redirectHostCountResult[0]?.value ?? 0;
|
||||
const deadHostsCount = deadHostCountResult[0]?.value ?? 0;
|
||||
const certificatesCount = certificateCountResult[0]?.value ?? 0;
|
||||
const accessListsCount = accessListCountResult[0]?.value ?? 0;
|
||||
|
||||
return [
|
||||
{ label: "Proxy Hosts", icon: "⇄", count: proxyHostsCount, href: "/proxy-hosts" },
|
||||
@@ -31,24 +45,24 @@ async function loadStats(): Promise<StatCard[]> {
|
||||
export default async function OverviewPage() {
|
||||
const session = await requireUser();
|
||||
const stats = await loadStats();
|
||||
const recentEvents = await prisma.auditEvent.findMany({
|
||||
select: {
|
||||
action: true,
|
||||
entityType: true,
|
||||
summary: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8
|
||||
});
|
||||
const recentEvents = await db
|
||||
.select({
|
||||
action: auditEvents.action,
|
||||
entityType: auditEvents.entityType,
|
||||
summary: auditEvents.summary,
|
||||
createdAt: auditEvents.createdAt
|
||||
})
|
||||
.from(auditEvents)
|
||||
.orderBy(desc(auditEvents.createdAt))
|
||||
.limit(8);
|
||||
|
||||
return (
|
||||
<OverviewClient
|
||||
userName={session.user.name ?? session.user.email ?? "Admin"}
|
||||
stats={stats}
|
||||
recentEvents={recentEvents.map((event: { action: string; entityType: string; summary: string | null; createdAt: Date }) => ({
|
||||
recentEvents={recentEvents.map((event) => ({
|
||||
summary: event.summary ?? `${event.action} on ${event.entityType}`,
|
||||
created_at: event.createdAt.toISOString()
|
||||
created_at: toIso(event.createdAt)!
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,9 +12,10 @@ services:
|
||||
# Node environment
|
||||
NODE_ENV: production
|
||||
|
||||
# REQUIRED: Change this to a random secure string in production
|
||||
# REQUIRED: Session secret for encrypting cookies and sessions
|
||||
# Generate with: openssl rand -base64 32
|
||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
||||
# SECURITY: You MUST set this to a unique value in production!
|
||||
SESSION_SECRET: ${SESSION_SECRET:?ERROR - SESSION_SECRET is required}
|
||||
|
||||
# Caddy API endpoint (internal communication)
|
||||
CADDY_API_URL: ${CADDY_API_URL:-http://caddy:2019}
|
||||
@@ -30,9 +31,10 @@ services:
|
||||
NEXTAUTH_URL: ${BASE_URL:-http://localhost:3000}
|
||||
|
||||
# REQUIRED: Admin credentials for login
|
||||
# WARNING: Change these values! Do not use defaults in production!
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||
# SECURITY: You MUST set these to secure values in production!
|
||||
# Password must be 12+ chars with uppercase, lowercase, numbers, and special chars
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:?ERROR - ADMIN_USERNAME is required}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ERROR - ADMIN_PASSWORD is required}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
|
||||
@@ -13,14 +13,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json* ./
|
||||
# Copy prisma schema before npm ci so postinstall can generate
|
||||
COPY prisma ./prisma
|
||||
# Set temporary DATABASE_URL for Prisma CLI
|
||||
ENV DATABASE_URL=file:/tmp/dev.db
|
||||
# Install dependencies (postinstall will run prisma generate)
|
||||
# Install dependencies
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
# Explicitly verify Prisma client is generated
|
||||
RUN npx prisma generate
|
||||
|
||||
FROM base AS builder
|
||||
ENV NODE_ENV=production
|
||||
@@ -28,16 +22,8 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Set a temporary database path for build
|
||||
ENV DATABASE_PATH=/tmp/build.db
|
||||
ENV DATABASE_URL=file:/tmp/build.db
|
||||
# Install openssl for Prisma query engine
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Generate Prisma client (ensures it's available in this stage)
|
||||
RUN npx prisma generate
|
||||
# Push schema to temporary database for build-time data access
|
||||
RUN npx prisma db push
|
||||
# Build the Next.js application
|
||||
RUN npm run build && rm -f /tmp/build.db
|
||||
|
||||
@@ -46,10 +32,9 @@ ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
WORKDIR /app
|
||||
|
||||
# Install gosu for privilege dropping and openssl for Prisma
|
||||
# Install gosu for privilege dropping
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -g 1001 nodejs && useradd -r -u 1001 -g nodejs nextjs
|
||||
@@ -63,11 +48,8 @@ COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/.next/server/instrumentation.js ./.next/server/instrumentation.js
|
||||
COPY --from=builder /app/.next/server/instrumentation ./.next/server/instrumentation
|
||||
COPY --from=builder /app/.next/server/chunks/ ./.next/server/chunks/
|
||||
|
||||
# Copy Prisma client
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
# Copy Drizzle migrations for runtime schema management
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
|
||||
# Create data directory for SQLite database
|
||||
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
|
||||
|
||||
46
docker/web/entrypoint.sh
Executable file → Normal file
46
docker/web/entrypoint.sh
Executable file → Normal file
@@ -1,50 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# This script runs as root first to fix permissions, then switches to nextjs user
|
||||
|
||||
DB_PATH="${DATABASE_PATH:-/app/data/caddy-proxy-manager.db}"
|
||||
DB_DIR=$(dirname "$DB_PATH")
|
||||
|
||||
echo "Setting up database directory permissions..."
|
||||
|
||||
# Ensure the data directory is owned by nextjs user
|
||||
echo "Ensuring database directory exists..."
|
||||
mkdir -p "$DB_DIR"
|
||||
chown -R nextjs:nodejs "$DB_DIR"
|
||||
|
||||
# Ensure node_modules is owned by nextjs user for Prisma client generation
|
||||
chown -R nextjs:nodejs /app/node_modules
|
||||
|
||||
# Remove old Prisma client to avoid permission issues during regeneration
|
||||
echo "Cleaning old Prisma client..."
|
||||
rm -rf /app/node_modules/.prisma/client
|
||||
|
||||
# Switch to nextjs user and initialize database if needed
|
||||
gosu nextjs sh -c '
|
||||
DB_PATH="'"$DB_PATH"'"
|
||||
|
||||
# Set npm cache to writable directory
|
||||
export NPM_CONFIG_CACHE=/tmp/.npm
|
||||
|
||||
# Generate real Prisma client at runtime (replaces build-time stub)
|
||||
echo "Generating Prisma client..."
|
||||
npx prisma generate || {
|
||||
echo "Warning: Prisma generate failed, attempting with checksum ignore..."
|
||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 npx prisma generate || {
|
||||
echo "Error: Failed to generate Prisma client"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "Database not found, initializing..."
|
||||
npx prisma db push --skip-generate
|
||||
echo "Database initialized successfully"
|
||||
else
|
||||
echo "Database exists, applying any schema changes..."
|
||||
npx prisma db push --skip-generate --accept-data-loss 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Starting application..."
|
||||
export HOSTNAME="0.0.0.0"
|
||||
exec node server.js
|
||||
'
|
||||
echo "Starting application..."
|
||||
exec gosu nextjs env HOSTNAME=0.0.0.0 node server.js
|
||||
|
||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./src/lib/db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "file:./data/caddy-proxy-manager.db"
|
||||
}
|
||||
});
|
||||
152
drizzle/0000_initial.sql
Normal file
152
drizzle/0000_initial.sql
Normal file
@@ -0,0 +1,152 @@
|
||||
CREATE TABLE `access_list_entries` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`access_list_id` integer NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`access_list_id`) REFERENCES `access_lists`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `access_list_entries_list_idx` ON `access_list_entries` (`access_list_id`);--> statement-breakpoint
|
||||
CREATE TABLE `access_lists` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`created_by` integer,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `api_tokens` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`token_hash` text NOT NULL,
|
||||
`created_by` integer NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`last_used_at` text,
|
||||
`expires_at` text,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `api_tokens_token_hash_unique` ON `api_tokens` (`token_hash`);--> statement-breakpoint
|
||||
CREATE TABLE `audit_events` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer,
|
||||
`action` text NOT NULL,
|
||||
`entity_type` text NOT NULL,
|
||||
`entity_id` integer,
|
||||
`summary` text,
|
||||
`data` text,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `certificates` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`domain_names` text NOT NULL,
|
||||
`auto_renew` integer DEFAULT true NOT NULL,
|
||||
`provider_options` text,
|
||||
`certificate_pem` text,
|
||||
`private_key_pem` text,
|
||||
`created_by` integer,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `dead_hosts` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`domains` text NOT NULL,
|
||||
`status_code` integer DEFAULT 503 NOT NULL,
|
||||
`response_body` text,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_by` integer,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `oauth_states` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`state` text NOT NULL,
|
||||
`code_verifier` text NOT NULL,
|
||||
`redirect_to` text,
|
||||
`created_at` text NOT NULL,
|
||||
`expires_at` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_state_unique` ON `oauth_states` (`state`);--> statement-breakpoint
|
||||
CREATE TABLE `proxy_hosts` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`domains` text NOT NULL,
|
||||
`upstreams` text NOT NULL,
|
||||
`certificate_id` integer,
|
||||
`access_list_id` integer,
|
||||
`owner_user_id` integer,
|
||||
`ssl_forced` integer DEFAULT true NOT NULL,
|
||||
`hsts_enabled` integer DEFAULT true NOT NULL,
|
||||
`hsts_subdomains` integer DEFAULT false NOT NULL,
|
||||
`allow_websocket` integer DEFAULT true NOT NULL,
|
||||
`preserve_host_header` integer DEFAULT true NOT NULL,
|
||||
`meta` text,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
`skip_https_hostname_validation` integer DEFAULT false NOT NULL,
|
||||
FOREIGN KEY (`certificate_id`) REFERENCES `certificates`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`access_list_id`) REFERENCES `access_lists`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `redirect_hosts` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`domains` text NOT NULL,
|
||||
`destination` text NOT NULL,
|
||||
`status_code` integer DEFAULT 302 NOT NULL,
|
||||
`preserve_query` integer DEFAULT true NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_by` integer,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`expires_at` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `settings` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`name` text,
|
||||
`password_hash` text,
|
||||
`role` text DEFAULT 'user' NOT NULL,
|
||||
`provider` text NOT NULL,
|
||||
`subject` text NOT NULL,
|
||||
`avatar_url` text,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
|
||||
CREATE INDEX `users_provider_subject_idx` ON `users` (`provider`,`subject`);
|
||||
1046
drizzle/meta/0001_snapshot.json
Normal file
1046
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1762515724134,
|
||||
"tag": "0000_initial",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
1510
package-lock.json
generated
1510
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,24 +9,25 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"postinstall": "prisma generate"
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.4",
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"next": "^16.0.1",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"prisma": "^6.18.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.6",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
name String?
|
||||
passwordHash String? @map("password_hash")
|
||||
role String @default("user")
|
||||
provider String
|
||||
subject String
|
||||
avatarUrl String? @map("avatar_url")
|
||||
status String @default("active")
|
||||
createdAt DateTime @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
|
||||
sessions Session[]
|
||||
accessLists AccessList[]
|
||||
certificates Certificate[]
|
||||
proxyHosts ProxyHost[]
|
||||
redirectHosts RedirectHost[]
|
||||
deadHosts DeadHost[]
|
||||
apiTokens ApiToken[]
|
||||
auditEvents AuditEvent[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @map("user_id")
|
||||
token String @unique
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([token])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model OAuthState {
|
||||
id Int @id @default(autoincrement())
|
||||
state String @unique
|
||||
codeVerifier String @map("code_verifier")
|
||||
redirectTo String? @map("redirect_to")
|
||||
createdAt DateTime @map("created_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
@@map("oauth_states")
|
||||
}
|
||||
|
||||
model Setting {
|
||||
key String @id
|
||||
value String
|
||||
updatedAt DateTime @map("updated_at")
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
model AccessList {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String?
|
||||
createdBy Int? @map("created_by")
|
||||
createdAt DateTime @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
|
||||
user User? @relation(fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
entries AccessListEntry[]
|
||||
proxyHosts ProxyHost[]
|
||||
|
||||
@@map("access_lists")
|
||||
}
|
||||
|
||||
model AccessListEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
accessListId Int @map("access_list_id")
|
||||
username String
|
||||
passwordHash String @map("password_hash")
|
||||
createdAt DateTime @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
|
||||
accessList AccessList @relation(fields: [accessListId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([accessListId])
|
||||
@@map("access_list_entries")
|
||||
}
|
||||
|
||||
model Certificate {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
type String
|
||||
domainNames String @map("domain_names")
|
||||
autoRenew Boolean @default(true) @map("auto_renew")
|
||||
providerOptions String? @map("provider_options")
|
||||
certificatePem String? @map("certificate_pem")
|
||||
privateKeyPem String? @map("private_key_pem")
|
||||
createdBy Int? @map("created_by")
|
||||
createdAt DateTime @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
|
||||
user User? @relation(fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
proxyHosts ProxyHost[]
|
||||
|
||||
@@map("certificates")
|
||||
}
|
||||
|
||||
model ProxyHost {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
domains String
|
||||
upstreams String
|
||||
certificateId Int? @map("certificate_id")
|
||||
accessListId Int? @map("access_list_id")
|
||||
ownerUserId Int? @map("owner_user_id")
|
||||
sslForced Boolean @default(true) @map("ssl_forced")
|
||||
hstsEnabled Boolean @default(true) @map("hsts_enabled")
|
||||
hstsSubdomains Boolean @default(false) @map("hsts_subdomains")
|
||||
allowWebsocket Boolean @default(true) @map("allow_websocket")
|
||||
preserveHostHeader Boolean @default(true) @map("preserve_host_header")
|
||||
meta String?
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
skipHttpsHostnameValidation Boolean @default(false) @map("skip_https_hostname_validation")
|
||||
|
||||
certificate Certificate? @relation(fields: [certificateId], references: [id], onDelete: SetNull)
|
||||
accessList AccessList? @relation(fields: [accessListId], references: [id], onDelete: SetNull)
|
||||
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("proxy_hosts")
|
||||
}
|
||||
|
||||
model RedirectHost {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
domains String
|
||||
destination String
|
||||
statusCode Int @default(302) @map("status_code")
|
||||
preserveQuery Boolean @default(true) @map("preserve_query")
|
||||
enabled Boolean @default(true)
|
||||
createdBy Int? @map("created_by")
|
||||
createdAt DateTime @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
|
||||
user User? @relation(fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("redirect_hosts")
|
||||
}
|
||||
|
||||
model DeadHost {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
domains String
|
||||
statusCode Int @default(503) @map("status_code")
|
||||
responseBody String? @map("response_body")
|
||||
enabled Boolean @default(true)
|
||||
createdBy Int? @map("created_by")
|
||||
createdAt DateTime @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
|
||||
user User? @relation(fields: [createdBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("dead_hosts")
|
||||
}
|
||||
|
||||
model ApiToken {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
tokenHash String @unique @map("token_hash")
|
||||
createdBy Int @map("created_by")
|
||||
createdAt DateTime @map("created_at")
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
|
||||
user User @relation(fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tokenHash])
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
model AuditEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int? @map("user_id")
|
||||
action String
|
||||
entityType String @map("entity_type")
|
||||
entityId Int? @map("entity_id")
|
||||
summary String?
|
||||
data String?
|
||||
createdAt DateTime @map("created_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("audit_events")
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import prisma, { nowIso } from "./db";
|
||||
import db, { nowIso } from "./db";
|
||||
import { auditEvents } from "./db/schema";
|
||||
|
||||
export function logAuditEvent(params: {
|
||||
userId?: number | null;
|
||||
@@ -8,18 +9,18 @@ export function logAuditEvent(params: {
|
||||
summary?: string | null;
|
||||
data?: unknown;
|
||||
}) {
|
||||
prisma.auditEvent.create({
|
||||
data: {
|
||||
try {
|
||||
db.insert(auditEvents).values({
|
||||
userId: params.userId ?? null,
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId ?? null,
|
||||
summary: params.summary ?? null,
|
||||
data: params.data ? JSON.stringify(params.data) : null,
|
||||
createdAt: new Date(nowIso())
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
createdAt: nowIso()
|
||||
}).run();
|
||||
} catch (error) {
|
||||
// Log error but don't throw to avoid breaking the main flow
|
||||
console.error("Failed to log audit event:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma from "./db";
|
||||
import db from "./db";
|
||||
import { config } from "./config";
|
||||
import { users } from "./db/schema";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
@@ -36,8 +37,8 @@ function createCredentialsProvider() {
|
||||
|
||||
// Look up user in database by email (constructed from username)
|
||||
const email = `${username}@localhost`;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (table, operators) => operators.eq(table.email, email)
|
||||
});
|
||||
|
||||
if (!user || user.status !== "active" || !user.passwordHash) {
|
||||
|
||||
142
src/lib/caddy.ts
142
src/lib/caddy.ts
@@ -1,9 +1,16 @@
|
||||
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import prisma, { nowIso } from "./db";
|
||||
import db, { nowIso } from "./db";
|
||||
import { config } from "./config";
|
||||
import { getCloudflareSettings, getGeneralSettings, setSetting } from "./settings";
|
||||
import {
|
||||
accessListEntries,
|
||||
certificates,
|
||||
deadHosts,
|
||||
proxyHosts,
|
||||
redirectHosts
|
||||
} from "./db/schema";
|
||||
|
||||
const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs");
|
||||
mkdirSync(CERTS_DIR, { recursive: true, mode: 0o700 });
|
||||
@@ -721,69 +728,68 @@ async function buildTlsAutomation(
|
||||
}
|
||||
|
||||
async function buildCaddyDocument() {
|
||||
const [proxyHosts, redirectHosts, deadHosts, certRows, accessListEntries] = await Promise.all([
|
||||
prisma.proxyHost.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
domains: true,
|
||||
upstreams: true,
|
||||
certificateId: true,
|
||||
accessListId: true,
|
||||
sslForced: true,
|
||||
hstsEnabled: true,
|
||||
hstsSubdomains: true,
|
||||
allowWebsocket: true,
|
||||
preserveHostHeader: true,
|
||||
skipHttpsHostnameValidation: true,
|
||||
meta: true,
|
||||
enabled: true
|
||||
}
|
||||
}),
|
||||
prisma.redirectHost.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
domains: true,
|
||||
destination: true,
|
||||
statusCode: true,
|
||||
preserveQuery: true,
|
||||
enabled: true
|
||||
}
|
||||
}),
|
||||
prisma.deadHost.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
domains: true,
|
||||
statusCode: true,
|
||||
responseBody: true,
|
||||
enabled: true
|
||||
}
|
||||
}),
|
||||
prisma.certificate.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
domainNames: true,
|
||||
certificatePem: true,
|
||||
privateKeyPem: true,
|
||||
autoRenew: true,
|
||||
providerOptions: true
|
||||
}
|
||||
}),
|
||||
prisma.accessListEntry.findMany({
|
||||
select: {
|
||||
accessListId: true,
|
||||
username: true,
|
||||
passwordHash: true
|
||||
}
|
||||
})
|
||||
const [proxyHostRecords, redirectHostRecords, deadHostRecords, certRows, accessListEntryRecords] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: proxyHosts.id,
|
||||
name: proxyHosts.name,
|
||||
domains: proxyHosts.domains,
|
||||
upstreams: proxyHosts.upstreams,
|
||||
certificateId: proxyHosts.certificateId,
|
||||
accessListId: proxyHosts.accessListId,
|
||||
sslForced: proxyHosts.sslForced,
|
||||
hstsEnabled: proxyHosts.hstsEnabled,
|
||||
hstsSubdomains: proxyHosts.hstsSubdomains,
|
||||
allowWebsocket: proxyHosts.allowWebsocket,
|
||||
preserveHostHeader: proxyHosts.preserveHostHeader,
|
||||
skipHttpsHostnameValidation: proxyHosts.skipHttpsHostnameValidation,
|
||||
meta: proxyHosts.meta,
|
||||
enabled: proxyHosts.enabled
|
||||
})
|
||||
.from(proxyHosts),
|
||||
db
|
||||
.select({
|
||||
id: redirectHosts.id,
|
||||
name: redirectHosts.name,
|
||||
domains: redirectHosts.domains,
|
||||
destination: redirectHosts.destination,
|
||||
statusCode: redirectHosts.statusCode,
|
||||
preserveQuery: redirectHosts.preserveQuery,
|
||||
enabled: redirectHosts.enabled
|
||||
})
|
||||
.from(redirectHosts),
|
||||
db
|
||||
.select({
|
||||
id: deadHosts.id,
|
||||
name: deadHosts.name,
|
||||
domains: deadHosts.domains,
|
||||
statusCode: deadHosts.statusCode,
|
||||
responseBody: deadHosts.responseBody,
|
||||
enabled: deadHosts.enabled
|
||||
})
|
||||
.from(deadHosts),
|
||||
db
|
||||
.select({
|
||||
id: certificates.id,
|
||||
name: certificates.name,
|
||||
type: certificates.type,
|
||||
domainNames: certificates.domainNames,
|
||||
certificatePem: certificates.certificatePem,
|
||||
privateKeyPem: certificates.privateKeyPem,
|
||||
autoRenew: certificates.autoRenew,
|
||||
providerOptions: certificates.providerOptions
|
||||
})
|
||||
.from(certificates),
|
||||
db
|
||||
.select({
|
||||
accessListId: accessListEntries.accessListId,
|
||||
username: accessListEntries.username,
|
||||
passwordHash: accessListEntries.passwordHash
|
||||
})
|
||||
.from(accessListEntries)
|
||||
]);
|
||||
|
||||
// Map Prisma results to expected types
|
||||
const proxyHostRows: ProxyHostRow[] = proxyHosts.map((h: typeof proxyHosts[0]) => ({
|
||||
const proxyHostRows: ProxyHostRow[] = proxyHostRecords.map((h) => ({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
domains: h.domains,
|
||||
@@ -800,7 +806,7 @@ async function buildCaddyDocument() {
|
||||
enabled: h.enabled ? 1 : 0
|
||||
}));
|
||||
|
||||
const redirectHostRows: RedirectHostRow[] = redirectHosts.map((h: typeof redirectHosts[0]) => ({
|
||||
const redirectHostRows: RedirectHostRow[] = redirectHostRecords.map((h) => ({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
domains: h.domains,
|
||||
@@ -810,7 +816,7 @@ async function buildCaddyDocument() {
|
||||
enabled: h.enabled ? 1 : 0
|
||||
}));
|
||||
|
||||
const deadHostRows: DeadHostRow[] = deadHosts.map((h: typeof deadHosts[0]) => ({
|
||||
const deadHostRows: DeadHostRow[] = deadHostRecords.map((h) => ({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
domains: h.domains,
|
||||
@@ -830,10 +836,10 @@ async function buildCaddyDocument() {
|
||||
provider_options: c.providerOptions
|
||||
}));
|
||||
|
||||
const accessListEntryRows: AccessListEntryRow[] = accessListEntries.map((e: typeof accessListEntries[0]) => ({
|
||||
access_list_id: e.accessListId,
|
||||
username: e.username,
|
||||
password_hash: e.passwordHash
|
||||
const accessListEntryRows: AccessListEntryRow[] = accessListEntryRecords.map((entry) => ({
|
||||
access_list_id: entry.accessListId,
|
||||
username: entry.username,
|
||||
password_hash: entry.passwordHash
|
||||
}));
|
||||
|
||||
const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));
|
||||
|
||||
@@ -1,62 +1,127 @@
|
||||
const DEV_SECRET = "dev-secret-change-in-production-12345678901234567890123456789012";
|
||||
const DEFAULT_ADMIN_USERNAME = "admin";
|
||||
const DEFAULT_ADMIN_PASSWORD = "admin";
|
||||
const DISALLOWED_SESSION_SECRETS = new Set([DEV_SECRET, "change-me-in-production"]);
|
||||
const DISALLOWED_SESSION_SECRETS = new Set([
|
||||
"change-me-in-production",
|
||||
"dev-secret-change-in-production-12345678901234567890123456789012"
|
||||
]);
|
||||
const DEFAULT_CADDY_URL = process.env.NODE_ENV === "development" ? "http://localhost:2019" : "http://caddy:2019";
|
||||
const MIN_SESSION_SECRET_LENGTH = 32;
|
||||
const MIN_ADMIN_PASSWORD_LENGTH = 12;
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const isNodeRuntime = process.env.NEXT_RUNTIME === "nodejs";
|
||||
const allowDevFallback = !isProduction || !isNodeRuntime;
|
||||
const isRuntimeProduction = isProduction && isNodeRuntime;
|
||||
const isDevelopment = process.env.NODE_ENV === "development";
|
||||
// Only enforce strict validation in actual production runtime, not during build
|
||||
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build" || !process.env.NEXT_RUNTIME;
|
||||
const isRuntimeProduction = isProduction && isNodeRuntime && !isBuildPhase;
|
||||
|
||||
function resolveSessionSecret(): string {
|
||||
const rawSecret = process.env.SESSION_SECRET ?? null;
|
||||
const secret = rawSecret?.trim();
|
||||
|
||||
// Always return a value (build phase needs this)
|
||||
if (!secret) {
|
||||
// In development, allow missing secret
|
||||
if (isDevelopment && !secret) {
|
||||
return DEV_SECRET;
|
||||
}
|
||||
|
||||
// Only validate in actual runtime production (not during build)
|
||||
// In production build phase, allow temporary value
|
||||
if (isProduction && !isNodeRuntime && !secret) {
|
||||
return DEV_SECRET;
|
||||
}
|
||||
|
||||
// Use provided secret or dev secret
|
||||
const finalSecret = secret || DEV_SECRET;
|
||||
|
||||
// Strict validation in production runtime
|
||||
if (isRuntimeProduction) {
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"SESSION_SECRET environment variable is required in production. " +
|
||||
"Generate a secure secret with: openssl rand -base64 32"
|
||||
);
|
||||
}
|
||||
if (DISALLOWED_SESSION_SECRETS.has(secret)) {
|
||||
throw new Error("SESSION_SECRET is using a known insecure placeholder value. Provide a unique secret.");
|
||||
throw new Error(
|
||||
"SESSION_SECRET is using a known insecure placeholder value. " +
|
||||
"Generate a secure secret with: openssl rand -base64 32"
|
||||
);
|
||||
}
|
||||
if (secret.length < MIN_SESSION_SECRET_LENGTH) {
|
||||
throw new Error(`SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long in production.`);
|
||||
throw new Error(
|
||||
`SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long in production. ` +
|
||||
"Generate a secure secret with: openssl rand -base64 32"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return secret;
|
||||
return finalSecret;
|
||||
}
|
||||
|
||||
function resolveAdminCredentials() {
|
||||
const rawUsername = process.env.ADMIN_USERNAME ?? DEFAULT_ADMIN_USERNAME;
|
||||
const rawPassword = process.env.ADMIN_PASSWORD ?? DEFAULT_ADMIN_PASSWORD;
|
||||
const username = rawUsername?.trim();
|
||||
const password = rawPassword?.trim();
|
||||
const rawUsername = process.env.ADMIN_USERNAME ?? null;
|
||||
const rawPassword = process.env.ADMIN_PASSWORD ?? null;
|
||||
const username = rawUsername?.trim() || DEFAULT_ADMIN_USERNAME;
|
||||
const password = rawPassword?.trim() || DEFAULT_ADMIN_PASSWORD;
|
||||
|
||||
// Always return values (build phase needs this)
|
||||
if (!username || !password) {
|
||||
return { username: DEFAULT_ADMIN_USERNAME, password: DEFAULT_ADMIN_PASSWORD };
|
||||
// In development, allow defaults
|
||||
if (isDevelopment) {
|
||||
if (username === DEFAULT_ADMIN_USERNAME || password === DEFAULT_ADMIN_PASSWORD) {
|
||||
console.log("Using default admin credentials for development (admin/admin)");
|
||||
}
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
// Only validate in actual runtime production (not during build)
|
||||
// In production build phase, allow defaults temporarily
|
||||
if (isProduction && !isNodeRuntime) {
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
// Strict validation in production runtime
|
||||
if (isRuntimeProduction) {
|
||||
if (username === DEFAULT_ADMIN_USERNAME) {
|
||||
throw new Error("ADMIN_USERNAME must be changed from the default value when running in production.");
|
||||
const errors: string[] = [];
|
||||
|
||||
// Username validation - just ensure it's set
|
||||
if (!rawUsername || !username) {
|
||||
errors.push(
|
||||
"ADMIN_USERNAME must be set"
|
||||
);
|
||||
}
|
||||
if (password === DEFAULT_ADMIN_PASSWORD) {
|
||||
throw new Error("ADMIN_PASSWORD must be changed from the default value when running in production.");
|
||||
|
||||
// Password validation - strict requirements
|
||||
if (!rawPassword || password === DEFAULT_ADMIN_PASSWORD) {
|
||||
errors.push(
|
||||
"ADMIN_PASSWORD must be set to a custom value in production (not 'admin')"
|
||||
);
|
||||
} else {
|
||||
if (password.length < MIN_ADMIN_PASSWORD_LENGTH) {
|
||||
errors.push(
|
||||
`ADMIN_PASSWORD must be at least ${MIN_ADMIN_PASSWORD_LENGTH} characters long`
|
||||
);
|
||||
}
|
||||
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password)) {
|
||||
errors.push(
|
||||
"ADMIN_PASSWORD must include both uppercase and lowercase letters"
|
||||
);
|
||||
}
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push(
|
||||
"ADMIN_PASSWORD must include at least one number"
|
||||
);
|
||||
}
|
||||
if (!/[^A-Za-z0-9]/.test(password)) {
|
||||
errors.push(
|
||||
"ADMIN_PASSWORD must include at least one special character"
|
||||
);
|
||||
}
|
||||
}
|
||||
if (password.length < MIN_ADMIN_PASSWORD_LENGTH) {
|
||||
throw new Error(`ADMIN_PASSWORD must be at least ${MIN_ADMIN_PASSWORD_LENGTH} characters long in production.`);
|
||||
}
|
||||
if (!/[A-Za-z]/.test(password) || !/[0-9]/.test(password)) {
|
||||
throw new Error("ADMIN_PASSWORD must include both letters and numbers for adequate complexity.");
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
"Admin credentials validation failed:\n" +
|
||||
errors.map(e => ` - ${e}`).join("\n") +
|
||||
"\n\nSet secure credentials using ADMIN_USERNAME and ADMIN_PASSWORD environment variables."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
130
src/lib/db.ts
130
src/lib/db.ts
@@ -1,20 +1,128 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
||||
import * as schema from "./db/schema";
|
||||
|
||||
// Prevent multiple instances of Prisma Client in development
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
const DEFAULT_SQLITE_URL = "file:./data/caddy-proxy-manager.db";
|
||||
|
||||
type GlobalForDrizzle = typeof globalThis & {
|
||||
__DRIZZLE_DB__?: ReturnType<typeof drizzle<typeof schema>>;
|
||||
__SQLITE_CLIENT__?: Database.Database;
|
||||
__MIGRATIONS_RAN__?: boolean;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
function resolveSqlitePath(rawUrl: string): string {
|
||||
if (!rawUrl) {
|
||||
return ":memory:";
|
||||
}
|
||||
if (rawUrl === ":memory:" || rawUrl === "file::memory:") {
|
||||
return ":memory:";
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
if (rawUrl.startsWith("file:./") || rawUrl.startsWith("file:../")) {
|
||||
const relative = rawUrl.slice("file:".length);
|
||||
return resolvePath(process.cwd(), relative);
|
||||
}
|
||||
|
||||
export default prisma;
|
||||
if (rawUrl.startsWith("file:")) {
|
||||
try {
|
||||
const fileUrl = new URL(rawUrl);
|
||||
if (fileUrl.host && fileUrl.host !== "localhost") {
|
||||
throw new Error("Remote SQLite hosts are not supported.");
|
||||
}
|
||||
return decodeURIComponent(fileUrl.pathname);
|
||||
} catch {
|
||||
const remainder = rawUrl.slice("file:".length);
|
||||
if (!remainder) {
|
||||
return ":memory:";
|
||||
}
|
||||
return isAbsolute(remainder) ? remainder : resolvePath(process.cwd(), remainder);
|
||||
}
|
||||
}
|
||||
|
||||
return isAbsolute(rawUrl) ? rawUrl : resolvePath(process.cwd(), rawUrl);
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? DEFAULT_SQLITE_URL;
|
||||
const sqlitePath = resolveSqlitePath(databaseUrl);
|
||||
|
||||
function ensureDirectoryFor(pathname: string) {
|
||||
if (pathname === ":memory:") {
|
||||
return;
|
||||
}
|
||||
const dir = dirname(pathname);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const globalForDrizzle = globalThis as GlobalForDrizzle;
|
||||
|
||||
const sqlite =
|
||||
globalForDrizzle.__SQLITE_CLIENT__ ??
|
||||
(() => {
|
||||
ensureDirectoryFor(sqlitePath);
|
||||
return new Database(sqlitePath);
|
||||
})();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForDrizzle.__SQLITE_CLIENT__ = sqlite;
|
||||
}
|
||||
|
||||
export const db =
|
||||
globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema, casing: "snake_case" });
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForDrizzle.__DRIZZLE_DB__ = db;
|
||||
}
|
||||
|
||||
const migrationsFolder = resolvePath(process.cwd(), "drizzle");
|
||||
|
||||
function runMigrations() {
|
||||
if (sqlitePath === ":memory:") {
|
||||
return;
|
||||
}
|
||||
if (globalForDrizzle.__MIGRATIONS_RAN__) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
migrate(db, { migrationsFolder });
|
||||
globalForDrizzle.__MIGRATIONS_RAN__ = true;
|
||||
} catch (error: any) {
|
||||
// During build, pages may be pre-rendered in parallel, causing race conditions
|
||||
// with migrations. If tables already exist, just continue.
|
||||
if (error?.code === 'SQLITE_ERROR' && error?.message?.includes('already exists')) {
|
||||
console.log('Database tables already exist, skipping migrations');
|
||||
globalForDrizzle.__MIGRATIONS_RAN__ = true;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runMigrations();
|
||||
} catch (error) {
|
||||
console.error("Failed to run database migrations:", error);
|
||||
// In build mode, allow the build to continue even if migrations fail
|
||||
// The runtime initialization will handle migrations properly
|
||||
if (process.env.NODE_ENV !== 'production' || process.env.NEXT_PHASE === 'phase-production-build') {
|
||||
console.warn('Continuing despite migration error during build phase');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { schema };
|
||||
export default db;
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function toIso(value: string | Date | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
175
src/lib/db/schema.ts
Normal file
175
src/lib/db/schema.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { integer, text, sqliteTable, uniqueIndex, index } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const users = sqliteTable(
|
||||
"users",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
email: text("email").notNull(),
|
||||
name: text("name"),
|
||||
passwordHash: text("password_hash"),
|
||||
role: text("role").notNull().default("user"),
|
||||
provider: text("provider").notNull(),
|
||||
subject: text("subject").notNull(),
|
||||
avatarUrl: text("avatar_url"),
|
||||
status: text("status").notNull().default("active"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
emailUnique: uniqueIndex("users_email_unique").on(table.email),
|
||||
providerSubjectIdx: index("users_provider_subject_idx").on(table.provider, table.subject)
|
||||
})
|
||||
);
|
||||
|
||||
export const sessions = sqliteTable(
|
||||
"sessions",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
token: text("token").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
tokenUnique: uniqueIndex("sessions_token_unique").on(table.token)
|
||||
})
|
||||
);
|
||||
|
||||
export const oauthStates = sqliteTable(
|
||||
"oauth_states",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
state: text("state").notNull(),
|
||||
codeVerifier: text("code_verifier").notNull(),
|
||||
redirectTo: text("redirect_to"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
stateUnique: uniqueIndex("oauth_state_unique").on(table.state)
|
||||
})
|
||||
);
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const accessLists = sqliteTable("access_lists", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const accessListEntries = sqliteTable(
|
||||
"access_list_entries",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
accessListId: integer("access_list_id")
|
||||
.references(() => accessLists.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
accessListIdIdx: index("access_list_entries_list_idx").on(table.accessListId)
|
||||
})
|
||||
);
|
||||
|
||||
export const certificates = sqliteTable("certificates", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
domainNames: text("domain_names").notNull(),
|
||||
autoRenew: integer("auto_renew", { mode: "boolean" }).notNull().default(true),
|
||||
providerOptions: text("provider_options"),
|
||||
certificatePem: text("certificate_pem"),
|
||||
privateKeyPem: text("private_key_pem"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const proxyHosts = sqliteTable("proxy_hosts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
domains: text("domains").notNull(),
|
||||
upstreams: text("upstreams").notNull(),
|
||||
certificateId: integer("certificate_id").references(() => certificates.id, { onDelete: "set null" }),
|
||||
accessListId: integer("access_list_id").references(() => accessLists.id, { onDelete: "set null" }),
|
||||
ownerUserId: integer("owner_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
sslForced: integer("ssl_forced", { mode: "boolean" }).notNull().default(true),
|
||||
hstsEnabled: integer("hsts_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
hstsSubdomains: integer("hsts_subdomains", { mode: "boolean" }).notNull().default(false),
|
||||
allowWebsocket: integer("allow_websocket", { mode: "boolean" }).notNull().default(true),
|
||||
preserveHostHeader: integer("preserve_host_header", { mode: "boolean" }).notNull().default(true),
|
||||
meta: text("meta"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
skipHttpsHostnameValidation: integer("skip_https_hostname_validation", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
|
||||
export const redirectHosts = sqliteTable("redirect_hosts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
domains: text("domains").notNull(),
|
||||
destination: text("destination").notNull(),
|
||||
statusCode: integer("status_code").notNull().default(302),
|
||||
preserveQuery: integer("preserve_query", { mode: "boolean" }).notNull().default(true),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const deadHosts = sqliteTable("dead_hosts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
domains: text("domains").notNull(),
|
||||
statusCode: integer("status_code").notNull().default(503),
|
||||
responseBody: text("response_body"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const apiTokens = sqliteTable(
|
||||
"api_tokens",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
createdBy: integer("created_by")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
expiresAt: text("expires_at")
|
||||
},
|
||||
(table) => ({
|
||||
tokenHashUnique: uniqueIndex("api_tokens_token_hash_unique").on(table.tokenHash)
|
||||
})
|
||||
);
|
||||
|
||||
export const auditEvents = sqliteTable("audit_events", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: integer("entity_id"),
|
||||
summary: text("summary"),
|
||||
data: text("data"),
|
||||
createdAt: text("created_at").notNull()
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma, { nowIso } from "./db";
|
||||
import db, { nowIso } from "./db";
|
||||
import { config } from "./config";
|
||||
import { users } from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Ensures the admin user from environment variables exists in the database.
|
||||
@@ -17,43 +19,41 @@ export async function ensureAdminUser(): Promise<void> {
|
||||
const passwordHash = bcrypt.hashSync(config.adminPassword, 12);
|
||||
|
||||
// Check if admin user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { id: adminId }
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, adminId)
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// Admin user exists, update credentials if needed
|
||||
// Always update password hash to handle password changes in env vars
|
||||
const now = new Date(nowIso());
|
||||
await prisma.user.update({
|
||||
where: { id: adminId },
|
||||
data: {
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
email: adminEmail,
|
||||
subject,
|
||||
passwordHash,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.where(eq(users.id, adminId));
|
||||
console.log(`Updated admin user: ${config.adminUsername}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create admin user with hashed password
|
||||
const now = new Date(nowIso());
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: adminId,
|
||||
email: adminEmail,
|
||||
name: config.adminUsername,
|
||||
passwordHash, // Store hashed password instead of plaintext
|
||||
role: "admin",
|
||||
provider,
|
||||
subject,
|
||||
avatarUrl: null,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
const now = nowIso();
|
||||
await db.insert(users).values({
|
||||
id: adminId,
|
||||
email: adminEmail,
|
||||
name: config.adminUsername,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
provider,
|
||||
subject,
|
||||
avatarUrl: null,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
console.log(`Created admin user: ${config.adminUsername}`);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma, { nowIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { accessListEntries, accessLists } from "../db/schema";
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
export type AccessListEntry = {
|
||||
id: number;
|
||||
@@ -25,94 +27,101 @@ export type AccessListInput = {
|
||||
users?: { username: string; password: string }[];
|
||||
};
|
||||
|
||||
function toAccessList(
|
||||
row: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
entries: {
|
||||
id: number;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}[]
|
||||
): AccessList {
|
||||
type AccessListRow = typeof accessLists.$inferSelect;
|
||||
type AccessListEntryRow = typeof accessListEntries.$inferSelect;
|
||||
|
||||
function buildEntry(row: AccessListEntryRow): AccessListEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
function toAccessList(row: AccessListRow, entries: AccessListEntryRow[]): AccessList {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
entries: entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
username: entry.username,
|
||||
created_at: entry.createdAt.toISOString(),
|
||||
updated_at: entry.updatedAt.toISOString()
|
||||
})),
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString()
|
||||
entries: entries
|
||||
.slice()
|
||||
.sort((a, b) => a.username.localeCompare(b.username))
|
||||
.map(buildEntry),
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAccessLists(): Promise<AccessList[]> {
|
||||
const lists = await prisma.accessList.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
entries: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
},
|
||||
orderBy: { username: "asc" }
|
||||
}
|
||||
}
|
||||
const lists = await db.query.accessLists.findMany({
|
||||
orderBy: (table) => asc(table.name)
|
||||
});
|
||||
return lists.map((list: typeof lists[0]) => toAccessList(list, list.entries));
|
||||
|
||||
if (lists.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const listIds = lists.map((list) => list.id);
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(accessListEntries)
|
||||
.where(inArray(accessListEntries.accessListId, listIds));
|
||||
|
||||
const entriesByList = new Map<number, AccessListEntryRow[]>();
|
||||
for (const entry of entries) {
|
||||
const bucket = entriesByList.get(entry.accessListId) ?? [];
|
||||
bucket.push(entry);
|
||||
entriesByList.set(entry.accessListId, bucket);
|
||||
}
|
||||
|
||||
return lists.map((list) => toAccessList(list, entriesByList.get(list.id) ?? []));
|
||||
}
|
||||
|
||||
export async function getAccessList(id: number): Promise<AccessList | null> {
|
||||
const list = await prisma.accessList.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
entries: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
},
|
||||
orderBy: { username: "asc" }
|
||||
}
|
||||
}
|
||||
const list = await db.query.accessLists.findFirst({
|
||||
where: (table, operators) => operators.eq(table.id, id)
|
||||
});
|
||||
return list ? toAccessList(list, list.entries) : null;
|
||||
if (!list) {
|
||||
return null;
|
||||
}
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(accessListEntries)
|
||||
.where(eq(accessListEntries.accessListId, id))
|
||||
.orderBy(asc(accessListEntries.username));
|
||||
return toAccessList(list, entries);
|
||||
}
|
||||
|
||||
export async function createAccessList(input: AccessListInput, actorUserId: number) {
|
||||
const now = new Date(nowIso());
|
||||
const now = nowIso();
|
||||
|
||||
const accessList = await prisma.accessList.create({
|
||||
data: {
|
||||
const [accessList] = await db
|
||||
.insert(accessLists)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
description: input.description ?? null,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
entries: input.users
|
||||
? {
|
||||
create: input.users.map((account) => ({
|
||||
username: account.username,
|
||||
passwordHash: bcrypt.hashSync(account.password, 10),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}))
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
});
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!accessList) {
|
||||
throw new Error("Failed to create access list");
|
||||
}
|
||||
|
||||
if (input.users && input.users.length > 0) {
|
||||
await db.insert(accessListEntries).values(
|
||||
input.users.map((account) => ({
|
||||
accessListId: accessList.id,
|
||||
username: account.username,
|
||||
passwordHash: bcrypt.hashSync(account.password, 10),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
@@ -136,15 +145,15 @@ export async function updateAccessList(
|
||||
throw new Error("Access list not found");
|
||||
}
|
||||
|
||||
const now = new Date(nowIso());
|
||||
await prisma.accessList.update({
|
||||
where: { id },
|
||||
data: {
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(accessLists)
|
||||
.set({
|
||||
name: input.name ?? existing.name,
|
||||
description: input.description ?? existing.description,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.where(eq(accessLists.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
@@ -163,23 +172,23 @@ export async function addAccessListEntry(
|
||||
entry: { username: string; password: string },
|
||||
actorUserId: number
|
||||
) {
|
||||
const list = await prisma.accessList.findUnique({
|
||||
where: { id: accessListId }
|
||||
const list = await db.query.accessLists.findFirst({
|
||||
where: (table, operators) => operators.eq(table.id, accessListId)
|
||||
});
|
||||
if (!list) {
|
||||
throw new Error("Access list not found");
|
||||
}
|
||||
const now = new Date(nowIso());
|
||||
|
||||
const now = nowIso();
|
||||
const hash = bcrypt.hashSync(entry.password, 10);
|
||||
await prisma.accessListEntry.create({
|
||||
data: {
|
||||
accessListId,
|
||||
username: entry.username,
|
||||
passwordHash: hash,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
await db.insert(accessListEntries).values({
|
||||
accessListId,
|
||||
username: entry.username,
|
||||
passwordHash: hash,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
@@ -192,15 +201,15 @@ export async function addAccessListEntry(
|
||||
}
|
||||
|
||||
export async function removeAccessListEntry(accessListId: number, entryId: number, actorUserId: number) {
|
||||
const list = await prisma.accessList.findUnique({
|
||||
where: { id: accessListId }
|
||||
const list = await db.query.accessLists.findFirst({
|
||||
where: (table, operators) => operators.eq(table.id, accessListId)
|
||||
});
|
||||
if (!list) {
|
||||
throw new Error("Access list not found");
|
||||
}
|
||||
await prisma.accessListEntry.delete({
|
||||
where: { id: entryId }
|
||||
});
|
||||
|
||||
await db.delete(accessListEntries).where(eq(accessListEntries.id, entryId));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
@@ -213,15 +222,15 @@ export async function removeAccessListEntry(accessListId: number, entryId: numbe
|
||||
}
|
||||
|
||||
export async function deleteAccessList(id: number, actorUserId: number) {
|
||||
const existing = await prisma.accessList.findUnique({
|
||||
where: { id }
|
||||
const existing = await db.query.accessLists.findFirst({
|
||||
where: (table, operators) => operators.eq(table.id, id)
|
||||
});
|
||||
if (!existing) {
|
||||
throw new Error("Access list not found");
|
||||
}
|
||||
await prisma.accessList.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
await db.delete(accessLists).where(eq(accessLists.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import prisma from "../db";
|
||||
import db, { toIso } from "../db";
|
||||
import { auditEvents } from "../db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
|
||||
export type AuditEvent = {
|
||||
id: number;
|
||||
@@ -11,18 +13,19 @@ export type AuditEvent = {
|
||||
};
|
||||
|
||||
export async function listAuditEvents(limit = 100): Promise<AuditEvent[]> {
|
||||
const events = await prisma.auditEvent.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit
|
||||
});
|
||||
const events = await db
|
||||
.select()
|
||||
.from(auditEvents)
|
||||
.orderBy(desc(auditEvents.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return events.map((event: typeof events[0]) => ({
|
||||
return events.map((event) => ({
|
||||
id: event.id,
|
||||
user_id: event.userId,
|
||||
action: event.action,
|
||||
entity_type: event.entityType,
|
||||
entity_id: event.entityId,
|
||||
summary: event.summary,
|
||||
created_at: event.createdAt.toISOString()
|
||||
created_at: toIso(event.createdAt)!
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma, { nowIso } from "../db";
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { certificates } from "../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
export type CertificateType = "managed" | "imported";
|
||||
|
||||
@@ -27,18 +29,9 @@ export type CertificateInput = {
|
||||
private_key_pem?: string | null;
|
||||
};
|
||||
|
||||
function parseCertificate(row: {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
domainNames: string;
|
||||
autoRenew: boolean;
|
||||
providerOptions: string | null;
|
||||
certificatePem: string | null;
|
||||
privateKeyPem: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Certificate {
|
||||
type CertificateRow = typeof certificates.$inferSelect;
|
||||
|
||||
function parseCertificate(row: CertificateRow): Certificate {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -48,21 +41,19 @@ function parseCertificate(row: {
|
||||
provider_options: row.providerOptions ? JSON.parse(row.providerOptions) : null,
|
||||
certificate_pem: row.certificatePem,
|
||||
private_key_pem: row.privateKeyPem,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString()
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCertificates(): Promise<Certificate[]> {
|
||||
const certificates = await prisma.certificate.findMany({
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
return certificates.map(parseCertificate);
|
||||
const rows = await db.select().from(certificates).orderBy(desc(certificates.createdAt));
|
||||
return rows.map(parseCertificate);
|
||||
}
|
||||
|
||||
export async function getCertificate(id: number): Promise<Certificate | null> {
|
||||
const cert = await prisma.certificate.findUnique({
|
||||
where: { id }
|
||||
const cert = await db.query.certificates.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, id)
|
||||
});
|
||||
return cert ? parseCertificate(cert) : null;
|
||||
}
|
||||
@@ -80,9 +71,10 @@ function validateCertificateInput(input: CertificateInput) {
|
||||
|
||||
export async function createCertificate(input: CertificateInput, actorUserId: number) {
|
||||
validateCertificateInput(input);
|
||||
const now = new Date(nowIso());
|
||||
const record = await prisma.certificate.create({
|
||||
data: {
|
||||
const now = nowIso();
|
||||
const [record] = await db
|
||||
.insert(certificates)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
domainNames: JSON.stringify(
|
||||
@@ -95,8 +87,13 @@ export async function createCertificate(input: CertificateInput, actorUserId: nu
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: actorUserId
|
||||
}
|
||||
});
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) {
|
||||
throw new Error("Failed to create certificate");
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
@@ -126,10 +123,10 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
|
||||
|
||||
validateCertificateInput(merged);
|
||||
|
||||
const now = new Date(nowIso());
|
||||
await prisma.certificate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
name: merged.name.trim(),
|
||||
type: merged.type,
|
||||
domainNames: JSON.stringify(Array.from(new Set(merged.domain_names))),
|
||||
@@ -138,8 +135,8 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
|
||||
certificatePem: merged.certificate_pem ?? null,
|
||||
privateKeyPem: merged.private_key_pem ?? null,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.where(eq(certificates.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
@@ -158,9 +155,7 @@ export async function deleteCertificate(id: number, actorUserId: number) {
|
||||
throw new Error("Certificate not found");
|
||||
}
|
||||
|
||||
await prisma.certificate.delete({
|
||||
where: { id }
|
||||
});
|
||||
await db.delete(certificates).where(eq(certificates.id, id));
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma, { nowIso } from "../db";
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { deadHosts } from "../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
export type DeadHost = {
|
||||
id: number;
|
||||
@@ -21,16 +23,9 @@ export type DeadHostInput = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function parse(row: {
|
||||
id: number;
|
||||
name: string;
|
||||
domains: string;
|
||||
statusCode: number;
|
||||
responseBody: string | null;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): DeadHost {
|
||||
type DeadHostRow = typeof deadHosts.$inferSelect;
|
||||
|
||||
function parse(row: DeadHostRow): DeadHost {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -38,21 +33,19 @@ function parse(row: {
|
||||
status_code: row.statusCode,
|
||||
response_body: row.responseBody,
|
||||
enabled: row.enabled,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString()
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDeadHosts(): Promise<DeadHost[]> {
|
||||
const hosts = await prisma.deadHost.findMany({
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
const hosts = await db.select().from(deadHosts).orderBy(desc(deadHosts.createdAt));
|
||||
return hosts.map(parse);
|
||||
}
|
||||
|
||||
export async function getDeadHost(id: number): Promise<DeadHost | null> {
|
||||
const host = await prisma.deadHost.findUnique({
|
||||
where: { id }
|
||||
const host = await db.query.deadHosts.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, id)
|
||||
});
|
||||
return host ? parse(host) : null;
|
||||
}
|
||||
@@ -62,9 +55,10 @@ export async function createDeadHost(input: DeadHostInput, actorUserId: number)
|
||||
throw new Error("At least one domain is required");
|
||||
}
|
||||
|
||||
const now = new Date(nowIso());
|
||||
const record = await prisma.deadHost.create({
|
||||
data: {
|
||||
const now = nowIso();
|
||||
const [record] = await db
|
||||
.insert(deadHosts)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
|
||||
statusCode: input.status_code ?? 503,
|
||||
@@ -73,8 +67,12 @@ export async function createDeadHost(input: DeadHostInput, actorUserId: number)
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: actorUserId
|
||||
}
|
||||
});
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) {
|
||||
throw new Error("Failed to create dead host");
|
||||
}
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
@@ -91,18 +89,18 @@ export async function updateDeadHost(id: number, input: Partial<DeadHostInput>,
|
||||
if (!existing) {
|
||||
throw new Error("Dead host not found");
|
||||
}
|
||||
const now = new Date(nowIso());
|
||||
await prisma.deadHost.update({
|
||||
where: { id },
|
||||
data: {
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(deadHosts)
|
||||
.set({
|
||||
name: input.name ?? existing.name,
|
||||
domains: JSON.stringify(input.domains ? Array.from(new Set(input.domains)) : existing.domains),
|
||||
statusCode: input.status_code ?? existing.status_code,
|
||||
responseBody: input.response_body ?? existing.response_body,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.where(eq(deadHosts.id, id));
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "update",
|
||||
@@ -119,9 +117,7 @@ export async function deleteDeadHost(id: number, actorUserId: number) {
|
||||
if (!existing) {
|
||||
throw new Error("Dead host not found");
|
||||
}
|
||||
await prisma.deadHost.delete({
|
||||
where: { id }
|
||||
});
|
||||
await db.delete(deadHosts).where(eq(deadHosts.id, id));
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma, { nowIso } from "../db";
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { proxyHosts } from "../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
const DEFAULT_AUTHENTIK_HEADERS = [
|
||||
"X-Authentik-Username",
|
||||
@@ -94,25 +96,7 @@ export type ProxyHostInput = {
|
||||
authentik?: ProxyHostAuthentikInput | null;
|
||||
};
|
||||
|
||||
type ProxyHostRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
domains: string;
|
||||
upstreams: string;
|
||||
certificateId: number | null;
|
||||
accessListId: number | null;
|
||||
ownerUserId: number | null;
|
||||
sslForced: boolean;
|
||||
hstsEnabled: boolean;
|
||||
hstsSubdomains: boolean;
|
||||
allowWebsocket: boolean;
|
||||
preserveHostHeader: boolean;
|
||||
meta: string | null;
|
||||
skipHttpsHostnameValidation: boolean;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
type ProxyHostRow = typeof proxyHosts.$inferSelect;
|
||||
|
||||
function normalizeMetaValue(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
@@ -394,8 +378,8 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
|
||||
preserve_host_header: row.preserveHostHeader,
|
||||
skip_https_hostname_validation: row.skipHttpsHostnameValidation,
|
||||
enabled: row.enabled,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
custom_reverse_proxy_json: meta.custom_reverse_proxy_json ?? null,
|
||||
custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null,
|
||||
authentik: hydrateAuthentik(meta.authentik)
|
||||
@@ -403,9 +387,7 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
|
||||
}
|
||||
|
||||
export async function listProxyHosts(): Promise<ProxyHost[]> {
|
||||
const hosts = await prisma.proxyHost.findMany({
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
const hosts = await db.select().from(proxyHosts).orderBy(desc(proxyHosts.createdAt));
|
||||
return hosts.map(parseProxyHost);
|
||||
}
|
||||
|
||||
@@ -417,10 +399,11 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
|
||||
throw new Error("At least one upstream must be specified");
|
||||
}
|
||||
|
||||
const now = new Date(nowIso());
|
||||
const now = nowIso();
|
||||
const meta = buildMeta({}, input);
|
||||
const record = await prisma.proxyHost.create({
|
||||
data: {
|
||||
const [record] = await db
|
||||
.insert(proxyHosts)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
|
||||
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
|
||||
@@ -437,8 +420,12 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
|
||||
enabled: input.enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) {
|
||||
throw new Error("Failed to create proxy host");
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
@@ -454,8 +441,8 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
|
||||
}
|
||||
|
||||
export async function getProxyHost(id: number): Promise<ProxyHost | null> {
|
||||
const host = await prisma.proxyHost.findUnique({
|
||||
where: { id }
|
||||
const host = await db.query.proxyHosts.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, id)
|
||||
});
|
||||
return host ? parseProxyHost(host) : null;
|
||||
}
|
||||
@@ -475,10 +462,10 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
|
||||
};
|
||||
const meta = buildMeta(existingMeta, input);
|
||||
|
||||
const now = new Date(nowIso());
|
||||
await prisma.proxyHost.update({
|
||||
where: { id },
|
||||
data: {
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(proxyHosts)
|
||||
.set({
|
||||
name: input.name ?? existing.name,
|
||||
domains,
|
||||
upstreams,
|
||||
@@ -493,8 +480,8 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
|
||||
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? existing.skip_https_hostname_validation,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.where(eq(proxyHosts.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
@@ -515,9 +502,7 @@ export async function deleteProxyHost(id: number, actorUserId: number) {
|
||||
throw new Error("Proxy host not found");
|
||||
}
|
||||
|
||||
await prisma.proxyHost.delete({
|
||||
where: { id }
|
||||
});
|
||||
await db.delete(proxyHosts).where(eq(proxyHosts.id, id));
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "delete",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import prisma, { nowIso } from "../db";
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { redirectHosts } from "../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
export type RedirectHost = {
|
||||
id: number;
|
||||
@@ -23,17 +25,9 @@ export type RedirectHostInput = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function parseDbRecord(record: {
|
||||
id: number;
|
||||
name: string;
|
||||
domains: string;
|
||||
destination: string;
|
||||
statusCode: number;
|
||||
preserveQuery: boolean;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): RedirectHost {
|
||||
type RedirectHostRow = typeof redirectHosts.$inferSelect;
|
||||
|
||||
function parseDbRecord(record: RedirectHostRow): RedirectHost {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
@@ -42,21 +36,19 @@ function parseDbRecord(record: {
|
||||
status_code: record.statusCode,
|
||||
preserve_query: record.preserveQuery,
|
||||
enabled: record.enabled,
|
||||
created_at: record.createdAt.toISOString(),
|
||||
updated_at: record.updatedAt.toISOString()
|
||||
created_at: toIso(record.createdAt)!,
|
||||
updated_at: toIso(record.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRedirectHosts(): Promise<RedirectHost[]> {
|
||||
const records = await prisma.redirectHost.findMany({
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
const records = await db.select().from(redirectHosts).orderBy(desc(redirectHosts.createdAt));
|
||||
return records.map(parseDbRecord);
|
||||
}
|
||||
|
||||
export async function getRedirectHost(id: number): Promise<RedirectHost | null> {
|
||||
const record = await prisma.redirectHost.findUnique({
|
||||
where: { id }
|
||||
const record = await db.query.redirectHosts.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, id)
|
||||
});
|
||||
return record ? parseDbRecord(record) : null;
|
||||
}
|
||||
@@ -66,9 +58,10 @@ export async function createRedirectHost(input: RedirectHostInput, actorUserId:
|
||||
throw new Error("At least one domain is required");
|
||||
}
|
||||
|
||||
const now = new Date(nowIso());
|
||||
const record = await prisma.redirectHost.create({
|
||||
data: {
|
||||
const now = nowIso();
|
||||
const [record] = await db
|
||||
.insert(redirectHosts)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
|
||||
destination: input.destination.trim(),
|
||||
@@ -78,8 +71,12 @@ export async function createRedirectHost(input: RedirectHostInput, actorUserId:
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: actorUserId
|
||||
}
|
||||
});
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) {
|
||||
throw new Error("Failed to create redirect host");
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
@@ -98,10 +95,10 @@ export async function updateRedirectHost(id: number, input: Partial<RedirectHost
|
||||
throw new Error("Redirect host not found");
|
||||
}
|
||||
|
||||
const now = new Date(nowIso());
|
||||
await prisma.redirectHost.update({
|
||||
where: { id },
|
||||
data: {
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(redirectHosts)
|
||||
.set({
|
||||
name: input.name ?? existing.name,
|
||||
domains: input.domains ? JSON.stringify(Array.from(new Set(input.domains))) : JSON.stringify(existing.domains),
|
||||
destination: input.destination ?? existing.destination,
|
||||
@@ -109,8 +106,8 @@ export async function updateRedirectHost(id: number, input: Partial<RedirectHost
|
||||
preserveQuery: input.preserve_query ?? existing.preserve_query,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.where(eq(redirectHosts.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
@@ -129,9 +126,7 @@ export async function deleteRedirectHost(id: number, actorUserId: number) {
|
||||
throw new Error("Redirect host not found");
|
||||
}
|
||||
|
||||
await prisma.redirectHost.delete({
|
||||
where: { id }
|
||||
});
|
||||
await db.delete(redirectHosts).where(eq(redirectHosts.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import prisma, { nowIso } from "../db";
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { users } from "../db/schema";
|
||||
import { and, asc, count, eq } from "drizzle-orm";
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
@@ -14,19 +16,9 @@ export type User = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function parseDbUser(user: {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
passwordHash: string | null;
|
||||
role: string;
|
||||
provider: string;
|
||||
subject: string;
|
||||
avatarUrl: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): User {
|
||||
type DbUser = typeof users.$inferSelect;
|
||||
|
||||
function parseDbUser(user: DbUser): User {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -37,38 +29,34 @@ function parseDbUser(user: {
|
||||
subject: user.subject,
|
||||
avatar_url: user.avatarUrl,
|
||||
status: user.status,
|
||||
created_at: user.createdAt.toISOString(),
|
||||
updated_at: user.updatedAt.toISOString()
|
||||
created_at: toIso(user.createdAt)!,
|
||||
updated_at: toIso(user.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserById(userId: number): Promise<User | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, userId)
|
||||
});
|
||||
return user ? parseDbUser(user) : null;
|
||||
}
|
||||
|
||||
export async function getUserCount(): Promise<number> {
|
||||
return await prisma.user.count();
|
||||
const result = await db.select({ value: count() }).from(users);
|
||||
return result[0]?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function findUserByProviderSubject(provider: string, subject: string): Promise<User | null> {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
provider,
|
||||
subject
|
||||
}
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (table, operators) => and(operators.eq(table.provider, provider), operators.eq(table.subject, subject))
|
||||
});
|
||||
return user ? parseDbUser(user) : null;
|
||||
}
|
||||
|
||||
export async function findUserByEmail(email: string): Promise<User | null> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: normalizedEmail
|
||||
}
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (table, { eq }) => eq(table.email, normalizedEmail)
|
||||
});
|
||||
return user ? parseDbUser(user) : null;
|
||||
}
|
||||
@@ -82,12 +70,13 @@ export async function createUser(data: {
|
||||
avatar_url?: string | null;
|
||||
passwordHash?: string | null;
|
||||
}): Promise<User> {
|
||||
const now = new Date(nowIso());
|
||||
const now = nowIso();
|
||||
const role = data.role ?? "user";
|
||||
const email = data.email.trim().toLowerCase();
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
name: data.name ?? null,
|
||||
passwordHash: data.passwordHash ?? null,
|
||||
@@ -98,8 +87,8 @@ export async function createUser(data: {
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.returning();
|
||||
|
||||
return parseDbUser(user);
|
||||
}
|
||||
@@ -110,45 +99,46 @@ export async function updateUserProfile(userId: number, data: { email?: string;
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date(nowIso());
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
const now = nowIso();
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
email: data.email ?? current.email,
|
||||
name: data.name ?? current.name,
|
||||
avatarUrl: data.avatar_url ?? current.avatar_url,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.returning();
|
||||
|
||||
return parseDbUser(user);
|
||||
return updated ? parseDbUser(updated) : null;
|
||||
}
|
||||
|
||||
export async function updateUserPassword(userId: number, passwordHash: string): Promise<void> {
|
||||
const now = new Date(nowIso());
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
passwordHash,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<User[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: "asc" }
|
||||
const rows = await db.query.users.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.createdAt)
|
||||
});
|
||||
return users.map(parseDbUser);
|
||||
return rows.map(parseDbUser);
|
||||
}
|
||||
|
||||
export async function promoteToAdmin(userId: number): Promise<void> {
|
||||
const now = new Date(nowIso());
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
const now = nowIso();
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
role: "admin",
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import prisma, { nowIso } from "./db";
|
||||
import db, { nowIso } from "./db";
|
||||
import { settings } from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type SettingValue<T> = T | null;
|
||||
|
||||
@@ -14,8 +16,8 @@ export type GeneralSettings = {
|
||||
};
|
||||
|
||||
export async function getSetting<T>(key: string): Promise<SettingValue<T>> {
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key }
|
||||
const setting = await db.query.settings.findFirst({
|
||||
where: (table, { eq }) => eq(table.key, key)
|
||||
});
|
||||
|
||||
if (!setting) {
|
||||
@@ -32,20 +34,22 @@ export async function getSetting<T>(key: string): Promise<SettingValue<T>> {
|
||||
|
||||
export async function setSetting<T>(key: string, value: T): Promise<void> {
|
||||
const payload = JSON.stringify(value);
|
||||
const now = new Date(nowIso());
|
||||
const now = nowIso();
|
||||
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: {
|
||||
value: payload,
|
||||
updatedAt: now
|
||||
},
|
||||
create: {
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
key,
|
||||
value: payload,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: settings.key,
|
||||
set: {
|
||||
value: payload,
|
||||
updatedAt: now
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCloudflareSettings(): Promise<CloudflareSettings | null> {
|
||||
|
||||
Reference in New Issue
Block a user