diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..5c1e72d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# Copy to .env.local and adjust for your setup. Every value is optional — the app falls back
+# to local paths under ./data so it runs with no configuration at all.
+
+# SQLite database — the single source of truth for records. Docker: /data/dnsmasq-admin.db
+DNSMASQ_ADMIN_DB_PATH=./data/dnsmasq-admin.db
+
+# Managed dnsmasq config file this app regenerates on every apply.
+# Docker: /etc/dnsmasq.d/dnsmasq-admin.conf
+DNSMASQ_ADMIN_CONF_PATH=./data/dnsmasq-admin.conf
+
+# Container restarted over the Docker socket after an apply.
+DNSMASQ_CONTAINER_NAME=dnsmasq
+
+# Docker socket. If it is missing (e.g. developing on Windows), the restart is skipped and
+# reported in the UI rather than failing.
+DNSMASQ_ADMIN_DOCKER_SOCKET=/var/run/docker.sock
+
+# DNS server used by the /resolve page. Point this at your dnsmasq container or LAN IP.
+DNSMASQ_SERVER_IP=127.0.0.1
diff --git a/.gitignore b/.gitignore
index 5ef6a52..396dd47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,10 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
+
+# local sqlite database and generated dnsmasq config
+/data
# vercel
.vercel
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..c2b94ba
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,15 @@
+{
+ "printWidth": 100,
+ "tabWidth": 2,
+ "useTabs": false,
+ "semi": false,
+ "singleQuote": false,
+ "quoteProps": "as-needed",
+ "jsxSingleQuote": false,
+ "trailingComma": "es5",
+ "bracketSpacing": true,
+ "bracketSameLine": true,
+ "arrowParens": "always",
+ "htmlWhitespaceSensitivity": "css",
+ "endOfLine": "lf"
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..6182de7
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,18 @@
+# node:sqlite needs Node >= 22.5, so this must not drop below 22.
+FROM node:24-alpine
+
+RUN corepack enable
+
+WORKDIR /app
+
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+RUN pnpm install --frozen-lockfile
+
+COPY . .
+RUN pnpm build
+
+ENV NODE_ENV=production
+ENV HOSTNAME=0.0.0.0
+EXPOSE 3000
+
+CMD ["pnpm", "start"]
diff --git a/README.md b/README.md
index e215bc4..eb5faf8 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,82 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+# dnsmasq-admin
-## Getting Started
+一个极简的 Web UI,用来管理运行在 Docker 里的 dnsmasq 的 DNS 记录:域名记录的增删改查 + 一个解析测试页。
-First, run the development server:
+- **记录列表**(`/records`):新增、编辑、启用/禁用、删除
+- **解析测试**(`/resolve`):向 dnsmasq 发起 DNS 查询,验证记录是否已生效
+- 支持 **A** 与 **CNAME** 两种记录类型
-```bash
-npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
+## 工作原理
+
+```
+ ┌──────────┐ 写 /etc/dnsmasq.d/dnsmasq-admin.conf ┌──────────┐
+ │ 浏览器 │ ──────────> dnsmasq-admin (Next.js) ─────>│ dnsmasq │
+ └──────────┘ Docker socket: restart 容器 └──────────┘
+ │
+ └─> SQLite(唯一数据源)
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+1. 所有记录存在 **SQLite** 里(用 Node 内置的 `node:sqlite`,**没有任何原生依赖**,`node_modules` 里不会多出编译产物)。
+2. 增删改都只改数据库,**不会立刻影响 dnsmasq**。
+3. 点「应用并重启」后:按启用的记录重新生成托管配置文件 → 通过 Docker socket 重启 dnsmasq 容器使其生效。
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+之所以做成手动触发,是为了让你能先攒一批改动,再一次性重启 —— 每次改一条就重启一次容器没有必要,而且会让 DNS 短暂中断。
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+`address=/example.com/192.168.1.10` 这种写法会同时命中 `example.com` 及其**所有子域名**,所以不需要额外的通配符语法。
-## Learn More
+## 本地开发
-To learn more about Next.js, take a look at the following resources:
+需要 **Node >= 22.5**(`node:sqlite` 的最低要求)。
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+```bash
+pnpm install
+pnpm dev
+```
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+打开 ,会被重定向到 `/records`。
-## Deploy on Vercel
+本地运行不需要配任何环境变量 —— 数据库默认落在 `./data/dnsmasq-admin.db`,生成的配置默认落在 `./data/dnsmasq-admin.conf`。需要调整时复制 `.env.example` 为 `.env.local`。
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+在 **Windows / macOS 桌面上开发**时没有 `/var/run/docker.sock`,点「应用并重启」会提示「重启已跳过」,但配置文件**照常生成**,可以用来自查内容是否正确。完整的重启链路需要在装了 Docker 的 Linux 上验证。
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+## Docker 部署
+
+```bash
+docker compose up -d
+```
+
+`docker-compose.yml` 里同时给了 dnsmasq 和 web 两个服务用于开箱联调。如果你已经有自己的 dnsmasq 容器,删掉 `dnsmasq` 服务,把 `dnsmasq-conf` 这个 volume 挂到你容器里的 `/etc/dnsmasq.d` 即可:
+
+```yaml
+# 在你现有的 dnsmasq 容器上加:
+volumes:
+ - dnsmasq-conf:/etc/dnsmasq.d
+```
+
+注意两个 volume 都要保留:`app-data` 存着 SQLite 数据库,删了等于记录全丢。
+
+## 环境变量
+
+见 [`.env.example`](.env.example)。
+
+| 变量 | 默认值 | 说明 |
+| --- | --- | --- |
+| `DNSMASQ_ADMIN_DB_PATH` | `./data/dnsmasq-admin.db` | SQLite 数据库位置 |
+| `DNSMASQ_ADMIN_CONF_PATH` | `./data/dnsmasq-admin.conf` | 生成的托管配置文件 |
+| `DNSMASQ_CONTAINER_NAME` | `dnsmasq` | 应用后要重启的容器名 |
+| `DNSMASQ_ADMIN_DOCKER_SOCKET` | `/var/run/docker.sock` | Docker socket 路径;不存在则跳过重启 |
+| `DNSMASQ_SERVER_IP` | `127.0.0.1` | `/resolve` 页查询用的 DNS 服务器 |
+
+## ⚠️ 安全提示
+
+**这个应用当前没有任何鉴权** —— 任何能访问到它的人都能增删 DNS 记录并重启你的 dnsmasq 容器。请务必只监听内网或本机,**不要暴露到公网**。
+
+在 compose 里 `web` 挂载了 `/var/run/docker.sock`,这意味着该容器具备控制宿主机 Docker 的能力。这是设计使然(需要在应用后重启 dnsmasq),但应当把它当作高权限服务看待:不要用它跑不可信代码,端口只映射到内网网卡(如 `127.0.0.1:3000:3000`)而非 `0.0.0.0`。
+
+## 技术备注(Next.js 16)
+
+本项目启用了 `cacheComponents: true`,即 Next 16 的 Cache Components / PPR 模式。
+
+这里踩到一个关键点:`node:sqlite` 是**同步**驱动,如果不显式处理,它的查询结果会在**构建期**被烤进预渲染的静态 shell,上线后页面永远显示构建时的旧数据。官方 issue 文档明确提到了这一点(`node_modules/next/dist/docs/01-app/03-api-reference/04-functions/io.md`),解法是在读库前 `await io()`,并把数据组件包在 `` 里 —— 参见 `lib/records.ts`。
+
+同理,时间戳一律用 SQL 的 `datetime('now')` 而非 JS 的 `new Date()`,后者在预渲染阶段会直接导致构建失败。
diff --git a/app/globals.css b/app/globals.css
index a2dc41e..2205352 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,26 +1,26 @@
@import "tailwindcss";
:root {
- --background: #ffffff;
- --foreground: #171717;
+ --background: #ffffff;
+ --foreground: #171717;
}
@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
+ :root {
+ --background: #0a0a0a;
+ --foreground: #ededed;
+ }
}
body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ background: var(--background);
+ color: var(--foreground);
+ font-family: Arial, Helvetica, sans-serif;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 9852c15..9330180 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,29 +1,48 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
+import type { Metadata } from "next"
+import { Geist, Geist_Mono } from "next/font/google"
+import Link from "next/link"
+import "./globals.css"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
-});
+})
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
-});
+})
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
-};
+ title: "dnsmasq 管理",
+ description: "管理 Docker 中 dnsmasq 的 DNS 记录",
+}
+
+const NAV_ITEMS = [
+ { href: "/records", label: "域名记录" },
+ { href: "/resolve", label: "解析测试" },
+]
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
-
- {children}
+
+
+
+
+
+
+ {children}
+
- );
+ )
}
diff --git a/app/page.tsx b/app/page.tsx
index c887311..9205432 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,69 +1,5 @@
-import Image from "next/image";
+import { redirect } from "next/navigation"
export default function Home() {
- return (
-
-
-
-
-
- To get started, edit the{" "}
-
- page.tsx
- {" "}
- file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
+ redirect("/records")
}
diff --git a/app/records/[id]/page.tsx b/app/records/[id]/page.tsx
new file mode 100644
index 0000000..47fa119
--- /dev/null
+++ b/app/records/[id]/page.tsx
@@ -0,0 +1,41 @@
+import { notFound } from "next/navigation"
+import { Suspense } from "react"
+import { getRecord } from "@/lib/records"
+import { RecordForm } from "../../_components/RecordForm"
+import { updateRecordAction } from "../../actions"
+
+export const metadata = { title: "编辑记录" }
+
+export default function EditRecordPage({ params }: PageProps<"/records/[id]">) {
+ return (
+
+ 编辑记录
+
+ {/* params are awaited inside Suspense so the shell can still be prerendered */}
+ 加载中…
}>
+
+
+
+ )
+}
+
+async function EditRecordForm({ params }: Pick, "params">) {
+ const { id } = await params
+ const numericId = Number(id)
+
+ // Guard against /records/abc: Number() would hand NaN to SQLite.
+ if (!Number.isInteger(numericId) || numericId <= 0) notFound()
+
+ const record = await getRecord(numericId)
+
+ if (!record) notFound()
+
+ return (
+
+ )
+}
diff --git a/app/records/_components/ApplyButton.tsx b/app/records/_components/ApplyButton.tsx
new file mode 100644
index 0000000..3e68d9f
--- /dev/null
+++ b/app/records/_components/ApplyButton.tsx
@@ -0,0 +1,37 @@
+"use client"
+
+import { useActionState } from "react"
+import type { ActionState } from "@/lib/action-state"
+
+export interface ApplyButtonProps {
+ action: (prev: ActionState, formData: FormData) => Promise
+}
+
+export function ApplyButton({ action }: ApplyButtonProps) {
+ const [state, formAction, pending] = useActionState(action, { ok: false })
+
+ return (
+
+
+
+ {state.message ? (
+
+
+ {state.message}
+
+ {state.detail ? (
+
{state.detail}
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/app/records/_components/DeleteButton.tsx b/app/records/_components/DeleteButton.tsx
new file mode 100644
index 0000000..79844ea
--- /dev/null
+++ b/app/records/_components/DeleteButton.tsx
@@ -0,0 +1,14 @@
+"use client"
+
+export function DeleteButton() {
+ return (
+
+ )
+}
diff --git a/app/records/_components/RecordForm.tsx b/app/records/_components/RecordForm.tsx
new file mode 100644
index 0000000..ad4a834
--- /dev/null
+++ b/app/records/_components/RecordForm.tsx
@@ -0,0 +1,127 @@
+"use client"
+
+import Link from "next/link"
+import { useActionState } from "react"
+import type { ActionState } from "@/lib/action-state"
+import type { DnsRecord } from "@/lib/db"
+
+const fieldClass =
+ "w-full rounded-md border border-black/10 bg-white px-3 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-500 dark:border-white/15 dark:bg-white/5 dark:text-zinc-100"
+
+const labelClass = "block text-sm font-medium text-zinc-700 dark:text-zinc-300"
+
+function FieldError({ message }: { message?: string }) {
+ if (!message) return null
+ return {message}
+}
+
+export interface RecordFormProps {
+ /** Server Action to run on submit: either create or update, both bound to this form. */
+ action: (prev: ActionState, formData: FormData) => Promise
+ record?: DnsRecord
+ submitLabel: string
+ cancelHref: string
+}
+
+export function RecordForm({ action, record, submitLabel, cancelHref }: RecordFormProps) {
+ const [state, formAction, pending] = useActionState(action, { ok: false })
+ const errors = state.fieldErrors ?? {}
+
+ return (
+
+ )
+}
diff --git a/app/records/actions.ts b/app/records/actions.ts
new file mode 100644
index 0000000..5b89157
--- /dev/null
+++ b/app/records/actions.ts
@@ -0,0 +1,115 @@
+"use server"
+
+import { revalidatePath } from "next/cache"
+import { redirect } from "next/navigation"
+import type { ActionState } from "@/lib/action-state"
+import { writeManagedConfig } from "@/lib/dnsmasq-config"
+import { restartDnsmasqContainer } from "@/lib/docker"
+import {
+ DuplicateDomainError,
+ createRecord,
+ listEnabledRecords,
+ removeRecord,
+ setRecordEnabled,
+ updateRecord,
+} from "@/lib/records"
+import { parseRecordForm } from "@/lib/validate"
+
+const RECORDS_PATH = "/records"
+
+function readId(formData: FormData): number | null {
+ const raw = formData.get("id")
+ const id = typeof raw === "string" ? Number(raw) : NaN
+ return Number.isInteger(id) && id > 0 ? id : null
+}
+
+function asDuplicateState(error: unknown): ActionState | null {
+ if (error instanceof DuplicateDomainError) {
+ return { ok: false, fieldErrors: { domain: `域名 ${error.domain} 已存在` } }
+ }
+ return null
+}
+
+export async function createRecordAction(
+ _prev: ActionState,
+ formData: FormData
+): Promise {
+ const parsed = parseRecordForm(formData)
+ if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors }
+
+ try {
+ createRecord(parsed.input)
+ } catch (error) {
+ const duplicate = asDuplicateState(error)
+ if (duplicate) return duplicate
+ throw error
+ }
+
+ revalidatePath(RECORDS_PATH)
+ redirect(RECORDS_PATH)
+}
+
+export async function updateRecordAction(
+ _prev: ActionState,
+ formData: FormData
+): Promise {
+ const id = readId(formData)
+ if (id === null) return { ok: false, message: "记录不存在,可能已被删除" }
+
+ const parsed = parseRecordForm(formData)
+ if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors }
+
+ try {
+ updateRecord(id, parsed.input)
+ } catch (error) {
+ const duplicate = asDuplicateState(error)
+ if (duplicate) return duplicate
+ throw error
+ }
+
+ revalidatePath(RECORDS_PATH)
+ redirect(RECORDS_PATH)
+}
+
+export async function deleteRecordAction(formData: FormData): Promise {
+ const id = readId(formData)
+ if (id === null) return
+ removeRecord(id)
+ revalidatePath(RECORDS_PATH)
+}
+
+export async function toggleRecordAction(formData: FormData): Promise {
+ const id = readId(formData)
+ if (id === null) return
+ // The hidden field carries the state this record should move *to*.
+ setRecordEnabled(id, formData.get("enabled") === "1")
+ revalidatePath(RECORDS_PATH)
+}
+
+/**
+ * Regenerate the managed dnsmasq config from SQLite and restart the container.
+ *
+ * Deliberately manual rather than automatic, so several edits can be batched into one
+ * restart (see the plan: 手动应用并重启).
+ */
+export async function applyConfigAction(
+ _prev: ActionState,
+ _formData: FormData
+): Promise {
+ const written = await writeManagedConfig(await listEnabledRecords())
+ const reload = await restartDnsmasqContainer()
+
+ revalidatePath(RECORDS_PATH)
+
+ const writtenMessage = `已写入 ${written.recordCount} 条记录到 ${written.path}`
+
+ if (reload.status === "restarted") {
+ return { ok: true, message: `${writtenMessage},${reload.detail}` }
+ }
+
+ if (reload.status === "skipped") {
+ return { ok: true, message: writtenMessage, detail: reload.detail }
+ }
+
+ return { ok: false, message: `${writtenMessage},但重启失败`, detail: reload.detail }
+}
diff --git a/app/records/new/page.tsx b/app/records/new/page.tsx
new file mode 100644
index 0000000..2dfafb4
--- /dev/null
+++ b/app/records/new/page.tsx
@@ -0,0 +1,22 @@
+import Link from "next/link"
+import { RecordForm } from "../_components/RecordForm"
+import { createRecordAction } from "../actions"
+
+export const metadata = { title: "新增记录" }
+
+export default function NewRecordPage() {
+ return (
+
+ )
+}
diff --git a/app/records/page.tsx b/app/records/page.tsx
new file mode 100644
index 0000000..6543a7e
--- /dev/null
+++ b/app/records/page.tsx
@@ -0,0 +1,118 @@
+import Link from "next/link"
+import { Suspense } from "react"
+import { env } from "@/lib/env"
+import { listRecords } from "@/lib/records"
+import { applyConfigAction, deleteRecordAction, toggleRecordAction } from "./actions"
+import { ApplyButton } from "./_components/ApplyButton"
+import { DeleteButton } from "./_components/DeleteButton"
+
+export const metadata = { title: "域名记录" }
+
+const cardClass = "rounded-lg border border-black/10 p-4 dark:border-white/15"
+
+export default function RecordsPage() {
+ return (
+
+
+
+
+
生效
+
+ 配置文件:{env.confPath} · 目标容器:{env.containerName}
+
+
+
+
+ 加载中…}>
+
+
+
+ )
+}
+
+async function RecordTable() {
+ const records = await listRecords()
+
+ if (records.length === 0) {
+ return (
+
+
+ 还没有任何记录,点右上角「新增记录」开始。
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ | 域名 |
+ 类型 |
+ 目标值 |
+ 状态 |
+ 备注 |
+ 操作 |
+
+
+
+ {records.map((record) => (
+
+ | {record.domain} |
+ {record.type} |
+ {record.value} |
+
+
+ {record.enabled ? "已启用" : "已禁用"}
+
+ |
+ {record.comment || "—"} |
+
+
+
+ 编辑
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )
+}
diff --git a/app/resolve/page.tsx b/app/resolve/page.tsx
new file mode 100644
index 0000000..30a0936
--- /dev/null
+++ b/app/resolve/page.tsx
@@ -0,0 +1,101 @@
+import { Suspense } from "react"
+import { env } from "@/lib/env"
+import { lookupDomain } from "@/lib/dns"
+import { normalizeDomain } from "@/lib/validate"
+
+export const metadata = { title: "解析测试" }
+
+const cardClass = "rounded-lg border border-black/10 p-4 dark:border-white/15"
+
+export default function ResolvePage({ searchParams }: PageProps<"/resolve">) {
+ return (
+
+
+
+
+
+ {/* searchParams are request-time data, so they must be awaited behind Suspense */}
+ 查询中…}>
+
+
+
+ )
+}
+
+async function LookupResult({ searchParams }: Pick, "searchParams">) {
+ const { domain } = await searchParams
+ const raw = Array.isArray(domain) ? domain[0] : domain
+
+ if (!raw) {
+ return (
+ 输入域名后点「解析」开始查询。
+ )
+ }
+
+ const target = normalizeDomain(raw)
+ const outcome = await lookupDomain(target)
+
+ return (
+
+
{target}
+
+ {outcome.status === "a" ? (
+
+
+
+ | IPv4 地址 |
+ TTL |
+
+
+
+ {outcome.addresses.map((entry) => (
+
+ | {entry.address} |
+ {entry.ttl} |
+
+ ))}
+
+
+ ) : null}
+
+ {outcome.status === "cname" ? (
+
+ {outcome.targets.map((item) => (
+ -
+ {target} → {item}
+
+ ))}
+
+ ) : null}
+
+ {outcome.status === "empty" ? (
+
{outcome.detail}
+ ) : null}
+
+ {outcome.status === "error" ? (
+
{outcome.detail}
+ ) : null}
+
+ )
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..aed8544
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,38 @@
+services:
+ # If you already run dnsmasq elsewhere, you can delete this service and simply mount the
+ # dnsmasq-conf volume at /etc/dnsmasq.d inside your existing container.
+ dnsmasq:
+ image: jpillora/dnsmasq
+ container_name: dnsmasq
+ cap_add:
+ - NET_ADMIN
+ ports:
+ - "53:53/udp"
+ volumes:
+ - dnsmasq-conf:/etc/dnsmasq.d
+ restart: unless-stopped
+
+ web:
+ build: .
+ ports:
+ - "3000:3000"
+ environment:
+ DNSMASQ_ADMIN_DB_PATH: /data/dnsmasq-admin.db
+ # Same mounted config directory dnsmasq reads from.
+ DNSMASQ_ADMIN_CONF_PATH: /etc/dnsmasq.d/dnsmasq-admin.conf
+ DNSMASQ_CONTAINER_NAME: dnsmasq
+ DNSMASQ_ADMIN_DOCKER_SOCKET: /var/run/docker.sock
+ # Composed DNS name of the dnsmasq service; use its LAN IP if it lives elsewhere.
+ DNSMASQ_SERVER_IP: dnsmasq
+ volumes:
+ # Shared with the dnsmasq container so the generated config is visible to it.
+ - dnsmasq-conf:/etc/dnsmasq.d
+ # Holds the SQLite database — keep this volume or you lose all records.
+ - app-data:/data
+ # Used only to restart the dnsmasq container after an apply.
+ - /var/run/docker.sock:/var/run/docker.sock
+ restart: unless-stopped
+
+volumes:
+ dnsmasq-conf:
+ app-data:
diff --git a/lib/action-state.ts b/lib/action-state.ts
new file mode 100644
index 0000000..d9be4b3
--- /dev/null
+++ b/lib/action-state.ts
@@ -0,0 +1,12 @@
+import type { FieldErrors } from "./validate"
+
+/**
+ * Shared shape returned by every Server Action that a form consumes via `useActionState`.
+ * Kept in its own module because a `'use server'` file may only export async functions.
+ */
+export interface ActionState {
+ ok: boolean
+ message?: string
+ detail?: string
+ fieldErrors?: FieldErrors
+}
diff --git a/lib/db.ts b/lib/db.ts
new file mode 100644
index 0000000..70f49ed
--- /dev/null
+++ b/lib/db.ts
@@ -0,0 +1,84 @@
+import { mkdirSync } from "node:fs"
+import path from "node:path"
+import { DatabaseSync } from "node:sqlite"
+import { env } from "./env"
+
+export type RecordType = "A" | "CNAME"
+
+export interface DnsRecord {
+ id: number
+ domain: string
+ type: RecordType
+ value: string
+ enabled: boolean
+ comment: string
+ createdAt: string
+ updatedAt: string
+}
+
+const CREATE_TABLE = `
+CREATE TABLE IF NOT EXISTS records (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain TEXT NOT NULL COLLATE NOCASE UNIQUE,
+ type TEXT NOT NULL CHECK (type IN ('A','CNAME')),
+ value TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ comment TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+)
+`
+
+function openDatabase(): DatabaseSync {
+ mkdirSync(path.dirname(env.dbPath), { recursive: true })
+ const db = new DatabaseSync(env.dbPath)
+ // WAL keeps reads from blocking writes; busy_timeout lets concurrent connections wait
+ // instead of failing immediately with SQLITE_BUSY.
+ db.exec("PRAGMA journal_mode = WAL")
+ db.exec("PRAGMA busy_timeout = 5000")
+ db.exec(CREATE_TABLE)
+ return db
+}
+
+// Cached on globalThis so Next.js dev HMR does not leak a new handle per reload.
+const globalForDb = globalThis as typeof globalThis & {
+ __dnsmasqAdminDb?: DatabaseSync
+}
+
+export function getDb(): DatabaseSync {
+ globalForDb.__dnsmasqAdminDb ??= openDatabase()
+ return globalForDb.__dnsmasqAdminDb
+}
+
+type Row = Record
+
+/** Normalise whatever the driver hands back into a well-typed record. */
+function rowsOf(result: unknown): Row[] {
+ return Array.isArray(result) ? (result as Row[]) : []
+}
+
+export function rowToRecord(row: Row): DnsRecord {
+ return {
+ id: Number(row.id),
+ domain: String(row.domain),
+ type: row.type === "CNAME" ? "CNAME" : "A",
+ value: String(row.value),
+ enabled: Number(row.enabled) === 1,
+ comment: String(row.comment ?? ""),
+ createdAt: String(row.created_at),
+ updatedAt: String(row.updated_at),
+ }
+}
+
+export function queryRows(sql: string, ...params: unknown[]): Row[] {
+ return rowsOf(getDb().prepare(sql).all(...params))
+}
+
+export function queryRow(sql: string, ...params: unknown[]): Row | null {
+ const row = getDb().prepare(sql).get(...params)
+ return row && typeof row === "object" ? (row as Row) : null
+}
+
+export function execute(sql: string, ...params: unknown[]): void {
+ getDb().prepare(sql).run(...params)
+}
diff --git a/lib/dns.ts b/lib/dns.ts
new file mode 100644
index 0000000..36521b2
--- /dev/null
+++ b/lib/dns.ts
@@ -0,0 +1,88 @@
+import { promises as dnsPromises } from "node:dns"
+import { io } from "next/cache"
+import { env } from "./env"
+import { isValidDomain } from "./validate"
+
+const LOOKUP_TIMEOUT_MS = 5000
+
+export type LookupOutcome =
+ | { status: "a"; addresses: { address: string; ttl: number }[] }
+ | { status: "cname"; targets: string[] }
+ | { status: "empty"; detail: string }
+ | { status: "error"; detail: string }
+
+const FAILURE_REASONS: Record = {
+ ENOTFOUND: "域名不存在(NXDOMAIN)",
+ ENODATA: "该域名没有对应类型的解析记录",
+ ETIMEOUT: `查询超时,请确认 DNSMASQ_SERVER_IP 指向正确的 dnsmasq(当前 ${env.dnsServer})`,
+ ECONNREFUSED: "无法连接 DNS 服务器,请确认地址与端口",
+ EREFUSED: "DNS 服务器拒绝查询",
+ BADRESP: "DNS 服务器返回了无法解析的响应",
+ SERVFAIL: "DNS 服务器返回 SERVFAIL,请检查 dnsmasq 配置",
+}
+
+function describeFailure(error: unknown): string {
+ const { code, message } = (error ?? {}) as { code?: string; message?: string }
+ if (code && FAILURE_REASONS[code]) return FAILURE_REASONS[code]
+ if (code) return `查询失败(${code})${message ? `:${message}` : ""}`
+ return failureMessage(error)
+}
+
+function failureMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+function isNoRecords(error: unknown): boolean {
+ const { code } = (error ?? {}) as { code?: string }
+ return code === "ENOTFOUND" || code === "ENODATA"
+}
+
+function withTimeout(promise: Promise): Promise {
+ let timer: NodeJS.Timeout | undefined
+ const guard = new Promise((_, reject) => {
+ timer = setTimeout(() => {
+ reject(Object.assign(new Error("ETIMEOUT"), { code: "ETIMEOUT" }))
+ }, LOOKUP_TIMEOUT_MS)
+ })
+
+ return Promise.race([promise, guard]).finally(() => {
+ if (timer) clearTimeout(timer)
+ })
+}
+
+/**
+ * Resolve a domain against the configured dnsmasq instance.
+ *
+ * A records are tried first because that is what most managed entries are; falling back to
+ * CNAME keeps aliases visible too. `await io()` keeps the lookup out of the static shell
+ * (it is a network read, and the result changes as records change).
+ */
+export async function lookupDomain(domain: string): Promise {
+ await io()
+
+ if (!isValidDomain(domain)) {
+ return { status: "error", detail: "域名格式不正确,例如 nas.home" }
+ }
+
+ const resolver = new dnsPromises.Resolver()
+ resolver.setServers([env.dnsServer])
+
+ try {
+ const answers = await withTimeout(resolver.resolve4(domain, { ttl: true }))
+ return {
+ status: "a",
+ addresses: answers.map((answer) => ({ address: answer.address, ttl: answer.ttl })),
+ }
+ } catch (error) {
+ if (!isNoRecords(error)) return { status: "error", detail: describeFailure(error) }
+ }
+
+ try {
+ const targets = await withTimeout(resolver.resolveCname(domain))
+ if (targets.length === 0) return { status: "empty", detail: "没有 A 记录,也没有 CNAME 记录" }
+ return { status: "cname", targets }
+ } catch (error) {
+ if (isNoRecords(error)) return { status: "empty", detail: "没有 A 记录,也没有 CNAME 记录" }
+ return { status: "error", detail: describeFailure(error) }
+ }
+}
diff --git a/lib/dnsmasq-config.ts b/lib/dnsmasq-config.ts
new file mode 100644
index 0000000..955a5db
--- /dev/null
+++ b/lib/dnsmasq-config.ts
@@ -0,0 +1,52 @@
+import { mkdirSync, renameSync, writeFileSync } from "node:fs"
+import path from "node:path"
+import type { DnsRecord } from "./db"
+import { env } from "./env"
+
+const HEADER = "# Generated by dnsmasq-admin — 请勿手动编辑,每次「应用并重启」都会覆盖此文件"
+
+/**
+ * dnsmasq's `address=/domain/ip` already answers for the domain *and* every subdomain, so
+ * there is no need for a separate wildcard syntax.
+ */
+export function renderRecord(record: DnsRecord): string {
+ return record.type === "A"
+ ? `address=/${record.domain}/${record.value}`
+ : `cname=${record.domain},${record.value}`
+}
+
+export function renderConfig(records: DnsRecord[]): string {
+ const lines = records
+ .filter((record) => record.enabled)
+ .map(renderRecord)
+
+ if (lines.length === 0) {
+ return `${HEADER}\n\n# 当前没有启用的记录\n`
+ }
+
+ return `${HEADER}\n\n${lines.join("\n")}\n`
+}
+
+/**
+ * Regenerate the managed config file for the given records.
+ *
+ * Written to a sibling temp file first and then renamed, so dnsmasq can never read a
+ * half-written file.
+ */
+export async function writeManagedConfig(records: DnsRecord[]): Promise<{
+ path: string
+ recordCount: number
+}> {
+ const dir = path.dirname(env.confPath)
+ mkdirSync(dir, { recursive: true })
+
+ const content = renderConfig(records)
+ const tempPath = `${env.confPath}.${process.pid}.tmp`
+ writeFileSync(tempPath, content, "utf8")
+ renameSync(tempPath, env.confPath)
+
+ return {
+ path: env.confPath,
+ recordCount: records.filter((record) => record.enabled).length,
+ }
+}
diff --git a/lib/docker.ts b/lib/docker.ts
new file mode 100644
index 0000000..4cad50c
--- /dev/null
+++ b/lib/docker.ts
@@ -0,0 +1,92 @@
+import { existsSync } from "node:fs"
+import http from "node:http"
+import { env } from "./env"
+
+export type ReloadOutcome =
+ | { status: "restarted"; detail: string }
+ | { status: "skipped"; detail: string }
+ | { status: "failed"; detail: string }
+
+interface DockerResponse {
+ statusCode: number
+ body: string
+}
+
+const REQUEST_TIMEOUT_MS = 5000
+
+function dockerRequest(socketPath: string, requestPath: string): Promise {
+ return new Promise((resolve, reject) => {
+ const request = http.request(
+ {
+ socketPath,
+ path: requestPath,
+ method: "POST",
+ headers: { Host: "localhost", Accept: "application/json" },
+ },
+ (response) => {
+ let body = ""
+ response.setEncoding("utf8")
+ response.on("data", (chunk) => {
+ body += chunk
+ })
+ response.on("end", () => {
+ resolve({ statusCode: response.statusCode ?? 0, body })
+ })
+ }
+ )
+
+ request.on("error", reject)
+ request.setTimeout(REQUEST_TIMEOUT_MS, () => {
+ request.destroy(new Error(`Docker API 请求超时(${REQUEST_TIMEOUT_MS}ms)`))
+ })
+ request.end()
+ })
+}
+
+function failureReason(error: unknown): string {
+ if (error instanceof Error) return error.message
+ return String(error)
+}
+
+/**
+ * Restart the dnsmasq container over the Docker socket.
+ *
+ * Never throws: hosts without a Docker socket (e.g. developing on Windows) get a "skipped"
+ * outcome so the UI can report it instead of blowing up.
+ */
+export async function restartDnsmasqContainer(): Promise {
+ if (!env.containerName) {
+ return { status: "skipped", detail: "未配置 DNSMASQ_CONTAINER_NAME,跳过重启" }
+ }
+
+ if (!existsSync(env.dockerSocket)) {
+ return { status: "skipped", detail: `Docker socket 不存在:${env.dockerSocket},跳过重启` }
+ }
+
+ try {
+ const response = await dockerRequest(
+ env.dockerSocket,
+ `/containers/${encodeURIComponent(env.containerName)}/restart`
+ )
+
+ if (response.statusCode >= 200 && response.statusCode < 300) {
+ return { status: "restarted", detail: `容器 ${env.containerName} 已重启` }
+ }
+
+ if (response.statusCode === 404) {
+ return { status: "failed", detail: `找不到容器 ${env.containerName}` }
+ }
+
+ if (response.statusCode === 403) {
+ return { status: "failed", detail: "Docker API 拒绝访问,请检查 socket 权限" }
+ }
+
+ const remote = response.body.trim().slice(0, 200)
+ return {
+ status: "failed",
+ detail: `Docker API 返回 ${response.statusCode}${remote ? `:${remote}` : ""}`,
+ }
+ } catch (error) {
+ return { status: "failed", detail: failureReason(error) }
+ }
+}
diff --git a/lib/env.ts b/lib/env.ts
new file mode 100644
index 0000000..14f11ba
--- /dev/null
+++ b/lib/env.ts
@@ -0,0 +1,26 @@
+import path from "node:path"
+
+function withDefault(name: string, fallback: string): string {
+ const raw = process.env[name]
+ return raw && raw.trim() ? raw.trim() : fallback
+}
+
+function toAbsolute(target: string): string {
+ return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target)
+}
+
+/**
+ * Runtime configuration. Everything is optional with sensible defaults so the app can
+ * boot locally without any env file; see `.env.example` for the deploy-time values.
+ */
+export const env = {
+ /** SQLite database file — the single source of truth for records. */
+ dbPath: toAbsolute(withDefault("DNSMASQ_ADMIN_DB_PATH", "./data/dnsmasq-admin.db")),
+ /** Managed dnsmasq config file that this app regenerates on every apply. */
+ confPath: toAbsolute(withDefault("DNSMASQ_ADMIN_CONF_PATH", "./data/dnsmasq-admin.conf")),
+ /** Container that gets restarted through the Docker socket after an apply. */
+ containerName: withDefault("DNSMASQ_CONTAINER_NAME", "dnsmasq"),
+ dockerSocket: withDefault("DNSMASQ_ADMIN_DOCKER_SOCKET", "/var/run/docker.sock"),
+ /** DNS server that the /resolve page queries. */
+ dnsServer: withDefault("DNSMASQ_SERVER_IP", "127.0.0.1"),
+}
diff --git a/lib/records.ts b/lib/records.ts
new file mode 100644
index 0000000..67e6121
--- /dev/null
+++ b/lib/records.ts
@@ -0,0 +1,91 @@
+import { io } from "next/cache"
+import { execute, queryRow, queryRows, rowToRecord, type DnsRecord } from "./db"
+import type { RecordInput } from "./validate"
+
+const SELECT_ALL = "SELECT * FROM records"
+
+export class DuplicateDomainError extends Error {
+ constructor(readonly domain: string) {
+ super(`domain already exists: ${domain}`)
+ this.name = "DuplicateDomainError"
+ }
+}
+
+function isUniqueViolation(error: unknown): boolean {
+ const { code, message } = (error ?? {}) as { code?: string; message?: string }
+ return (
+ code === "SQLITE_CONSTRAINT_UNIQUE" ||
+ code === "SQLITE_CONSTRAINT_PRIMARYKEY" ||
+ String(message ?? "").includes("UNIQUE constraint failed")
+ )
+}
+
+/**
+ * Reads go through `await io()`.
+ *
+ * `node:sqlite` is a synchronous driver, so under `cacheComponents` its results would
+ * otherwise be captured into the prerendered static shell at build time and the page would
+ * show stale rows forever. `io()` suspends during prerender so the read happens at request
+ * time. See node_modules/next/dist/docs/01-app/03-api-reference/04-functions/io.md.
+ */
+export async function listRecords(): Promise {
+ await io()
+ return queryRows(`${SELECT_ALL} ORDER BY domain COLLATE NOCASE`).map(rowToRecord)
+}
+
+export async function getRecord(id: number): Promise {
+ await io()
+ const row = queryRow(`${SELECT_ALL} WHERE id = ?`, id)
+ return row ? rowToRecord(row) : null
+}
+
+export async function listEnabledRecords(): Promise {
+ await io()
+ return queryRows(`${SELECT_ALL} WHERE enabled = 1 ORDER BY domain COLLATE NOCASE`).map(rowToRecord)
+}
+
+// Writes below only ever run inside Server Actions, which always execute at request time,
+// so they do not need an `io()` guard.
+
+export function createRecord(input: RecordInput): void {
+ try {
+ execute(
+ "INSERT INTO records (domain, type, value, enabled, comment) VALUES (?, ?, ?, ?, ?)",
+ input.domain,
+ input.type,
+ input.value,
+ input.enabled ? 1 : 0,
+ input.comment
+ )
+ } catch (error) {
+ if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain)
+ throw error
+ }
+}
+
+export function updateRecord(id: number, input: RecordInput): void {
+ try {
+ execute(
+ `UPDATE records
+ SET domain = ?, type = ?, value = ?, enabled = ?, comment = ?, updated_at = datetime('now')
+ WHERE id = ?`,
+ input.domain,
+ input.type,
+ input.value,
+ input.enabled ? 1 : 0,
+ input.comment,
+ id
+ )
+ } catch (error) {
+ if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain)
+ throw error
+ }
+}
+
+export function removeRecord(id: number): void {
+ execute("DELETE FROM records WHERE id = ?", id)
+}
+
+export function setRecordEnabled(id: number, enabled: boolean): void {
+ execute("UPDATE records SET enabled = ?, updated_at = datetime('now') WHERE id = ?", enabled ? 1 : 0, id)
+}
diff --git a/lib/validate.ts b/lib/validate.ts
new file mode 100644
index 0000000..3138008
--- /dev/null
+++ b/lib/validate.ts
@@ -0,0 +1,88 @@
+import type { RecordType } from "./db"
+
+export type FieldErrors = Record
+
+export interface RecordInput {
+ domain: string
+ type: RecordType
+ value: string
+ enabled: boolean
+ comment: string
+}
+
+export type ParseResult =
+ | { ok: true; input: RecordInput }
+ | { ok: false; fieldErrors: FieldErrors }
+
+const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
+
+/** Lowercase, trim surrounding space and drop a trailing dot (`nas.home.` -> `nas.home`). */
+export function normalizeDomain(raw: string): string {
+ return raw
+ .trim()
+ .toLowerCase()
+ .replace(/\.+$/, "")
+}
+
+export function isValidDomain(domain: string): boolean {
+ if (!domain || domain.length > 253) return false
+ const labels = domain.split(".")
+ if (labels.length === 0) return false
+ return labels.every((label) => label.length > 0 && label.length <= 63 && LABEL.test(label))
+}
+
+export function isValidIPv4(value: string): boolean {
+ const parts = value.trim().split(".")
+ if (parts.length !== 4) return false
+ return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
+}
+
+function readText(formData: FormData, key: string): string {
+ const value = formData.get(key)
+ return typeof value === "string" ? value.trim() : ""
+}
+
+/**
+ * Validate a submitted record form. Used by both the create/update Server Actions and the
+ * resolve page, so every user-supplied domain goes through the same rules.
+ */
+export function parseRecordForm(formData: FormData): ParseResult {
+ const fieldErrors: FieldErrors = {}
+
+ const domain = normalizeDomain(readText(formData, "domain"))
+ if (!domain) {
+ fieldErrors.domain = "请填写域名"
+ } else if (!isValidDomain(domain)) {
+ fieldErrors.domain = "域名格式不正确,例如 nas.home"
+ }
+
+ const rawType = readText(formData, "type")
+ const type: RecordType = rawType === "CNAME" ? "CNAME" : "A"
+
+ const value = normalizeDomain(readText(formData, "value"))
+ if (!value) {
+ fieldErrors.value = "请填写目标值"
+ } else if (type === "A") {
+ if (!isValidIPv4(value)) fieldErrors.value = "A 记录的目标必须是 IPv4 地址"
+ } else if (!isValidDomain(value)) {
+ fieldErrors.value = "CNAME 的目标必须是域名"
+ } else if (value === domain) {
+ fieldErrors.value = "CNAME 的目标不能指向自己"
+ }
+
+ const comment = readText(formData, "comment")
+ if (comment.length > 200) fieldErrors.comment = "备注请控制在 200 字以内"
+
+ if (Object.keys(fieldErrors).length > 0) return { ok: false, fieldErrors }
+
+ return {
+ ok: true,
+ input: {
+ domain,
+ type,
+ value,
+ enabled: formData.get("enabled") !== null,
+ comment,
+ },
+ }
+}
diff --git a/package.json b/package.json
index e0950ae..9b3f95a 100644
--- a/package.json
+++ b/package.json
@@ -15,11 +15,12 @@
},
"devDependencies": {
"@tailwindcss/turbopack": "^4",
- "@types/node": "^20",
+ "@types/node": "24",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.4.0",
+ "prettier": "^3.9.9",
"tailwindcss": "^4",
"typescript": "^5"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6113aa6..a24a034 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -168,7 +168,7 @@ importers:
dependencies:
next:
specifier: 16.4.0
- version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react:
specifier: 19.3.0
version: 19.3.0
@@ -180,8 +180,8 @@ importers:
specifier: ^4
version: 4.3.3
'@types/node':
- specifier: ^20
- version: 20.19.43
+ specifier: '24'
+ version: 24.19.1
'@types/react':
specifier: ^19
version: 19.3.0
@@ -194,6 +194,9 @@ importers:
eslint-config-next:
specifier: 16.4.0
version: 16.4.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ prettier:
+ specifier: ^3.9.9
+ version: 3.9.9
tailwindcss:
specifier: ^4
version: 4.3.3
@@ -719,8 +722,8 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
- '@types/node@20.19.43':
- resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
+ '@types/node@24.19.1':
+ resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==}
'@types/react-dom@19.3.0':
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
@@ -1862,6 +1865,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
+ prettier@3.9.9:
+ resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==}
+ engines: {node: '>=14'}
+ hasBin: true
+
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -2106,8 +2114,8 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
- undici-types@6.21.0:
- resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ undici-types@7.24.6:
+ resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
unrs-resolver@1.12.2:
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
@@ -2619,9 +2627,9 @@ snapshots:
'@types/json5@0.0.29': {}
- '@types/node@20.19.43':
+ '@types/node@24.19.1':
dependencies:
- undici-types: 6.21.0
+ undici-types: 7.24.6
'@types/react-dom@19.3.0(@types/react@19.3.0)':
dependencies:
@@ -3779,7 +3787,7 @@ snapshots:
natural-compare@1.4.0: {}
- next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
'@next/env': 16.4.0
'@swc/helpers': 0.5.23
@@ -3798,7 +3806,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.4.0
'@next/swc-win32-arm64-msvc': 16.4.0
'@next/swc-win32-x64-msvc': 16.4.0
- sharp: 0.35.5(@types/node@20.19.43)
+ sharp: 0.35.5(@types/node@24.19.1)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
@@ -3905,6 +3913,8 @@ snapshots:
prelude-ls@1.2.1: {}
+ prettier@3.9.9: {}
+
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -4010,7 +4020,7 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.2
- sharp@0.35.5(@types/node@20.19.43):
+ sharp@0.35.5(@types/node@24.19.1):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
@@ -4041,7 +4051,7 @@ snapshots:
'@img/sharp-win32-arm64': 0.35.5
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
- '@types/node': 20.19.43
+ '@types/node': 24.19.1
optional: true
shebang-command@2.0.0:
@@ -4237,7 +4247,7 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
- undici-types@6.21.0: {}
+ undici-types@7.24.6: {}
unrs-resolver@1.12.2:
dependencies: