removed redirect feature

This commit is contained in:
fuomag9
2026-02-13 22:53:11 +01:00
parent 78309e8435
commit 7e4df5e50b
11 changed files with 11 additions and 886 deletions
@@ -22,7 +22,6 @@ import {
import MenuIcon from "@mui/icons-material/Menu";
import DashboardIcon from "@mui/icons-material/Dashboard";
import DnsIcon from "@mui/icons-material/Dns";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import SecurityIcon from "@mui/icons-material/Security";
import ShieldIcon from "@mui/icons-material/Shield";
import SettingsIcon from "@mui/icons-material/Settings";
@@ -39,7 +38,6 @@ type User = {
const NAV_ITEMS = [
{ href: "/", label: "Overview", icon: DashboardIcon },
{ href: "/proxy-hosts", label: "Proxy Hosts", icon: DnsIcon },
{ href: "/redirects", label: "Redirects", icon: SwapHorizIcon },
{ href: "/access-lists", label: "Access Lists", icon: SecurityIcon },
{ href: "/certificates", label: "Certificates", icon: ShieldIcon },
{ href: "/settings", label: "Settings", icon: SettingsIcon },
+2 -7
View File
@@ -5,12 +5,10 @@ import {
accessLists,
auditEvents,
certificates,
proxyHosts,
redirectHosts
proxyHosts
} from "@/src/lib/db/schema";
import { count, desc } from "drizzle-orm";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import TurnRightIcon from "@mui/icons-material/TurnRight";
import SecurityIcon from "@mui/icons-material/Security";
import VpnKeyIcon from "@mui/icons-material/VpnKey";
import { ReactNode } from "react";
@@ -23,21 +21,18 @@ type StatCard = {
};
async function loadStats(): Promise<StatCard[]> {
const [proxyHostCountResult, redirectHostCountResult, certificateCountResult, accessListCountResult] =
const [proxyHostCountResult, certificateCountResult, accessListCountResult] =
await Promise.all([
db.select({ value: count() }).from(proxyHosts),
db.select({ value: count() }).from(redirectHosts),
db.select({ value: count() }).from(certificates),
db.select({ value: count() }).from(accessLists)
]);
const proxyHostsCount = proxyHostCountResult[0]?.value ?? 0;
const redirectHostsCount = redirectHostCountResult[0]?.value ?? 0;
const certificatesCount = certificateCountResult[0]?.value ?? 0;
const accessListsCount = accessListCountResult[0]?.value ?? 0;
return [
{ label: "Proxy Hosts", icon: <SwapHorizIcon fontSize="large" />, count: proxyHostsCount, href: "/proxy-hosts" },
{ label: "Redirects", icon: <TurnRightIcon fontSize="large" />, count: redirectHostsCount, href: "/redirects" },
{ label: "Certificates", icon: <SecurityIcon fontSize="large" />, count: certificatesCount, href: "/certificates" },
{ label: "Access Lists", icon: <VpnKeyIcon fontSize="large" />, count: accessListsCount, href: "/access-lists" }
];
@@ -1,536 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
Alert,
Box,
Button,
Card,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
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 SearchIcon from "@mui/icons-material/Search";
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, toggleRedirectAction } from "./actions";
type Props = {
redirects: RedirectHost[];
};
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);
const [searchTerm, setSearchTerm] = useState("");
const filteredRedirects = useMemo(() => {
if (!searchTerm.trim()) return redirects;
const search = searchTerm.toLowerCase();
return redirects.filter((redirect) => {
// Search in name
if (redirect.name.toLowerCase().includes(search)) return true;
// Search in domains
if (redirect.domains.some(domain => domain.toLowerCase().includes(search))) return true;
// Search in destination
if (redirect.destination.toLowerCase().includes(search)) return true;
// Search in status code
if (redirect.status_code.toString().includes(search)) return true;
return false;
});
}, [redirects, searchTerm]);
const handleToggleEnabled = async (id: number, enabled: boolean) => {
await toggleRedirectAction(id, enabled);
};
return (
<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)"
}}
>
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>
<TextField
placeholder="Search redirects..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
slotProps={{
input: {
startAdornment: <SearchIcon sx={{ mr: 1, color: "rgba(255, 255, 255, 0.5)" }} />
}
}}
sx={{
maxWidth: 500,
"& .MuiOutlinedInput-root": {
bgcolor: "rgba(20, 20, 22, 0.6)",
"&:hover": {
bgcolor: "rgba(20, 20, 22, 0.8)"
}
}
}}
/>
<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>
{filteredRedirects.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 6, color: "text.secondary" }}>
{searchTerm ? "No redirects match your search." : "No redirects configured. Click \"Create Redirect\" to add one."}
</TableCell>
</TableRow>
) : (
filteredRedirects.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"
}}
/>
</TableCell>
<TableCell>
<Switch
checked={redirect.enabled}
onChange={(e) => handleToggleEnabled(redirect.id, e.target.checked)}
size="small"
sx={{
"& .MuiSwitch-switchBase.Mui-checked": {
color: "rgba(34, 197, 94, 1)"
},
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": {
backgroundColor: "rgba(34, 197, 94, 0.5)"
}
}}
/>
</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>
<CreateRedirectDialog open={createOpen} onClose={() => setCreateOpen(false)} />
{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);
const router = useRouter();
useEffect(() => {
if (state.status === "success") {
router.refresh();
setTimeout(onClose, 1000);
}
}, [state.status, router, onClose]);
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"}>
{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);
const router = useRouter();
useEffect(() => {
if (state.status === "success") {
router.refresh();
setTimeout(onClose, 1000);
}
}, [state.status, router, onClose]);
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"}>
{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);
const router = useRouter();
useEffect(() => {
if (state.status === "success") {
router.refresh();
setTimeout(onClose, 1000);
}
}, [state.status, router, onClose]);
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"}>
{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}
size="small"
sx={{
color: "rgba(148, 163, 184, 0.6)",
"&.Mui-checked": { color: "#6366f1" }
}}
/>
}
label={<Typography variant="body2">{label}</Typography>}
/>
</Box>
);
}
-86
View File
@@ -1,86 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } 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") {
return [];
}
return value
.replace(/\n/g, ",")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
export async function createRedirectAction(_prevState: ActionState, formData: FormData): Promise<ActionState> {
try {
const session = await requireAdmin();
const userId = Number(session.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, _prevState: ActionState, formData: FormData): Promise<ActionState> {
try {
const session = await requireAdmin();
const userId = Number(session.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, _prevState: ActionState): Promise<ActionState> {
try {
const session = await requireAdmin();
const userId = Number(session.user.id);
await deleteRedirectHost(id, userId);
revalidatePath("/redirects");
return actionSuccess("Redirect deleted successfully");
} catch (error) {
return actionError(error, "Failed to delete redirect");
}
}
export async function toggleRedirectAction(id: number, enabled: boolean): Promise<ActionState> {
try {
const session = await requireAdmin();
const userId = Number(session.user.id);
await updateRedirectHost(id, { enabled }, userId);
revalidatePath("/redirects");
return actionSuccess(`Redirect ${enabled ? "enabled" : "disabled"}.`);
} catch (error) {
return actionError(error, "Failed to toggle redirect");
}
}
-9
View File
@@ -1,9 +0,0 @@
import RedirectsClient from "./RedirectsClient";
import { listRedirectHosts } from "@/src/lib/models/redirect-hosts";
import { requireAdmin } from "@/src/lib/auth";
export default async function RedirectsPage() {
await requireAdmin();
const redirects = await listRedirectHosts();
return <RedirectsClient redirects={redirects} />;
}
+1 -18
View File
@@ -145,22 +145,6 @@ function isProxyHost(value: unknown): value is SyncPayload["data"]["proxyHosts"]
);
}
function isRedirectHost(value: unknown): value is SyncPayload["data"]["redirectHosts"][number] {
if (!isRecord(value)) return false;
return (
isNumber(value.id) &&
isString(value.name) &&
isString(value.domains) &&
isString(value.destination) &&
isNumber(value.statusCode) &&
isBoolean(value.preserveQuery) &&
isBoolean(value.enabled) &&
isNullableNumber(value.createdBy) &&
isString(value.createdAt) &&
isString(value.updatedAt)
);
}
/**
* Validates that the payload has the expected structure for syncing
*/
@@ -197,8 +181,7 @@ function isValidSyncPayload(payload: unknown): payload is SyncPayload {
validateArray(d.certificates, isCertificate) &&
validateArray(d.accessLists, isAccessList) &&
validateArray(d.accessListEntries, isAccessListEntry) &&
validateArray(d.proxyHosts, isProxyHost) &&
validateArray(d.redirectHosts, isRedirectHost)
validateArray(d.proxyHosts, isProxyHost)
);
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS redirect_hosts;
+3 -60
View File
@@ -8,8 +8,7 @@ import { syncInstances } from "./instance-sync";
import {
accessListEntries,
certificates,
proxyHosts,
redirectHosts
proxyHosts
} from "./db/schema";
const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs");
@@ -144,16 +143,6 @@ type LoadBalancerRouteConfig = {
} | null;
};
type RedirectHostRow = {
id: number;
name: string;
domains: string;
destination: string;
status_code: number;
preserve_query: number;
enabled: number;
};
type AccessListEntryRow = {
access_list_id: number;
username: string;
@@ -688,30 +677,6 @@ function buildProxyRoutes(
return routes;
}
function buildRedirectRoutes(rows: RedirectHostRow[]): CaddyHttpRoute[] {
return rows
.filter((row) => Boolean(row.enabled))
.map((row) => {
const domains = parseJson<string[]>(row.domains, []);
const preserveQuery = Boolean(row.preserve_query);
const location = preserveQuery ? `${row.destination}{uri}` : row.destination;
return {
match: [{ host: domains }],
handle: [
{
handler: "static_response",
status_code: row.status_code,
headers: {
Location: [location],
"Strict-Transport-Security": ["max-age=63072000"]
}
}
],
terminal: true
};
});
}
function buildTlsConnectionPolicies(
usage: Map<number, CertificateUsage>,
managedCertificatesWithAutomation: Set<number>,
@@ -907,7 +872,7 @@ async function buildTlsAutomation(
}
async function buildCaddyDocument() {
const [proxyHostRecords, redirectHostRecords, certRows, accessListEntryRecords] = await Promise.all([
const [proxyHostRecords, certRows, accessListEntryRecords] = await Promise.all([
db
.select({
id: proxyHosts.id,
@@ -926,17 +891,6 @@ async function buildCaddyDocument() {
enabled: proxyHosts.enabled
})
.from(proxyHosts),
db
.select({
id: redirectHosts.id,
name: redirectHosts.name,
domains: redirectHosts.domains,
destination: redirectHosts.destination,
statusCode: redirectHosts.statusCode,
preserveQuery: redirectHosts.preserveQuery,
enabled: redirectHosts.enabled
})
.from(redirectHosts),
db
.select({
id: certificates.id,
@@ -975,16 +929,6 @@ async function buildCaddyDocument() {
enabled: h.enabled ? 1 : 0
}));
const redirectHostRows: RedirectHostRow[] = redirectHostRecords.map((h) => ({
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 certRowsMapped: CertificateRow[] = certRows.map((c: typeof certRows[0]) => ({
id: c.id,
name: c.name,
@@ -1023,8 +967,7 @@ async function buildCaddyDocument() {
);
const httpRoutes: CaddyHttpRoute[] = [
...buildProxyRoutes(proxyHostRows, accessMap, readyCertificates, autoManagedDomains),
...buildRedirectRoutes(redirectHostRows)
...buildProxyRoutes(proxyHostRows, accessMap, readyCertificates, autoManagedDomains)
];
const hasTls = tlsConnectionPolicies.length > 0;
-13
View File
@@ -150,19 +150,6 @@ export const proxyHosts = sqliteTable("proxy_hosts", {
.default(false)
});
export const redirectHosts = sqliteTable("redirect_hosts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
domains: text("domains").notNull(),
destination: text("destination").notNull(),
statusCode: integer("status_code").notNull().default(302),
preserveQuery: integer("preserve_query", { mode: "boolean" }).notNull().default(true),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
});
export const apiTokens = sqliteTable(
"api_tokens",
{
+4 -16
View File
@@ -1,5 +1,5 @@
import db, { nowIso } from "./db";
import { accessListEntries, accessLists, certificates, proxyHosts, redirectHosts } from "./db/schema";
import { accessListEntries, accessLists, certificates, proxyHosts } from "./db/schema";
import { getSetting, setSetting } from "./settings";
import { recordInstanceSyncResult, updateInstance } from "./models/instances";
import { decryptSecret, encryptSecret, isEncryptedSecret } from "./secret";
@@ -23,7 +23,6 @@ export type SyncPayload = {
accessLists: Array<typeof accessLists.$inferSelect>;
accessListEntries: Array<typeof accessListEntries.$inferSelect>;
proxyHosts: Array<typeof proxyHosts.$inferSelect>;
redirectHosts: Array<typeof redirectHosts.$inferSelect>;
};
};
@@ -229,12 +228,11 @@ export async function clearSyncedSetting(key: string): Promise<void> {
}
export async function buildSyncPayload(): Promise<SyncPayload> {
const [certRows, accessListRows, accessEntryRows, proxyRows, redirectRows] = await Promise.all([
const [certRows, accessListRows, accessEntryRows, proxyRows] = await Promise.all([
db.select().from(certificates),
db.select().from(accessLists),
db.select().from(accessListEntries),
db.select().from(proxyHosts),
db.select().from(redirectHosts)
db.select().from(proxyHosts)
]);
const settings = {
@@ -256,11 +254,6 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
createdBy: null
}));
const sanitizedRedirects = redirectRows.map((row) => ({
...row,
createdBy: null
}));
const sanitizedProxyHosts = proxyRows.map((row) => ({
...row,
ownerUserId: null
@@ -273,8 +266,7 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
certificates: sanitizedCertificates,
accessLists: sanitizedAccessLists,
accessListEntries: accessEntryRows,
proxyHosts: sanitizedProxyHosts,
redirectHosts: sanitizedRedirects
proxyHosts: sanitizedProxyHosts
}
};
}
@@ -407,7 +399,6 @@ export async function applySyncPayload(payload: SyncPayload) {
// better-sqlite3 is synchronous, so transaction callback must be synchronous
db.transaction((tx) => {
tx.delete(proxyHosts).run();
tx.delete(redirectHosts).run();
tx.delete(accessListEntries).run();
tx.delete(accessLists).run();
tx.delete(certificates).run();
@@ -424,8 +415,5 @@ export async function applySyncPayload(payload: SyncPayload) {
if (payload.data.proxyHosts.length > 0) {
tx.insert(proxyHosts).values(payload.data.proxyHosts).run();
}
if (payload.data.redirectHosts.length > 0) {
tx.insert(redirectHosts).values(payload.data.redirectHosts).run();
}
});
}
-139
View File
@@ -1,139 +0,0 @@
import db, { nowIso, toIso } from "../db";
import { logAuditEvent } from "../audit";
import { applyCaddyConfig } from "../caddy";
import { redirectHosts } from "../db/schema";
import { desc, eq } from "drizzle-orm";
export type RedirectHost = {
id: number;
name: string;
domains: string[];
destination: string;
status_code: number;
preserve_query: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
};
export type RedirectHostInput = {
name: string;
domains: string[];
destination: string;
status_code?: number;
preserve_query?: boolean;
enabled?: boolean;
};
type RedirectHostRow = typeof redirectHosts.$inferSelect;
function parseDbRecord(record: RedirectHostRow): RedirectHost {
return {
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: toIso(record.createdAt)!,
updated_at: toIso(record.updatedAt)!
};
}
export async function listRedirectHosts(): Promise<RedirectHost[]> {
const records = await db.select().from(redirectHosts).orderBy(desc(redirectHosts.createdAt));
return records.map(parseDbRecord);
}
export async function getRedirectHost(id: number): Promise<RedirectHost | null> {
const record = await db.query.redirectHosts.findFirst({
where: (table, { eq }) => eq(table.id, id)
});
return record ? parseDbRecord(record) : null;
}
export async function createRedirectHost(input: RedirectHostInput, actorUserId: number) {
if (!input.domains || input.domains.length === 0) {
throw new Error("At least one domain is required");
}
const now = nowIso();
const [record] = await db
.insert(redirectHosts)
.values({
name: input.name.trim(),
domains: JSON.stringify(Array.from(new Set(input.domains.map((d) => d.trim().toLowerCase())))),
destination: input.destination.trim(),
statusCode: input.status_code ?? 302,
preserveQuery: input.preserve_query ?? true,
enabled: input.enabled ?? true,
createdAt: now,
updatedAt: now,
createdBy: actorUserId
})
.returning();
if (!record) {
throw new Error("Failed to create redirect host");
}
logAuditEvent({
userId: actorUserId,
action: "create",
entityType: "redirect_host",
entityId: record.id,
summary: `Created redirect ${input.name}`
});
await applyCaddyConfig();
return (await getRedirectHost(record.id))!;
}
export async function updateRedirectHost(id: number, input: Partial<RedirectHostInput>, actorUserId: number) {
const existing = await getRedirectHost(id);
if (!existing) {
throw new Error("Redirect host not found");
}
const now = nowIso();
await db
.update(redirectHosts)
.set({
name: input.name ?? existing.name,
domains: input.domains ? JSON.stringify(Array.from(new Set(input.domains))) : JSON.stringify(existing.domains),
destination: input.destination ?? existing.destination,
statusCode: input.status_code ?? existing.status_code,
preserveQuery: input.preserve_query ?? existing.preserve_query,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
})
.where(eq(redirectHosts.id, id));
logAuditEvent({
userId: actorUserId,
action: "update",
entityType: "redirect_host",
entityId: id,
summary: `Updated redirect ${input.name ?? existing.name}`
});
await applyCaddyConfig();
return (await getRedirectHost(id))!;
}
export async function deleteRedirectHost(id: number, actorUserId: number) {
const existing = await getRedirectHost(id);
if (!existing) {
throw new Error("Redirect host not found");
}
await db.delete(redirectHosts).where(eq(redirectHosts.id, id));
logAuditEvent({
userId: actorUserId,
action: "delete",
entityType: "redirect_host",
entityId: id,
summary: `Deleted redirect ${existing.name}`
});
await applyCaddyConfig();
}