From ccef82cca81b80ca16106d42e1da1aad954eb6e6 Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Wed, 25 Feb 2026 01:22:04 +0100 Subject: [PATCH] feat: add GeoIP status API route and improved geoblock UI --- app/api/geoip-status/route.ts | 12 ++ docker-compose.yml | 1 + src/components/proxy-hosts/GeoBlockFields.tsx | 182 ++++++++++++++---- 3 files changed, 156 insertions(+), 39 deletions(-) create mode 100644 app/api/geoip-status/route.ts diff --git a/app/api/geoip-status/route.ts b/app/api/geoip-status/route.ts new file mode 100644 index 00000000..15aa3ef7 --- /dev/null +++ b/app/api/geoip-status/route.ts @@ -0,0 +1,12 @@ +import { existsSync } from "node:fs"; +import { NextResponse } from "next/server"; + +const COUNTRY_DB = "/usr/share/GeoIP/GeoLite2-Country.mmdb"; +const ASN_DB = "/usr/share/GeoIP/GeoLite2-ASN.mmdb"; + +export async function GET() { + return NextResponse.json({ + country: existsSync(COUNTRY_DB), + asn: existsSync(ASN_DB), + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index a36cc7fc..90d14275 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,7 @@ services: OAUTH_ALLOW_AUTO_LINKING: ${OAUTH_ALLOW_AUTO_LINKING:-false} volumes: - caddy-manager-data:/app/data + - geoip-data:/usr/share/GeoIP:ro,z depends_on: caddy: condition: service_healthy diff --git a/src/components/proxy-hosts/GeoBlockFields.tsx b/src/components/proxy-hosts/GeoBlockFields.tsx index 47e5a196..523180c4 100644 --- a/src/components/proxy-hosts/GeoBlockFields.tsx +++ b/src/components/proxy-hosts/GeoBlockFields.tsx @@ -7,6 +7,7 @@ import { Autocomplete, Box, Chip, + CircularProgress, Collapse, Divider, Grid, @@ -16,18 +17,68 @@ import { Tab, Tabs, TextField, - ToggleButton, - ToggleButtonGroup, Tooltip, Typography } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import AddIcon from "@mui/icons-material/Add"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; -import { useState, SyntheticEvent } from "react"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import PublicIcon from "@mui/icons-material/Public"; +import { useState, useEffect, SyntheticEvent } from "react"; import { GeoBlockSettings } from "@/src/lib/settings"; import { GeoBlockMode } from "@/src/lib/models/proxy-hosts"; +// ─── GeoIpStatus ───────────────────────────────────────────────────────────── + +type GeoIpStatusData = { country: boolean; asn: boolean } | null; + +function GeoIpStatus() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/geoip-status") + .then((r) => r.json()) + .then((d) => setStatus(d)) + .catch(() => setStatus(null)) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ; + } + + const allLoaded = status?.country && status?.asn; + const noneLoaded = !status?.country && !status?.asn; + + const color = allLoaded ? "success" : noneLoaded ? "error" : "warning"; + const Icon = allLoaded ? CheckCircleOutlineIcon : noneLoaded ? ErrorOutlineIcon : WarningAmberIcon; + const label = allLoaded ? "GeoIP ready" : noneLoaded ? "GeoIP missing" : "GeoIP partial"; + const tooltip = noneLoaded + ? "GeoIP databases not found — country/continent/ASN blocking will not work. Enable the geoipupdate service." + : !status?.country + ? "GeoLite2-Country database missing — country/continent blocking disabled" + : !status?.asn + ? "GeoLite2-ASN database missing — ASN blocking disabled" + : "GeoLite2-Country and GeoLite2-ASN databases loaded"; + + return ( + + } + label={label} + color={color} + variant="outlined" + sx={{ height: 22, fontSize: "0.7rem", fontWeight: 600, letterSpacing: 0.3, cursor: "default", "& .MuiChip-icon": { ml: "6px" } }} + /> + + ); +} + // ─── TagInput ──────────────────────────────────────────────────────────────── type TagInputProps = { @@ -42,17 +93,22 @@ type TagInputProps = { function TagInput({ name, label, initialValues = [], placeholder, helperText, validate, uppercase = false }: TagInputProps) { const [tags, setTags] = useState(initialValues); + const [inputValue, setInputValue] = useState(""); function processValue(raw: string): string { return uppercase ? raw.trim().toUpperCase() : raw.trim(); } - function addTag(raw: string) { + function commitInput(raw: string) { const value = processValue(raw); if (!value) return; if (validate && !validate(value)) return; - if (tags.includes(value)) return; + if (tags.includes(value)) { + setInputValue(""); + return; + } setTags((prev) => [...prev, value]); + setInputValue(""); } return ( @@ -63,19 +119,19 @@ function TagInput({ name, label, initialValues = [], placeholder, helperText, va freeSolo options={[]} value={tags} + inputValue={inputValue} + onInputChange={(_, value, reason) => { + if (reason === "input") setInputValue(value); + }} onChange={(_, newValue) => { + // Called when user selects/removes a chip via Autocomplete internals const processed = newValue.map((v) => processValue(v as string)).filter((v) => { if (!v) return false; if (validate && !validate(v)) return false; return true; }); - // Deduplicate setTags([...new Set(processed)]); }} - onBlur={(e) => { - const input = (e.target as HTMLInputElement).value; - if (input.trim()) addTag(input); - }} renderTags={(value, getTagProps) => value.map((option, index) => { const { key, ...tagProps } = getTagProps({ index }); @@ -90,17 +146,17 @@ function TagInput({ name, label, initialValues = [], placeholder, helperText, va helperText={helperText} size="small" onKeyDown={(e) => { - if (e.key === "," || e.key === " ") { + if (e.key === "," || e.key === " " || e.key === "Enter") { e.preventDefault(); - const input = (e.target as HTMLInputElement).value; - if (input.trim()) { - addTag(input); - (e.target as HTMLInputElement).value = ""; - } + e.stopPropagation(); + commitInput(inputValue); } }} /> )} + onBlur={() => { + if (inputValue.trim()) commitInput(inputValue); + }} /> ); @@ -193,42 +249,90 @@ export function GeoBlockFields({ initialValues, showModeSelector = true }: GeoBl {/* Header */} - - - - Geo Blocking - - - Block or allow traffic by country, continent, ASN, CIDR, or IP - - + + + + + + + + + Geo Blocking + + + + + Block or allow traffic by country, continent, ASN, CIDR, or IP + + + setEnabled(checked)} + sx={{ flexShrink: 0 }} /> {/* Mode selector */} - {showModeSelector && ( - - { if (v) setMode(v); }} - > - Merge with global - Override global - - - )} {/* Detail fields */} - + {showModeSelector && ( + <> + + {(["merge", "override"] as GeoBlockMode[]).map((v) => ( + setMode(v)} + sx={{ + flex: 1, + py: 0.75, + px: 1.5, + borderRadius: 1.5, + border: "1.5px solid", + borderColor: mode === v ? "warning.main" : "divider", + bgcolor: mode === v + ? (theme) => theme.palette.mode === "dark" ? "rgba(237,108,2,0.12)" : "rgba(237,108,2,0.08)" + : "transparent", + cursor: "pointer", + textAlign: "center", + transition: "all 0.15s ease", + userSelect: "none", + "&:hover": { + borderColor: mode === v ? "warning.main" : "text.disabled", + }, + }} + > + + {v === "merge" ? "Merge with global" : "Override global"} + + + ))} + + + + )} + {!showModeSelector && } {/* Block / Allow tabs */}