updated a lot of stuff
This commit is contained in:
50
.dockerignore
Normal file
50
.dockerignore
Normal 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
76
.github/workflows/docker-build.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -7,3 +7,5 @@ data
|
||||
.env*
|
||||
/.idea
|
||||
tsconfig.tsbuildinfo
|
||||
/caddy-data
|
||||
/caddy-config
|
||||
|
||||
119
README.md
119
README.md
@@ -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.js 16 (App Router), Material UI, 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.js 16 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 provider’s 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
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
32
compose.yaml
32
compose.yaml
@@ -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
78
docker-compose.yml
Normal 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
|
||||
@@ -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
404
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
205
prisma/schema.prisma
Normal 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
0
public/.gitkeep
Normal file
25
src/lib/actions.ts
Normal file
25
src/lib/actions.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
222
src/lib/auth.ts
222
src/lib/auth.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
502
src/lib/caddy.ts
502
src/lib/caddy.ts
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user