updated a lot of stuff

This commit is contained in:
fuomag9
2025-11-02 22:16:13 +01:00
parent b064003c34
commit 668b667fe9
58 changed files with 3935 additions and 2960 deletions

50
.dockerignore Normal file
View File

@@ -0,0 +1,50 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
.next
.turbo
out
dist
build
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Misc
.DS_Store
*.pem
# Debug
*.log
# Local development
.git
.gitignore
README.md
.vscode
.idea
# Data directories - MUST exclude to prevent database lock errors
data/
*.db
*.db-shm
*.db-wal
# Caddy data
caddy-data/
caddy-config/
# Docker files
docker-compose.yml
docker-compose.*.yml

76
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Build and Push Docker Images
on:
push:
branches:
- main
- develop
tags:
- 'v*'
pull_request:
branches:
- main
- develop
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- service: web
dockerfile: docker/web/Dockerfile
context: .
- service: caddy
dockerfile: docker/caddy/Dockerfile
context: .
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{ matrix.service }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ data
.env*
/.idea
tsconfig.tsbuildinfo
/caddy-data
/caddy-config

119
README.md
View File

@@ -2,14 +2,14 @@
[https://caddyproxymanager.com](https://caddyproxymanager.com)
Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse proxy configuration, TLS automation, access control, and observability. The stack is built with Next.js16 (App Router), MaterialUI, and a lightweight SQLite data layer. It ships with OAuth2 SSO, first-class Caddy admin API integration, and tooling for Cloudflare DNS challenge automation.
Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse proxy configuration, TLS automation, access control, and observability. The stack is built with Next.js 16 (App Router), Material UI, and a lightweight SQLite data layer. It features simple username/password authentication, first-class Caddy admin API integration, and tooling for Cloudflare DNS challenge automation.
## Highlights
- **Next.js16 App Router** server components for data loading, client components for interactivity, and a unified API surface.
- **Next.js 16 App Router** server components for data loading, client components for interactivity, and a unified API surface.
- **Material UI dark mode** fast, responsive dashboard with ready-made components and accessibility baked in.
- **OAuth2 single sign-on** PKCE flow with configurable claims; the first authenticated user is promoted to administrator.
- **End-to-end Caddy orchestration** generate JSON for HTTP(S) proxies, redirects, 404 hosts, and TCP/UDP streams via the Caddy admin API.
- **Simple authentication** environment-based username/password login configured via docker-compose.
- **End-to-end Caddy orchestration** generate JSON for HTTP(S) proxies, redirects, 404 hosts via the Caddy admin API.
- **Cloudflare DNS challenge support** xcaddy build bundles the `cloudflare` DNS and `layer4` modules; credentials are configurable in the UI.
- **Security-by-default** HSTS (`Strict-Transport-Security: max-age=63072000`) applied to every managed host.
- **Embedded audit log** every configuration change is recorded with actor, summary, and timestamp.
@@ -19,17 +19,17 @@ Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse
```
.
├── app/ # Next.js App Router entrypoint (layouts, routes, server actions)
│ ├── (auth)/ # Login + OAuth setup flows
│ ├── (auth)/ # Login flow
│ ├── (dashboard)/ # Dashboard layout, feature surface, client renderers
│ ├── api/ # Route handlers for auth callbacks/logout
│ ├── providers.tsx # Global MUI theme + CssBaseline
│ └── layout.tsx # Root HTML/body wrapper
├── src/
│ └── lib/ # SQLite integration, migrations, models, Caddy config builder
│ └── lib/ # SQLite integration, models, Caddy config builder
├── docker/
│ ├── web/ # Next.js production image (standalone output)
│ └── caddy/ # xcaddy build with Cloudflare + layer4 modules
├── compose.yaml # Multi-container deployment (Next.js app + Caddy)
├── docker-compose.yml # Multi-container deployment (Next.js app + Caddy)
├── data/ # Generated at runtime (SQLite DB, cert storage, Caddy state)
└── README.md # You are here
```
@@ -39,24 +39,22 @@ Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse
- `ProxyHostsClient.tsx` create/update/delete HTTP(S) reverse proxies, assign certs/access lists.
- `RedirectsClient.tsx` manage 301/302 redirects with optional path/query preservation.
- `DeadHostsClient.tsx` serve custom offline pages with programmable status codes.
- `StreamsClient.tsx` configure TCP/UDP layer4 proxies.
- `AccessListsClient.tsx` manage HTTP basic auth credentials and membership.
- `CertificatesClient.tsx` import PEMs or request managed ACME certificates.
- `SettingsClient.tsx` general metadata, OAuth2 endpoints, Cloudflare DNS token.
- `SettingsClient.tsx` general metadata and Cloudflare DNS token configuration.
- `AuditLogClient.tsx` list chronological administrative activity.
## Feature Overview
### Authentication & Authorization
- OAuth2/OIDC login with PKCE.
- First user bootstrap to admin role.
- Session persistence via signed, rotating cookies stored in SQLite.
- Simple username/password authentication configured via environment variables.
- Credentials set in docker-compose or `.env` file.
- Session persistence via signed JWT tokens.
### Reverse Proxy Management
- HTTP(S) proxy hosts with TLS enforcement, WebSocket + HTTP/2 toggles.
- Redirect hosts with custom status codes and query preservation.
- Dead/maintenance hosts with custom responses.
- Stream (TCP/UDP) forwarding powered by the Caddy layer4 module.
- Access list (basic auth) integration for protected hosts.
- TLS certificate lifecycle: managed ACME (DNS-01 via Cloudflare) or imported PEMs.
@@ -70,87 +68,122 @@ Caddy Proxy Manager is a modern control panel for Caddy that simplifies reverse
- Node.js 20+ (development)
- Docker + Docker Compose v2 (deployment)
- OAuth2 identity provider (OIDC compliant preferred)
- Optional: Cloudflare DNS API token for automated certificate issuance
## Quick Start
### Development
1. **Install dependencies**
```bash
npm install
```
> Package downloads require network access.
2. **Create environment file**
2. **Run the development server**
```bash
cp .env.example .env
```
Edit `.env` and set your admin credentials:
```env
ADMIN_USERNAME=your-username
ADMIN_PASSWORD=your-secure-password
SESSION_SECRET=your-random-secret-here
```
3. **Run the development server**
```bash
npm run dev
```
3. **Configure OAuth2**
4. **Login**
- Visit `http://localhost:3000/setup/oauth`.
- Supply your identity providers authorization, token, and userinfo endpoints plus client credentials.
- Sign in; the first user becomes an administrator.
- Visit `http://localhost:3000/login`
- Enter your configured username and password
- You're now logged in as administrator
4. **Configure Cloudflare DNS (optional)**
5. **Configure Cloudflare DNS (optional)**
- Navigate to **Settings → Cloudflare DNS**.
- Provide an API token with `Zone.DNS:Edit` scope and the relevant zone/account IDs.
- Any managed certificates attached to hosts will now request TLS via DNS validation.
### Production Deployment
## Docker Compose
`compose.yaml` defines a two-container stack:
`docker-compose.yml` defines a two-container stack:
- `app`: Next.js server with SQLite database and certificate store in `/data`.
- `caddy`: xcaddy-built binary with Cloudflare DNS provider and layer4 modules. The default configuration responds on `caddyproxymanager.com` and serves the required HSTS header:
```caddyfile
caddyproxymanager.com {
header Strict-Transport-Security "max-age=63072000"
respond "Caddy Proxy Manager is running" 200
}
```
- `web`: Next.js server with SQLite database and certificate store in `/data`.
- `caddy`: xcaddy-built binary with Cloudflare DNS provider and layer4 modules.
Launch the stack:
```bash
# Create .env file with your credentials
cp .env.example .env
# Edit .env and set secure values for:
# - ADMIN_USERNAME
# - ADMIN_PASSWORD
# - SESSION_SECRET
# Start the containers
docker compose up -d
```
Environment variables:
### Environment Variables
- `SESSION_SECRET`: random 32+ character string used to sign session cookies.
- `DATABASE_PATH`: path to the SQLite database (default `/data/app/app.db` in containers).
- `CERTS_DIRECTORY`: directory for imported PEM files shared with the Caddy container.
- `CADDY_API_URL`: URL for the Caddy admin API (default `http://caddy:2019` inside the compose network).
- `PRIMARY_DOMAIN`: default domain served by the bootstrap Caddyfile (defaults to `caddyproxymanager.com`).
**Required (Security):**
- `SESSION_SECRET`: Random 32+ character string used to sign session tokens. Generate with: `openssl rand -base64 32`
- `ADMIN_USERNAME`: Username for admin login (default: `admin`)
- `ADMIN_PASSWORD`: Password for admin login (default: `admin`)
**Optional (Application):**
- `BASE_URL`: Public base URL for the application (default: `http://localhost:3000`)
- `PRIMARY_DOMAIN`: Default domain served by Caddy (default: `caddyproxymanager.com`)
- `CADDY_API_URL`: URL for the Caddy admin API (default: `http://caddy:2019`)
- `DATABASE_PATH`: Path to the SQLite database (default: `/app/data/caddy-proxy-manager.db`)
⚠️ **Important**: Always change the default `ADMIN_USERNAME` and `ADMIN_PASSWORD` in production!
## Data Locations
- `data/app/app.db`: SQLite database storing configuration, sessions, and audit log.
- `data/caddy-proxy-manager.db`: SQLite database storing configuration, sessions, and audit log.
- `data/certs/`: Imported TLS certificates and keys generated by the UI.
- `data/caddy/`: Autogenerated Caddy state (ACME storage, etc.).
- `caddy-data/`: Autogenerated Caddy state (ACME storage, etc.).
- `caddy-config/`: Caddy configuration storage.
## UI Features
- **Proxy Hosts:** HTTP(S) reverse proxies with HSTS, access lists, optional custom certificates, and WebSocket support.
- **Redirects:** 301/302 responses with optional path/query preservation.
- **Dead Hosts:** Branded responses for offline services.
- **Streams:** TCP/UDP forwarding powered by the Caddy layer4 module.
- **Access Lists:** Bcrypt-backed basic auth credentials, assignable to proxy hosts.
- **Certificates:** Managed (ACME) or imported PEM certificates with audit history.
- **Audit Log:** Chronological record of every configuration change and actor.
- **Settings:** General metadata, OAuth2 endpoints, and Cloudflare DNS credentials.
- **Settings:** General metadata and Cloudflare DNS credentials.
## Development Notes
- SQLite schema migrations are embedded in `src/lib/migrations.ts` and run automatically on startup.
- SQLite schema migrations are embedded and run automatically on startup via Prisma.
- Caddy configuration is rebuilt on every change and pushed via the admin API. Failures are surfaced to the UI.
- OAuth2 login uses PKCE and stores session tokens as HMAC-signed cookies backed by the database.
- Authentication uses NextAuth.js with JWT session strategy.
- Type checking: `npm run typecheck`
- Build: `npm run build`
## Security Considerations
1. **Change default credentials**: Never use `admin/admin` in production
2. **Use strong SESSION_SECRET**: Generate with `openssl rand -base64 32`
3. **Use HTTPS in production**: Configure BASE_URL with `https://` protocol
4. **Restrict network access**: Ensure port 3000 is only accessible via reverse proxy
5. **Keep updated**: Regularly update dependencies and Docker images
## License

View File

@@ -1,38 +1,68 @@
"use client";
import Link from "next/link";
import { Alert, Box, Button, Card, CardContent, Stack, Typography } from "@mui/material";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
import { Alert, Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material";
import { signIn } from "next-auth/react";
export default function LoginClient({ oauthConfigured, providerId }: { oauthConfigured: boolean; providerId: string }) {
const handleSignIn = async () => {
await signIn(providerId, { callbackUrl: "/" });
export default function LoginClient() {
const router = useRouter();
const [loginError, setLoginError] = useState<string | null>(null);
const [loginPending, setLoginPending] = useState(false);
const handleSignIn = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoginError(null);
setLoginPending(true);
const formData = new FormData(event.currentTarget);
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!username || !password) {
setLoginError("Username and password are required.");
setLoginPending(false);
return;
}
const result = await signIn("credentials", {
redirect: false,
callbackUrl: "/",
username,
password
});
if (!result || result.error) {
setLoginError("Invalid username or password.");
setLoginPending(false);
return;
}
router.replace(result.url ?? "/");
router.refresh();
};
return (
<Box sx={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", bgcolor: "background.default" }}>
<Card sx={{ maxWidth: 420, width: "100%", p: 1.5 }} elevation={6}>
<Card sx={{ maxWidth: 440, width: "100%", p: 1.5 }} elevation={6}>
<CardContent>
<Stack spacing={3} textAlign="center">
<Stack spacing={1}>
<Stack spacing={3}>
<Stack spacing={1} textAlign="center">
<Typography variant="h5" fontWeight={600}>
Caddy Proxy Manager
</Typography>
<Typography color="text.secondary">
Sign in with your organization&apos;s OAuth2 provider to continue.
</Typography>
<Typography color="text.secondary">Sign in with your credentials</Typography>
</Stack>
{oauthConfigured ? (
<Button onClick={handleSignIn} variant="contained" size="large" fullWidth>
Sign in with OAuth2
{loginError && <Alert severity="error">{loginError}</Alert>}
<Stack component="form" onSubmit={handleSignIn} spacing={2}>
<TextField name="username" label="Username" required fullWidth autoComplete="username" autoFocus />
<TextField name="password" label="Password" type="password" required fullWidth autoComplete="current-password" />
<Button type="submit" variant="contained" size="large" fullWidth disabled={loginPending}>
{loginPending ? "Signing in…" : "Sign in"}
</Button>
) : (
<Alert severity="warning">
The system administrator needs to configure OAuth2 settings before logins are allowed. If this is a fresh installation,
start with the <Link href="/setup/oauth">OAuth setup wizard</Link>.
</Alert>
)}
</Stack>
</Stack>
</CardContent>
</Card>

View File

@@ -1,6 +1,5 @@
import { redirect } from "next/navigation";
import { auth } from "@/src/lib/auth";
import { getOAuthSettings } from "@/src/lib/settings";
import LoginClient from "./LoginClient";
export default async function LoginPage() {
@@ -9,11 +8,5 @@ export default async function LoginPage() {
redirect("/");
}
const settings = getOAuthSettings();
const oauthConfigured = Boolean(settings);
// Determine provider ID based on settings
const providerId = settings?.providerType === "authentik" ? "authentik" : "oauth";
return <LoginClient oauthConfigured={oauthConfigured} providerId={providerId} />;
return <LoginClient />;
}

View File

@@ -3,22 +3,26 @@
import { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Box, Button, Divider, List, ListItemButton, ListItemText, Stack, Typography } from "@mui/material";
import type { UserRecord } from "@/src/lib/auth/session";
import { Avatar, Box, Button, Divider, List, ListItemButton, ListItemText, Stack, Typography } from "@mui/material";
type User = {
id: string;
name?: string | null;
email?: string | null;
};
const NAV_ITEMS = [
{ href: "/", label: "Overview" },
{ href: "/proxy-hosts", label: "Proxy Hosts" },
{ href: "/redirects", label: "Redirects" },
{ href: "/dead-hosts", label: "Dead Hosts" },
{ href: "/streams", label: "Streams" },
{ href: "/access-lists", label: "Access Lists" },
{ href: "/certificates", label: "Certificates" },
{ href: "/settings", label: "Settings" },
{ href: "/audit-log", label: "Audit Log" }
] as const;
export default function DashboardLayoutClient({ user, children }: { user: UserRecord; children: ReactNode }) {
export default function DashboardLayoutClient({ user, children }: { user: User; children: ReactNode }) {
const pathname = usePathname();
return (
@@ -27,77 +31,170 @@ export default function DashboardLayoutClient({ user, children }: { user: UserRe
display: "flex",
minHeight: "100vh",
position: "relative",
overflow: "hidden"
background:
"radial-gradient(circle at 12% -20%, rgba(99, 102, 241, 0.28), transparent 45%), radial-gradient(circle at 88% 8%, rgba(45, 212, 191, 0.24), transparent 46%), linear-gradient(160deg, rgba(2, 3, 9, 1) 0%, rgba(4, 10, 22, 1) 40%, rgba(2, 6, 18, 1) 100%)"
}}
>
<Box
component="aside"
sx={{
width: 280,
bgcolor: "rgba(10, 15, 25, 0.9)",
backdropFilter: "blur(18px)",
borderRight: "1px solid rgba(99, 102, 241, 0.2)",
boxShadow: "24px 0 60px rgba(2, 6, 23, 0.45)",
width: 240,
minWidth: 240,
maxWidth: 240,
height: "100vh",
position: "fixed",
top: 0,
left: 0,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
gap: 3,
p: 3
px: 2,
py: 3,
background: "rgba(20, 20, 22, 0.95)",
borderRight: "0.5px solid rgba(255, 255, 255, 0.08)",
zIndex: 1000,
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
overflowY: "auto",
overflowX: "hidden"
}}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
<Typography
variant="body2"
sx={{ textTransform: "uppercase", letterSpacing: 4, color: "rgba(148, 163, 184, 0.5)" }}
>
Caddy
</Typography>
<Typography
variant="h6"
<Stack spacing={2} sx={{ flex: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5, px: 1.5, pt: 1 }}>
<Typography
variant="h6"
sx={{
fontWeight: 600,
fontSize: "1.125rem",
letterSpacing: "-0.02em",
color: "rgba(255, 255, 255, 0.95)"
}}
>
Caddy Proxy
</Typography>
<Typography
variant="caption"
sx={{
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
letterSpacing: "0.01em"
}}
>
Manager
</Typography>
</Box>
<Divider sx={{ borderColor: "rgba(255, 255, 255, 0.06)" }} />
<List
component="nav"
sx={{
fontWeight: 700,
letterSpacing: -0.4,
background: "linear-gradient(120deg, #7f5bff 0%, #22d3ee 70%)",
WebkitBackgroundClip: "text",
color: "transparent"
display: "grid",
gap: 0.25,
flex: 1,
py: 0.5
}}
>
Proxy Manager
</Typography>
<Typography variant="body2" color="text.secondary">
{user.name ?? user.email}
</Typography>
</Box>
{NAV_ITEMS.map((item) => {
const selected = pathname === item.href;
return (
<ListItemButton
key={item.href}
component={Link}
href={item.href}
selected={selected}
sx={{
borderRadius: 1.25,
px: 2,
py: 1,
minHeight: 36,
backgroundColor: selected ? "rgba(255, 255, 255, 0.1)" : "transparent",
transition: "background-color 0.15s ease",
"&:hover": {
backgroundColor: selected ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.05)"
}
}}
>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontWeight: selected ? 500 : 400,
fontSize: "0.875rem",
letterSpacing: "-0.005em",
color: selected ? "rgba(255, 255, 255, 0.95)" : "rgba(255, 255, 255, 0.65)"
}}
/>
</ListItemButton>
);
})}
</List>
</Stack>
<Divider sx={{ borderColor: "rgba(148, 163, 184, 0.1)" }} />
<Stack spacing={2}>
<Divider sx={{ borderColor: "rgba(255, 255, 255, 0.06)" }} />
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, px: 1.5 }}>
<Avatar
sx={{
bgcolor: "rgba(100, 100, 255, 0.2)",
border: "0.5px solid rgba(255, 255, 255, 0.15)",
color: "rgba(255, 255, 255, 0.95)",
fontSize: 13,
fontWeight: 500,
width: 32,
height: 32
}}
>
{(user.name ?? user.email ?? "A").slice(0, 2).toUpperCase()}
</Avatar>
<Box sx={{ overflow: "hidden" }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 500,
fontSize: "0.8125rem",
color: "rgba(255, 255, 255, 0.85)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
}}
>
{user.name ?? user.email ?? "Admin"}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: "0.6875rem",
color: "rgba(255, 255, 255, 0.4)"
}}
>
Administrator
</Typography>
</Box>
</Box>
<List component="nav" sx={{ flexGrow: 1, display: "grid", gap: 0.5 }}>
{NAV_ITEMS.map((item) => {
const selected = pathname === item.href;
return (
<ListItemButton key={item.href} component={Link} href={item.href} selected={selected} sx={{ borderRadius: 2 }}>
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: selected ? 600 : 500 }} />
</ListItemButton>
);
})}
</List>
<form action="/api/auth/logout" method="POST">
<Button
type="submit"
variant="contained"
fullWidth
sx={{
background: "linear-gradient(135deg, rgba(127, 91, 255, 0.9), rgba(34, 211, 238, 0.9))",
color: "#05030a",
"&:hover": {
background: "linear-gradient(135deg, rgba(127, 91, 255, 0.8), rgba(34, 211, 238, 0.8))",
boxShadow: "0 18px 44px rgba(34, 211, 238, 0.35)"
}
}}
>
Sign out
</Button>
</form>
<form action="/api/auth/logout" method="POST">
<Button
type="submit"
variant="text"
fullWidth
sx={{
color: "rgba(255, 255, 255, 0.6)",
py: 1,
fontSize: "0.8125rem",
fontWeight: 400,
textTransform: "none",
borderRadius: 1.25,
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.8)"
}
}}
>
Sign Out
</Button>
</form>
</Stack>
</Box>
<Stack
@@ -105,9 +202,13 @@ export default function DashboardLayoutClient({ user, children }: { user: UserRe
sx={{
flex: 1,
position: "relative",
p: { xs: 3, md: 6 },
marginLeft: "240px",
px: { xs: 3, md: 6, xl: 8 },
py: { xs: 5, md: 6 },
gap: 4,
bgcolor: "transparent"
bgcolor: "transparent",
overflowX: "hidden",
minHeight: "100vh"
}}
>
<Box
@@ -116,10 +217,10 @@ export default function DashboardLayoutClient({ user, children }: { user: UserRe
inset: 0,
pointerEvents: "none",
background:
"radial-gradient(circle at 20% -10%, rgba(56, 189, 248, 0.18), transparent 40%), radial-gradient(circle at 80% 0%, rgba(168, 85, 247, 0.15), transparent 45%)"
"radial-gradient(circle at 18% -12%, rgba(56, 189, 248, 0.18), transparent 42%), radial-gradient(circle at 86% 0%, rgba(168, 85, 247, 0.15), transparent 45%)"
}}
/>
<Stack sx={{ position: "relative", gap: 4 }}>{children}</Stack>
<Stack sx={{ position: "relative", gap: 4, width: "100%", maxWidth: 1160, mx: "auto" }}>{children}</Stack>
</Stack>
</Box>
);

View File

@@ -11,7 +11,9 @@ import {
} from "@/src/lib/models/access-lists";
export async function createAccessListAction(formData: FormData) {
const { user } = await requireUser();
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
const rawUsers = String(formData.get("users") ?? "");
const accounts = rawUsers
.split("\n")
@@ -29,45 +31,53 @@ export async function createAccessListAction(formData: FormData) {
description: formData.get("description") ? String(formData.get("description")) : null,
users: accounts
},
user.id
userId
);
revalidatePath("/access-lists");
}
export async function updateAccessListAction(id: number, formData: FormData) {
const { user } = await requireUser();
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await updateAccessList(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
description: formData.get("description") ? String(formData.get("description")) : undefined
},
user.id
userId
);
revalidatePath("/access-lists");
}
export async function deleteAccessListAction(id: number) {
const { user } = await requireUser();
await deleteAccessList(id, user.id);
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await deleteAccessList(id, userId);
revalidatePath("/access-lists");
}
export async function addAccessEntryAction(id: number, formData: FormData) {
const { user } = await requireUser();
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await addAccessListEntry(
id,
{
username: String(formData.get("username") ?? ""),
password: String(formData.get("password") ?? "")
},
user.id
userId
);
revalidatePath("/access-lists");
}
export async function deleteAccessEntryAction(accessListId: number, entryId: number) {
const { user } = await requireUser();
await removeAccessListEntry(accessListId, entryId, user.id);
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await removeAccessListEntry(accessListId, entryId, userId);
revalidatePath("/access-lists");
}

View File

@@ -1,7 +1,7 @@
import AccessListsClient from "./AccessListsClient";
import { listAccessLists } from "@/src/lib/models/access-lists";
export default function AccessListsPage() {
const lists = listAccessLists();
export default async function AccessListsPage() {
const lists = await listAccessLists();
return <AccessListsClient lists={lists} />;
}

View File

@@ -2,9 +2,9 @@ import AuditLogClient from "./AuditLogClient";
import { listAuditEvents } from "@/src/lib/models/audit";
import { listUsers } from "@/src/lib/models/user";
export default function AuditLogPage() {
const events = listAuditEvents(200);
const users = listUsers();
export default async function AuditLogPage() {
const events = await listAuditEvents(200);
const users = await listUsers();
const userMap = new Map(users.map((user) => [user.id, user]));
return (

View File

@@ -16,7 +16,9 @@ function parseDomains(value: FormDataEntryValue | null): string[] {
}
export async function createCertificateAction(formData: FormData) {
const { user } = await requireUser();
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
const type = String(formData.get("type") ?? "managed") as "managed" | "imported";
await createCertificate(
{
@@ -27,13 +29,15 @@ export async function createCertificateAction(formData: FormData) {
certificate_pem: type === "imported" ? String(formData.get("certificate_pem") ?? "") : null,
private_key_pem: type === "imported" ? String(formData.get("private_key_pem") ?? "") : null
},
user.id
userId
);
revalidatePath("/certificates");
}
export async function updateCertificateAction(id: number, formData: FormData) {
const { user } = await requireUser();
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
const type = formData.get("type") ? (String(formData.get("type")) as "managed" | "imported") : undefined;
await updateCertificate(
id,
@@ -45,13 +49,15 @@ export async function updateCertificateAction(id: number, formData: FormData) {
certificate_pem: formData.get("certificate_pem") ? String(formData.get("certificate_pem")) : undefined,
private_key_pem: formData.get("private_key_pem") ? String(formData.get("private_key_pem")) : undefined
},
user.id
userId
);
revalidatePath("/certificates");
}
export async function deleteCertificateAction(id: number) {
const { user } = await requireUser();
await deleteCertificate(id, user.id);
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await deleteCertificate(id, userId);
revalidatePath("/certificates");
}

View File

@@ -1,7 +1,7 @@
import CertificatesClient from "./CertificatesClient";
import { listCertificates } from "@/src/lib/models/certificates";
export default function CertificatesPage() {
const certificates = listCertificates();
export default async function CertificatesPage() {
const certificates = await listCertificates();
return <CertificatesClient certificates={certificates} />;
}

View File

@@ -16,7 +16,9 @@ function parseDomains(value: FormDataEntryValue | null): string[] {
}
export async function createDeadHostAction(formData: FormData) {
const { user } = await requireUser();
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await createDeadHost(
{
name: String(formData.get("name") ?? "Dead host"),
@@ -25,13 +27,15 @@ export async function createDeadHostAction(formData: FormData) {
response_body: formData.get("response_body") ? String(formData.get("response_body")) : null,
enabled: formData.has("enabled") ? formData.get("enabled") === "on" : true
},
user.id
userId
);
revalidatePath("/dead-hosts");
}
export async function updateDeadHostAction(id: number, formData: FormData) {
const { user } = await requireUser();
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await updateDeadHost(
id,
{
@@ -41,13 +45,15 @@ export async function updateDeadHostAction(id: number, formData: FormData) {
response_body: formData.get("response_body") ? String(formData.get("response_body")) : undefined,
enabled: formData.has("enabled_present") ? formData.get("enabled") === "on" : undefined
},
user.id
userId
);
revalidatePath("/dead-hosts");
}
export async function deleteDeadHostAction(id: number) {
const { user } = await requireUser();
await deleteDeadHost(id, user.id);
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await deleteDeadHost(id, userId);
revalidatePath("/dead-hosts");
}

View File

@@ -1,7 +1,7 @@
import DeadHostsClient from "./DeadHostsClient";
import { listDeadHosts } from "@/src/lib/models/dead-hosts";
export default function DeadHostsPage() {
const hosts = listDeadHosts();
export default async function DeadHostsPage() {
const hosts = await listDeadHosts();
return <DeadHostsClient hosts={hosts} />;
}

View File

@@ -3,6 +3,6 @@ import { requireUser } from "@/src/lib/auth";
import DashboardLayoutClient from "./DashboardLayoutClient";
export default async function DashboardLayout({ children }: { children: ReactNode }) {
const { user } = await requireUser();
return <DashboardLayoutClient user={user}>{children}</DashboardLayoutClient>;
const session = await requireUser();
return <DashboardLayoutClient user={session.user}>{children}</DashboardLayoutClient>;
}

View File

@@ -1,4 +1,4 @@
import db from "@/src/lib/db";
import prisma from "@/src/lib/db";
import { requireUser } from "@/src/lib/auth";
import OverviewClient from "./OverviewClient";
@@ -9,46 +9,46 @@ type StatCard = {
href: string;
};
function loadStats(): StatCard[] {
const metrics = [
{ label: "Proxy Hosts", table: "proxy_hosts", href: "/proxy-hosts", icon: "⇄" },
{ label: "Redirects", table: "redirect_hosts", href: "/redirects", icon: "↪" },
{ label: "Dead Hosts", table: "dead_hosts", href: "/dead-hosts", icon: "☠" },
{ label: "Streams", table: "stream_hosts", href: "/streams", icon: "≋" },
{ label: "Certificates", table: "certificates", href: "/certificates", icon: "🔐" },
{ label: "Access Lists", table: "access_lists", href: "/access-lists", icon: "🔒" }
] as const;
async function loadStats(): Promise<StatCard[]> {
const [proxyHostsCount, redirectHostsCount, deadHostsCount, certificatesCount, accessListsCount] =
await Promise.all([
prisma.proxyHost.count(),
prisma.redirectHost.count(),
prisma.deadHost.count(),
prisma.certificate.count(),
prisma.accessList.count()
]);
return metrics.map((metric) => {
const row = db.prepare(`SELECT COUNT(*) as count FROM ${metric.table}`).get() as { count: number };
return {
label: metric.label,
icon: metric.icon,
count: Number(row.count),
href: metric.href
};
});
return [
{ label: "Proxy Hosts", icon: "⇄", count: proxyHostsCount, href: "/proxy-hosts" },
{ label: "Redirects", icon: "↪", count: redirectHostsCount, href: "/redirects" },
{ label: "Dead Hosts", icon: "☠", count: deadHostsCount, href: "/dead-hosts" },
{ label: "Certificates", icon: "🔐", count: certificatesCount, href: "/certificates" },
{ label: "Access Lists", icon: "🔒", count: accessListsCount, href: "/access-lists" }
];
}
export default async function OverviewPage() {
const { user } = await requireUser();
const stats = loadStats();
const recentEvents = db
.prepare(
`SELECT action, entity_type, summary, created_at
FROM audit_events
ORDER BY created_at DESC
LIMIT 8`
)
.all() as { action: string; entity_type: string; summary: string | null; created_at: string }[];
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
});
return (
<OverviewClient
userName={user.name ?? user.email}
userName={session.user.name ?? session.user.email ?? "Admin"}
stats={stats}
recentEvents={recentEvents.map((event) => ({
summary: event.summary ?? `${event.action} on ${event.entity_type}`,
created_at: event.created_at
recentEvents={recentEvents.map((event: { action: string; entityType: string; summary: string | null; createdAt: Date }) => ({
summary: event.summary ?? `${event.action} on ${event.entityType}`,
created_at: event.createdAt.toISOString()
}))}
/>
);

View File

@@ -1,11 +1,43 @@
"use client";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import Grid from "@mui/material/Grid";
import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Card, CardContent, Chip, FormControlLabel, MenuItem, Stack, TextField, Typography, Checkbox } from "@mui/material";
import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
Collapse,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
MenuItem,
Stack,
Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
Tooltip
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
import { useFormState } from "react-dom";
import type { AccessList } from "@/src/lib/models/access-lists";
import type { Certificate } from "@/src/lib/models/certificates";
import type { ProxyHost } from "@/src/lib/models/proxy-hosts";
import { INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions";
import { createProxyHostAction, deleteProxyHostAction, updateProxyHostAction } from "./actions";
type Props = {
@@ -14,307 +46,626 @@ type Props = {
accessLists: AccessList[];
};
const AUTHENTIK_DEFAULT_HEADERS = [
"X-Authentik-Username",
"X-Authentik-Groups",
"X-Authentik-Entitlements",
"X-Authentik-Email",
"X-Authentik-Name",
"X-Authentik-Uid",
"X-Authentik-Jwt",
"X-Authentik-Meta-Jwks",
"X-Authentik-Meta-Outpost",
"X-Authentik-Meta-Provider",
"X-Authentik-Meta-App",
"X-Authentik-Meta-Version"
];
const AUTHENTIK_DEFAULT_TRUSTED_PROXIES = ["private_ranges"];
export default function ProxyHostsClient({ hosts, certificates, accessLists }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editHost, setEditHost] = useState<ProxyHost | null>(null);
const [deleteHost, setDeleteHost] = useState<ProxyHost | null>(null);
return (
<Stack spacing={5} sx={{ width: "100%" }}>
<Stack spacing={1.5}>
<Typography variant="overline" sx={{ color: "rgba(148, 163, 184, 0.6)", letterSpacing: 4 }}>
HTTP Edge
</Typography>
<Typography
variant="h4"
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={2}>
<Stack spacing={1}>
<Typography
variant="h4"
sx={{
fontWeight: 600,
letterSpacing: "-0.02em",
color: "rgba(255, 255, 255, 0.95)"
}}
>
Proxy Hosts
</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 600 }}>
Define HTTP(S) reverse proxies orchestrated by Caddy with automated certificates.
</Typography>
</Stack>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setCreateOpen(true)}
sx={{
fontWeight: 700,
background: "linear-gradient(120deg, rgba(127, 91, 255, 1) 0%, rgba(34, 211, 238, 0.9) 80%)",
WebkitBackgroundClip: "text",
color: "transparent"
bgcolor: "rgba(99, 102, 241, 0.9)",
"&:hover": { bgcolor: "rgba(99, 102, 241, 1)" }
}}
>
Proxy Hosts
</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 560 }}>
Define HTTP(S) reverse proxies orchestrated by Caddy with automated certificates, shields, and zero-downtime
reloads.
</Typography>
Create Host
</Button>
</Stack>
<Grid container spacing={3} alignItems="stretch">
{hosts.map((host) => (
<Grid key={host.id} size={{ xs: 12, md: 6 }}>
<Card
sx={{
height: "100%",
border: "1px solid rgba(148, 163, 184, 0.12)",
background: "linear-gradient(160deg, rgba(17, 25, 40, 0.95), rgba(12, 18, 30, 0.78))"
}}
>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Box
<TableContainer
component={Card}
sx={{
background: "rgba(20, 20, 22, 0.6)",
border: "0.5px solid rgba(255, 255, 255, 0.08)"
}}
>
<Table>
<TableHead>
<TableRow sx={{ bgcolor: "rgba(255, 255, 255, 0.02)" }}>
<TableCell sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Name</TableCell>
<TableCell sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Domains</TableCell>
<TableCell sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Upstreams</TableCell>
<TableCell sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Status</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{hosts.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ py: 6, color: "text.secondary" }}>
No proxy hosts configured. Click "Create Host" to add one.
</TableCell>
</TableRow>
) : (
hosts.map((host) => (
<TableRow
key={host.id}
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 2
"&:hover": { bgcolor: "rgba(255, 255, 255, 0.02)" }
}}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ letterSpacing: -0.2 }}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500, color: "rgba(255, 255, 255, 0.9)" }}>
{host.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
{host.domains.join(", ")}
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ color: "rgba(255, 255, 255, 0.7)", fontSize: "0.8125rem" }}>
{host.domains.slice(0, 2).join(", ")}
{host.domains.length > 2 && ` +${host.domains.length - 2} more`}
</Typography>
</Box>
<Chip
label={host.enabled ? "Enabled" : "Disabled"}
color={host.enabled ? "success" : "default"}
sx={{
fontWeight: 600,
borderRadius: 999,
background: host.enabled
? "linear-gradient(135deg, rgba(34, 197, 94, 0.22), rgba(52, 211, 153, 0.32))"
: "rgba(148, 163, 184, 0.1)",
border: "1px solid rgba(148, 163, 184, 0.2)",
color: host.enabled ? "#4ade80" : "rgba(148,163,184,0.8)"
}}
/>
</Box>
<Accordion
elevation={0}
disableGutters
sx={{
bgcolor: "transparent",
borderRadius: 3,
border: "1px solid rgba(148,163,184,0.12)",
overflow: "hidden",
"&::before": { display: "none" }
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon sx={{ color: "rgba(226, 232, 240, 0.6)" }} />}
sx={{ px: 2, bgcolor: "rgba(15, 23, 42, 0.45)" }}
>
<Typography fontWeight={600}>Edit configuration</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, py: 3 }}>
<Stack component="form" action={(formData) => updateProxyHostAction(host.id, formData)} spacing={2.5}>
<TextField name="name" label="Name" defaultValue={host.name} required fullWidth />
<TextField
name="domains"
label="Domains"
helperText="Comma or newline separated"
defaultValue={host.domains.join("\n")}
multiline
minRows={3}
fullWidth
/>
<TextField
name="upstreams"
label="Upstreams"
helperText="Comma or newline separated"
defaultValue={host.upstreams.join("\n")}
multiline
minRows={3}
fullWidth
/>
<TextField select name="certificate_id" label="Certificate" defaultValue={host.certificate_id ?? ""} fullWidth>
<MenuItem value="">Managed by Caddy</MenuItem>
{certificates.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.name}
</MenuItem>
))}
</TextField>
<TextField select name="access_list_id" label="Access List" defaultValue={host.access_list_id ?? ""} fullWidth>
<MenuItem value="">None</MenuItem>
{accessLists.map((list) => (
<MenuItem key={list.id} value={list.id}>
{list.name}
</MenuItem>
))}
</TextField>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 1.5,
mt: 1
}}
>
<HiddenCheckboxField
name="hsts_subdomains"
defaultChecked={host.hsts_subdomains}
label="Include subdomains in HSTS"
/>
<HiddenCheckboxField
name="skip_https_hostname_validation"
defaultChecked={host.skip_https_hostname_validation}
label="Skip HTTPS hostname validation"
/>
<HiddenCheckboxField name="enabled" defaultChecked={host.enabled} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1.5 }}>
<Button type="submit" variant="contained">
Save Changes
</Button>
</Box>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ color: "rgba(255, 255, 255, 0.7)", fontSize: "0.8125rem" }}>
{host.upstreams.slice(0, 2).join(", ")}
{host.upstreams.length > 2 && ` +${host.upstreams.length - 2} more`}
</Typography>
</TableCell>
<TableCell>
<Chip
label={host.enabled ? "Enabled" : "Disabled"}
size="small"
sx={{
bgcolor: host.enabled ? "rgba(34, 197, 94, 0.15)" : "rgba(148, 163, 184, 0.15)",
color: host.enabled ? "rgba(34, 197, 94, 1)" : "rgba(148, 163, 184, 0.8)",
border: "1px solid",
borderColor: host.enabled ? "rgba(34, 197, 94, 0.3)" : "rgba(148, 163, 184, 0.3)",
fontWeight: 500,
fontSize: "0.75rem"
}}
/>
</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Edit">
<IconButton
size="small"
onClick={() => setEditHost(host)}
sx={{
color: "rgba(99, 102, 241, 0.8)",
"&:hover": { bgcolor: "rgba(99, 102, 241, 0.1)" }
}}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
onClick={() => setDeleteHost(host)}
sx={{
color: "rgba(239, 68, 68, 0.8)",
"&:hover": { bgcolor: "rgba(239, 68, 68, 0.1)" }
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</AccordionDetails>
</Accordion>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<Box component="form" action={deleteProxyHostAction.bind(null, host.id)}>
<Button type="submit" variant="outlined" color="error">
Delete
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
<CreateHostDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
certificates={certificates}
accessLists={accessLists}
/>
<Stack spacing={2} component="section">
<Box>
<Typography variant="h6" fontWeight={600}>
Create proxy host
</Typography>
<Typography variant="body2" color="text.secondary">
Deploy a new reverse proxy route powered by Caddy.
</Typography>
</Box>
<Card
sx={{
border: "1px solid rgba(148, 163, 184, 0.12)",
background: "linear-gradient(160deg, rgba(19, 28, 45, 0.95), rgba(12, 18, 30, 0.78))"
}}
>
<CardContent sx={{ p: { xs: 3, md: 4 } }}>
<Stack component="form" action={createProxyHostAction} spacing={2.5}>
<TextField name="name" label="Name" placeholder="Internal service" required fullWidth />
<TextField
name="domains"
label="Domains"
placeholder="app.example.com"
multiline
minRows={2}
required
fullWidth
/>
<TextField
name="upstreams"
label="Upstreams"
placeholder="http://10.0.0.5:8080"
multiline
minRows={2}
required
fullWidth
/>
<TextField select name="certificate_id" label="Certificate" defaultValue="" fullWidth>
<MenuItem value="">Managed by Caddy</MenuItem>
{certificates.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.name}
</MenuItem>
))}
</TextField>
<TextField select name="access_list_id" label="Access List" defaultValue="" fullWidth>
<MenuItem value="">None</MenuItem>
{accessLists.map((list) => (
<MenuItem key={list.id} value={list.id}>
{list.name}
</MenuItem>
))}
</TextField>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 1.5,
mt: 1
}}
>
<Box sx={{ p: 1, borderRadius: 2, border: "1px solid rgba(148, 163, 184, 0.12)", bgcolor: "rgba(9, 13, 23, 0.6)" }}>
<FormControlLabel
control={
<Checkbox
name="hsts_subdomains"
sx={{
color: "rgba(148, 163, 184, 0.6)",
"&.Mui-checked": { color: "#7f5bff" }
}}
/>
}
label="Include subdomains in HSTS"
sx={{ width: "100%", m: 0 }}
/>
</Box>
<Box sx={{ p: 1, borderRadius: 2, border: "1px solid rgba(148, 163, 184, 0.12)", bgcolor: "rgba(9, 13, 23, 0.6)" }}>
<FormControlLabel
control={
<Checkbox
name="skip_https_hostname_validation"
sx={{
color: "rgba(148, 163, 184, 0.6)",
"&.Mui-checked": { color: "#7f5bff" }
}}
/>
}
label="Skip HTTPS hostname validation"
sx={{ width: "100%", m: 0 }}
/>
</Box>
<Box sx={{ p: 1, borderRadius: 2, border: "1px solid rgba(148, 163, 184, 0.12)", bgcolor: "rgba(9, 13, 23, 0.6)" }}>
<FormControlLabel
control={
<Checkbox
name="enabled"
defaultChecked
sx={{
color: "rgba(148, 163, 184, 0.6)",
"&.Mui-checked": { color: "#7f5bff" }
}}
/>
}
label="Enabled"
sx={{ width: "100%", m: 0 }}
/>
</Box>
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create Host
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
{editHost && (
<EditHostDialog
open={!!editHost}
host={editHost}
onClose={() => setEditHost(null)}
certificates={certificates}
accessLists={accessLists}
/>
)}
{deleteHost && (
<DeleteHostDialog
open={!!deleteHost}
host={deleteHost}
onClose={() => setDeleteHost(null)}
/>
)}
</Stack>
);
}
function HiddenCheckboxField({ name, defaultChecked, label }: { name: string; defaultChecked: boolean; label: string }) {
function CreateHostDialog({
open,
onClose,
certificates,
accessLists
}: {
open: boolean;
onClose: () => void;
certificates: Certificate[];
accessLists: AccessList[];
}) {
const [state, formAction] = useFormState(createProxyHostAction, INITIAL_ACTION_STATE);
return (
<Box sx={{ p: 1, borderRadius: 2, border: "1px solid rgba(148, 163, 184, 0.12)", bgcolor: "rgba(9, 13, 23, 0.6)" }}>
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
bgcolor: "rgba(20, 20, 22, 0.98)",
border: "0.5px solid rgba(255, 255, 255, 0.1)",
backgroundImage: "none"
}
}}
>
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Create Proxy Host
</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Stack component="form" id="create-form" action={formAction} spacing={2.5}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"} onClose={() => state.status === "success" && onClose()}>
{state.message}
</Alert>
)}
<TextField name="name" label="Name" placeholder="My Service" required fullWidth />
<TextField
name="domains"
label="Domains"
placeholder="app.example.com"
helperText="One per line or comma-separated"
multiline
minRows={2}
required
fullWidth
/>
<TextField
name="upstreams"
label="Upstreams"
placeholder="http://10.0.0.5:8080"
helperText="One per line or comma-separated"
multiline
minRows={2}
required
fullWidth
/>
<TextField select name="certificate_id" label="Certificate" defaultValue="" fullWidth>
<MenuItem value="">Managed by Caddy (Auto)</MenuItem>
{certificates.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.name}
</MenuItem>
))}
</TextField>
<TextField select name="access_list_id" label="Access List" defaultValue="" fullWidth>
<MenuItem value="">None</MenuItem>
{accessLists.map((list) => (
<MenuItem key={list.id} value={list.id}>
{list.name}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={2} flexWrap="wrap">
<HiddenCheckboxField name="hsts_subdomains" defaultChecked={false} label="HSTS Subdomains" />
<HiddenCheckboxField name="skip_https_hostname_validation" defaultChecked={false} label="Skip HTTPS Validation" />
<HiddenCheckboxField name="enabled" defaultChecked={true} label="Enabled" />
</Stack>
<TextField
name="custom_pre_handlers_json"
label="Custom Pre-Handlers (JSON)"
placeholder='[{"handler": "headers", ...}]'
helperText="Optional JSON array of Caddy handlers"
multiline
minRows={3}
fullWidth
/>
<TextField
name="custom_reverse_proxy_json"
label="Custom Reverse Proxy (JSON)"
placeholder='{"headers": {"request": {...}}}'
helperText="Deep-merge into reverse_proxy handler"
multiline
minRows={3}
fullWidth
/>
<AuthentikFields />
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} sx={{ color: "rgba(255, 255, 255, 0.6)" }}>
Cancel
</Button>
<Button type="submit" form="create-form" variant="contained">
Create
</Button>
</DialogActions>
</Dialog>
);
}
function EditHostDialog({
open,
host,
onClose,
certificates,
accessLists
}: {
open: boolean;
host: ProxyHost;
onClose: () => void;
certificates: Certificate[];
accessLists: AccessList[];
}) {
const [state, formAction] = useFormState(updateProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
bgcolor: "rgba(20, 20, 22, 0.98)",
border: "0.5px solid rgba(255, 255, 255, 0.1)",
backgroundImage: "none"
}
}}
>
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Edit Proxy Host
</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Stack component="form" id="edit-form" action={formAction} spacing={2.5}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"} onClose={() => state.status === "success" && onClose()}>
{state.message}
</Alert>
)}
<TextField name="name" label="Name" defaultValue={host.name} required fullWidth />
<TextField
name="domains"
label="Domains"
defaultValue={host.domains.join("\n")}
helperText="One per line or comma-separated"
multiline
minRows={2}
fullWidth
/>
<TextField
name="upstreams"
label="Upstreams"
defaultValue={host.upstreams.join("\n")}
helperText="One per line or comma-separated"
multiline
minRows={2}
fullWidth
/>
<TextField select name="certificate_id" label="Certificate" defaultValue={host.certificate_id ?? ""} fullWidth>
<MenuItem value="">Managed by Caddy (Auto)</MenuItem>
{certificates.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.name}
</MenuItem>
))}
</TextField>
<TextField select name="access_list_id" label="Access List" defaultValue={host.access_list_id ?? ""} fullWidth>
<MenuItem value="">None</MenuItem>
{accessLists.map((list) => (
<MenuItem key={list.id} value={list.id}>
{list.name}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={2} flexWrap="wrap">
<HiddenCheckboxField name="hsts_subdomains" defaultChecked={host.hsts_subdomains} label="HSTS Subdomains" />
<HiddenCheckboxField name="skip_https_hostname_validation" defaultChecked={host.skip_https_hostname_validation} label="Skip HTTPS Validation" />
<HiddenCheckboxField name="enabled" defaultChecked={host.enabled} label="Enabled" />
</Stack>
<TextField
name="custom_pre_handlers_json"
label="Custom Pre-Handlers (JSON)"
defaultValue={host.custom_pre_handlers_json ?? ""}
helperText="Optional JSON array of Caddy handlers"
multiline
minRows={3}
fullWidth
/>
<TextField
name="custom_reverse_proxy_json"
label="Custom Reverse Proxy (JSON)"
defaultValue={host.custom_reverse_proxy_json ?? ""}
helperText="Deep-merge into reverse_proxy handler"
multiline
minRows={3}
fullWidth
/>
<AuthentikFields authentik={host.authentik} />
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} sx={{ color: "rgba(255, 255, 255, 0.6)" }}>
Cancel
</Button>
<Button type="submit" form="edit-form" variant="contained">
Save Changes
</Button>
</DialogActions>
</Dialog>
);
}
function DeleteHostDialog({
open,
host,
onClose
}: {
open: boolean;
host: ProxyHost;
onClose: () => void;
}) {
const [state, formAction] = useFormState(deleteProxyHostAction.bind(null, host.id), INITIAL_ACTION_STATE);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
PaperProps={{
sx: {
bgcolor: "rgba(20, 20, 22, 0.98)",
border: "0.5px solid rgba(239, 68, 68, 0.3)",
backgroundImage: "none"
}
}}
>
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: "rgba(239, 68, 68, 1)" }}>
Delete Proxy Host
</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"} onClose={() => state.status === "success" && onClose()}>
{state.message}
</Alert>
)}
<Typography variant="body1">
Are you sure you want to delete the proxy host <strong>{host.name}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary">
This will remove the configuration for:
</Typography>
<Box sx={{ pl: 2 }}>
<Typography variant="body2" color="text.secondary">
Domains: {host.domains.join(", ")}
</Typography>
<Typography variant="body2" color="text.secondary">
Upstreams: {host.upstreams.join(", ")}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: "rgba(239, 68, 68, 0.9)", fontWeight: 500 }}>
This action cannot be undone.
</Typography>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} sx={{ color: "rgba(255, 255, 255, 0.6)" }}>
Cancel
</Button>
<form action={formAction} style={{ display: 'inline' }}>
<Button
type="submit"
variant="contained"
color="error"
>
Delete
</Button>
</form>
</DialogActions>
</Dialog>
);
}
function AuthentikFields({ authentik }: { authentik?: ProxyHost["authentik"] | null }) {
const initial = authentik ?? null;
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
const copyHeadersValue =
initial && initial.copyHeaders.length > 0 ? initial.copyHeaders.join("\n") : AUTHENTIK_DEFAULT_HEADERS.join("\n");
const trustedProxiesValue =
initial && initial.trustedProxies.length > 0
? initial.trustedProxies.join("\n")
: AUTHENTIK_DEFAULT_TRUSTED_PROXIES.join("\n");
const setHostHeaderDefault = initial?.setOutpostHostHeader ?? true;
return (
<Box
sx={{
borderRadius: 2,
border: "1px solid rgba(99, 102, 241, 0.2)",
background: "rgba(99, 102, 241, 0.05)",
p: 2.5
}}
>
<input type="hidden" name="authentik_present" value="1" />
<input type="hidden" name="authentik_enabled_present" value="1" />
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Authentik Forward Auth
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: "0.8125rem" }}>
Proxy authentication via Authentik outpost
</Typography>
</Box>
<Switch
name="authentik_enabled"
checked={enabled}
onChange={(_, checked) => setEnabled(checked)}
/>
</Stack>
<Collapse in={enabled} timeout="auto" unmountOnExit>
<Stack spacing={2}>
<TextField
name="authentik_outpost_domain"
label="Outpost Domain"
placeholder="outpost.goauthentik.io"
defaultValue={initial?.outpostDomain ?? ""}
required={enabled}
disabled={!enabled}
size="small"
fullWidth
/>
<TextField
name="authentik_outpost_upstream"
label="Outpost Upstream URL"
placeholder="https://outpost.internal:9000"
defaultValue={initial?.outpostUpstream ?? ""}
required={enabled}
disabled={!enabled}
size="small"
fullWidth
/>
<TextField
name="authentik_auth_endpoint"
label="Auth Endpoint (Optional)"
placeholder="/outpost.goauthentik.io/auth/caddy"
defaultValue={initial?.authEndpoint ?? ""}
disabled={!enabled}
size="small"
fullWidth
/>
<TextField
name="authentik_copy_headers"
label="Headers to Copy"
defaultValue={copyHeadersValue}
disabled={!enabled}
multiline
minRows={3}
size="small"
fullWidth
/>
<TextField
name="authentik_trusted_proxies"
label="Trusted Proxies"
defaultValue={trustedProxiesValue}
disabled={!enabled}
size="small"
fullWidth
/>
<HiddenCheckboxField
name="authentik_set_host_header"
defaultChecked={setHostHeaderDefault}
label="Set Host Header"
disabled={!enabled}
/>
</Stack>
</Collapse>
</Stack>
</Box>
);
}
function HiddenCheckboxField({
name,
defaultChecked,
label,
disabled
}: {
name: string;
defaultChecked: boolean;
label: string;
disabled?: boolean;
}) {
return (
<Box>
<input type="hidden" name={`${name}_present`} value="1" />
<FormControlLabel
control={
<Checkbox
name={name}
defaultChecked={defaultChecked}
disabled={disabled}
size="small"
sx={{
color: "rgba(148, 163, 184, 0.6)",
"&.Mui-checked": {
color: "#7f5bff"
}
"&.Mui-checked": { color: "#6366f1" }
}}
/>
}
label={label}
sx={{ width: "100%", m: 0 }}
label={<Typography variant="body2">{label}</Typography>}
disabled={disabled}
/>
</Box>
);

View File

@@ -2,7 +2,8 @@
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth";
import { createProxyHost, deleteProxyHost, updateProxyHost } from "@/src/lib/models/proxy-hosts";
import { actionError, actionSuccess, INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions";
import { createProxyHost, deleteProxyHost, updateProxyHost, type ProxyHostAuthentikInput } from "@/src/lib/models/proxy-hosts";
function parseCsv(value: FormDataEntryValue | null): string[] {
if (!value || typeof value !== "string") {
@@ -19,46 +20,144 @@ function parseCheckbox(value: FormDataEntryValue | null): boolean {
return value === "on" || value === "true" || value === "1";
}
export async function createProxyHostAction(formData: FormData) {
const { user } = await requireUser();
await createProxyHost(
{
name: String(formData.get("name") ?? "Untitled"),
domains: parseCsv(formData.get("domains")),
upstreams: parseCsv(formData.get("upstreams")),
certificate_id: formData.get("certificate_id") ? Number(formData.get("certificate_id")) : null,
access_list_id: formData.get("access_list_id") ? Number(formData.get("access_list_id")) : null,
hsts_subdomains: parseCheckbox(formData.get("hsts_subdomains")),
skip_https_hostname_validation: parseCheckbox(formData.get("skip_https_hostname_validation")),
enabled: parseCheckbox(formData.get("enabled"))
},
user.id
);
revalidatePath("/proxy-hosts");
function parseOptionalText(value: FormDataEntryValue | null): string | null {
if (!value || typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export async function updateProxyHostAction(id: number, formData: FormData) {
const { user } = await requireUser();
const boolField = (key: string) => (formData.has(`${key}_present`) ? parseCheckbox(formData.get(key)) : undefined);
await updateProxyHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseCsv(formData.get("domains")) : undefined,
upstreams: formData.get("upstreams") ? parseCsv(formData.get("upstreams")) : undefined,
certificate_id: formData.get("certificate_id") ? Number(formData.get("certificate_id")) : undefined,
access_list_id: formData.get("access_list_id") ? Number(formData.get("access_list_id")) : undefined,
hsts_subdomains: boolField("hsts_subdomains"),
skip_https_hostname_validation: boolField("skip_https_hostname_validation"),
enabled: boolField("enabled")
},
user.id
);
revalidatePath("/proxy-hosts");
function parseAuthentikConfig(formData: FormData): ProxyHostAuthentikInput | undefined {
if (!formData.has("authentik_present")) {
return undefined;
}
const enabledIndicator = formData.has("authentik_enabled_present");
const enabledValue = enabledIndicator
? formData.has("authentik_enabled")
? parseCheckbox(formData.get("authentik_enabled"))
: false
: undefined;
const outpostDomain = parseOptionalText(formData.get("authentik_outpost_domain"));
const outpostUpstream = parseOptionalText(formData.get("authentik_outpost_upstream"));
const authEndpoint = parseOptionalText(formData.get("authentik_auth_endpoint"));
const copyHeaders = parseCsv(formData.get("authentik_copy_headers"));
const trustedProxies = parseCsv(formData.get("authentik_trusted_proxies"));
const setHostHeader = formData.has("authentik_set_host_header_present")
? parseCheckbox(formData.get("authentik_set_host_header"))
: undefined;
const result: ProxyHostAuthentikInput = {};
if (enabledValue !== undefined) {
result.enabled = enabledValue;
}
if (outpostDomain !== null) {
result.outpostDomain = outpostDomain;
}
if (outpostUpstream !== null) {
result.outpostUpstream = outpostUpstream;
}
if (authEndpoint !== null) {
result.authEndpoint = authEndpoint;
}
if (copyHeaders.length > 0 || formData.has("authentik_copy_headers")) {
result.copyHeaders = copyHeaders;
}
if (trustedProxies.length > 0 || formData.has("authentik_trusted_proxies")) {
result.trustedProxies = trustedProxies;
}
if (setHostHeader !== undefined) {
result.setOutpostHostHeader = setHostHeader;
}
return Object.keys(result).length > 0 ? result : undefined;
}
export async function deleteProxyHostAction(id: number) {
const { user } = await requireUser();
await deleteProxyHost(id, user.id);
revalidatePath("/proxy-hosts");
export async function createProxyHostAction(
_prevState: ActionState = INITIAL_ACTION_STATE,
formData: FormData
): Promise<ActionState> {
try {
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await createProxyHost(
{
name: String(formData.get("name") ?? "Untitled"),
domains: parseCsv(formData.get("domains")),
upstreams: parseCsv(formData.get("upstreams")),
certificate_id: formData.get("certificate_id") ? Number(formData.get("certificate_id")) : null,
access_list_id: formData.get("access_list_id") ? Number(formData.get("access_list_id")) : null,
hsts_subdomains: parseCheckbox(formData.get("hsts_subdomains")),
skip_https_hostname_validation: parseCheckbox(formData.get("skip_https_hostname_validation")),
enabled: parseCheckbox(formData.get("enabled")),
custom_pre_handlers_json: parseOptionalText(formData.get("custom_pre_handlers_json")),
custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")),
authentik: parseAuthentikConfig(formData)
},
userId
);
revalidatePath("/proxy-hosts");
return actionSuccess("Proxy host created and queued for Caddy reload.");
} catch (error) {
console.error("Failed to create proxy host:", error);
return actionError(error, "Failed to create proxy host. Please check the logs for details.");
}
}
export async function updateProxyHostAction(
id: number,
_prevState: ActionState = INITIAL_ACTION_STATE,
formData: FormData
): Promise<ActionState> {
try {
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
const boolField = (key: string) => (formData.has(`${key}_present`) ? parseCheckbox(formData.get(key)) : undefined);
await updateProxyHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseCsv(formData.get("domains")) : undefined,
upstreams: formData.get("upstreams") ? parseCsv(formData.get("upstreams")) : undefined,
certificate_id: formData.get("certificate_id") ? Number(formData.get("certificate_id")) : undefined,
access_list_id: formData.get("access_list_id") ? Number(formData.get("access_list_id")) : undefined,
hsts_subdomains: boolField("hsts_subdomains"),
skip_https_hostname_validation: boolField("skip_https_hostname_validation"),
enabled: boolField("enabled"),
custom_pre_handlers_json: formData.has("custom_pre_handlers_json")
? parseOptionalText(formData.get("custom_pre_handlers_json"))
: undefined,
custom_reverse_proxy_json: formData.has("custom_reverse_proxy_json")
? parseOptionalText(formData.get("custom_reverse_proxy_json"))
: undefined,
authentik: parseAuthentikConfig(formData)
},
userId
);
revalidatePath("/proxy-hosts");
return actionSuccess("Proxy host updated.");
} catch (error) {
console.error(`Failed to update proxy host ${id}:`, error);
return actionError(error, "Failed to update proxy host. Please check the logs for details.");
}
}
export async function deleteProxyHostAction(
id: number,
_prevState: ActionState = INITIAL_ACTION_STATE
): Promise<ActionState> {
try {
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await deleteProxyHost(id, userId);
revalidatePath("/proxy-hosts");
return actionSuccess("Proxy host deleted.");
} catch (error) {
console.error(`Failed to delete proxy host ${id}:`, error);
return actionError(error, "Failed to delete proxy host. Please check the logs for details.");
}
}

View File

@@ -3,10 +3,12 @@ import { listProxyHosts } from "@/src/lib/models/proxy-hosts";
import { listCertificates } from "@/src/lib/models/certificates";
import { listAccessLists } from "@/src/lib/models/access-lists";
export default function ProxyHostsPage() {
const hosts = listProxyHosts();
const certificates = listCertificates();
const accessLists = listAccessLists();
export default async function ProxyHostsPage() {
const [hosts, certificates, accessLists] = await Promise.all([
listProxyHosts(),
listCertificates(),
listAccessLists()
]);
return <ProxyHostsClient hosts={hosts} certificates={certificates} accessLists={accessLists} />;
}

View File

@@ -1,22 +1,37 @@
"use client";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { useState } from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Button,
Card,
CardContent,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
Checkbox
Tooltip
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
import { useFormState } from "react-dom";
import type { RedirectHost } from "@/src/lib/models/redirect-hosts";
import { INITIAL_ACTION_STATE } from "@/src/lib/actions";
import { createRedirectAction, deleteRedirectAction, updateRedirectAction } from "./actions";
type Props = {
@@ -24,134 +39,424 @@ type Props = {
};
export default function RedirectsClient({ redirects }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editRedirect, setEditRedirect] = useState<RedirectHost | null>(null);
const [deleteRedirect, setDeleteRedirect] = useState<RedirectHost | null>(null);
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Redirects
</Typography>
<Typography color="text.secondary">Return HTTP 301/302 responses to guide clients toward canonical hosts.</Typography>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={2}>
<Stack spacing={1}>
<Typography
variant="h4"
sx={{
fontWeight: 600,
letterSpacing: "-0.02em",
color: "rgba(255, 255, 255, 0.95)"
}}
>
Redirects
</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 600 }}>
Return HTTP 301/302 responses to guide clients toward canonical hosts.
</Typography>
</Stack>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setCreateOpen(true)}
sx={{
bgcolor: "rgba(99, 102, 241, 0.9)",
"&:hover": { bgcolor: "rgba(99, 102, 241, 1)" }
}}
>
Create Redirect
</Button>
</Stack>
<Stack spacing={3}>
{redirects.map((redirect) => (
<Card key={redirect.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{redirect.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{redirect.domains.join(", ")}
</Typography>
</Box>
<Chip
label={redirect.enabled ? "Enabled" : "Disabled"}
color={redirect.enabled ? "success" : "warning"}
variant={redirect.enabled ? "filled" : "outlined"}
/>
</Box>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateRedirectAction(redirect.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={redirect.name} fullWidth />
<TextField
name="domains"
label="Domains"
defaultValue={redirect.domains.join("\n")}
multiline
minRows={2}
fullWidth
<TableContainer
component={Card}
sx={{
background: "rgba(20, 20, 22, 0.6)",
border: "0.5px solid rgba(255, 255, 255, 0.08)"
}}
>
<Table>
<TableHead>
<TableRow sx={{ bgcolor: "rgba(255, 255, 255, 0.02)" }}>
<TableCell sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Name</TableCell>
<TableCell sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Domains</TableCell>
<TableCell sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Destination</TableCell>
<TableCell sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Status Code</TableCell>
<TableCell sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Status</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, color: "rgba(255, 255, 255, 0.7)" }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{redirects.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 6, color: "text.secondary" }}>
No redirects configured. Click "Create Redirect" to add one.
</TableCell>
</TableRow>
) : (
redirects.map((redirect) => (
<TableRow
key={redirect.id}
sx={{
"&:hover": { bgcolor: "rgba(255, 255, 255, 0.02)" }
}}
>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500, color: "rgba(255, 255, 255, 0.9)" }}>
{redirect.name}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ color: "rgba(255, 255, 255, 0.7)", fontSize: "0.8125rem" }}>
{redirect.domains.join(", ")}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ color: "rgba(255, 255, 255, 0.7)", fontSize: "0.8125rem" }}>
{redirect.destination}
</Typography>
</TableCell>
<TableCell>
<Chip
label={redirect.status_code}
size="small"
sx={{
bgcolor: "rgba(99, 102, 241, 0.15)",
color: "rgba(99, 102, 241, 1)",
border: "1px solid rgba(99, 102, 241, 0.3)",
fontWeight: 500,
fontSize: "0.75rem"
}}
/>
<TextField name="destination" label="Destination URL" defaultValue={redirect.destination} fullWidth />
<TextField
name="status_code"
label="Status code"
type="number"
inputProps={{ min: 200, max: 399 }}
defaultValue={redirect.status_code}
fullWidth
</TableCell>
<TableCell>
<Chip
label={redirect.enabled ? "Enabled" : "Disabled"}
size="small"
sx={{
bgcolor: redirect.enabled ? "rgba(34, 197, 94, 0.15)" : "rgba(148, 163, 184, 0.15)",
color: redirect.enabled ? "rgba(34, 197, 94, 1)" : "rgba(148, 163, 184, 0.8)",
border: "1px solid",
borderColor: redirect.enabled ? "rgba(34, 197, 94, 0.3)" : "rgba(148, 163, 184, 0.3)",
fontWeight: 500,
fontSize: "0.75rem"
}}
/>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 1 }}>
<HiddenCheckboxField
name="preserve_query"
defaultChecked={redirect.preserve_query}
label="Preserve path/query"
/>
<HiddenCheckboxField name="enabled" defaultChecked={redirect.enabled} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
</TableCell>
<TableCell align="right">
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
<Tooltip title="Edit">
<IconButton
size="small"
onClick={() => setEditRedirect(redirect)}
sx={{
color: "rgba(99, 102, 241, 0.8)",
"&:hover": { bgcolor: "rgba(99, 102, 241, 0.1)" }
}}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
onClick={() => setDeleteRedirect(redirect)}
sx={{
color: "rgba(239, 68, 68, 0.8)",
"&:hover": { bgcolor: "rgba(239, 68, 68, 0.1)" }
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<Box component="form" action={deleteRedirectAction.bind(null, redirect.id)}>
<Button type="submit" variant="outlined" color="error">
Delete
</Button>
</Box>
</CardContent>
</Card>
))}
</Stack>
<CreateRedirectDialog open={createOpen} onClose={() => setCreateOpen(false)} />
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create redirect
</Typography>
<Card>
<CardContent>
<Stack component="form" action={createRedirectAction} spacing={2}>
<TextField name="name" label="Name" placeholder="Example redirect" required fullWidth />
<TextField
name="domains"
label="Domains"
placeholder="old.example.com"
multiline
minRows={2}
required
fullWidth
/>
<TextField name="destination" label="Destination URL" placeholder="https://new.example.com" required fullWidth />
<TextField
name="status_code"
label="Status code"
type="number"
inputProps={{ min: 200, max: 399 }}
defaultValue={302}
fullWidth
/>
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 1 }}>
<FormControlLabel control={<Checkbox name="preserve_query" defaultChecked />} label="Preserve path/query" />
<FormControlLabel control={<Checkbox name="enabled" defaultChecked />} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create Redirect
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
{editRedirect && (
<EditRedirectDialog
open={!!editRedirect}
redirect={editRedirect}
onClose={() => setEditRedirect(null)}
/>
)}
{deleteRedirect && (
<DeleteRedirectDialog
open={!!deleteRedirect}
redirect={deleteRedirect}
onClose={() => setDeleteRedirect(null)}
/>
)}
</Stack>
);
}
function CreateRedirectDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const [state, formAction] = useFormState(createRedirectAction, INITIAL_ACTION_STATE);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: "rgba(20, 20, 22, 0.98)",
border: "0.5px solid rgba(255, 255, 255, 0.1)",
backgroundImage: "none"
}
}}
>
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Create Redirect
</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Stack component="form" id="create-form" action={formAction} spacing={2.5}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"} onClose={() => state.status === "success" && onClose()}>
{state.message}
</Alert>
)}
<TextField name="name" label="Name" placeholder="Example redirect" required fullWidth />
<TextField
name="domains"
label="Domains"
placeholder="old.example.com"
helperText="One per line or comma-separated"
multiline
minRows={2}
required
fullWidth
/>
<TextField
name="destination"
label="Destination URL"
placeholder="https://new.example.com"
required
fullWidth
/>
<TextField
name="status_code"
label="Status Code"
type="number"
inputProps={{ min: 200, max: 399 }}
defaultValue={302}
fullWidth
/>
<Stack direction="row" spacing={2}>
<HiddenCheckboxField name="preserve_query" defaultChecked={true} label="Preserve Path/Query" />
<HiddenCheckboxField name="enabled" defaultChecked={true} label="Enabled" />
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} sx={{ color: "rgba(255, 255, 255, 0.6)" }}>
Cancel
</Button>
<Button type="submit" form="create-form" variant="contained">
Create
</Button>
</DialogActions>
</Dialog>
);
}
function EditRedirectDialog({
open,
redirect,
onClose
}: {
open: boolean;
redirect: RedirectHost;
onClose: () => void;
}) {
const [state, formAction] = useFormState(updateRedirectAction.bind(null, redirect.id), INITIAL_ACTION_STATE);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: "rgba(20, 20, 22, 0.98)",
border: "0.5px solid rgba(255, 255, 255, 0.1)",
backgroundImage: "none"
}
}}
>
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Edit Redirect
</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Stack component="form" id="edit-form" action={formAction} spacing={2.5}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"} onClose={() => state.status === "success" && onClose()}>
{state.message}
</Alert>
)}
<TextField name="name" label="Name" defaultValue={redirect.name} fullWidth />
<TextField
name="domains"
label="Domains"
defaultValue={redirect.domains.join("\n")}
helperText="One per line or comma-separated"
multiline
minRows={2}
fullWidth
/>
<TextField
name="destination"
label="Destination URL"
defaultValue={redirect.destination}
fullWidth
/>
<TextField
name="status_code"
label="Status Code"
type="number"
inputProps={{ min: 200, max: 399 }}
defaultValue={redirect.status_code}
fullWidth
/>
<Stack direction="row" spacing={2}>
<HiddenCheckboxField name="preserve_query" defaultChecked={redirect.preserve_query} label="Preserve Path/Query" />
<HiddenCheckboxField name="enabled" defaultChecked={redirect.enabled} label="Enabled" />
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} sx={{ color: "rgba(255, 255, 255, 0.6)" }}>
Cancel
</Button>
<Button type="submit" form="edit-form" variant="contained">
Save Changes
</Button>
</DialogActions>
</Dialog>
);
}
function DeleteRedirectDialog({
open,
redirect,
onClose
}: {
open: boolean;
redirect: RedirectHost;
onClose: () => void;
}) {
const [state, formAction] = useFormState(deleteRedirectAction.bind(null, redirect.id), INITIAL_ACTION_STATE);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
PaperProps={{
sx: {
bgcolor: "rgba(20, 20, 22, 0.98)",
border: "0.5px solid rgba(239, 68, 68, 0.3)",
backgroundImage: "none"
}
}}
>
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 600, color: "rgba(239, 68, 68, 1)" }}>
Delete Redirect
</Typography>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
{state.status !== "idle" && state.message && (
<Alert severity={state.status === "error" ? "error" : "success"} onClose={() => state.status === "success" && onClose()}>
{state.message}
</Alert>
)}
<Typography variant="body1">
Are you sure you want to delete the redirect <strong>{redirect.name}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary">
This will remove the redirect from:
</Typography>
<Box sx={{ pl: 2 }}>
<Typography variant="body2" color="text.secondary">
Domains: {redirect.domains.join(", ")}
</Typography>
<Typography variant="body2" color="text.secondary">
To: {redirect.destination}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: "rgba(239, 68, 68, 0.9)", fontWeight: 500 }}>
This action cannot be undone.
</Typography>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} sx={{ color: "rgba(255, 255, 255, 0.6)" }}>
Cancel
</Button>
<form action={formAction} style={{ display: 'inline' }}>
<Button
type="submit"
variant="contained"
color="error"
>
Delete
</Button>
</form>
</DialogActions>
</Dialog>
);
}
function HiddenCheckboxField({ name, defaultChecked, label }: { name: string; defaultChecked: boolean; label: string }) {
return (
<Box>
<input type="hidden" name={`${name}_present`} value="1" />
<FormControlLabel control={<Checkbox name={name} defaultChecked={defaultChecked} />} label={label} />
<FormControlLabel
control={
<Checkbox
name={name}
defaultChecked={defaultChecked}
size="small"
sx={{
color: "rgba(148, 163, 184, 0.6)",
"&.Mui-checked": { color: "#6366f1" }
}}
/>
}
label={<Typography variant="body2">{label}</Typography>}
/>
</Box>
);
}

View File

@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth";
import { createRedirectHost, deleteRedirectHost, updateRedirectHost } from "@/src/lib/models/redirect-hosts";
import { actionSuccess, actionError, type ActionState } from "@/src/lib/actions";
function parseList(value: FormDataEntryValue | null): string[] {
if (!value || typeof value !== "string") {
@@ -15,41 +16,62 @@ function parseList(value: FormDataEntryValue | null): string[] {
.filter(Boolean);
}
export async function createRedirectAction(formData: FormData) {
const { user } = await requireUser();
await createRedirectHost(
{
name: String(formData.get("name") ?? "Redirect"),
domains: parseList(formData.get("domains")),
destination: String(formData.get("destination") ?? ""),
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : 302,
preserve_query: formData.get("preserve_query") === "on",
enabled: formData.has("enabled") ? formData.get("enabled") === "on" : true
},
user.id
);
revalidatePath("/redirects");
export async function createRedirectAction(_prevState: ActionState, formData: FormData): Promise<ActionState> {
try {
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await createRedirectHost(
{
name: String(formData.get("name") ?? "Redirect"),
domains: parseList(formData.get("domains")),
destination: String(formData.get("destination") ?? ""),
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : 302,
preserve_query: formData.get("preserve_query") === "on",
enabled: formData.has("enabled") ? formData.get("enabled") === "on" : true
},
userId
);
revalidatePath("/redirects");
return actionSuccess("Redirect created successfully");
} catch (error) {
return actionError(error, "Failed to create redirect");
}
}
export async function updateRedirectAction(id: number, formData: FormData) {
const { user } = await requireUser();
await updateRedirectHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseList(formData.get("domains")) : undefined,
destination: formData.get("destination") ? String(formData.get("destination")) : undefined,
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : undefined,
preserve_query: formData.has("preserve_query_present") ? formData.get("preserve_query") === "on" : undefined,
enabled: formData.has("enabled_present") ? formData.get("enabled") === "on" : undefined
},
user.id
);
revalidatePath("/redirects");
export async function updateRedirectAction(id: number, _prevState: ActionState, formData: FormData): Promise<ActionState> {
try {
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await updateRedirectHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseList(formData.get("domains")) : undefined,
destination: formData.get("destination") ? String(formData.get("destination")) : undefined,
status_code: formData.get("status_code") ? Number(formData.get("status_code")) : undefined,
preserve_query: formData.has("preserve_query_present") ? formData.get("preserve_query") === "on" : undefined,
enabled: formData.has("enabled_present") ? formData.get("enabled") === "on" : undefined
},
userId
);
revalidatePath("/redirects");
return actionSuccess("Redirect updated successfully");
} catch (error) {
return actionError(error, "Failed to update redirect");
}
}
export async function deleteRedirectAction(id: number) {
const { user } = await requireUser();
await deleteRedirectHost(id, user.id);
revalidatePath("/redirects");
export async function deleteRedirectAction(id: number, _prevState: ActionState): Promise<ActionState> {
try {
const session = await requireUser();
const user = session.user;
const userId = Number(user.id);
await deleteRedirectHost(id, userId);
revalidatePath("/redirects");
return actionSuccess("Redirect deleted successfully");
} catch (error) {
return actionError(error, "Failed to delete redirect");
}
}

View File

@@ -1,7 +1,7 @@
import RedirectsClient from "./RedirectsClient";
import { listRedirectHosts } from "@/src/lib/models/redirect-hosts";
export default function RedirectsPage() {
const redirects = listRedirectHosts();
export default async function RedirectsPage() {
const redirects = await listRedirectHosts();
return <RedirectsClient redirects={redirects} />;
}

View File

@@ -1,29 +1,29 @@
"use client";
import { useState } from "react";
import { Box, Button, Card, CardContent, FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Stack, TextField, Typography } from "@mui/material";
import type { CloudflareSettings, GeneralSettings, OAuthSettings } from "@/src/lib/settings";
import { useFormState } from "react-dom";
import { Alert, Box, Button, Card, CardContent, Stack, TextField, Typography } from "@mui/material";
import type { CloudflareSettings, GeneralSettings } from "@/src/lib/settings";
import {
updateCloudflareSettingsAction,
updateGeneralSettingsAction,
updateOAuthSettingsAction
updateGeneralSettingsAction
} from "./actions";
type Props = {
general: GeneralSettings | null;
oauth: OAuthSettings | null;
cloudflare: CloudflareSettings | null;
};
export default function SettingsClient({ general, oauth, cloudflare }: Props) {
const [providerType, setProviderType] = useState<"authentik" | "generic">(oauth?.providerType || "authentik");
export default function SettingsClient({ general, cloudflare }: Props) {
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null);
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Settings
</Typography>
<Typography color="text.secondary">Configure organization-wide defaults, authentication, and DNS automation.</Typography>
<Typography color="text.secondary">Configure organization-wide defaults and DNS automation.</Typography>
</Stack>
<Card>
@@ -31,7 +31,12 @@ export default function SettingsClient({ general, oauth, cloudflare }: Props) {
<Typography variant="h6" fontWeight={600} gutterBottom>
General
</Typography>
<Stack component="form" action={updateGeneralSettingsAction} spacing={2}>
<Stack component="form" action={generalFormAction} spacing={2}>
{generalState?.message && (
<Alert severity={generalState.success ? "success" : "error"}>
{generalState.message}
</Alert>
)}
<TextField
name="primaryDomain"
label="Primary domain"
@@ -55,67 +60,6 @@ export default function SettingsClient({ general, oauth, cloudflare }: Props) {
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>
OAuth2/OIDC Authentication
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Provide the OAuth 2.0/OIDC endpoints and client credentials issued by your identity provider. Scopes should include profile and
email data.
</Typography>
<Stack component="form" action={updateOAuthSettingsAction} spacing={2}>
<FormControl component="fieldset">
<FormLabel component="legend" sx={{ mb: 1 }}>Provider Type</FormLabel>
<RadioGroup
row
name="providerType"
value={providerType}
onChange={(e) => setProviderType(e.target.value as "authentik" | "generic")}
>
<FormControlLabel value="authentik" control={<Radio />} label="Authentik (OIDC)" />
<FormControlLabel value="generic" control={<Radio />} label="Generic OAuth2" />
</RadioGroup>
</FormControl>
{providerType === "authentik" ? (
<>
<TextField
name="authorizationUrl"
label="Authorization URL"
defaultValue={oauth?.authorizationUrl ?? ""}
helperText="Other endpoints will be auto-discovered from the OIDC issuer"
required
fullWidth
/>
<TextField name="clientId" label="Client ID" defaultValue={oauth?.clientId ?? ""} required fullWidth />
<TextField name="clientSecret" label="Client secret" defaultValue={oauth?.clientSecret ?? ""} required fullWidth type="password" />
<TextField name="scopes" label="Scopes" defaultValue={oauth?.scopes ?? "openid email profile"} fullWidth />
</>
) : (
<>
<TextField name="authorizationUrl" label="Authorization URL" defaultValue={oauth?.authorizationUrl ?? ""} required fullWidth />
<TextField name="tokenUrl" label="Token URL" defaultValue={oauth?.tokenUrl ?? ""} required fullWidth />
<TextField name="userInfoUrl" label="User info URL" defaultValue={oauth?.userInfoUrl ?? ""} required fullWidth />
<TextField name="clientId" label="Client ID" defaultValue={oauth?.clientId ?? ""} required fullWidth />
<TextField name="clientSecret" label="Client secret" defaultValue={oauth?.clientSecret ?? ""} required fullWidth type="password" />
<TextField name="scopes" label="Scopes" defaultValue={oauth?.scopes ?? "openid email profile"} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField name="emailClaim" label="Email claim" defaultValue={oauth?.emailClaim ?? "email"} fullWidth />
<TextField name="nameClaim" label="Name claim" defaultValue={oauth?.nameClaim ?? "name"} fullWidth />
<TextField name="avatarClaim" label="Avatar claim" defaultValue={oauth?.avatarClaim ?? "picture"} fullWidth />
</Stack>
</>
)}
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save OAuth settings
</Button>
</Box>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>
@@ -124,7 +68,12 @@ export default function SettingsClient({ general, oauth, cloudflare }: Props) {
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates.
</Typography>
<Stack component="form" action={updateCloudflareSettingsAction} spacing={2}>
<Stack component="form" action={cloudflareFormAction} spacing={2}>
{cloudflareState?.message && (
<Alert severity={cloudflareState.success ? "success" : "warning"}>
{cloudflareState.message}
</Alert>
)}
<TextField name="apiToken" label="API token" defaultValue={cloudflare?.apiToken ?? ""} fullWidth />
<TextField name="zoneId" label="Zone ID" defaultValue={cloudflare?.zoneId ?? ""} fullWidth />
<TextField name="accountId" label="Account ID" defaultValue={cloudflare?.accountId ?? ""} fullWidth />

View File

@@ -1,51 +1,60 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth";
import { requireAdmin } from "@/src/lib/auth";
import { applyCaddyConfig } from "@/src/lib/caddy";
import { saveCloudflareSettings, saveGeneralSettings, saveOAuthSettings } from "@/src/lib/settings";
import { saveCloudflareSettings, saveGeneralSettings } from "@/src/lib/settings";
export async function updateGeneralSettingsAction(formData: FormData) {
await requireUser(); // ensure authenticated
saveGeneralSettings({
primaryDomain: String(formData.get("primaryDomain") ?? ""),
acmeEmail: formData.get("acmeEmail") ? String(formData.get("acmeEmail")) : undefined
});
revalidatePath("/settings");
}
type ActionResult = {
success: boolean;
message?: string;
};
export async function updateOAuthSettingsAction(formData: FormData) {
await requireUser();
const providerType = String(formData.get("providerType") ?? "authentik");
saveOAuthSettings({
providerType: providerType === "generic" ? "generic" : "authentik",
authorizationUrl: String(formData.get("authorizationUrl") ?? ""),
tokenUrl: String(formData.get("tokenUrl") ?? ""),
clientId: String(formData.get("clientId") ?? ""),
clientSecret: String(formData.get("clientSecret") ?? ""),
userInfoUrl: String(formData.get("userInfoUrl") ?? ""),
scopes: String(formData.get("scopes") ?? ""),
emailClaim: formData.get("emailClaim") ? String(formData.get("emailClaim")) : undefined,
nameClaim: formData.get("nameClaim") ? String(formData.get("nameClaim")) : undefined,
avatarClaim: formData.get("avatarClaim") ? String(formData.get("avatarClaim")) : undefined
});
revalidatePath("/settings");
}
export async function updateCloudflareSettingsAction(formData: FormData) {
await requireUser();
const apiToken = String(formData.get("apiToken") ?? "");
if (!apiToken) {
saveCloudflareSettings({ apiToken: "", zoneId: undefined, accountId: undefined });
} else {
saveCloudflareSettings({
apiToken,
zoneId: formData.get("zoneId") ? String(formData.get("zoneId")) : undefined,
accountId: formData.get("accountId") ? String(formData.get("accountId")) : undefined
export async function updateGeneralSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
saveGeneralSettings({
primaryDomain: String(formData.get("primaryDomain") ?? ""),
acmeEmail: formData.get("acmeEmail") ? String(formData.get("acmeEmail")) : undefined
});
revalidatePath("/settings");
return { success: true, message: "General settings saved successfully" };
} catch (error) {
console.error("Failed to save general settings:", error);
return { success: false, message: error instanceof Error ? error.message : "Failed to save general settings" };
}
}
export async function updateCloudflareSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const apiToken = String(formData.get("apiToken") ?? "");
if (!apiToken) {
saveCloudflareSettings({ apiToken: "", zoneId: undefined, accountId: undefined });
} else {
saveCloudflareSettings({
apiToken,
zoneId: formData.get("zoneId") ? String(formData.get("zoneId")) : undefined,
accountId: formData.get("accountId") ? String(formData.get("accountId")) : undefined
});
}
// Try to apply the config, but don't fail if Caddy is unreachable
try {
await applyCaddyConfig();
revalidatePath("/settings");
return { success: true, message: "Cloudflare settings saved and applied to Caddy successfully" };
} catch (error) {
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
return {
success: true, // Settings were saved successfully
message: `Settings saved, but could not apply to Caddy: ${errorMsg}. You may need to start Caddy or check your configuration.`
};
}
} catch (error) {
console.error("Failed to save Cloudflare settings:", error);
return { success: false, message: error instanceof Error ? error.message : "Failed to save Cloudflare settings" };
}
await applyCaddyConfig();
revalidatePath("/settings");
}

View File

@@ -1,12 +1,19 @@
import SettingsClient from "./SettingsClient";
import { getCloudflareSettings, getGeneralSettings, getOAuthSettings } from "@/src/lib/settings";
import { getCloudflareSettings, getGeneralSettings } from "@/src/lib/settings";
import { requireAdmin } from "@/src/lib/auth";
export default async function SettingsPage() {
await requireAdmin();
const [general, cloudflare] = await Promise.all([
getGeneralSettings(),
getCloudflareSettings()
]);
export default function SettingsPage() {
return (
<SettingsClient
general={getGeneralSettings()}
oauth={getOAuthSettings()}
cloudflare={getCloudflareSettings()}
general={general}
cloudflare={cloudflare}
/>
);
}

View File

@@ -1,134 +0,0 @@
"use client";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Card,
CardContent,
Chip,
FormControlLabel,
MenuItem,
Stack,
TextField,
Typography,
Checkbox
} from "@mui/material";
import type { StreamHost } from "@/src/lib/models/stream-hosts";
import { createStreamAction, deleteStreamAction, updateStreamAction } from "./actions";
type Props = {
streams: StreamHost[];
};
export default function StreamsClient({ streams }: Props) {
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Streams
</Typography>
<Typography color="text.secondary">Forward raw TCP/UDP connections through Caddy&apos;s layer4 module.</Typography>
</Stack>
<Stack spacing={3}>
{streams.map((stream) => (
<Card key={stream.id}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Box>
<Typography variant="h6" fontWeight={600}>
{stream.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Listens on :{stream.listen_port} ({stream.protocol.toUpperCase()}) {stream.upstream}
</Typography>
</Box>
<Chip
label={stream.enabled ? "Enabled" : "Disabled"}
color={stream.enabled ? "success" : "warning"}
variant={stream.enabled ? "filled" : "outlined"}
/>
</Box>
<Accordion elevation={0} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ px: 0 }}>
<Typography fontWeight={600}>Edit</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<Stack component="form" action={(formData) => updateStreamAction(stream.id, formData)} spacing={2}>
<TextField name="name" label="Name" defaultValue={stream.name} fullWidth />
<TextField
name="listen_port"
label="Listen port"
type="number"
inputProps={{ min: 1, max: 65535 }}
defaultValue={stream.listen_port}
fullWidth
/>
<TextField select name="protocol" label="Protocol" defaultValue={stream.protocol} fullWidth>
<MenuItem value="tcp">TCP</MenuItem>
<MenuItem value="udp">UDP</MenuItem>
</TextField>
<TextField name="upstream" label="Upstream" defaultValue={stream.upstream} fullWidth />
<Box>
<input type="hidden" name="enabled_present" value="1" />
<FormControlLabel control={<Checkbox name="enabled" defaultChecked={stream.enabled} />} label="Enabled" />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save
</Button>
</Box>
</Stack>
</AccordionDetails>
</Accordion>
<Box component="form" action={deleteStreamAction.bind(null, stream.id)}>
<Button type="submit" variant="outlined" color="error">
Delete
</Button>
</Box>
</CardContent>
</Card>
))}
</Stack>
<Stack spacing={2} component="section">
<Typography variant="h6" fontWeight={600}>
Create stream
</Typography>
<Card>
<CardContent>
<Stack component="form" action={createStreamAction} spacing={2}>
<TextField name="name" label="Name" placeholder="SSH tunnel" required fullWidth />
<TextField
name="listen_port"
label="Listen port"
type="number"
inputProps={{ min: 1, max: 65535 }}
placeholder="2222"
required
fullWidth
/>
<TextField select name="protocol" label="Protocol" defaultValue="tcp" fullWidth>
<MenuItem value="tcp">TCP</MenuItem>
<MenuItem value="udp">UDP</MenuItem>
</TextField>
<TextField name="upstream" label="Upstream" placeholder="10.0.0.12:22" required fullWidth />
<FormControlLabel control={<Checkbox name="enabled" defaultChecked />} label="Enabled" />
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Create Stream
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
</Stack>
);
}

View File

@@ -1,42 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireUser } from "@/src/lib/auth";
import { createStreamHost, deleteStreamHost, updateStreamHost } from "@/src/lib/models/stream-hosts";
export async function createStreamAction(formData: FormData) {
const { user } = await requireUser();
await createStreamHost(
{
name: String(formData.get("name") ?? "Stream"),
listen_port: Number(formData.get("listen_port")),
protocol: String(formData.get("protocol") ?? "tcp"),
upstream: String(formData.get("upstream") ?? ""),
enabled: formData.has("enabled") ? formData.get("enabled") === "on" : true
},
user.id
);
revalidatePath("/streams");
}
export async function updateStreamAction(id: number, formData: FormData) {
const { user } = await requireUser();
await updateStreamHost(
id,
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
listen_port: formData.get("listen_port") ? Number(formData.get("listen_port")) : undefined,
protocol: formData.get("protocol") ? String(formData.get("protocol")) : undefined,
upstream: formData.get("upstream") ? String(formData.get("upstream")) : undefined,
enabled: formData.has("enabled_present") ? formData.get("enabled") === "on" : undefined
},
user.id
);
revalidatePath("/streams");
}
export async function deleteStreamAction(id: number) {
const { user } = await requireUser();
await deleteStreamHost(id, user.id);
revalidatePath("/streams");
}

View File

@@ -1,7 +0,0 @@
import StreamsClient from "./StreamsClient";
import { listStreamHosts } from "@/src/lib/models/stream-hosts";
export default function StreamsPage() {
const streams = listStreamHosts();
return <StreamsClient streams={streams} />;
}

View File

@@ -1,97 +0,0 @@
"use client";
import { useState } from "react";
import Grid from "@mui/material/Grid";
import { Box, Button, Card, CardContent, FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Stack, TextField, Typography } from "@mui/material";
export default function OAuthSetupClient({ startSetup }: { startSetup: (formData: FormData) => void }) {
const [providerType, setProviderType] = useState<"authentik" | "generic">("authentik");
return (
<Box sx={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", bgcolor: "background.default" }}>
<Card sx={{ width: { xs: "90vw", sm: 640 }, p: { xs: 2, sm: 3 } }}>
<CardContent>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography variant="h4" fontWeight={600}>
Configure OAuth2/OIDC
</Typography>
<Typography color="text.secondary">
Provide the OAuth configuration for your identity provider to finish setting up Caddy Proxy Manager. The first user who
signs in becomes the administrator.
</Typography>
</Stack>
<Stack component="form" action={startSetup} spacing={2}>
<FormControl component="fieldset">
<FormLabel component="legend" sx={{ mb: 1 }}>Provider Type</FormLabel>
<RadioGroup
row
name="providerType"
value={providerType}
onChange={(e) => setProviderType(e.target.value as "authentik" | "generic")}
>
<FormControlLabel value="authentik" control={<Radio />} label="Authentik (OIDC)" />
<FormControlLabel value="generic" control={<Radio />} label="Generic OAuth2" />
</RadioGroup>
</FormControl>
{providerType === "authentik" ? (
<>
<TextField
name="authorizationUrl"
label="Authorization URL"
placeholder="https://authentik.example.com/application/o/myapp/authorization/authorize/"
helperText="Other endpoints will be auto-discovered from the OIDC issuer"
required
fullWidth
/>
<TextField name="clientId" label="Client ID" placeholder="client-id" required fullWidth />
<TextField name="clientSecret" label="Client secret" placeholder="client-secret" required fullWidth type="password" />
<TextField name="scopes" label="Scopes" defaultValue="openid email profile" fullWidth />
</>
) : (
<>
<TextField
name="authorizationUrl"
label="Authorization URL"
placeholder="https://id.example.com/oauth2/authorize"
required
fullWidth
/>
<TextField name="tokenUrl" label="Token URL" placeholder="https://id.example.com/oauth2/token" required fullWidth />
<TextField
name="userInfoUrl"
label="User info URL"
placeholder="https://id.example.com/oauth2/userinfo"
required
fullWidth
/>
<TextField name="clientId" label="Client ID" placeholder="client-id" required fullWidth />
<TextField name="clientSecret" label="Client secret" placeholder="client-secret" required fullWidth type="password" />
<TextField name="scopes" label="Scopes" defaultValue="openid email profile" fullWidth />
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField name="emailClaim" label="Email claim" defaultValue="email" fullWidth />
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField name="nameClaim" label="Name claim" defaultValue="name" fullWidth />
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField name="avatarClaim" label="Avatar claim" defaultValue="picture" fullWidth />
</Grid>
</Grid>
</>
)}
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained" size="large">
Save OAuth configuration
</Button>
</Box>
</Stack>
</Stack>
</CardContent>
</Card>
</Box>
);
}

View File

@@ -1,25 +0,0 @@
"use server";
import { redirect } from "next/navigation";
import { getUserCount } from "@/src/lib/models/user";
import { saveOAuthSettings } from "@/src/lib/settings";
export async function initialOAuthSetupAction(formData: FormData) {
// Allow reconfiguring OAuth even if users exist (in case settings were lost)
// Just save the settings and redirect
const providerType = String(formData.get("providerType") ?? "authentik");
saveOAuthSettings({
providerType: providerType === "generic" ? "generic" : "authentik",
authorizationUrl: String(formData.get("authorizationUrl") ?? ""),
tokenUrl: String(formData.get("tokenUrl") ?? ""),
userInfoUrl: String(formData.get("userInfoUrl") ?? ""),
clientId: String(formData.get("clientId") ?? ""),
clientSecret: String(formData.get("clientSecret") ?? ""),
scopes: String(formData.get("scopes") ?? ""),
emailClaim: formData.get("emailClaim") ? String(formData.get("emailClaim")) : undefined,
nameClaim: formData.get("nameClaim") ? String(formData.get("nameClaim")) : undefined,
avatarClaim: formData.get("avatarClaim") ? String(formData.get("avatarClaim")) : undefined
});
redirect("/login");
}

View File

@@ -1,18 +0,0 @@
import { redirect } from "next/navigation";
import { getOAuthSettings } from "@/src/lib/settings";
import { getUserCount } from "@/src/lib/models/user";
import { initialOAuthSetupAction } from "./actions";
import OAuthSetupClient from "./SetupClient";
export default function OAuthSetupPage() {
// Only redirect if BOTH users exist AND OAuth is configured
// This allows reconfiguring OAuth even if users exist
const hasUsers = getUserCount() > 0;
const hasOAuth = getOAuthSettings();
if (hasUsers && hasOAuth) {
redirect("/login");
}
return <OAuthSetupClient startSetup={initialOAuthSetupAction} />;
}

View File

@@ -1,32 +0,0 @@
services:
app:
build:
context: .
dockerfile: docker/web/Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_PATH: /data/app/app.db
SESSION_SECRET: ${SESSION_SECRET:-change-me}
CADDY_API_URL: http://caddy:2019
CERTS_DIRECTORY: /data/certs
volumes:
- ./data/app:/data/app
- ./data/certs:/data/certs
depends_on:
- caddy
caddy:
build:
context: .
dockerfile: docker/caddy/Dockerfile
ports:
- "80:80"
- "443:443"
- "2019:2019"
environment:
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:-caddyproxymanager.com}
volumes:
- ./data/caddy:/data
- ./data/certs:/data/certs:ro

78
docker-compose.yml Normal file
View File

@@ -0,0 +1,78 @@
services:
web:
container_name: caddy-proxy-manager-web
image: ghcr.io/fuomag9/caddy-proxy-manager-web:latest
build:
context: .
dockerfile: docker/web/Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
# Node environment
NODE_ENV: production
# REQUIRED: Change this to a random secure string in production
# Generate with: openssl rand -base64 32
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
# Caddy API endpoint (internal communication)
CADDY_API_URL: ${CADDY_API_URL:-http://caddy:2019}
# Public base URL for the application
BASE_URL: ${BASE_URL:-http://localhost:3000}
# Database configuration
DATABASE_PATH: /app/data/caddy-proxy-manager.db
DATABASE_URL: file:/app/data/caddy-proxy-manager.db
# NextAuth configuration
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}
volumes:
- ./data:/app/data
depends_on:
caddy:
condition: service_healthy
networks:
- caddy-network
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
caddy:
container_name: caddy-proxy-manager-caddy
image: ghcr.io/fuomag9/caddy-proxy-manager-caddy:latest
build:
context: .
dockerfile: docker/caddy/Dockerfile
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "2019:2019"
environment:
# Primary domain for Caddy configuration
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:-caddyproxymanager.com}
volumes:
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- caddy-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:2019/config/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
caddy-network:
driver: bridge

View File

@@ -1,30 +1,50 @@
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS base
FROM node:20-slim AS base
WORKDIR /app
FROM base AS deps
# Install build dependencies for native modules like better-sqlite3
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
FROM base AS builder
ENV NODE_ENV=production
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
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
RUN npx prisma generate
RUN npx prisma db push --skip-generate
RUN npm run build && rm -f /tmp/build.db
FROM base AS runner
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
RUN addgroup -g 1001 nodejs && adduser -S nextjs -G nodejs
RUN groupadd -g 1001 nodejs && useradd -r -u 1001 -g nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/package.json ./package.json
# 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
# Create data directory for SQLite database
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000

404
package-lock.json generated
View File

@@ -12,11 +12,13 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"@prisma/client": "^6.18.0",
"@types/better-sqlite3": "^7.6.13",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1",
"next": "^16.0.1",
"next-auth": "^5.0.0-beta.30",
"prisma": "^6.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -88,6 +90,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -428,6 +431,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -471,6 +475,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1240,6 +1245,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.4",
@@ -1668,6 +1674,85 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@prisma/client": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.18.0.tgz",
"integrity": "sha512-jnL2I9gDnPnw4A+4h5SuNn8Gc+1mL1Z79U/3I9eE2gbxJG1oSA+62ByPW4xkeDgwE0fqMzzpAZ7IHxYnLZ4iQA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"prisma": "*",
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@prisma/config": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.18.0.tgz",
"integrity": "sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==",
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.18.4",
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz",
"integrity": "sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.18.0",
"@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
"@prisma/fetch-engine": "6.18.0",
"@prisma/get-platform": "6.18.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f.tgz",
"integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz",
"integrity": "sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.18.0",
"@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
"@prisma/get-platform": "6.18.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.18.0.tgz",
"integrity": "sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.18.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1675,6 +1760,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1751,6 +1842,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1820,6 +1912,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -2350,6 +2443,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2769,6 +2863,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -2807,6 +2902,34 @@
"ieee754": "^1.1.13"
}
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
"confbox": "^0.2.2",
"defu": "^6.1.4",
"dotenv": "^16.6.1",
"exsolve": "^1.0.7",
"giget": "^2.0.0",
"jiti": "^2.4.2",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^1.0.0",
"pkg-types": "^2.2.0",
"rc9": "^2.1.2"
},
"peerDependencies": {
"magicast": "^0.3.5"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -2903,12 +3026,36 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2951,6 +3098,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
@@ -3103,6 +3265,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -3139,6 +3310,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT"
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3158,6 +3341,18 @@
"csstype": "^3.0.2"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3173,6 +3368,16 @@
"node": ">= 0.4"
}
},
"node_modules/effect": {
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"fast-check": "^3.23.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.244",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz",
@@ -3187,6 +3392,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/empathic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -3410,6 +3624,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3595,6 +3810,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3909,6 +4125,34 @@
"node": ">=6"
}
},
"node_modules/exsolve": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
"integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^6.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4198,6 +4442,23 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/giget": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.0",
"defu": "^6.1.4",
"node-fetch-native": "^1.6.6",
"nypm": "^0.6.0",
"pathe": "^2.0.3"
},
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
@@ -4908,6 +5169,15 @@
"node": ">= 0.4"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
@@ -5231,6 +5501,7 @@
"resolved": "https://registry.npmjs.org/next/-/next-16.0.1.tgz",
"integrity": "sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "16.0.1",
"@swc/helpers": "0.5.15",
@@ -5317,6 +5588,12 @@
"node": ">=10"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -5324,6 +5601,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/nypm": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz",
"integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==",
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"consola": "^3.4.2",
"pathe": "^2.0.3",
"pkg-types": "^2.3.0",
"tinyexec": "^1.0.1"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
"node": "^14.16.0 || >=16.10.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.2.tgz",
@@ -5455,6 +5751,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -5597,6 +5899,18 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5616,6 +5930,17 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5659,6 +5984,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -5709,6 +6035,32 @@
"node": ">= 0.8.0"
}
},
"node_modules/prisma": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.18.0.tgz",
"integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.18.0",
"@prisma/engines": "6.18.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5740,6 +6092,22 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5785,11 +6153,22 @@
"node": ">=0.10.0"
}
},
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
"destr": "^2.0.3"
}
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5799,6 +6178,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5842,6 +6222,19 @@
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6555,6 +6948,12 @@
"node": ">=6"
}
},
"node_modules/tinyexec": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6596,6 +6995,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6755,8 +7155,9 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7052,6 +7453,7 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -15,11 +15,13 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"@prisma/client": "^6.18.0",
"@types/better-sqlite3": "^7.6.13",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1",
"next": "^16.0.1",
"next-auth": "^5.0.0-beta.30",
"prisma": "^6.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},

205
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,205 @@
// 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"
}
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")
}

0
public/.gitkeep Normal file
View File

25
src/lib/actions.ts Normal file
View File

@@ -0,0 +1,25 @@
export type ActionState = {
status: "idle" | "success" | "error";
message?: string;
};
export const INITIAL_ACTION_STATE: ActionState = { status: "idle" };
export function actionSuccess(message?: string): ActionState {
return {
status: "success",
message
};
}
export function actionError(error: unknown, fallbackMessage: string): ActionState {
const message = error instanceof Error ? error.message : fallbackMessage;
return {
status: "error",
message
};
}
export function extractErrorMessage(error: unknown, fallbackMessage: string): string {
return error instanceof Error ? error.message : fallbackMessage;
}

View File

@@ -1,4 +1,4 @@
import db, { nowIso } from "./db";
import prisma, { nowIso } from "./db";
export function logAuditEvent(params: {
userId?: number | null;
@@ -8,16 +8,18 @@ export function logAuditEvent(params: {
summary?: string | null;
data?: unknown;
}) {
db.prepare(
`INSERT INTO audit_events (user_id, action, entity_type, entity_id, summary, data, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(
params.userId ?? null,
params.action,
params.entityType,
params.entityId ?? null,
params.summary ?? null,
params.data ? JSON.stringify(params.data) : null,
nowIso()
);
prisma.auditEvent.create({
data: {
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) => {
// Log error but don't throw to avoid breaking the main flow
console.error("Failed to log audit event:", error);
});
}

View File

@@ -1,9 +1,6 @@
import NextAuth, { type DefaultSession } from "next-auth";
import Authentik from "next-auth/providers/authentik";
import { CustomAdapter } from "./auth/adapter";
import { getOAuthSettings } from "./settings";
import Credentials from "next-auth/providers/credentials";
import { config } from "./config";
import type { SessionContext, UserRecord } from "./auth/session";
declare module "next-auth" {
interface Session {
@@ -18,153 +15,67 @@ declare module "next-auth" {
}
}
// Legacy compatibility types
export type { SessionContext, UserRecord };
// Simple credentials provider that checks against environment variables
function createCredentialsProvider() {
return Credentials({
id: "credentials",
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
const username = credentials?.username ? String(credentials.username).trim() : "";
const password = credentials?.password ? String(credentials.password) : "";
/**
* Creates the appropriate OAuth provider based on settings.
*/
function createOAuthProvider() {
const settings = getOAuthSettings();
if (!settings) {
return null;
}
// Use official Authentik provider for OIDC
if (settings.providerType === "authentik") {
// Extract issuer from authorization URL
// Authentik format: https://domain/application/o/APP_SLUG/authorization/authorize/
// Issuer should be: https://domain/application/o/APP_SLUG/
let issuer: string;
try {
const url = new URL(settings.authorizationUrl);
const pathParts = url.pathname.split('/').filter(Boolean);
const oIndex = pathParts.indexOf('o');
if (oIndex >= 0 && pathParts[oIndex + 2] === 'authorization') {
const slug = pathParts[oIndex + 1];
issuer = `${url.origin}/application/o/${slug}/`;
} else {
// Fallback: remove the authorization path
issuer = settings.authorizationUrl.replace(/\/authorization\/authorize\/?$/, '/');
if (!username || !password) {
return null;
}
// Check against environment variables
if (username === config.adminUsername && password === config.adminPassword) {
return {
id: "1",
name: config.adminUsername,
email: `${config.adminUsername}@localhost`,
role: "admin"
};
}
console.log('[Auth.js] Derived Authentik issuer:', issuer);
console.log('[Auth.js] Will attempt OIDC discovery at:', `${issuer}.well-known/openid-configuration`);
} catch (e) {
console.error("Failed to parse Authentik issuer from URL", e);
return null;
}
return Authentik({
clientId: settings.clientId,
clientSecret: settings.clientSecret,
issuer,
authorization: {
params: {
scope: settings.scopes || "openid email profile",
},
},
});
}
// Generic OAuth2 provider for non-OIDC providers
const checks: Array<"pkce" | "state" | "none"> = ["state", "pkce"];
return {
id: "oauth",
name: "OAuth2",
type: "oauth" as const,
authorization: {
url: settings.authorizationUrl,
params: {
scope: settings.scopes || "openid email profile",
},
},
token: {
url: settings.tokenUrl,
},
userinfo: {
url: settings.userInfoUrl,
},
clientId: settings.clientId,
clientSecret: settings.clientSecret,
checks,
profile(profile: any) {
const emailClaim = settings.emailClaim || "email";
const nameClaim = settings.nameClaim || "name";
const avatarClaim = settings.avatarClaim || "picture";
return {
id: String(profile.sub || profile.id || profile.user_id || profile[emailClaim]),
email: String(profile[emailClaim]),
name: profile[nameClaim] ? String(profile[nameClaim]) : null,
image: profile[avatarClaim] ? String(profile[avatarClaim]) : null,
};
},
};
});
}
const oauthProvider = createOAuthProvider();
const credentialsProvider = createCredentialsProvider();
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: CustomAdapter(),
providers: oauthProvider ? [oauthProvider] : [],
providers: [credentialsProvider],
session: {
strategy: "database",
strategy: "jwt",
maxAge: 7 * 24 * 60 * 60, // 7 days
},
pages: {
signIn: "/login",
},
callbacks: {
async session({ session, user }) {
async jwt({ token, user }) {
// On sign in, add user info to token
if (user) {
token.id = user.id;
token.email = user.email;
token.role = "admin";
}
return token;
},
async session({ session, token }) {
// Add user info from token to session
if (session.user) {
session.user.id = user.id;
// Fetch role from database
const db = (await import("./db")).default;
const dbUser = db.prepare("SELECT role FROM users WHERE id = ?").get(user.id) as { role: string } | undefined;
session.user.role = dbUser?.role || "user";
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
async signIn({ user, account, profile }) {
// Auto-assign admin role to first user
const db = (await import("./db")).default;
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number };
if (userCount.count === 1) {
// This is the first user, make them admin
db.prepare("UPDATE users SET role = ? WHERE id = ?").run("admin", user.id);
}
return true;
},
async redirect({ url, baseUrl }) {
// Validate redirect URL to prevent open redirect attacks
if (url.startsWith("/")) {
// Reject URLs starting with // (protocol-relative URLs)
if (url.startsWith("//")) {
return baseUrl;
}
// Check for encoded slashes
if (url.includes('%2f%2f') || url.toLowerCase().includes('%2f%2f')) {
return baseUrl;
}
// Reject protocol specifications in the path
if (/^\/[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
return baseUrl;
}
return url;
}
// Only allow redirects to same origin
if (url.startsWith(baseUrl)) {
return url;
}
return baseUrl;
},
},
secret: config.sessionSecret,
trustHost: true,
@@ -173,47 +84,28 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
/**
* Helper function to get the current session on the server.
* Returns user and session data in the legacy format for compatibility.
*/
export async function getSessionLegacy(): Promise<SessionContext | null> {
const session = await auth();
if (!session?.user) {
return null;
}
const db = (await import("./db")).default;
const user = db.prepare(
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
FROM users WHERE id = ?`
).get(session.user.id) as UserRecord | undefined;
if (!user) {
return null;
}
return {
session: {
id: 0, // Auth.js doesn't expose session ID
user_id: Number(session.user.id),
token: "", // Not exposed by Auth.js
expires_at: session.expires || "",
created_at: ""
},
user
};
export async function getSession() {
return await auth();
}
/**
* Helper function to require authentication, throwing if not authenticated.
* Returns user and session data in the legacy format for compatibility.
*/
export async function requireUser(): Promise<SessionContext> {
const context = await getSessionLegacy();
if (!context) {
export async function requireUser() {
const session = await auth();
if (!session?.user) {
const { redirect } = await import("next/navigation");
redirect("/login");
// TypeScript doesn't know redirect() never returns, so we throw to help the type checker
throw new Error("Redirecting to login");
throw new Error("Redirecting to login"); // TypeScript doesn't know redirect() never returns
}
return context;
return session;
}
export async function requireAdmin() {
const session = await requireUser();
if (session.user.role !== "admin") {
throw new Error("Administrator privileges required");
}
return session;
}

View File

@@ -1,205 +0,0 @@
import type { Adapter, AdapterUser, AdapterAccount, AdapterSession, VerificationToken } from "next-auth/adapters";
import db, { nowIso } from "../db";
import crypto from "node:crypto";
/**
* Custom Auth.js adapter for our existing SQLite database schema.
* Maps our existing users/sessions tables to Auth.js expectations.
*/
export function CustomAdapter(): Adapter {
return {
async createUser(user: Omit<AdapterUser, "id">): Promise<AdapterUser> {
const stmt = db.prepare(
`INSERT INTO users (email, name, avatar_url, provider, subject, role, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
// For Auth.js, we'll use 'oidc' as provider and email as subject initially
const subject = crypto.randomBytes(16).toString("hex");
const info = stmt.run(
user.email,
user.name || null,
user.image || null,
"oidc",
subject,
"user",
"active",
nowIso(),
nowIso()
);
return {
id: String(info.lastInsertRowid),
email: user.email,
emailVerified: user.emailVerified || null,
name: user.name || null,
image: user.image || null
};
},
async getUser(id: string): Promise<AdapterUser | null> {
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as any;
if (!user) return null;
return {
id: String(user.id),
email: user.email,
emailVerified: null,
name: user.name,
image: user.avatar_url
};
},
async getUserByEmail(email: string): Promise<AdapterUser | null> {
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email) as any;
if (!user) return null;
return {
id: String(user.id),
email: user.email,
emailVerified: null,
name: user.name,
image: user.avatar_url
};
},
async getUserByAccount({ providerAccountId, provider }): Promise<AdapterUser | null> {
// For Authentik OIDC, match by subject (sub claim)
const user = db.prepare(
"SELECT * FROM users WHERE subject = ?"
).get(providerAccountId) as any;
if (!user) return null;
return {
id: String(user.id),
email: user.email,
emailVerified: null,
name: user.name,
image: user.avatar_url
};
},
async updateUser(user: Partial<AdapterUser> & Pick<AdapterUser, "id">): Promise<AdapterUser> {
const existing = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id) as any;
db.prepare(
`UPDATE users SET email = ?, name = ?, avatar_url = ?, updated_at = ?
WHERE id = ?`
).run(
user.email || existing.email,
user.name || existing.name,
user.image || existing.avatar_url,
nowIso(),
user.id
);
return {
id: user.id,
email: user.email || existing.email,
emailVerified: user.emailVerified || null,
name: user.name || existing.name,
image: user.image || existing.avatar_url
};
},
async deleteUser(userId: string): Promise<void> {
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
},
async linkAccount(account: AdapterAccount): Promise<AdapterAccount | null | undefined> {
// Update the user's subject to the OIDC sub claim
db.prepare(
`UPDATE users SET subject = ?, updated_at = ?
WHERE id = ?`
).run(account.providerAccountId, nowIso(), account.userId);
return account;
},
async unlinkAccount({ providerAccountId, provider }): Promise<void> {
// Set subject back to random
db.prepare(
`UPDATE users SET subject = ?, updated_at = ?
WHERE subject = ?`
).run(crypto.randomBytes(16).toString("hex"), nowIso(), providerAccountId);
},
async createSession({ sessionToken, userId, expires }): Promise<AdapterSession> {
const expiresAt = expires.toISOString();
db.prepare(
`INSERT INTO sessions (user_id, token, expires_at, created_at)
VALUES (?, ?, ?, ?)`
).run(userId, sessionToken, expiresAt, nowIso());
return {
sessionToken,
userId,
expires
};
},
async getSessionAndUser(sessionToken: string): Promise<{ session: AdapterSession; user: AdapterUser } | null> {
const result = db.prepare(
`SELECT s.token, s.user_id, s.expires_at, u.id, u.email, u.name, u.avatar_url
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token = ?`
).get(sessionToken) as any;
if (!result) return null;
const expires = new Date(result.expires_at);
if (expires.getTime() < Date.now()) {
db.prepare("DELETE FROM sessions WHERE token = ?").run(sessionToken);
return null;
}
return {
session: {
sessionToken: result.token,
userId: String(result.user_id),
expires
},
user: {
id: String(result.id),
email: result.email,
emailVerified: null,
name: result.name,
image: result.avatar_url
}
};
},
async updateSession(session: Partial<AdapterSession> & Pick<AdapterSession, "sessionToken">): Promise<AdapterSession | null | undefined> {
if (session.expires) {
db.prepare(
"UPDATE sessions SET expires_at = ? WHERE token = ?"
).run(session.expires.toISOString(), session.sessionToken);
}
const existing = db.prepare("SELECT * FROM sessions WHERE token = ?").get(session.sessionToken) as any;
if (!existing) return null;
return {
sessionToken: session.sessionToken,
userId: String(existing.user_id),
expires: session.expires || new Date(existing.expires_at)
};
},
async deleteSession(sessionToken: string): Promise<void> {
db.prepare("DELETE FROM sessions WHERE token = ?").run(sessionToken);
},
// Verification tokens not currently used, but required by adapter interface
async createVerificationToken(token: VerificationToken): Promise<VerificationToken | null | undefined> {
return token;
},
async useVerificationToken({ identifier, token }): Promise<VerificationToken | null> {
return null;
}
};
}

View File

@@ -1,227 +0,0 @@
import { redirect } from "next/navigation";
import crypto from "node:crypto";
import db, { nowIso } from "../db";
import { config } from "../config";
import { getOAuthSettings, OAuthSettings } from "../settings";
import { createUser, findUserByProviderSubject, getUserCount, updateUserProfile, User } from "../models/user";
const OAUTH_STATE_TTL_MS = 1000 * 60 * 10; // 10 minutes
type TokenResponse = {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
id_token?: string;
};
/**
* Validates that a redirect path is safe for internal redirection.
* Only allows paths that start with / but not //
* @param path - The path to validate
* @returns true if the path is safe, false otherwise
*/
function isValidRedirectPath(path: string): boolean {
if (!path) return false;
// Must start with / but not // (which could redirect to external site)
// Must not contain any protocol (http:, https:, ftp:, etc.)
if (!path.startsWith('/')) return false;
if (path.startsWith('//')) return false;
// Check for encoded slashes and protocols
if (path.includes('%2f%2f') || path.toLowerCase().includes('%2f%2f')) return false;
// Ensure no protocol specification
if (/^\/[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path)) return false;
return true;
}
export function requireOAuthSettings(): OAuthSettings {
const settings = getOAuthSettings();
if (!settings) {
redirect("/setup/oauth");
}
return settings;
}
function createCodeVerifier(): string {
return crypto.randomBytes(32).toString("base64url");
}
function codeChallengeFromVerifier(verifier: string): string {
const hashed = crypto.createHash("sha256").update(verifier).digest();
return Buffer.from(hashed)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function storeOAuthState(state: string, codeVerifier: string, redirectTo?: string) {
const expiresAt = new Date(Date.now() + OAUTH_STATE_TTL_MS).toISOString();
db.prepare(
`INSERT INTO oauth_states (state, code_verifier, redirect_to, created_at, expires_at)
VALUES (?, ?, ?, ? ,?)`
).run(state, codeVerifier, redirectTo ?? null, nowIso(), expiresAt);
}
function consumeOAuthState(state: string): { codeVerifier: string; redirectTo: string | null } | null {
const row = db
.prepare(
`SELECT id, code_verifier, redirect_to, expires_at
FROM oauth_states WHERE state = ?`
)
.get(state) as { id: number; code_verifier: string; redirect_to: string | null; expires_at: string } | undefined;
if (!row) {
return null;
}
db.prepare("DELETE FROM oauth_states WHERE id = ?").run(row.id);
if (new Date(row.expires_at).getTime() < Date.now()) {
return null;
}
return { codeVerifier: row.code_verifier, redirectTo: row.redirect_to };
}
export function buildAuthorizationUrl(redirectTo?: string): string {
const settings = requireOAuthSettings();
// Validate redirectTo parameter to prevent open redirect attacks
let safeRedirectTo: string | undefined;
if (redirectTo) {
if (!isValidRedirectPath(redirectTo)) {
console.warn(`Invalid redirectTo parameter rejected: ${redirectTo}`);
safeRedirectTo = undefined;
} else {
safeRedirectTo = redirectTo;
}
}
const state = crypto.randomBytes(24).toString("base64url");
const verifier = createCodeVerifier();
const challenge = codeChallengeFromVerifier(verifier);
storeOAuthState(state, verifier, safeRedirectTo);
const redirectUri = `${config.baseUrl}/api/auth/callback`;
const url = new URL(settings.authorizationUrl);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", settings.clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("scope", settings.scopes);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
async function exchangeCode(settings: OAuthSettings, code: string, codeVerifier: string): Promise<TokenResponse> {
const redirectUri = `${config.baseUrl}/api/auth/callback`;
const response = await fetch(settings.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json"
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: settings.clientId,
client_secret: settings.clientSecret,
code_verifier: codeVerifier
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`OAuth token exchange failed: ${response.status} ${text}`);
}
return (await response.json()) as TokenResponse;
}
async function fetchUserInfo(settings: OAuthSettings, tokenResponse: TokenResponse): Promise<Record<string, unknown>> {
if (!settings.userInfoUrl) {
throw new Error("OAuth userInfoUrl is not configured");
}
const response = await fetch(settings.userInfoUrl, {
headers: {
Authorization: `Bearer ${tokenResponse.access_token}`,
Accept: "application/json"
}
});
if (!response.ok) {
const text = await response.text();
throw new Error(`OAuth userinfo fetch failed: ${response.status} ${text}`);
}
return (await response.json()) as Record<string, unknown>;
}
function extractUserFromClaims(settings: OAuthSettings, claims: Record<string, unknown>): {
email: string;
name: string | null;
avatar_url: string | null;
subject: string;
} {
const subject = String(claims.sub ?? claims.id ?? claims.user_id ?? "");
if (!subject) {
throw new Error("OAuth userinfo response missing subject claim");
}
const emailClaim = settings.emailClaim ?? "email";
const nameClaim = settings.nameClaim ?? "name";
const avatarClaim = settings.avatarClaim ?? "picture";
const rawEmail = claims[emailClaim];
if (!rawEmail || typeof rawEmail !== "string") {
throw new Error(`OAuth userinfo response missing ${emailClaim}`);
}
const name = typeof claims[nameClaim] === "string" ? (claims[nameClaim] as string) : null;
const avatar = typeof claims[avatarClaim] === "string" ? (claims[avatarClaim] as string) : null;
return {
email: rawEmail,
name,
avatar_url: avatar,
subject
};
}
export async function finalizeOAuthLogin(code: string, state: string): Promise<{ user: User; redirectTo: string | null }> {
const container = consumeOAuthState(state);
if (!container) {
throw new Error("Invalid or expired OAuth state");
}
const settings = requireOAuthSettings();
const tokenResponse = await exchangeCode(settings, code, container.codeVerifier);
const claims = await fetchUserInfo(settings, tokenResponse);
const profile = extractUserFromClaims(settings, claims);
let user = findUserByProviderSubject(settings.authorizationUrl, profile.subject);
if (!user) {
const totalUsers = getUserCount();
const role = totalUsers === 0 ? "admin" : "user";
user = createUser({
email: profile.email,
name: profile.name,
avatar_url: profile.avatar_url,
provider: settings.authorizationUrl,
subject: profile.subject,
role
});
} else {
user = updateUserProfile(user.id, {
email: profile.email,
name: profile.name,
avatar_url: profile.avatar_url
})!;
}
return { user, redirectTo: container.redirectTo };
}

View File

@@ -1,143 +0,0 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import crypto from "node:crypto";
import db, { nowIso } from "../db";
import { config } from "../config";
const SESSION_COOKIE = "cpm_session";
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
type CookiesHandle = Awaited<ReturnType<typeof cookies>>;
async function getCookieStore(): Promise<CookiesHandle> {
return (await cookies()) as CookiesHandle;
}
function hashToken(token: string): string {
return crypto.createHmac("sha256", config.sessionSecret).update(token).digest("hex");
}
export type SessionRecord = {
id: number;
user_id: number;
token: string;
expires_at: string;
created_at: string;
};
export type UserRecord = {
id: number;
email: string;
name: string | null;
role: string;
provider: string;
subject: string;
avatar_url: string | null;
status: string;
created_at: string;
updated_at: string;
};
export type SessionContext = {
session: SessionRecord;
user: UserRecord;
};
export async function createSession(userId: number): Promise<SessionRecord> {
const token = crypto.randomBytes(48).toString("base64url");
const hashed = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
const stmt = db.prepare(
`INSERT INTO sessions (user_id, token, expires_at, created_at)
VALUES (?, ?, ?, ?)`
);
const info = stmt.run(userId, hashed, expiresAt, nowIso());
const session = {
id: Number(info.lastInsertRowid),
user_id: userId,
token: hashed,
expires_at: expiresAt,
created_at: nowIso()
};
const cookieStore = await getCookieStore();
if (typeof cookieStore.set === "function") {
cookieStore.set({
name: SESSION_COOKIE,
value: token,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: new Date(expiresAt)
});
} else {
console.warn("Unable to set session cookie in this context.");
}
return session;
}
export async function destroySession() {
const cookieStore = await getCookieStore();
const token = typeof cookieStore.get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (typeof cookieStore.delete === "function") {
cookieStore.delete(SESSION_COOKIE);
}
if (!token) {
return;
}
const hashed = hashToken(token.value);
db.prepare("DELETE FROM sessions WHERE token = ?").run(hashed);
}
export async function getSession(): Promise<SessionContext | null> {
const cookieStore = await getCookieStore();
const token = typeof cookieStore.get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (!token) {
return null;
}
const hashed = hashToken(token.value);
const session = db
.prepare(
`SELECT id, user_id, token, expires_at, created_at
FROM sessions
WHERE token = ?`
)
.get(hashed) as SessionRecord | undefined;
if (!session) {
return null;
}
if (new Date(session.expires_at).getTime() < Date.now()) {
db.prepare("DELETE FROM sessions WHERE id = ?").run(session.id);
return null;
}
const user = db
.prepare(
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
FROM users WHERE id = ?`
)
.get(session.user_id) as UserRecord | undefined;
if (!user || user.status !== "active") {
return null;
}
return { session, user };
}
export async function requireUser(): Promise<SessionContext> {
const context = await getSession();
if (!context) {
redirect("/login");
}
return context;
}

View File

@@ -1,13 +1,30 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import crypto from "node:crypto";
import db, { nowIso } from "./db";
import prisma, { nowIso } from "./db";
import { config } from "./config";
import { getCloudflareSettings, setSetting } from "./settings";
const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs");
mkdirSync(CERTS_DIR, { recursive: true });
const DEFAULT_AUTHENTIK_HEADERS = [
"X-Authentik-Username",
"X-Authentik-Groups",
"X-Authentik-Entitlements",
"X-Authentik-Email",
"X-Authentik-Name",
"X-Authentik-Uid",
"X-Authentik-Jwt",
"X-Authentik-Meta-Jwks",
"X-Authentik-Meta-Outpost",
"X-Authentik-Meta-Provider",
"X-Authentik-Meta-App",
"X-Authentik-Meta-Version"
];
const DEFAULT_AUTHENTIK_TRUSTED_PROXIES = ["private_ranges"];
type ProxyHostRow = {
id: number;
name: string;
@@ -25,6 +42,32 @@ type ProxyHostRow = {
enabled: number;
};
type ProxyHostMeta = {
custom_reverse_proxy_json?: string;
custom_pre_handlers_json?: string;
authentik?: ProxyHostAuthentikMeta;
};
type ProxyHostAuthentikMeta = {
enabled?: boolean;
outpost_domain?: string;
outpost_upstream?: string;
auth_endpoint?: string;
copy_headers?: string[];
trusted_proxies?: string[];
set_outpost_host_header?: boolean;
};
type AuthentikRouteConfig = {
enabled: boolean;
outpostDomain: string;
outpostUpstream: string;
authEndpoint: string;
copyHeaders: string[];
trustedProxies: string[];
setOutpostHostHeader: boolean;
};
type RedirectHostRow = {
id: number;
name: string;
@@ -44,15 +87,6 @@ type DeadHostRow = {
enabled: number;
};
type StreamHostRow = {
id: number;
name: string;
listen_port: number;
protocol: string;
upstream: string;
enabled: number;
};
type AccessListEntryRow = {
access_list_id: number;
username: string;
@@ -72,6 +106,10 @@ type CertificateRow = {
type CaddyHttpRoute = Record<string, unknown>;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function parseJson<T>(value: string | null, fallback: T): T {
if (!value) {
return fallback;
@@ -84,6 +122,46 @@ function parseJson<T>(value: string | null, fallback: T): T {
}
}
function parseOptionalJson(value: string | null | undefined) {
if (!value) {
return null;
}
try {
return JSON.parse(value);
} catch (error) {
console.warn("Failed to parse custom JSON", error);
return null;
}
}
function mergeDeep(target: Record<string, unknown>, source: Record<string, unknown>) {
for (const [key, value] of Object.entries(source)) {
const existing = target[key];
if (isPlainObject(existing) && isPlainObject(value)) {
mergeDeep(existing, value);
} else {
target[key] = value;
}
}
}
function parseCustomHandlers(value: string | null | undefined): Record<string, unknown>[] {
const parsed = parseOptionalJson(value);
if (!parsed) {
return [];
}
const list = Array.isArray(parsed) ? parsed : [parsed];
const handlers: Record<string, unknown>[] = [];
for (const item of list) {
if (isPlainObject(item)) {
handlers.push(item);
} else {
console.warn("Ignoring custom handler entry that is not an object", item);
}
}
return handlers;
}
function writeCertificateFiles(cert: CertificateRow) {
if (cert.type !== "imported" || !cert.certificate_pem || !cert.private_key_pem) {
return null;
@@ -117,6 +195,9 @@ function buildProxyRoutes(
}
const handlers: Record<string, unknown>[] = [];
const meta = parseJson<ProxyHostMeta>(row.meta, {});
const authentik = parseAuthentikConfig(meta.authentik);
const hostRoutes: CaddyHttpRoute[] = [];
if (row.hsts_enabled) {
const value = row.hsts_subdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
@@ -147,22 +228,92 @@ function buildProxyRoutes(
}
}
handlers.push({
const reverseProxyHandler: Record<string, unknown> = {
handler: "reverse_proxy",
upstreams: upstreams.map((dial) => ({ dial })),
preserve_host: Boolean(row.preserve_host_header),
...(row.skip_https_hostname_validation
? {
transport: {
http: {
tls: {
insecure_skip_verify: true
}
}
upstreams: upstreams.map((dial) => ({ dial }))
};
if (authentik) {
const outpostHandler: Record<string, unknown> = {
handler: "reverse_proxy",
upstreams: [
{
dial: authentik.outpostUpstream
}
]
};
if (authentik.setOutpostHostHeader) {
outpostHandler.headers = {
request: {
set: {
Host: ["{http.reverse_proxy.upstream.host}"]
}
}
: {})
});
};
}
hostRoutes.push({
match: [
{
host: domains,
path: [`/${authentik.outpostDomain}/*`]
}
],
handle: [outpostHandler],
terminal: true
});
}
if (row.preserve_host_header) {
reverseProxyHandler.headers = {
request: {
set: {
Host: ["{http.request.host}"]
}
}
};
}
if (row.skip_https_hostname_validation) {
reverseProxyHandler.transport = {
http: {
tls: {
insecure_skip_verify: true
}
}
};
}
const customReverseProxy = parseOptionalJson(meta.custom_reverse_proxy_json);
if (customReverseProxy) {
if (isPlainObject(customReverseProxy)) {
mergeDeep(reverseProxyHandler, customReverseProxy as Record<string, unknown>);
} else {
console.warn("Ignoring custom reverse proxy JSON because it is not an object", customReverseProxy);
}
}
const customHandlers = parseCustomHandlers(meta.custom_pre_handlers_json);
if (customHandlers.length > 0) {
handlers.push(...customHandlers);
}
if (authentik) {
handlers.push({
handler: "forward_auth",
upstreams: [
{
dial: authentik.outpostUpstream
}
],
uri: authentik.authEndpoint,
copy_headers: authentik.copyHeaders,
trusted_proxies: authentik.trustedProxies
});
}
handlers.push(reverseProxyHandler);
const route: CaddyHttpRoute = {
match: [
@@ -191,7 +342,8 @@ function buildProxyRoutes(
}
}
routes.push(route);
hostRoutes.push(route);
routes.push(...hostRoutes);
}
return routes;
@@ -240,127 +392,130 @@ function buildDeadRoutes(rows: DeadHostRow[]): CaddyHttpRoute[] {
}));
}
function buildStreamServers(rows: StreamHostRow[]) {
if (rows.length === 0) {
return undefined;
}
const servers: Record<string, unknown> = {};
for (const row of rows) {
if (!row.enabled) {
continue;
}
const key = `stream_${row.id}`;
servers[key] = {
listen: [`:${row.listen_port}`],
routes: [
{
match: [
{
protocol: [row.protocol]
}
],
handle: [
{
handler: "proxy",
upstreams: [{ dial: row.upstream }]
}
]
}
]
};
}
if (Object.keys(servers).length === 0) {
return undefined;
}
return servers;
}
function buildTlsAutomation(certificates: Map<number, CertificateRow>) {
const managedDomains = new Set<string>();
for (const cert of certificates.values()) {
if (cert.type === "managed") {
const domains = parseJson<string[]>(cert.domain_names, []);
domains.forEach((domain) => managedDomains.add(domain));
}
}
const cloudflare = getCloudflareSettings();
if (!cloudflare) {
return undefined;
}
const subjects = Array.from(managedDomains);
if (subjects.length === 0) {
return undefined;
}
return {
automation: {
policies: [
{
subjects,
issuers: [
{
module: "acme",
challenges: {
dns: {
provider: {
name: "cloudflare",
api_token: cloudflare.apiToken
}
}
}
}
]
}
]
}
};
// TODO: This function needs to be migrated to async to use getCloudflareSettings()
// For now, Cloudflare DNS challenges are disabled until migration is complete
return undefined;
}
function buildCaddyDocument() {
const proxyHosts = db
.prepare(
`SELECT id, name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
hsts_subdomains, allow_websocket, preserve_host_header, skip_https_hostname_validation, meta, enabled
FROM proxy_hosts`
)
.all() as ProxyHostRow[];
const redirectHosts = db
.prepare(
`SELECT id, name, domains, destination, status_code, preserve_query, enabled
FROM redirect_hosts`
)
.all() as RedirectHostRow[];
const deadHosts = db
.prepare(
`SELECT id, name, domains, status_code, response_body, enabled
FROM dead_hosts`
)
.all() as DeadHostRow[];
const streamHosts = db
.prepare(
`SELECT id, name, listen_port, protocol, upstream, enabled
FROM stream_hosts`
)
.all() as StreamHostRow[];
const certRows = db
.prepare(
`SELECT id, name, type, domain_names, certificate_pem, private_key_pem, auto_renew, provider_options
FROM certificates`
)
.all() as CertificateRow[];
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 accessListEntries = db
.prepare(
`SELECT access_list_id, username, password_hash
FROM access_list_entries`
)
.all() as AccessListEntryRow[];
// Map Prisma results to expected types
const proxyHostRows: ProxyHostRow[] = proxyHosts.map((h: typeof proxyHosts[0]) => ({
id: h.id,
name: h.name,
domains: h.domains,
upstreams: h.upstreams,
certificate_id: h.certificateId,
access_list_id: h.accessListId,
ssl_forced: h.sslForced ? 1 : 0,
hsts_enabled: h.hstsEnabled ? 1 : 0,
hsts_subdomains: h.hstsSubdomains ? 1 : 0,
allow_websocket: h.allowWebsocket ? 1 : 0,
preserve_host_header: h.preserveHostHeader ? 1 : 0,
skip_https_hostname_validation: h.skipHttpsHostnameValidation ? 1 : 0,
meta: h.meta,
enabled: h.enabled ? 1 : 0
}));
const certificateMap = new Map(certRows.map((cert) => [cert.id, cert]));
const accessMap = accessListEntries.reduce<Map<number, AccessListEntryRow[]>>((map, entry) => {
const redirectHostRows: RedirectHostRow[] = redirectHosts.map((h: typeof redirectHosts[0]) => ({
id: h.id,
name: h.name,
domains: h.domains,
destination: h.destination,
status_code: h.statusCode,
preserve_query: h.preserveQuery ? 1 : 0,
enabled: h.enabled ? 1 : 0
}));
const deadHostRows: DeadHostRow[] = deadHosts.map((h: typeof deadHosts[0]) => ({
id: h.id,
name: h.name,
domains: h.domains,
status_code: h.statusCode,
response_body: h.responseBody,
enabled: h.enabled ? 1 : 0
}));
const certRowsMapped: CertificateRow[] = certRows.map((c: typeof certRows[0]) => ({
id: c.id,
name: c.name,
type: c.type as "managed" | "imported",
domain_names: c.domainNames,
certificate_pem: c.certificatePem,
private_key_pem: c.privateKeyPem,
auto_renew: c.autoRenew ? 1 : 0,
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 certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));
const accessMap = accessListEntryRows.reduce<Map<number, AccessListEntryRow[]>>((map, entry) => {
if (!map.has(entry.access_list_id)) {
map.set(entry.access_list_id, []);
}
@@ -369,9 +524,9 @@ function buildCaddyDocument() {
}, new Map());
const httpRoutes: CaddyHttpRoute[] = [
...buildProxyRoutes(proxyHosts, certificateMap, accessMap),
...buildRedirectRoutes(redirectHosts),
...buildDeadRoutes(deadHosts)
...buildProxyRoutes(proxyHostRows, certificateMap, accessMap),
...buildRedirectRoutes(redirectHostRows),
...buildDeadRoutes(deadHostRows)
];
const tlsSection = buildTlsAutomation(certificateMap);
@@ -390,26 +545,16 @@ function buildCaddyDocument() {
}
: {};
const layer4Servers = buildStreamServers(streamHosts);
const layer4App = layer4Servers
? {
layer4: {
servers: layer4Servers
}
}
: {};
return {
apps: {
...httpApp,
...(tlsSection ? { tls: tlsSection } : {}),
...layer4App
...(tlsSection ? { tls: tlsSection } : {})
}
};
}
export async function applyCaddyConfig() {
const document = buildCaddyDocument();
const document = await buildCaddyDocument();
const payload = JSON.stringify(document);
const hash = crypto.createHash("sha256").update(payload).digest("hex");
setSetting("caddy_config_hash", { hash, updated_at: nowIso() });
@@ -429,6 +574,53 @@ export async function applyCaddyConfig() {
}
} catch (error) {
console.error("Failed to apply Caddy config", error);
// Check if it's a fetch error with ECONNREFUSED or ENOTFOUND
const err = error as { cause?: NodeJS.ErrnoException };
const causeCode = err?.cause?.code;
if (causeCode === "ENOTFOUND" || causeCode === "ECONNREFUSED") {
throw new Error(`Unable to reach Caddy API at ${config.caddyApiUrl}. Ensure Caddy is running and accessible.`);
}
throw error;
}
}
function parseAuthentikConfig(meta: ProxyHostAuthentikMeta | undefined | null): AuthentikRouteConfig | null {
if (!meta || !meta.enabled) {
return null;
}
const outpostDomain = typeof meta.outpost_domain === "string" ? meta.outpost_domain.trim() : "";
const outpostUpstream = typeof meta.outpost_upstream === "string" ? meta.outpost_upstream.trim() : "";
if (!outpostDomain || !outpostUpstream) {
return null;
}
const authEndpointRaw = typeof meta.auth_endpoint === "string" ? meta.auth_endpoint.trim() : "";
const authEndpoint = authEndpointRaw || `/${outpostDomain}/auth/caddy`;
const copyHeaders =
Array.isArray(meta.copy_headers) && meta.copy_headers.length > 0
? meta.copy_headers.map((header) => header?.trim()).filter((header): header is string => Boolean(header))
: DEFAULT_AUTHENTIK_HEADERS;
const trustedProxies =
Array.isArray(meta.trusted_proxies) && meta.trusted_proxies.length > 0
? meta.trusted_proxies.map((item) => item?.trim()).filter((item): item is string => Boolean(item))
: DEFAULT_AUTHENTIK_TRUSTED_PROXIES;
const setOutpostHostHeader =
meta.set_outpost_host_header !== undefined ? Boolean(meta.set_outpost_host_header) : true;
return {
enabled: true,
outpostDomain,
outpostUpstream,
authEndpoint,
copyHeaders,
trustedProxies,
setOutpostHostHeader
};
}

View File

@@ -8,8 +8,20 @@ function requireEnv(name: string, fallback?: string): string {
return value;
}
// Generate a stable development secret (WARNING: only for development!)
// In production, SESSION_SECRET must be set in environment variables
const DEV_SECRET = "dev-secret-change-in-production-12345678901234567890123456789012";
const DEFAULT_CADDY_URL = process.env.NODE_ENV === "development" ? "http://localhost:2019" : "http://caddy:2019";
// During build time or in development, use DEV_SECRET as fallback
// In production runtime, SESSION_SECRET must be set
const isProduction = process.env.NODE_ENV === "production";
const isBuildTime = typeof window === "undefined" && !process.env.SESSION_SECRET;
export const config = {
sessionSecret: requireEnv("SESSION_SECRET", process.env.NODE_ENV === "development" ? crypto.randomBytes(32).toString("hex") : undefined),
caddyApiUrl: process.env.CADDY_API_URL ?? "http://caddy:2019",
baseUrl: process.env.BASE_URL ?? "http://localhost:3000"
sessionSecret: requireEnv("SESSION_SECRET", !isProduction || isBuildTime ? DEV_SECRET : undefined),
caddyApiUrl: process.env.CADDY_API_URL ?? DEFAULT_CADDY_URL,
baseUrl: process.env.BASE_URL ?? "http://localhost:3000",
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
adminPassword: process.env.ADMIN_PASSWORD ?? "admin"
};

View File

@@ -1,20 +1,19 @@
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { runMigrations } from "./migrations";
import { PrismaClient } from "@prisma/client";
const defaultDbPath = join(process.cwd(), "data", "caddy-proxy-manager.db");
const dbPath = process.env.DATABASE_PATH || defaultDbPath;
// Prevent multiple instances of Prisma Client in development
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
mkdirSync(dirname(dbPath), { recursive: true });
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("busy_timeout = 5000");
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
runMigrations(db);
export default db;
export default prisma;
export function nowIso(): string {
return new Date().toISOString();

View File

@@ -1,236 +0,0 @@
import Database from "better-sqlite3";
type Migration = {
id: number;
description: string;
up: (db: Database.Database) => void;
};
const MIGRATIONS: Migration[] = [
{
id: 1,
description: "initial schema",
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id INTEGER PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT,
role TEXT NOT NULL DEFAULT 'user',
provider TEXT NOT NULL,
subject TEXT NOT NULL,
avatar_url TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE TABLE IF NOT EXISTS oauth_states (
id INTEGER PRIMARY KEY AUTOINCREMENT,
state TEXT NOT NULL UNIQUE,
code_verifier TEXT NOT NULL,
redirect_to TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS access_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS access_list_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_access_list_entries_access_list_id
ON access_list_entries(access_list_id);
CREATE TABLE IF NOT EXISTS certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
domain_names TEXT NOT NULL,
auto_renew INTEGER NOT NULL DEFAULT 1,
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 DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS proxy_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 NOT NULL DEFAULT 1,
hsts_enabled INTEGER NOT NULL DEFAULT 1,
hsts_subdomains INTEGER NOT NULL DEFAULT 0,
allow_websocket INTEGER NOT NULL DEFAULT 1,
preserve_host_header INTEGER NOT NULL DEFAULT 1,
meta TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL,
FOREIGN KEY (access_list_id) REFERENCES access_lists(id) ON DELETE SET NULL,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS redirect_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
domains TEXT NOT NULL,
destination TEXT NOT NULL,
status_code INTEGER NOT NULL DEFAULT 302,
preserve_query INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
created_by INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS dead_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
domains TEXT NOT NULL,
status_code INTEGER NOT NULL DEFAULT 503,
response_body TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_by INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS stream_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
listen_port INTEGER NOT NULL,
protocol TEXT NOT NULL,
upstream TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_by INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 DELETE SET NULL
);
`);
}
},
{
id: 2,
description: "add provider type to OAuth settings",
up: (db) => {
// Add providerType field to existing OAuth settings
// Default to 'authentik' for existing installations since that's what we're supporting
const settings = db.prepare("SELECT value FROM settings WHERE key = 'oauth'").get() as { value: string } | undefined;
if (settings) {
try {
const oauth = JSON.parse(settings.value);
// Only update if providerType doesn't exist
if (!oauth.providerType) {
oauth.providerType = 'authentik';
db.prepare("UPDATE settings SET value = ? WHERE key = 'oauth'").run(JSON.stringify(oauth));
}
} catch (e) {
console.error("Failed to migrate OAuth settings:", e);
}
}
}
},
{
id: 3,
description: "add skip https hostname validation flag",
up: (db) => {
const columns = db.prepare("PRAGMA table_info(proxy_hosts)").all() as { name: string }[];
const hasColumn = columns.some((column) => column.name === "skip_https_hostname_validation");
if (!hasColumn) {
db.exec("ALTER TABLE proxy_hosts ADD COLUMN skip_https_hostname_validation INTEGER NOT NULL DEFAULT 0;");
}
}
}
];
export function runMigrations(db: Database.Database) {
db.exec("PRAGMA foreign_keys = ON;");
db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY);");
const appliedStmt = db.prepare("SELECT id FROM schema_migrations");
const appliedRows = appliedStmt.all() as Array<{ id: number }>;
const applied = new Set<number>(appliedRows.map((row) => row.id));
const insertStmt = db.prepare("INSERT INTO schema_migrations (id) VALUES (?)");
for (const migration of MIGRATIONS) {
if (applied.has(migration.id)) {
continue;
}
db.transaction(() => {
migration.up(db);
insertStmt.run(migration.id);
})();
}
}

View File

@@ -1,5 +1,5 @@
import bcrypt from "bcryptjs";
import db, { nowIso } from "../db";
import prisma, { nowIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
@@ -25,86 +25,105 @@ export type AccessListInput = {
users?: { username: string; password: string }[];
};
function parseAccessList(row: any): AccessList {
const entries = db
.prepare(
`SELECT id, username, created_at, updated_at
FROM access_list_entries
WHERE access_list_id = ?
ORDER BY username ASC`
)
.all(row.id) as AccessListEntry[];
function toAccessList(
row: {
id: number;
name: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
},
entries: {
id: number;
username: string;
createdAt: Date;
updatedAt: Date;
}[]
): AccessList {
return {
id: row.id,
name: row.name,
description: row.description,
entries,
created_at: row.created_at,
updated_at: row.updated_at
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()
};
}
export function listAccessLists(): AccessList[] {
const rows = db
.prepare(
`SELECT id, name, description, created_at, updated_at
FROM access_lists
ORDER BY name ASC`
)
.all();
return rows.map(parseAccessList);
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" }
}
}
});
return lists.map((list: typeof lists[0]) => toAccessList(list, list.entries));
}
export function getAccessList(id: number): AccessList | null {
const row = db
.prepare(
`SELECT id, name, description, created_at, updated_at
FROM access_lists WHERE id = ?`
)
.get(id);
if (!row) {
return null;
}
return parseAccessList(row);
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" }
}
}
});
return list ? toAccessList(list, list.entries) : null;
}
export async function createAccessList(input: AccessListInput, actorUserId: number) {
const now = nowIso();
const tx = db.transaction(() => {
const result = db
.prepare(
`INSERT INTO access_lists (name, description, created_at, updated_at, created_by)
VALUES (?, ?, ?, ?, ?)`
)
.run(input.name.trim(), input.description ?? null, now, now, actorUserId);
const accessListId = Number(result.lastInsertRowid);
const now = new Date(nowIso());
if (input.users) {
const insert = db.prepare(
`INSERT INTO access_list_entries (access_list_id, username, password_hash, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`
);
for (const account of input.users) {
const hash = bcrypt.hashSync(account.password, 10);
insert.run(accessListId, account.username, hash, now, now);
}
const accessList = await prisma.accessList.create({
data: {
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
}
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "access_list",
entityId: accessListId,
summary: `Created access list ${input.name}`
});
return accessListId;
});
const id = tx();
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "access_list",
entityId: accessList.id,
summary: `Created access list ${input.name}`
});
await applyCaddyConfig();
return getAccessList(id)!;
return (await getAccessList(accessList.id))!;
}
export async function updateAccessList(
@@ -112,15 +131,20 @@ export async function updateAccessList(
input: { name?: string; description?: string | null },
actorUserId: number
) {
const existing = getAccessList(id);
const existing = await getAccessList(id);
if (!existing) {
throw new Error("Access list not found");
}
const now = nowIso();
db.prepare(
`UPDATE access_lists SET name = ?, description = ?, updated_at = ? WHERE id = ?`
).run(input.name ?? existing.name, input.description ?? existing.description, now, id);
const now = new Date(nowIso());
await prisma.accessList.update({
where: { id },
data: {
name: input.name ?? existing.name,
description: input.description ?? existing.description,
updatedAt: now
}
});
logAuditEvent({
userId: actorUserId,
@@ -131,7 +155,7 @@ export async function updateAccessList(
});
await applyCaddyConfig();
return getAccessList(id)!;
return (await getAccessList(id))!;
}
export async function addAccessListEntry(
@@ -139,16 +163,23 @@ export async function addAccessListEntry(
entry: { username: string; password: string },
actorUserId: number
) {
const list = getAccessList(accessListId);
const list = await prisma.accessList.findUnique({
where: { id: accessListId }
});
if (!list) {
throw new Error("Access list not found");
}
const now = nowIso();
const now = new Date(nowIso());
const hash = bcrypt.hashSync(entry.password, 10);
db.prepare(
`INSERT INTO access_list_entries (access_list_id, username, password_hash, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`
).run(accessListId, entry.username, hash, now, now);
await prisma.accessListEntry.create({
data: {
accessListId,
username: entry.username,
passwordHash: hash,
createdAt: now,
updatedAt: now
}
});
logAuditEvent({
userId: actorUserId,
action: "create",
@@ -157,15 +188,19 @@ export async function addAccessListEntry(
summary: `Added user ${entry.username} to access list ${list.name}`
});
await applyCaddyConfig();
return getAccessList(accessListId)!;
return (await getAccessList(accessListId))!;
}
export async function removeAccessListEntry(accessListId: number, entryId: number, actorUserId: number) {
const list = getAccessList(accessListId);
const list = await prisma.accessList.findUnique({
where: { id: accessListId }
});
if (!list) {
throw new Error("Access list not found");
}
db.prepare("DELETE FROM access_list_entries WHERE id = ?").run(entryId);
await prisma.accessListEntry.delete({
where: { id: entryId }
});
logAuditEvent({
userId: actorUserId,
action: "delete",
@@ -174,15 +209,19 @@ export async function removeAccessListEntry(accessListId: number, entryId: numbe
summary: `Removed entry from access list ${list.name}`
});
await applyCaddyConfig();
return getAccessList(accessListId)!;
return (await getAccessList(accessListId))!;
}
export async function deleteAccessList(id: number, actorUserId: number) {
const existing = getAccessList(id);
const existing = await prisma.accessList.findUnique({
where: { id }
});
if (!existing) {
throw new Error("Access list not found");
}
db.prepare("DELETE FROM access_lists WHERE id = ?").run(id);
await prisma.accessList.delete({
where: { id }
});
logAuditEvent({
userId: actorUserId,
action: "delete",

View File

@@ -1,4 +1,4 @@
import db from "../db";
import prisma from "../db";
export type AuditEvent = {
id: number;
@@ -10,14 +10,19 @@ export type AuditEvent = {
created_at: string;
};
export function listAuditEvents(limit = 100): AuditEvent[] {
const rows = db
.prepare(
`SELECT id, user_id, action, entity_type, entity_id, summary, created_at
FROM audit_events
ORDER BY created_at DESC
LIMIT ?`
)
.all(limit) as AuditEvent[];
return rows;
export async function listAuditEvents(limit = 100): Promise<AuditEvent[]> {
const events = await prisma.auditEvent.findMany({
orderBy: { createdAt: "desc" },
take: limit
});
return events.map((event: typeof events[0]) => ({
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()
}));
}

View File

@@ -1,4 +1,4 @@
import db, { nowIso } from "../db";
import prisma, { nowIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
@@ -27,41 +27,44 @@ export type CertificateInput = {
private_key_pem?: string | null;
};
function parseCertificate(row: any): Certificate {
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 {
return {
id: row.id,
name: row.name,
type: row.type,
domain_names: JSON.parse(row.domain_names),
auto_renew: Boolean(row.auto_renew),
provider_options: row.provider_options ? JSON.parse(row.provider_options) : null,
certificate_pem: row.certificate_pem,
private_key_pem: row.private_key_pem,
created_at: row.created_at,
updated_at: row.updated_at
type: row.type as CertificateType,
domain_names: JSON.parse(row.domainNames),
auto_renew: row.autoRenew,
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()
};
}
export function listCertificates(): Certificate[] {
const rows = db
.prepare(
`SELECT id, name, type, domain_names, auto_renew, provider_options, certificate_pem, private_key_pem,
created_at, updated_at
FROM certificates ORDER BY created_at DESC`
)
.all();
return rows.map(parseCertificate);
export async function listCertificates(): Promise<Certificate[]> {
const certificates = await prisma.certificate.findMany({
orderBy: { createdAt: "desc" }
});
return certificates.map(parseCertificate);
}
export function getCertificate(id: number): Certificate | null {
const row = db
.prepare(
`SELECT id, name, type, domain_names, auto_renew, provider_options, certificate_pem, private_key_pem,
created_at, updated_at
FROM certificates WHERE id = ?`
)
.get(id);
return row ? parseCertificate(row) : null;
export async function getCertificate(id: number): Promise<Certificate | null> {
const cert = await prisma.certificate.findUnique({
where: { id }
});
return cert ? parseCertificate(cert) : null;
}
function validateCertificateInput(input: CertificateInput) {
@@ -77,39 +80,36 @@ function validateCertificateInput(input: CertificateInput) {
export async function createCertificate(input: CertificateInput, actorUserId: number) {
validateCertificateInput(input);
const now = nowIso();
const result = db
.prepare(
`INSERT INTO certificates (name, type, domain_names, auto_renew, provider_options, certificate_pem, private_key_pem,
created_at, updated_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
input.name.trim(),
input.type,
JSON.stringify(Array.from(new Set(input.domain_names.map((domain) => domain.trim().toLowerCase())))),
(input.auto_renew ?? true) ? 1 : 0,
input.provider_options ? JSON.stringify(input.provider_options) : null,
input.certificate_pem ?? null,
input.private_key_pem ?? null,
now,
now,
actorUserId
);
const id = Number(result.lastInsertRowid);
const now = new Date(nowIso());
const record = await prisma.certificate.create({
data: {
name: input.name.trim(),
type: input.type,
domainNames: JSON.stringify(
Array.from(new Set(input.domain_names.map((domain) => domain.trim().toLowerCase())))
),
autoRenew: input.auto_renew ?? true,
providerOptions: input.provider_options ? JSON.stringify(input.provider_options) : null,
certificatePem: input.certificate_pem ?? null,
privateKeyPem: input.private_key_pem ?? null,
createdAt: now,
updatedAt: now,
createdBy: actorUserId
}
});
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "certificate",
entityId: id,
entityId: record.id,
summary: `Created certificate ${input.name}`
});
await applyCaddyConfig();
return getCertificate(id)!;
return (await getCertificate(record.id))!;
}
export async function updateCertificate(id: number, input: Partial<CertificateInput>, actorUserId: number) {
const existing = getCertificate(id);
const existing = await getCertificate(id);
if (!existing) {
throw new Error("Certificate not found");
}
@@ -126,22 +126,20 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
validateCertificateInput(merged);
const now = nowIso();
db.prepare(
`UPDATE certificates
SET name = ?, type = ?, domain_names = ?, auto_renew = ?, provider_options = ?, certificate_pem = ?, private_key_pem = ?, updated_at = ?
WHERE id = ?`
).run(
merged.name.trim(),
merged.type,
JSON.stringify(Array.from(new Set(merged.domain_names))),
merged.auto_renew ? 1 : 0,
merged.provider_options ? JSON.stringify(merged.provider_options) : null,
merged.certificate_pem ?? null,
merged.private_key_pem ?? null,
now,
id
);
const now = new Date(nowIso());
await prisma.certificate.update({
where: { id },
data: {
name: merged.name.trim(),
type: merged.type,
domainNames: JSON.stringify(Array.from(new Set(merged.domain_names))),
autoRenew: merged.auto_renew,
providerOptions: merged.provider_options ? JSON.stringify(merged.provider_options) : null,
certificatePem: merged.certificate_pem ?? null,
privateKeyPem: merged.private_key_pem ?? null,
updatedAt: now
}
});
logAuditEvent({
userId: actorUserId,
@@ -151,16 +149,18 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
summary: `Updated certificate ${merged.name}`
});
await applyCaddyConfig();
return getCertificate(id)!;
return (await getCertificate(id))!;
}
export async function deleteCertificate(id: number, actorUserId: number) {
const existing = getCertificate(id);
const existing = await getCertificate(id);
if (!existing) {
throw new Error("Certificate not found");
}
db.prepare("DELETE FROM certificates WHERE id = ?").run(id);
await prisma.certificate.delete({
where: { id }
});
logAuditEvent({
userId: actorUserId,
action: "delete",

View File

@@ -1,4 +1,4 @@
import db, { nowIso } from "../db";
import prisma, { nowIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
@@ -21,37 +21,40 @@ export type DeadHostInput = {
enabled?: boolean;
};
function parse(row: any): DeadHost {
function parse(row: {
id: number;
name: string;
domains: string;
statusCode: number;
responseBody: string | null;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}): DeadHost {
return {
id: row.id,
name: row.name,
domains: JSON.parse(row.domains),
status_code: row.status_code,
response_body: row.response_body,
enabled: Boolean(row.enabled),
created_at: row.created_at,
updated_at: row.updated_at
status_code: row.statusCode,
response_body: row.responseBody,
enabled: row.enabled,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString()
};
}
export function listDeadHosts(): DeadHost[] {
const rows = db
.prepare(
`SELECT id, name, domains, status_code, response_body, enabled, created_at, updated_at
FROM dead_hosts ORDER BY created_at DESC`
)
.all();
return rows.map(parse);
export async function listDeadHosts(): Promise<DeadHost[]> {
const hosts = await prisma.deadHost.findMany({
orderBy: { createdAt: "desc" }
});
return hosts.map(parse);
}
export function getDeadHost(id: number): DeadHost | null {
const row = db
.prepare(
`SELECT id, name, domains, status_code, response_body, enabled, created_at, updated_at
FROM dead_hosts WHERE id = ?`
)
.get(id);
return row ? parse(row) : null;
export async function getDeadHost(id: number): Promise<DeadHost | null> {
const host = await prisma.deadHost.findUnique({
where: { id }
});
return host ? parse(host) : null;
}
export async function createDeadHost(input: DeadHostInput, actorUserId: number) {
@@ -59,53 +62,47 @@ export async function createDeadHost(input: DeadHostInput, actorUserId: number)
throw new Error("At least one domain is required");
}
const now = nowIso();
const result = db
.prepare(
`INSERT INTO dead_hosts (name, domains, status_code, response_body, enabled, created_at, updated_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
input.name.trim(),
JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
input.status_code ?? 503,
input.response_body ?? null,
(input.enabled ?? true) ? 1 : 0,
now,
now,
actorUserId
);
const id = Number(result.lastInsertRowid);
const now = new Date(nowIso());
const record = await prisma.deadHost.create({
data: {
name: input.name.trim(),
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
statusCode: input.status_code ?? 503,
responseBody: input.response_body ?? null,
enabled: input.enabled ?? true,
createdAt: now,
updatedAt: now,
createdBy: actorUserId
}
});
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "dead_host",
entityId: id,
entityId: record.id,
summary: `Created dead host ${input.name}`
});
await applyCaddyConfig();
return getDeadHost(id)!;
return (await getDeadHost(record.id))!;
}
export async function updateDeadHost(id: number, input: Partial<DeadHostInput>, actorUserId: number) {
const existing = getDeadHost(id);
const existing = await getDeadHost(id);
if (!existing) {
throw new Error("Dead host not found");
}
const now = nowIso();
db.prepare(
`UPDATE dead_hosts
SET name = ?, domains = ?, status_code = ?, response_body = ?, enabled = ?, updated_at = ?
WHERE id = ?`
).run(
input.name ?? existing.name,
JSON.stringify(input.domains ? Array.from(new Set(input.domains)) : existing.domains),
input.status_code ?? existing.status_code,
input.response_body ?? existing.response_body,
(input.enabled ?? existing.enabled) ? 1 : 0,
now,
id
);
const now = new Date(nowIso());
await prisma.deadHost.update({
where: { id },
data: {
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
}
});
logAuditEvent({
userId: actorUserId,
action: "update",
@@ -114,15 +111,17 @@ export async function updateDeadHost(id: number, input: Partial<DeadHostInput>,
summary: `Updated dead host ${input.name ?? existing.name}`
});
await applyCaddyConfig();
return getDeadHost(id)!;
return (await getDeadHost(id))!;
}
export async function deleteDeadHost(id: number, actorUserId: number) {
const existing = getDeadHost(id);
const existing = await getDeadHost(id);
if (!existing) {
throw new Error("Dead host not found");
}
db.prepare("DELETE FROM dead_hosts WHERE id = ?").run(id);
await prisma.deadHost.delete({
where: { id }
});
logAuditEvent({
userId: actorUserId,
action: "delete",

View File

@@ -1,7 +1,60 @@
import db, { nowIso } from "../db";
import prisma, { nowIso } from "../db";
import { applyCaddyConfig } from "../caddy";
import { logAuditEvent } from "../audit";
const DEFAULT_AUTHENTIK_HEADERS = [
"X-Authentik-Username",
"X-Authentik-Groups",
"X-Authentik-Entitlements",
"X-Authentik-Email",
"X-Authentik-Name",
"X-Authentik-Uid",
"X-Authentik-Jwt",
"X-Authentik-Meta-Jwks",
"X-Authentik-Meta-Outpost",
"X-Authentik-Meta-Provider",
"X-Authentik-Meta-App",
"X-Authentik-Meta-Version"
];
const DEFAULT_AUTHENTIK_TRUSTED_PROXIES = ["private_ranges"];
export type ProxyHostAuthentikConfig = {
enabled: boolean;
outpostDomain: string | null;
outpostUpstream: string | null;
authEndpoint: string | null;
copyHeaders: string[];
trustedProxies: string[];
setOutpostHostHeader: boolean;
};
export type ProxyHostAuthentikInput = {
enabled?: boolean;
outpostDomain?: string | null;
outpostUpstream?: string | null;
authEndpoint?: string | null;
copyHeaders?: string[] | null;
trustedProxies?: string[] | null;
setOutpostHostHeader?: boolean | null;
};
type ProxyHostAuthentikMeta = {
enabled?: boolean;
outpost_domain?: string;
outpost_upstream?: string;
auth_endpoint?: string;
copy_headers?: string[];
trusted_proxies?: string[];
set_outpost_host_header?: boolean;
};
type ProxyHostMeta = {
custom_reverse_proxy_json?: string;
custom_pre_handlers_json?: string;
authentik?: ProxyHostAuthentikMeta;
};
export type ProxyHost = {
id: number;
name: string;
@@ -18,6 +71,9 @@ export type ProxyHost = {
enabled: boolean;
created_at: string;
updated_at: string;
custom_reverse_proxy_json: string | null;
custom_pre_handlers_json: string | null;
authentik: ProxyHostAuthentikConfig | null;
};
export type ProxyHostInput = {
@@ -33,39 +89,324 @@ export type ProxyHostInput = {
preserve_host_header?: boolean;
skip_https_hostname_validation?: boolean;
enabled?: boolean;
custom_reverse_proxy_json?: string | null;
custom_pre_handlers_json?: string | null;
authentik?: ProxyHostAuthentikInput | null;
};
function parseProxyHost(row: any): ProxyHost {
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;
};
function normalizeMetaValue(value: string | null | undefined) {
if (!value) {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function sanitizeAuthentikMeta(meta: ProxyHostAuthentikMeta | undefined): ProxyHostAuthentikMeta | undefined {
if (!meta) {
return undefined;
}
const normalized: ProxyHostAuthentikMeta = {};
if (meta.enabled !== undefined) {
normalized.enabled = Boolean(meta.enabled);
}
const domain = normalizeMetaValue(meta.outpost_domain ?? null);
if (domain) {
normalized.outpost_domain = domain;
}
const upstream = normalizeMetaValue(meta.outpost_upstream ?? null);
if (upstream) {
normalized.outpost_upstream = upstream;
}
const authEndpoint = normalizeMetaValue(meta.auth_endpoint ?? null);
if (authEndpoint) {
normalized.auth_endpoint = authEndpoint;
}
if (Array.isArray(meta.copy_headers)) {
const headers = meta.copy_headers.map((header) => header?.trim()).filter((header): header is string => Boolean(header));
if (headers.length > 0) {
normalized.copy_headers = headers;
}
}
if (Array.isArray(meta.trusted_proxies)) {
const proxies = meta.trusted_proxies.map((proxy) => proxy?.trim()).filter((proxy): proxy is string => Boolean(proxy));
if (proxies.length > 0) {
normalized.trusted_proxies = proxies;
}
}
if (meta.set_outpost_host_header !== undefined) {
normalized.set_outpost_host_header = Boolean(meta.set_outpost_host_header);
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function serializeMeta(meta: ProxyHostMeta | null | undefined) {
if (!meta) {
return null;
}
const normalized: ProxyHostMeta = {};
const reverse = normalizeMetaValue(meta.custom_reverse_proxy_json ?? null);
const preHandlers = normalizeMetaValue(meta.custom_pre_handlers_json ?? null);
if (reverse) {
normalized.custom_reverse_proxy_json = reverse;
}
if (preHandlers) {
normalized.custom_pre_handlers_json = preHandlers;
}
const authentik = sanitizeAuthentikMeta(meta.authentik);
if (authentik) {
normalized.authentik = authentik;
}
return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null;
}
function parseMeta(value: string | null): ProxyHostMeta {
if (!value) {
return {};
}
try {
const parsed = JSON.parse(value) as ProxyHostMeta;
return {
custom_reverse_proxy_json: normalizeMetaValue(parsed.custom_reverse_proxy_json ?? null) ?? undefined,
custom_pre_handlers_json: normalizeMetaValue(parsed.custom_pre_handlers_json ?? null) ?? undefined,
authentik: sanitizeAuthentikMeta(parsed.authentik)
};
} catch (error) {
console.warn("Failed to parse proxy host meta", error);
return {};
}
}
function normalizeAuthentikInput(
input: ProxyHostAuthentikInput | null | undefined,
existing: ProxyHostAuthentikMeta | undefined
): ProxyHostAuthentikMeta | undefined {
if (input === undefined) {
return existing;
}
if (input === null) {
return undefined;
}
const next: ProxyHostAuthentikMeta = { ...(existing ?? {}) };
if (input.enabled !== undefined) {
next.enabled = Boolean(input.enabled);
}
if (input.outpostDomain !== undefined) {
const domain = normalizeMetaValue(input.outpostDomain ?? null);
if (domain) {
next.outpost_domain = domain;
} else {
delete next.outpost_domain;
}
}
if (input.outpostUpstream !== undefined) {
const upstream = normalizeMetaValue(input.outpostUpstream ?? null);
if (upstream) {
next.outpost_upstream = upstream;
} else {
delete next.outpost_upstream;
}
}
if (input.authEndpoint !== undefined) {
const endpoint = normalizeMetaValue(input.authEndpoint ?? null);
if (endpoint) {
next.auth_endpoint = endpoint;
} else {
delete next.auth_endpoint;
}
}
if (input.copyHeaders !== undefined) {
const headers = (input.copyHeaders ?? [])
.map((header) => header?.trim())
.filter((header): header is string => Boolean(header));
if (headers.length > 0) {
next.copy_headers = headers;
} else {
delete next.copy_headers;
}
}
if (input.trustedProxies !== undefined) {
const proxies = (input.trustedProxies ?? [])
.map((proxy) => proxy?.trim())
.filter((proxy): proxy is string => Boolean(proxy));
if (proxies.length > 0) {
next.trusted_proxies = proxies;
} else {
delete next.trusted_proxies;
}
}
if (input.setOutpostHostHeader !== undefined) {
next.set_outpost_host_header = Boolean(input.setOutpostHostHeader);
}
if ((next.enabled ?? false) && next.outpost_domain && !next.auth_endpoint) {
next.auth_endpoint = `/${next.outpost_domain}/auth/caddy`;
}
return Object.keys(next).length > 0 ? next : undefined;
}
function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): string | null {
const next: ProxyHostMeta = { ...existing };
if (input.custom_reverse_proxy_json !== undefined) {
const reverse = normalizeMetaValue(input.custom_reverse_proxy_json ?? null);
if (reverse) {
next.custom_reverse_proxy_json = reverse;
} else {
delete next.custom_reverse_proxy_json;
}
}
if (input.custom_pre_handlers_json !== undefined) {
const pre = normalizeMetaValue(input.custom_pre_handlers_json ?? null);
if (pre) {
next.custom_pre_handlers_json = pre;
} else {
delete next.custom_pre_handlers_json;
}
}
if (input.authentik !== undefined) {
const authentik = normalizeAuthentikInput(input.authentik, existing.authentik);
if (authentik) {
next.authentik = authentik;
} else {
delete next.authentik;
}
}
return serializeMeta(next);
}
function hydrateAuthentik(meta: ProxyHostAuthentikMeta | undefined): ProxyHostAuthentikConfig | null {
if (!meta) {
return null;
}
const enabled = Boolean(meta.enabled);
const outpostDomain = normalizeMetaValue(meta.outpost_domain ?? null);
const outpostUpstream = normalizeMetaValue(meta.outpost_upstream ?? null);
const authEndpoint =
normalizeMetaValue(meta.auth_endpoint ?? null) ?? (outpostDomain ? `/${outpostDomain}/auth/caddy` : null);
const copyHeaders =
Array.isArray(meta.copy_headers) && meta.copy_headers.length > 0 ? meta.copy_headers : DEFAULT_AUTHENTIK_HEADERS;
const trustedProxies =
Array.isArray(meta.trusted_proxies) && meta.trusted_proxies.length > 0
? meta.trusted_proxies
: DEFAULT_AUTHENTIK_TRUSTED_PROXIES;
const setOutpostHostHeader =
meta.set_outpost_host_header !== undefined ? Boolean(meta.set_outpost_host_header) : true;
return {
enabled,
outpostDomain,
outpostUpstream,
authEndpoint,
copyHeaders,
trustedProxies,
setOutpostHostHeader
};
}
function dehydrateAuthentik(config: ProxyHostAuthentikConfig | null): ProxyHostAuthentikMeta | undefined {
if (!config) {
return undefined;
}
const meta: ProxyHostAuthentikMeta = {
enabled: config.enabled
};
if (config.outpostDomain) {
meta.outpost_domain = config.outpostDomain;
}
if (config.outpostUpstream) {
meta.outpost_upstream = config.outpostUpstream;
}
if (config.authEndpoint) {
meta.auth_endpoint = config.authEndpoint;
}
if (config.copyHeaders.length > 0) {
meta.copy_headers = [...config.copyHeaders];
}
if (config.trustedProxies.length > 0) {
meta.trusted_proxies = [...config.trustedProxies];
}
meta.set_outpost_host_header = config.setOutpostHostHeader;
return meta;
}
function parseProxyHost(row: ProxyHostRow): ProxyHost {
const meta = parseMeta(row.meta ?? null);
return {
id: row.id,
name: row.name,
domains: JSON.parse(row.domains),
upstreams: JSON.parse(row.upstreams),
certificate_id: row.certificate_id ?? null,
access_list_id: row.access_list_id ?? null,
ssl_forced: Boolean(row.ssl_forced),
hsts_enabled: Boolean(row.hsts_enabled),
hsts_subdomains: Boolean(row.hsts_subdomains),
allow_websocket: Boolean(row.allow_websocket),
preserve_host_header: Boolean(row.preserve_host_header),
skip_https_hostname_validation: Boolean(row.skip_https_hostname_validation),
enabled: Boolean(row.enabled),
created_at: row.created_at,
updated_at: row.updated_at
certificate_id: row.certificateId ?? null,
access_list_id: row.accessListId ?? null,
ssl_forced: row.sslForced,
hsts_enabled: row.hstsEnabled,
hsts_subdomains: row.hstsSubdomains,
allow_websocket: row.allowWebsocket,
preserve_host_header: row.preserveHostHeader,
skip_https_hostname_validation: row.skipHttpsHostnameValidation,
enabled: row.enabled,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
custom_reverse_proxy_json: meta.custom_reverse_proxy_json ?? null,
custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null,
authentik: hydrateAuthentik(meta.authentik)
};
}
export function listProxyHosts(): ProxyHost[] {
const rows = db
.prepare(
`SELECT id, name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
hsts_subdomains, allow_websocket, preserve_host_header, skip_https_hostname_validation,
enabled, created_at, updated_at
FROM proxy_hosts
ORDER BY created_at DESC`
)
.all();
return rows.map(parseProxyHost);
export async function listProxyHosts(): Promise<ProxyHost[]> {
const hosts = await prisma.proxyHost.findMany({
orderBy: { createdAt: "desc" }
});
return hosts.map(parseProxyHost);
}
export async function createProxyHost(input: ProxyHostInput, actorUserId: number) {
@@ -76,99 +417,84 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
throw new Error("At least one upstream must be specified");
}
const now = nowIso();
const tx = db.transaction(() => {
const result = db
.prepare(
`INSERT INTO proxy_hosts
(name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
hsts_subdomains, allow_websocket, preserve_host_header, skip_https_hostname_validation,
enabled, created_at, updated_at, owner_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
input.name.trim(),
JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
input.certificate_id ?? null,
input.access_list_id ?? null,
(input.ssl_forced ?? true) ? 1 : 0,
(input.hsts_enabled ?? true) ? 1 : 0,
input.hsts_subdomains ? 1 : 0,
(input.allow_websocket ?? true) ? 1 : 0,
(input.preserve_host_header ?? true) ? 1 : 0,
input.skip_https_hostname_validation ? 1 : 0,
(input.enabled ?? true) ? 1 : 0,
now,
now,
actorUserId
);
const id = Number(result.lastInsertRowid);
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "proxy_host",
entityId: id,
summary: `Created proxy host ${input.name}`,
data: input
});
return id;
const now = new Date(nowIso());
const meta = buildMeta({}, input);
const record = await prisma.proxyHost.create({
data: {
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())))),
certificateId: input.certificate_id ?? null,
accessListId: input.access_list_id ?? null,
ownerUserId: actorUserId,
sslForced: input.ssl_forced ?? true,
hstsEnabled: input.hsts_enabled ?? true,
hstsSubdomains: input.hsts_subdomains ?? false,
allowWebsocket: input.allow_websocket ?? true,
preserveHostHeader: input.preserve_host_header ?? true,
meta,
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? false,
enabled: input.enabled ?? true,
createdAt: now,
updatedAt: now
}
});
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "proxy_host",
entityId: record.id,
summary: `Created proxy host ${input.name}`,
data: input
});
const id = tx();
await applyCaddyConfig();
return getProxyHost(id)!;
return (await getProxyHost(record.id))!;
}
export function getProxyHost(id: number): ProxyHost | null {
const row = db
.prepare(
`SELECT id, name, domains, upstreams, certificate_id, access_list_id, ssl_forced, hsts_enabled,
hsts_subdomains, allow_websocket, preserve_host_header, skip_https_hostname_validation,
enabled, created_at, updated_at
FROM proxy_hosts WHERE id = ?`
)
.get(id);
if (!row) {
return null;
}
return parseProxyHost(row);
export async function getProxyHost(id: number): Promise<ProxyHost | null> {
const host = await prisma.proxyHost.findUnique({
where: { id }
});
return host ? parseProxyHost(host) : null;
}
export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>, actorUserId: number) {
const existing = getProxyHost(id);
const existing = await getProxyHost(id);
if (!existing) {
throw new Error("Proxy host not found");
}
const domains = input.domains ? JSON.stringify(Array.from(new Set(input.domains))) : JSON.stringify(existing.domains);
const upstreams = input.upstreams ? JSON.stringify(Array.from(new Set(input.upstreams))) : JSON.stringify(existing.upstreams);
const existingMeta: ProxyHostMeta = {
custom_reverse_proxy_json: existing.custom_reverse_proxy_json ?? undefined,
custom_pre_handlers_json: existing.custom_pre_handlers_json ?? undefined,
authentik: dehydrateAuthentik(existing.authentik)
};
const meta = buildMeta(existingMeta, input);
const now = nowIso();
db.prepare(
`UPDATE proxy_hosts
SET name = ?, domains = ?, upstreams = ?, certificate_id = ?, access_list_id = ?, ssl_forced = ?, hsts_enabled = ?,
hsts_subdomains = ?, allow_websocket = ?, preserve_host_header = ?, skip_https_hostname_validation = ?,
enabled = ?, updated_at = ?
WHERE id = ?`
).run(
input.name ?? existing.name,
domains,
upstreams,
input.certificate_id ?? existing.certificate_id,
input.access_list_id ?? existing.access_list_id,
(input.ssl_forced ?? existing.ssl_forced) ? 1 : 0,
(input.hsts_enabled ?? existing.hsts_enabled) ? 1 : 0,
(input.hsts_subdomains ?? existing.hsts_subdomains) ? 1 : 0,
(input.allow_websocket ?? existing.allow_websocket) ? 1 : 0,
(input.preserve_host_header ?? existing.preserve_host_header) ? 1 : 0,
(input.skip_https_hostname_validation ?? existing.skip_https_hostname_validation) ? 1 : 0,
(input.enabled ?? existing.enabled) ? 1 : 0,
now,
id
);
const now = new Date(nowIso());
await prisma.proxyHost.update({
where: { id },
data: {
name: input.name ?? existing.name,
domains,
upstreams,
certificateId: input.certificate_id ?? existing.certificate_id,
accessListId: input.access_list_id ?? existing.access_list_id,
sslForced: input.ssl_forced ?? existing.ssl_forced,
hstsEnabled: input.hsts_enabled ?? existing.hsts_enabled,
hstsSubdomains: input.hsts_subdomains ?? existing.hsts_subdomains,
allowWebsocket: input.allow_websocket ?? existing.allow_websocket,
preserveHostHeader: input.preserve_host_header ?? existing.preserve_host_header,
meta,
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? existing.skip_https_hostname_validation,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
}
});
logAuditEvent({
userId: actorUserId,
@@ -180,16 +506,18 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
});
await applyCaddyConfig();
return getProxyHost(id)!;
return (await getProxyHost(id))!;
}
export async function deleteProxyHost(id: number, actorUserId: number) {
const existing = getProxyHost(id);
const existing = await getProxyHost(id);
if (!existing) {
throw new Error("Proxy host not found");
}
db.prepare("DELETE FROM proxy_hosts WHERE id = ?").run(id);
await prisma.proxyHost.delete({
where: { id }
});
logAuditEvent({
userId: actorUserId,
action: "delete",

View File

@@ -1,4 +1,4 @@
import db, { nowIso } from "../db";
import prisma, { nowIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
@@ -23,38 +23,42 @@ export type RedirectHostInput = {
enabled?: boolean;
};
function parse(row: any): RedirectHost {
function parseDbRecord(record: {
id: number;
name: string;
domains: string;
destination: string;
statusCode: number;
preserveQuery: boolean;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}): RedirectHost {
return {
id: row.id,
name: row.name,
domains: JSON.parse(row.domains),
destination: row.destination,
status_code: row.status_code,
preserve_query: Boolean(row.preserve_query),
enabled: Boolean(row.enabled),
created_at: row.created_at,
updated_at: row.updated_at
id: record.id,
name: record.name,
domains: JSON.parse(record.domains),
destination: record.destination,
status_code: record.statusCode,
preserve_query: record.preserveQuery,
enabled: record.enabled,
created_at: record.createdAt.toISOString(),
updated_at: record.updatedAt.toISOString()
};
}
export function listRedirectHosts(): RedirectHost[] {
const rows = db
.prepare(
`SELECT id, name, domains, destination, status_code, preserve_query, enabled, created_at, updated_at
FROM redirect_hosts ORDER BY created_at DESC`
)
.all();
return rows.map(parse);
export async function listRedirectHosts(): Promise<RedirectHost[]> {
const records = await prisma.redirectHost.findMany({
orderBy: { createdAt: "desc" }
});
return records.map(parseDbRecord);
}
export function getRedirectHost(id: number): RedirectHost | null {
const row = db
.prepare(
`SELECT id, name, domains, destination, status_code, preserve_query, enabled, created_at, updated_at
FROM redirect_hosts WHERE id = ?`
)
.get(id);
return row ? parse(row) : null;
export async function getRedirectHost(id: number): Promise<RedirectHost | null> {
const record = await prisma.redirectHost.findUnique({
where: { id }
});
return record ? parseDbRecord(record) : null;
}
export async function createRedirectHost(input: RedirectHostInput, actorUserId: number) {
@@ -62,56 +66,51 @@ export async function createRedirectHost(input: RedirectHostInput, actorUserId:
throw new Error("At least one domain is required");
}
const now = nowIso();
const result = db
.prepare(
`INSERT INTO redirect_hosts (name, domains, destination, status_code, preserve_query, enabled, created_at, updated_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
input.name.trim(),
JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
input.destination.trim(),
input.status_code ?? 302,
input.preserve_query ? 1 : 0,
(input.enabled ?? true) ? 1 : 0,
now,
now,
actorUserId
);
const id = Number(result.lastInsertRowid);
const now = new Date(nowIso());
const record = await prisma.redirectHost.create({
data: {
name: input.name.trim(),
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
destination: input.destination.trim(),
statusCode: input.status_code ?? 302,
preserveQuery: input.preserve_query ?? true,
enabled: input.enabled ?? true,
createdAt: now,
updatedAt: now,
createdBy: actorUserId
}
});
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "redirect_host",
entityId: id,
entityId: record.id,
summary: `Created redirect ${input.name}`
});
await applyCaddyConfig();
return getRedirectHost(id)!;
return (await getRedirectHost(record.id))!;
}
export async function updateRedirectHost(id: number, input: Partial<RedirectHostInput>, actorUserId: number) {
const existing = getRedirectHost(id);
const existing = await getRedirectHost(id);
if (!existing) {
throw new Error("Redirect host not found");
}
const now = nowIso();
db.prepare(
`UPDATE redirect_hosts
SET name = ?, domains = ?, destination = ?, status_code = ?, preserve_query = ?, enabled = ?, updated_at = ?
WHERE id = ?`
).run(
input.name ?? existing.name,
JSON.stringify(input.domains ? Array.from(new Set(input.domains)) : existing.domains),
input.destination ?? existing.destination,
input.status_code ?? existing.status_code,
(input.preserve_query ?? existing.preserve_query) ? 1 : 0,
(input.enabled ?? existing.enabled) ? 1 : 0,
now,
id
);
const now = new Date(nowIso());
await prisma.redirectHost.update({
where: { id },
data: {
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,
statusCode: input.status_code ?? existing.status_code,
preserveQuery: input.preserve_query ?? existing.preserve_query,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
}
});
logAuditEvent({
userId: actorUserId,
@@ -121,15 +120,19 @@ export async function updateRedirectHost(id: number, input: Partial<RedirectHost
summary: `Updated redirect ${input.name ?? existing.name}`
});
await applyCaddyConfig();
return getRedirectHost(id)!;
return (await getRedirectHost(id))!;
}
export async function deleteRedirectHost(id: number, actorUserId: number) {
const existing = getRedirectHost(id);
const existing = await getRedirectHost(id);
if (!existing) {
throw new Error("Redirect host not found");
}
db.prepare("DELETE FROM redirect_hosts WHERE id = ?").run(id);
await prisma.redirectHost.delete({
where: { id }
});
logAuditEvent({
userId: actorUserId,
action: "delete",

View File

@@ -1,139 +0,0 @@
import db, { nowIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
export type StreamHost = {
id: number;
name: string;
listen_port: number;
protocol: string;
upstream: string;
enabled: boolean;
created_at: string;
updated_at: string;
};
export type StreamHostInput = {
name: string;
listen_port: number;
protocol: string;
upstream: string;
enabled?: boolean;
};
function parse(row: any): StreamHost {
return {
id: row.id,
name: row.name,
listen_port: row.listen_port,
protocol: row.protocol,
upstream: row.upstream,
enabled: Boolean(row.enabled),
created_at: row.created_at,
updated_at: row.updated_at
};
}
export function listStreamHosts(): StreamHost[] {
const rows = db
.prepare(
`SELECT id, name, listen_port, protocol, upstream, enabled, created_at, updated_at
FROM stream_hosts ORDER BY created_at DESC`
)
.all();
return rows.map(parse);
}
export function getStreamHost(id: number): StreamHost | null {
const row = db
.prepare(
`SELECT id, name, listen_port, protocol, upstream, enabled, created_at, updated_at
FROM stream_hosts WHERE id = ?`
)
.get(id);
return row ? parse(row) : null;
}
function assertProtocol(protocol: string) {
if (!["tcp", "udp"].includes(protocol.toLowerCase())) {
throw new Error("Protocol must be tcp or udp");
}
}
export async function createStreamHost(input: StreamHostInput, actorUserId: number) {
assertProtocol(input.protocol);
const now = nowIso();
const result = db
.prepare(
`INSERT INTO stream_hosts (name, listen_port, protocol, upstream, enabled, created_at, updated_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
input.name.trim(),
input.listen_port,
input.protocol.toLowerCase(),
input.upstream.trim(),
(input.enabled ?? true) ? 1 : 0,
now,
now,
actorUserId
);
const id = Number(result.lastInsertRowid);
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "stream_host",
entityId: id,
summary: `Created stream ${input.name}`
});
await applyCaddyConfig();
return getStreamHost(id)!;
}
export async function updateStreamHost(id: number, input: Partial<StreamHostInput>, actorUserId: number) {
const existing = getStreamHost(id);
if (!existing) {
throw new Error("Stream host not found");
}
const protocol = input.protocol ? input.protocol.toLowerCase() : existing.protocol;
assertProtocol(protocol);
const now = nowIso();
db.prepare(
`UPDATE stream_hosts
SET name = ?, listen_port = ?, protocol = ?, upstream = ?, enabled = ?, updated_at = ?
WHERE id = ?`
).run(
input.name ?? existing.name,
input.listen_port ?? existing.listen_port,
protocol,
input.upstream ?? existing.upstream,
(input.enabled ?? existing.enabled) ? 1 : 0,
now,
id
);
logAuditEvent({
userId: actorUserId,
action: "update",
entityType: "stream_host",
entityId: id,
summary: `Updated stream ${input.name ?? existing.name}`
});
await applyCaddyConfig();
return getStreamHost(id)!;
}
export async function deleteStreamHost(id: number, actorUserId: number) {
const existing = getStreamHost(id);
if (!existing) {
throw new Error("Stream host not found");
}
db.prepare("DELETE FROM stream_hosts WHERE id = ?").run(id);
logAuditEvent({
userId: actorUserId,
action: "delete",
entityType: "stream_host",
entityId: id,
summary: `Deleted stream ${existing.name}`
});
await applyCaddyConfig();
}

View File

@@ -1,9 +1,10 @@
import db, { nowIso } from "../db";
import prisma, { nowIso } from "../db";
export type User = {
id: number;
email: string;
name: string | null;
password_hash: string | null;
role: "admin" | "user" | "viewer";
provider: string;
subject: string;
@@ -13,107 +14,141 @@ export type User = {
updated_at: string;
};
export function getUserById(userId: number): User | null {
const row = db
.prepare(
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
FROM users WHERE id = ?`
)
.get(userId) as User | undefined;
return row ?? null;
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 {
return {
id: user.id,
email: user.email,
name: user.name,
password_hash: user.passwordHash,
role: user.role as "admin" | "user" | "viewer",
provider: user.provider,
subject: user.subject,
avatar_url: user.avatarUrl,
status: user.status,
created_at: user.createdAt.toISOString(),
updated_at: user.updatedAt.toISOString()
};
}
export function getUserCount(): number {
const row = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number };
return Number(row.count);
export async function getUserById(userId: number): Promise<User | null> {
const user = await prisma.user.findUnique({
where: { id: userId }
});
return user ? parseDbUser(user) : null;
}
export function findUserByProviderSubject(provider: string, subject: string): User | null {
const row = db
.prepare(
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
FROM users WHERE provider = ? AND subject = ?`
)
.get(provider, subject) as User | undefined;
return row ?? null;
export async function getUserCount(): Promise<number> {
return await prisma.user.count();
}
export function findUserByEmail(email: string): User | null {
const row = db
.prepare(
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
FROM users WHERE email = ?`
)
.get(email) as User | undefined;
return row ?? null;
export async function findUserByProviderSubject(provider: string, subject: string): Promise<User | null> {
const user = await prisma.user.findFirst({
where: {
provider,
subject
}
});
return user ? parseDbUser(user) : null;
}
export function createUser(data: {
export async function findUserByEmail(email: string): Promise<User | null> {
const normalizedEmail = email.trim().toLowerCase();
const user = await prisma.user.findFirst({
where: {
email: normalizedEmail
}
});
return user ? parseDbUser(user) : null;
}
export async function createUser(data: {
email: string;
name?: string | null;
role?: User["role"];
provider: string;
subject: string;
avatar_url?: string | null;
}): User {
const now = nowIso();
passwordHash?: string | null;
}): Promise<User> {
const now = new Date(nowIso());
const role = data.role ?? "user";
const stmt = db.prepare(
`INSERT INTO users (email, name, role, provider, subject, avatar_url, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`
);
const info = stmt.run(data.email, data.name ?? null, role, data.provider, data.subject, data.avatar_url ?? null, now, now);
const email = data.email.trim().toLowerCase();
return {
id: Number(info.lastInsertRowid),
email: data.email,
name: data.name ?? null,
role,
provider: data.provider,
subject: data.subject,
avatar_url: data.avatar_url ?? null,
status: "active",
created_at: now,
updated_at: now
};
const user = await prisma.user.create({
data: {
email,
name: data.name ?? null,
passwordHash: data.passwordHash ?? null,
role,
provider: data.provider,
subject: data.subject,
avatarUrl: data.avatar_url ?? null,
status: "active",
createdAt: now,
updatedAt: now
}
});
return parseDbUser(user);
}
export function updateUserProfile(userId: number, data: { email?: string; name?: string | null; avatar_url?: string | null }): User | null {
const current = getUserById(userId);
export async function updateUserProfile(userId: number, data: { email?: string; name?: string | null; avatar_url?: string | null }): Promise<User | null> {
const current = await getUserById(userId);
if (!current) {
return null;
}
const nextEmail = data.email ?? current.email;
const nextName = data.name ?? current.name;
const nextAvatar = data.avatar_url ?? current.avatar_url;
const now = nowIso();
db.prepare(
`UPDATE users
SET email = ?, name = ?, avatar_url = ?, updated_at = ?
WHERE id = ?`
).run(nextEmail, nextName, nextAvatar, now, userId);
return {
...current,
email: nextEmail,
name: nextName,
avatar_url: nextAvatar,
updated_at: now
};
const now = new Date(nowIso());
const user = await prisma.user.update({
where: { id: userId },
data: {
email: data.email ?? current.email,
name: data.name ?? current.name,
avatarUrl: data.avatar_url ?? current.avatar_url,
updatedAt: now
}
});
return parseDbUser(user);
}
export function listUsers(): User[] {
const rows = db
.prepare(
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
FROM users
ORDER BY created_at ASC`
)
.all() as User[];
return rows;
export async function updateUserPassword(userId: number, passwordHash: string): Promise<void> {
const now = new Date(nowIso());
await prisma.user.update({
where: { id: userId },
data: {
passwordHash,
updatedAt: now
}
});
}
export function promoteToAdmin(userId: number) {
const now = nowIso();
db.prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?").run(now, userId);
export async function listUsers(): Promise<User[]> {
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" }
});
return users.map(parseDbUser);
}
export async function promoteToAdmin(userId: number): Promise<void> {
const now = new Date(nowIso());
await prisma.user.update({
where: { id: userId },
data: {
role: "admin",
updatedAt: now
}
});
}

View File

@@ -1,20 +1,7 @@
import db, { nowIso } from "./db";
import prisma, { nowIso } from "./db";
export type SettingValue<T> = T | null;
export type OAuthSettings = {
providerType: "authentik" | "generic";
authorizationUrl: string;
tokenUrl: string;
clientId: string;
clientSecret: string;
userInfoUrl: string;
scopes: string;
emailClaim?: string;
nameClaim?: string;
avatarClaim?: string;
};
export type CloudflareSettings = {
apiToken: string;
zoneId?: string;
@@ -26,49 +13,53 @@ export type GeneralSettings = {
acmeEmail?: string;
};
export function getSetting<T>(key: string): SettingValue<T> {
const row = db
.prepare("SELECT value FROM settings WHERE key = ?")
.get(key) as { value: string } | undefined;
if (!row) {
export async function getSetting<T>(key: string): Promise<SettingValue<T>> {
const setting = await prisma.setting.findUnique({
where: { key }
});
if (!setting) {
return null;
}
try {
return JSON.parse(row.value) as T;
return JSON.parse(setting.value) as T;
} catch (error) {
console.warn(`Failed to parse setting ${key}`, error);
return null;
}
}
export function setSetting<T>(key: string, value: T) {
export async function setSetting<T>(key: string, value: T): Promise<void> {
const payload = JSON.stringify(value);
db.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`
).run(key, payload, nowIso());
const now = new Date(nowIso());
await prisma.setting.upsert({
where: { key },
update: {
value: payload,
updatedAt: now
},
create: {
key,
value: payload,
updatedAt: now
}
});
}
export function getOAuthSettings(): OAuthSettings | null {
return getSetting<OAuthSettings>("oauth");
export async function getCloudflareSettings(): Promise<CloudflareSettings | null> {
return await getSetting<CloudflareSettings>("cloudflare");
}
export function saveOAuthSettings(settings: OAuthSettings) {
setSetting("oauth", settings);
export async function saveCloudflareSettings(settings: CloudflareSettings): Promise<void> {
await setSetting("cloudflare", settings);
}
export function getCloudflareSettings(): CloudflareSettings | null {
return getSetting<CloudflareSettings>("cloudflare");
export async function getGeneralSettings(): Promise<GeneralSettings | null> {
return await getSetting<GeneralSettings>("general");
}
export function saveCloudflareSettings(settings: CloudflareSettings) {
setSetting("cloudflare", settings);
}
export function getGeneralSettings(): GeneralSettings | null {
return getSetting<GeneralSettings>("general");
}
export function saveGeneralSettings(settings: GeneralSettings) {
setSetting("general", settings);
export async function saveGeneralSettings(settings: GeneralSettings): Promise<void> {
await setSetting("general", settings);
}

File diff suppressed because one or more lines are too long