Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions web/e2e/admin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,18 @@ test("sign up, register a domain, and see the DNS records", async ({ page }) =>
await page.locator("#destination").fill("https://example.com/e2e");
await page.getByRole("button", { name: "Register domain" }).click();

// The domain card appears; open the DNS records bottom-sheet.
// The domain card appears.
await expect(page.getByText(host).first()).toBeVisible();
await page.getByRole("button", { name: "View DNS records" }).click();

// The sheet shows the required apex A record to the static IP.
// Edit the destination in place (no delete+recreate).
await page.getByRole("button", { name: "Edit destination" }).click();
const dest = page.locator('input[id^="dest-"]');
await dest.fill("https://example.com/edited");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("https://example.com/edited")).toBeVisible();

// Open the DNS records bottom-sheet: required apex A record to the static IP.
await page.getByRole("button", { name: "View DNS records" }).click();
await expect(page.getByText("34.172.36.60")).toBeVisible();
await expect(page.getByText("required").first()).toBeVisible();
});
64 changes: 55 additions & 9 deletions web/src/app/api/domains/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,70 @@ import { and, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { domain } from "@/db/schema";
import { exportConfigToGCS } from "@/lib/gcs-export";
import { dnsInstructions, updateSchema } from "@/lib/domains";
import { deleteCertsForHost, exportConfigToGCS } from "@/lib/gcs-export";

// DELETE /api/domains/:id — remove one of the signed-in user's domains.
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
async function requireUserId(): Promise<string | null> {
const sess = await auth.api.getSession({ headers: await headers() });
const userId = sess?.user?.id;
return sess?.user?.id ?? null;
}

// PUT /api/domains/:id — update the redirect destination (owner only).
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
const userId = await requireUserId();
if (!userId) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

const parsed = updateSchema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "invalid input" },
{ status: 400 },
);
}

const { id } = await params;
const [updated] = await db
.update(domain)
.set({ destinationUrl: parsed.data.destination })
.where(and(eq(domain.id, id), eq(domain.userId, userId)))
.returning();

if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });

await exportConfigToGCS();

return NextResponse.json({
domain: {
id: updated.id,
hostname: updated.hostname,
destination: updated.destinationUrl,
dns: dnsInstructions(updated.hostname),
},
});
}

// DELETE /api/domains/:id — remove one of the signed-in user's domains, then
// garbage-collect its TLS cert material from GCS.
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const userId = await requireUserId();
if (!userId) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

const { id } = await params;
const deleted = await db
const [deleted] = await db
.delete(domain)
.where(and(eq(domain.id, id), eq(domain.userId, userId)))
.returning();

if (deleted.length === 0) {
return NextResponse.json({ error: "not found" }, { status: 404 });
}
if (!deleted) return NextResponse.json({ error: "not found" }, { status: 404 });

await exportConfigToGCS();
return NextResponse.json({ ok: true });
// Best-effort cert cleanup — never fail the request on this.
let certsRemoved = 0;
try {
certsRemoved = await deleteCertsForHost(deleted.hostname);
} catch {
certsRemoved = 0;
}

return NextResponse.json({ ok: true, certsRemoved });
}
44 changes: 43 additions & 1 deletion web/src/app/api/domains/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { eq, inArray } from "drizzle-orm";
import { db } from "@/db";
import { domain, user } from "@/db/schema";
import { GET, POST } from "./route";
import { DELETE } from "./[id]/route";
import { DELETE, PUT } from "./[id]/route";

const UA = "itest-user-a";
const UB = "itest-user-b";
Expand Down Expand Up @@ -99,6 +99,48 @@ describe("GET /api/domains", () => {
});
});

function put(id: string, body: unknown) {
return [
new Request("http://t", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id }) },
] as const;
}

describe("PUT /api/domains/:id", () => {
it("updates the destination for the owner", async () => {
session.current = { user: { id: UA, email: "a" } };
const created = await (await POST(post({ hostname: HOSTS[0], destination: "https://a.com/old" }))).json();
const id = created.domain.id;

const res = await PUT(...put(id, { destination: "https://a.com/new" }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.domain.destination).toBe("https://a.com/new");

const rows = await db.select().from(domain).where(eq(domain.id, id));
expect(rows[0].destinationUrl).toBe("https://a.com/new");
});

it("400 on an invalid destination", async () => {
session.current = { user: { id: UA, email: "a" } };
const created = await (await POST(post({ hostname: HOSTS[0], destination: "https://a.com" }))).json();
const res = await PUT(...put(created.domain.id, { destination: "not-a-url" }));
expect(res.status).toBe(400);
});

it("404 when another account tries to edit it", async () => {
session.current = { user: { id: UA, email: "a" } };
const created = await (await POST(post({ hostname: HOSTS[0], destination: "https://a.com" }))).json();
session.current = { user: { id: UB, email: "b" } };
const res = await PUT(...put(created.domain.id, { destination: "https://hijack.com" }));
expect(res.status).toBe(404);
});
});

describe("DELETE /api/domains/:id", () => {
it("removes only the caller's own domain", async () => {
session.current = { user: { id: UA, email: "a" } };
Expand Down
126 changes: 102 additions & 24 deletions web/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,102 @@ function DnsSheet({ domain }: { domain: DomainItem }) {
);
}

function DomainCard({
domain,
onChange,
onRemove,
}: {
domain: DomainItem;
onChange: (d: DomainItem) => void;
onRemove: (id: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(domain.destination);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

async function save() {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/domains/${domain.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ destination: draft }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "update failed");
return;
}
onChange(data.domain);
setEditing(false);
} finally {
setBusy(false);
}
}

async function remove() {
setBusy(true);
try {
const res = await fetch(`/api/domains/${domain.id}`, { method: "DELETE" });
if (res.ok) onRemove(domain.id);
} finally {
setBusy(false);
}
}

return (
<div className="card">
<div className="row">
<div className="domain-head">
<strong className="mono">{domain.hostname}</strong>
{!editing && <div className="muted mono dest">→ {domain.destination}</div>}
</div>
{!editing && (
<button className="danger" disabled={busy} onClick={remove}>
Remove
</button>
)}
</div>

{editing ? (
<div style={{ marginTop: "0.6rem" }}>
<label htmlFor={`dest-${domain.id}`}>Redirect destination</label>
<input
id={`dest-${domain.id}`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
autoCapitalize="off"
autoCorrect="off"
/>
<div style={{ marginTop: "0.6rem", display: "flex", gap: "0.5rem" }}>
<button className="primary" disabled={busy} onClick={save}>
{busy ? "Saving…" : "Save"}
</button>
<button
disabled={busy}
onClick={() => {
setEditing(false);
setDraft(domain.destination);
setError(null);
}}
>
Cancel
</button>
</div>
{error && <p className="error">{error}</p>}
</div>
) : (
<div style={{ marginTop: "0.6rem", display: "flex", gap: "0.5rem" }}>
<button onClick={() => setEditing(true)}>Edit destination</button>
<DnsSheet domain={domain} />
</div>
)}
</div>
);
}

export function Dashboard({
initial,
userEmail,
Expand Down Expand Up @@ -124,16 +220,6 @@ export function Dashboard({
}
}

async function remove(id: string) {
setBusy(true);
try {
const res = await fetch(`/api/domains/${id}`, { method: "DELETE" });
if (res.ok) setDomains((d) => d.filter((x) => x.id !== id));
} finally {
setBusy(false);
}
}

return (
<div>
<div className="row">
Expand Down Expand Up @@ -190,20 +276,12 @@ export function Dashboard({
{domains.length === 0 && <p className="muted">No domains yet — register one above.</p>}

{domains.map((d) => (
<div className="card" key={d.id}>
<div className="row">
<div className="domain-head">
<strong className="mono">{d.hostname}</strong>
<div className="muted mono dest">→ {d.destination}</div>
</div>
<button className="danger" disabled={busy} onClick={() => remove(d.id)}>
Remove
</button>
</div>
<div style={{ marginTop: "0.6rem" }}>
<DnsSheet domain={d} />
</div>
</div>
<DomainCard
key={d.id}
domain={d}
onChange={(u) => setDomains((ds) => ds.map((x) => (x.id === u.id ? u : x)))}
onRemove={(id) => setDomains((ds) => ds.filter((x) => x.id !== id))}
/>
))}
</div>
);
Expand Down
30 changes: 18 additions & 12 deletions web/src/lib/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,33 @@ export function apexOf(host: string): string {

const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;

const destinationField = z
.string()
.trim()
.min(1, "destination URL is required")
.refine((u) => {
try {
const parsed = new URL(u);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}, "must be an absolute http(s) URL");

export const registerSchema = z.object({
hostname: z
.string()
.trim()
.min(1, "hostname is required")
.transform(normalizeHost)
.refine((h) => HOSTNAME_RE.test(h), "must be a valid fully-qualified domain (e.g. example.com)"),
destination: z
.string()
.trim()
.min(1, "destination URL is required")
.refine((u) => {
try {
const parsed = new URL(u);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}, "must be an absolute http(s) URL"),
destination: destinationField,
});

// Editing a redirect only changes the destination; the hostname is immutable
// (changing it is a different registration).
export const updateSchema = z.object({ destination: destinationField });

export type DnsRecord = {
type: "A" | "CNAME";
name: string;
Expand Down
31 changes: 31 additions & 0 deletions web/src/lib/gcs-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { certObjectsForHost } from "./gcs-export";

describe("certObjectsForHost", () => {
const names = [
"certs/certificates/acme-v02.api.letsencrypt.org-directory/f3muletown.com/f3muletown.com.crt",
"certs/certificates/acme-v02.api.letsencrypt.org-directory/f3muletown.com/f3muletown.com.json",
"certs/certificates/acme-v02.api.letsencrypt.org-directory/www.f3muletown.com/www.f3muletown.com.crt",
"certs/certificates/acme-v02.api.letsencrypt.org-directory/stats.f3muletown.com/stats.f3muletown.com.crt",
"certs/acme/acme-v02.api.letsencrypt.org-directory/users/patrick@pstaylor.net/patrick.json",
];

it("matches a host's cert objects by whole path segment", () => {
const got = certObjectsForHost(names, "f3muletown.com");
expect(got).toHaveLength(2);
expect(got.every((n) => n.includes("/f3muletown.com/"))).toBe(true);
});

it("does NOT match www.<host> when cleaning the apex (no over-deletion)", () => {
const got = certObjectsForHost(names, "f3muletown.com");
expect(got.some((n) => n.includes("www.f3muletown.com"))).toBe(false);
});

it("matches a subdomain's own certs", () => {
expect(certObjectsForHost(names, "stats.f3muletown.com")).toHaveLength(1);
});

it("returns nothing for an unknown host", () => {
expect(certObjectsForHost(names, "nope.example.com")).toHaveLength(0);
});
});
Loading
Loading