removed redirect feature
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS redirect_hosts;
|
||||
+3
-60
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user