diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa6753..4349741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Changed +- **Setting up on an address the Fediverse can't reach is now a deliberate choice** (#326) — the wizard used to let you finish on `localhost` or a private-network address with nothing but a warning. That bakes an identity nobody can follow into your ActivityPub actor, and because posts store their full URL, into everything you publish before you move. It's still allowed — testing locally is a perfectly reasonable thing to do — but you now have to tick a box confirming that's what you're doing. Setting up on a real domain is unchanged. + +### Fixed +- **An instance could be completely undiscoverable while looking perfectly healthy** (#326) — if you set `SITE_URL` but not `FEDI_DOMAIN`, your site advertised `@you@yourdomain` on every page, served a valid profile, and federated posts — but WebFinger, the endpoint every other server uses to *find* you, answered only to `@you@localhost`. So every search from Mastodon returned "not found", nobody could follow you, and nothing in the logs said anything was wrong. Your Fediverse identity now comes from a single place, so the address your site shows and the address it answers to cannot disagree. This also means a site URL with a stray trailing slash, or a non-default port, no longer produces a subtly different identity. + ## 1.19.0 (2026-07-23) ### Added diff --git a/site.config.ts b/site.config.ts index 6b60ecc..d0c56eb 100644 --- a/site.config.ts +++ b/site.config.ts @@ -1,3 +1,5 @@ +import { getIdentity } from "./src/lib/identity"; + /** * FediHome — Site Configuration * @@ -6,9 +8,10 @@ * You can also edit them directly here or via the admin panel. */ -const siteUrl = process.env.SITE_URL || "http://localhost:3000"; -const fediHandle = process.env.FEDI_HANDLE || "me"; -const fediDomain = process.env.FEDI_DOMAIN || new URL(siteUrl).hostname; +// Federation identity comes from ONE derivation (src/lib/identity.ts) so the +// actor id, WebFinger subject and signature keyId can never disagree — see the +// module docstring for why that class of mismatch is invisible when it breaks. +const { siteUrl, fediHandle, fediDomain, fediAddress } = getIdentity(); export const siteConfig = { // Site identity @@ -25,7 +28,7 @@ export const siteConfig = { // Fediverse identity fediHandle, fediDomain, - fediAddress: `@${fediHandle}@${fediDomain}`, + fediAddress, actorSummary: process.env.ACTOR_SUMMARY || "A personal blog on the Fediverse, powered by FediHome.", // Public landing / showcase mode. diff --git a/src/app/(public)/audio/feed.xml/route.ts b/src/app/(public)/audio/feed.xml/route.ts index 34cc941..ac358b5 100644 --- a/src/app/(public)/audio/feed.xml/route.ts +++ b/src/app/(public)/audio/feed.xml/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { getRuntimeSiteConfig } from "@/lib/site-settings"; import { getRuntimeProfile } from "@/lib/site-profile"; +import { getSiteUrl } from "@/lib/identity"; export const dynamic = "force-dynamic"; @@ -22,7 +23,7 @@ function formatDuration(sec: number | null): string { } export async function GET() { - const siteUrl = process.env.SITE_URL || "http://localhost:3000"; + const siteUrl = getSiteUrl(); // Web-editable overrides (#59) with sensible per-profile defaults, so a // web-edited author name / podcast title is reflected here (previously this // read process.env directly and ignored admin edits). diff --git a/src/app/(public)/users/[username]/page.tsx b/src/app/(public)/users/[username]/page.tsx index 99d915e..863c1f9 100644 --- a/src/app/(public)/users/[username]/page.tsx +++ b/src/app/(public)/users/[username]/page.tsx @@ -5,10 +5,8 @@ import { prisma } from "@/lib/db"; import type { Metadata } from "next"; import { siteConfig } from "@/../site.config"; import { getRuntimeProfile } from "@/lib/site-profile"; +import { getIdentity } from "@/lib/identity"; -const siteUrl = siteConfig.url; -const fediHandle = siteConfig.fediHandle; -const siteDomain = siteConfig.fediDomain; export async function generateMetadata({ params, @@ -16,14 +14,14 @@ export async function generateMetadata({ params: Promise<{ username: string }>; }): Promise { const { username } = await params; - if (username !== fediHandle) return { title: "Not Found" }; + if (username !== getIdentity().fediHandle) return { title: "Not Found" }; const profile = await getRuntimeProfile(); const desc = profile.authorBio || profile.authorTagline || "Follow me on the Fediverse."; return { - title: `${profile.authorName} (@${fediHandle}@${siteDomain})`, + title: `${profile.authorName} (@${getIdentity().fediHandle}@${getIdentity().fediDomain})`, description: desc, openGraph: { - title: `${profile.authorName} (@${fediHandle}@${siteDomain})`, + title: `${profile.authorName} (@${getIdentity().fediHandle}@${getIdentity().fediDomain})`, description: desc, images: [{ url: profile.avatarPath, width: 400, height: 400 }], }, @@ -36,7 +34,7 @@ export default async function UserProfilePage({ params: Promise<{ username: string }>; }) { const { username } = await params; - if (username !== fediHandle) notFound(); + if (username !== getIdentity().fediHandle) notFound(); const [postCount, followerCount, followingCount, profile] = await Promise.all([ prisma.post.count({ where: { inReplyToPostId: null } }), @@ -67,7 +65,7 @@ export default async function UserProfilePage({ {profile.authorName}

- @{fediHandle}@{siteDomain} + @{getIdentity().fediHandle}@{getIdentity().fediDomain}

diff --git a/src/app/.well-known/oauth-authorization-server/route.ts b/src/app/.well-known/oauth-authorization-server/route.ts index 92a257c..5bf0cde 100644 --- a/src/app/.well-known/oauth-authorization-server/route.ts +++ b/src/app/.well-known/oauth-authorization-server/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { SUPPORTED_SCOPES } from "@/lib/oauth"; +import { getSiteUrl } from "@/lib/identity"; /** * OAuth 2.0 Authorization Server Metadata (RFC 8414). Native FediHome apps fetch @@ -7,7 +8,7 @@ import { SUPPORTED_SCOPES } from "@/lib/oauth"; * PKCE S256 + public clients are supported before starting the login flow. */ export async function GET() { - const siteUrl = process.env.SITE_URL || "http://localhost:3000"; + const siteUrl = getSiteUrl(); return NextResponse.json( { diff --git a/src/app/.well-known/webfinger/route.ts b/src/app/.well-known/webfinger/route.ts index 51024ef..515be5e 100644 --- a/src/app/.well-known/webfinger/route.ts +++ b/src/app/.well-known/webfinger/route.ts @@ -1,12 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; +import { getIdentity } from "@/lib/identity"; export async function GET(req: NextRequest) { const resource = req.nextUrl.searchParams.get("resource"); - const domain = process.env.FEDI_DOMAIN || "localhost"; - const handle = process.env.FEDI_HANDLE || "me"; - const siteUrl = process.env.SITE_URL || `https://${domain}`; - - const expected = `acct:${handle}@${domain}`; + // One derivation, shared with the rest of the app (#326). This route used to + // resolve identity on its own — `FEDI_DOMAIN || "localhost"` — so an instance + // that set SITE_URL but not FEDI_DOMAIN advertised @you@yourdomain everywhere + // while WebFinger answered only to acct:you@localhost. Every remote lookup got + // a 404 and the site looked perfectly healthy from the inside: undiscoverable, + // unfollowable, no error anywhere. Identity must come from one place. + const { siteUrl, webfingerSubject: expected, actorId } = getIdentity(); if (resource !== expected) { return NextResponse.json( @@ -18,12 +21,12 @@ export async function GET(req: NextRequest) { return NextResponse.json( { subject: expected, - aliases: [`${siteUrl}/ap/actor`], + aliases: [actorId], links: [ { rel: "self", type: "application/activity+json", - href: `${siteUrl}/ap/actor`, + href: actorId, }, { rel: "http://webfinger.net/rel/profile-page", diff --git a/src/app/ap/followers/route.ts b/src/app/ap/followers/route.ts index cb200d1..671acb4 100644 --- a/src/app/ap/followers/route.ts +++ b/src/app/ap/followers/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { getRuntimeSiteConfig } from "@/lib/site-settings"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = process.env.SITE_URL || "http://localhost:3000"; export async function GET() { const hidden = (await getRuntimeSiteConfig()).hideSocialGraph; @@ -15,7 +15,7 @@ export async function GET() { const collection: Record = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/followers`, + id: `${getSiteUrl()}/ap/followers`, type: "OrderedCollection", totalItems, }; diff --git a/src/app/ap/following/route.ts b/src/app/ap/following/route.ts index f123dc3..488a160 100644 --- a/src/app/ap/following/route.ts +++ b/src/app/ap/following/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { getRuntimeSiteConfig } from "@/lib/site-settings"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = process.env.SITE_URL || "http://localhost:3000"; export async function GET() { const hidden = (await getRuntimeSiteConfig()).hideSocialGraph; @@ -11,7 +11,7 @@ export async function GET() { const collection: Record = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/following`, + id: `${getSiteUrl()}/ap/following`, type: "OrderedCollection", totalItems, }; diff --git a/src/app/ap/inbox/route.ts b/src/app/ap/inbox/route.ts index 6ef861b..59e17a1 100644 --- a/src/app/ap/inbox/route.ts +++ b/src/app/ap/inbox/route.ts @@ -7,8 +7,8 @@ import { assertPublicHost } from "@/lib/url-guard"; import { sendPushToOwner } from "@/lib/push"; import { resolveOwnedTarget } from "@/lib/notifications"; import { htmlToText } from "@/lib/html-text"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = process.env.SITE_URL || "http://localhost:3000"; const ACTOR_FETCH_TIMEOUT_MS = 8000; const DEBUG = process.env.FEDIHOME_DEBUG === "true"; @@ -189,9 +189,9 @@ async function handleFollow(actorUri: string, activity: Record) try { await deliverActivity(info.inbox, { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/accept/${Date.now()}`, + id: `${getSiteUrl()}/ap/accept/${Date.now()}`, type: "Accept", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, object: activity, }); } catch (err) { @@ -571,7 +571,7 @@ async function handleNote(actorUri: string, note: Record) { const toList = Array.isArray(note.to) ? note.to as string[] : [note.to as string].filter(Boolean); const ccList = Array.isArray(note.cc) ? note.cc as string[] : [note.cc as string].filter(Boolean); const isPublic = [...toList, ...ccList].includes("https://www.w3.org/ns/activitystreams#Public"); - const isDirectToUs = toList.includes(`${siteUrl}/ap/actor`) && !isPublic; + const isDirectToUs = toList.includes(`${getSiteUrl()}/ap/actor`) && !isPublic; if (isDirectToUs) { // Store as a direct message diff --git a/src/app/ap/outbox/route.ts b/src/app/ap/outbox/route.ts index 81f4c62..71fad50 100644 --- a/src/app/ap/outbox/route.ts +++ b/src/app/ap/outbox/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { buildPostObject } from "@/lib/ap-post"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = process.env.SITE_URL || "http://localhost:3000"; export async function GET() { const posts = await prisma.post.findMany({ @@ -14,7 +14,7 @@ export async function GET() { const items = posts.map((post) => ({ type: "Create", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, published: post.publishedAt.toISOString(), object: buildPostObject(post), })); @@ -22,7 +22,7 @@ export async function GET() { return NextResponse.json( { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/outbox`, + id: `${getSiteUrl()}/ap/outbox`, type: "OrderedCollection", totalItems: items.length, orderedItems: items, diff --git a/src/app/api/admin/_actions/bluesky.ts b/src/app/api/admin/_actions/bluesky.ts index ee495a2..a6d353e 100644 --- a/src/app/api/admin/_actions/bluesky.ts +++ b/src/app/api/admin/_actions/bluesky.ts @@ -11,8 +11,8 @@ import { } from "@/lib/bluesky-graph"; import { syncBlueskyNotifications } from "@/lib/bluesky-notifications"; import type { AdminBody } from "./types"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = siteConfig.url; export async function bskyReply(body: AdminBody): Promise { const { content: bskyReplyContent, blueskyUri: parentUri, crosspostFedi } = body; @@ -54,21 +54,21 @@ export async function bskyReply(body: AdminBody): Promise { (url: string) => `${url}` ); const fediHtml = `

${linkedContent}

`; - const fediReplyId = `${siteUrl}/ap/reply/${Date.now()}`; + const fediReplyId = `${getSiteUrl()}/ap/reply/${Date.now()}`; const fediActivity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/create/bsky-reply-${Date.now()}`, + id: `${getSiteUrl()}/ap/create/bsky-reply-${Date.now()}`, type: "Create", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, published: new Date().toISOString(), object: { type: "Note", id: fediReplyId, - attributedTo: `${siteUrl}/ap/actor`, + attributedTo: `${getSiteUrl()}/ap/actor`, content: fediHtml, published: new Date().toISOString(), to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [`${siteUrl}/ap/followers`], + cc: [`${getSiteUrl()}/ap/followers`], ...(localPostFromBsky?.apId ? { inReplyTo: localPostFromBsky.apId } : {}), }, }; @@ -76,11 +76,11 @@ export async function bskyReply(body: AdminBody): Promise { console.error("Fedi crosspost from bsky_reply failed:", err) ); const fediHandle = siteConfig.fediHandle; - const siteDomain = new URL(siteUrl).hostname; + const siteDomain = new URL(getSiteUrl()).hostname; await prisma.fediPost.upsert({ where: { apId: fediReplyId }, create: { - actorUri: `${siteUrl}/ap/actor`, + actorUri: `${getSiteUrl()}/ap/actor`, apId: fediReplyId, content: bskyReplyContent, contentHtml: fediHtml, diff --git a/src/app/api/admin/_actions/comments.ts b/src/app/api/admin/_actions/comments.ts index f7cc29a..9fe55e2 100644 --- a/src/app/api/admin/_actions/comments.ts +++ b/src/app/api/admin/_actions/comments.ts @@ -1,10 +1,9 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { deliverToFollowers } from "@/lib/http-signatures"; -import { siteConfig } from "@/../site.config"; import type { AdminBody } from "./types"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = siteConfig.url; export async function approveComment(body: AdminBody): Promise { const { commentId } = body; @@ -20,30 +19,30 @@ export async function approveComment(body: AdminBody): Promise { // Bridge to Fediverse — publish as reply from our actor const targetApId = comment.post?.apId || comment.photo?.apId; if (targetApId) { - const noteId = `${siteUrl}/ap/comment/${comment.id}`; + const noteId = `${getSiteUrl()}/ap/comment/${comment.id}`; // H3: HTML-escape guest-supplied content before embedding it in the // federated Note. Receivers re-sanitize, but unsanitized HTML on the // wire is still a stored-XSS waiting to happen on small fedi servers // and on our own site if rendering paths change. const escape = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); - const noteContent = `

${escape(comment.guestName)} (via ${escape(new URL(siteUrl).hostname)}):

${escape(comment.content)}

`; + const noteContent = `

${escape(comment.guestName)} (via ${escape(new URL(getSiteUrl()).hostname)}):

${escape(comment.content)}

`; const activity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/create/${comment.id}`, + id: `${getSiteUrl()}/ap/create/${comment.id}`, type: "Create", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, published: new Date().toISOString(), object: { type: "Note", id: noteId, - attributedTo: `${siteUrl}/ap/actor`, + attributedTo: `${getSiteUrl()}/ap/actor`, inReplyTo: targetApId, content: noteContent, published: new Date().toISOString(), to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [`${siteUrl}/ap/followers`], + cc: [`${getSiteUrl()}/ap/followers`], }, }; diff --git a/src/app/api/admin/_actions/fedi-graph.ts b/src/app/api/admin/_actions/fedi-graph.ts index c861ffe..f5485c5 100644 --- a/src/app/api/admin/_actions/fedi-graph.ts +++ b/src/app/api/admin/_actions/fedi-graph.ts @@ -5,10 +5,9 @@ import { resolveActorInbox } from "@/lib/fedi-resolve"; import { processAttachments, fetchLinkEmbed } from "@/lib/fedi-media"; import { assertPublicHost, isPrivateUrl } from "@/lib/url-guard"; import { sanitizeHtml } from "@/lib/sanitize"; -import { siteConfig } from "@/../site.config"; import type { AdminBody } from "./types"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = siteConfig.url; const REMOTE_FETCH_TIMEOUT_MS = 8000; export async function follow(body: AdminBody): Promise { @@ -140,9 +139,9 @@ export async function follow(body: AdminBody): Promise { // Send signed Follow activity await deliverActivity(actor.inbox, { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/follow/${Date.now()}`, + id: `${getSiteUrl()}/ap/follow/${Date.now()}`, type: "Follow", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, object: actorLink.href, }); @@ -166,12 +165,12 @@ export async function unfollow(body: AdminBody): Promise { try { await deliverActivity(record.inbox, { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/undo/${Date.now()}`, + id: `${getSiteUrl()}/ap/undo/${Date.now()}`, type: "Undo", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, object: { type: "Follow", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, object: record.actorUri, }, }); @@ -195,12 +194,12 @@ export async function unfollowByUri(body: AdminBody): Promise { try { await deliverActivity(record.inbox, { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/undo/${Date.now()}`, + id: `${getSiteUrl()}/ap/undo/${Date.now()}`, type: "Undo", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, object: { type: "Follow", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, object: unfollowUri, }, }); @@ -227,12 +226,12 @@ export async function block(body: AdminBody): Promise { try { await deliverActivity(followRecord.inbox, { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/undo/${Date.now()}`, + id: `${getSiteUrl()}/ap/undo/${Date.now()}`, type: "Undo", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, object: { type: "Follow", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, object: actorUri, }, }); @@ -248,9 +247,9 @@ export async function block(body: AdminBody): Promise { try { await deliverActivity(follower.inbox, { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/block/${Date.now()}`, + id: `${getSiteUrl()}/ap/block/${Date.now()}`, type: "Block", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, object: actorUri, }); } catch { @@ -306,10 +305,10 @@ export async function unblock(body: AdminBody): Promise { if (inbox) { await deliverActivity(inbox, { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/undo/${Date.now()}`, + id: `${getSiteUrl()}/ap/undo/${Date.now()}`, type: "Undo", - actor: `${siteUrl}/ap/actor`, - object: { type: "Block", actor: `${siteUrl}/ap/actor`, object: actorUri }, + actor: `${getSiteUrl()}/ap/actor`, + object: { type: "Block", actor: `${getSiteUrl()}/ap/actor`, object: actorUri }, }).catch(() => {}); } diff --git a/src/app/api/admin/_actions/fedi-interactions.ts b/src/app/api/admin/_actions/fedi-interactions.ts index 404c08c..027ccfa 100644 --- a/src/app/api/admin/_actions/fedi-interactions.ts +++ b/src/app/api/admin/_actions/fedi-interactions.ts @@ -2,10 +2,9 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { deliverActivity, deliverToFollowers } from "@/lib/http-signatures"; import { resolveActorInbox, originalApId } from "@/lib/fedi-resolve"; -import { siteConfig } from "@/../site.config"; import type { AdminBody } from "./types"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = siteConfig.url; /** * The target post author's real inbox, resolved server-side from the stored @@ -52,9 +51,9 @@ export async function like(body: AdminBody): Promise { const likeActivity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/like/${Date.now()}`, + id: `${getSiteUrl()}/ap/like/${Date.now()}`, type: "Like", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, // Federated object must be the ORIGINAL post URL, not a synthetic boost: apId. object: originalApId(postApId), }; @@ -81,14 +80,14 @@ export async function boost(body: AdminBody): Promise { const announceActivity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/announce/${Date.now()}`, + id: `${getSiteUrl()}/ap/announce/${Date.now()}`, type: "Announce", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, // Federated object must be the ORIGINAL post URL, not a synthetic boost: apId. object: originalApId(boostApId), published: new Date().toISOString(), to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [`${siteUrl}/ap/followers`], + cc: [`${getSiteUrl()}/ap/followers`], }; // A boost belongs in our followers' feeds and also notifies the original @@ -114,10 +113,10 @@ export async function unlike(body: AdminBody): Promise { const undoActivity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/undo/${Date.now()}`, + id: `${getSiteUrl()}/ap/undo/${Date.now()}`, type: "Undo", - actor: `${siteUrl}/ap/actor`, - object: { type: "Like", actor: `${siteUrl}/ap/actor`, object: originalApId(postApId) }, + actor: `${getSiteUrl()}/ap/actor`, + object: { type: "Like", actor: `${getSiteUrl()}/ap/actor`, object: originalApId(postApId) }, }; // Mirror like: the Undo goes only to the author (likes aren't broadcast). (#119) @@ -139,10 +138,10 @@ export async function unboost(body: AdminBody): Promise { const undoActivity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/undo/${Date.now()}`, + id: `${getSiteUrl()}/ap/undo/${Date.now()}`, type: "Undo", - actor: `${siteUrl}/ap/actor`, - object: { type: "Announce", actor: `${siteUrl}/ap/actor`, object: originalApId(boostApId) }, + actor: `${getSiteUrl()}/ap/actor`, + object: { type: "Announce", actor: `${getSiteUrl()}/ap/actor`, object: originalApId(boostApId) }, }; // Mirror boost: tell our followers to drop it, plus the author directly unless diff --git a/src/app/api/admin/_actions/profile.ts b/src/app/api/admin/_actions/profile.ts index 87ebb66..b2bc311 100644 --- a/src/app/api/admin/_actions/profile.ts +++ b/src/app/api/admin/_actions/profile.ts @@ -4,10 +4,9 @@ import { deliverToFollowers } from "@/lib/http-signatures"; import { getActorProfile } from "@/lib/federation"; import { getRuntimeProfile, invalidateProfileCache } from "@/lib/site-profile"; import { isThemeId } from "@/lib/themes"; -import { siteConfig } from "@/../site.config"; import type { AdminBody } from "./types"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = siteConfig.url; const MAX_TEXT = 500; // Same characters the setup wizard forbids in .env values — even though these @@ -32,7 +31,7 @@ function textField(name: string, value: unknown): string { function imagePath(name: string, value: unknown): string { if (typeof value !== "string") throw new Error(`${name} must be a string`); let path = value.trim(); - if (path.startsWith(siteUrl)) path = path.slice(siteUrl.length); // strip our own origin + if (path.startsWith(getSiteUrl())) path = path.slice(getSiteUrl().length); // strip our own origin if (!IMAGE_PATH_RE.test(path) || path.includes("..")) { throw new Error(`${name} must be an uploaded image path (/uploads/… or /images/…)`); } @@ -112,9 +111,9 @@ export async function updateProfile(body: AdminBody): Promise { const actor = await getActorProfile(); void deliverToFollowers({ "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/actor#update-${Date.now()}`, + id: `${getSiteUrl()}/ap/actor#update-${Date.now()}`, type: "Update", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, to: ["https://www.w3.org/ns/activitystreams#Public"], object: actor, }).catch((err) => console.error("Failed to federate profile update:", err)); @@ -130,8 +129,8 @@ export async function updateProfile(body: AdminBody): Promise { summary: profile.actorSummary, accentColor: profile.accentColor, themeAccents: profile.themeAccents, - avatar: `${siteUrl}${profile.avatarPath}`, - banner: `${siteUrl}${profile.bannerPath}`, + avatar: `${getSiteUrl()}${profile.avatarPath}`, + banner: `${getSiteUrl()}${profile.bannerPath}`, }, }); } diff --git a/src/app/api/admin/_actions/replies.ts b/src/app/api/admin/_actions/replies.ts index ae20226..3fae1dd 100644 --- a/src/app/api/admin/_actions/replies.ts +++ b/src/app/api/admin/_actions/replies.ts @@ -5,8 +5,8 @@ import { siteConfig } from "@/../site.config"; import { parseMentions, linkMentions, buildApMentionTags, collectMentionInboxes } from "@/lib/mentions"; import { resolveActorInbox, originalApId } from "@/lib/fedi-resolve"; import type { AdminBody } from "./types"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = siteConfig.url; export async function reply(body: AdminBody): Promise { // Reply to a Fedi post/comment from the admin panel @@ -50,8 +50,8 @@ export async function reply(body: AdminBody): Promise { : ""; const contentHtml = `

${mentionHtml}${withMentions}

`; - const replyId = `${siteUrl}/ap/reply/${Date.now()}`; - const ccList = [`${siteUrl}/ap/followers`]; + const replyId = `${getSiteUrl()}/ap/reply/${Date.now()}`; + const ccList = [`${getSiteUrl()}/ap/followers`]; if (replyActorUri) ccList.push(replyActorUri); for (const m of replyMentions.fedi) { if (m.actorUri && !ccList.includes(m.actorUri)) ccList.push(m.actorUri); @@ -67,14 +67,14 @@ export async function reply(body: AdminBody): Promise { const activity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/create/reply-${Date.now()}`, + id: `${getSiteUrl()}/ap/create/reply-${Date.now()}`, type: "Create", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, published: new Date().toISOString(), object: { type: "Note", id: replyId, - attributedTo: `${siteUrl}/ap/actor`, + attributedTo: `${getSiteUrl()}/ap/actor`, // Federated inReplyTo must be the ORIGINAL post URL, not a synthetic boost: apId. inReplyTo: originalApId(inReplyTo), content: contentHtml, @@ -105,11 +105,11 @@ export async function reply(body: AdminBody): Promise { // Store our outgoing reply so we can match incoming replies to it const fediHandle = siteConfig.fediHandle; - const siteDomain = new URL(siteUrl).hostname; + const siteDomain = new URL(getSiteUrl()).hostname; await prisma.fediPost.upsert({ where: { apId: replyId }, create: { - actorUri: `${siteUrl}/ap/actor`, + actorUri: `${getSiteUrl()}/ap/actor`, apId: replyId, content: bodyText, contentHtml, @@ -191,20 +191,20 @@ export async function editReply(body: AdminBody): Promise { const editMentionActors = editMentions.fedi .filter((m) => !!m.actorUri) .map((m) => m.actorUri!); - const ccList = [`${siteUrl}/ap/followers`, ...editMentionActors]; + const ccList = [`${getSiteUrl()}/ap/followers`, ...editMentionActors]; const editTags = buildApMentionTags(editMentions); const updateActivity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/update/reply-${existingReply.id}/${Date.now()}`, + id: `${getSiteUrl()}/ap/update/reply-${existingReply.id}/${Date.now()}`, type: "Update", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, published: now.toISOString(), to: ["https://www.w3.org/ns/activitystreams#Public"], cc: ccList, object: { type: "Note", id: existingReply.apId, - attributedTo: `${siteUrl}/ap/actor`, + attributedTo: `${getSiteUrl()}/ap/actor`, inReplyTo: existingReply.inReplyTo, content: newContentHtml, published: existingReply.publishedAt.toISOString(), diff --git a/src/app/api/compose/route.ts b/src/app/api/compose/route.ts index 89c29b7..ab6f14d 100644 --- a/src/app/api/compose/route.ts +++ b/src/app/api/compose/route.ts @@ -10,8 +10,8 @@ import { buildMediaUpdate } from "@/lib/post-media"; import { enqueueFailedCrosspost } from "@/lib/crosspost-retry"; import { normalizeCategory } from "@/lib/categories"; import path from "path"; +import { getSiteUrl } from "@/lib/identity"; -const siteUrl = process.env.SITE_URL || "http://localhost:3000"; function slugify(text: string): string { return text @@ -293,7 +293,7 @@ async function composeHandler(req: NextRequest) { audioCovers, published: !isScheduled, ...(isScheduled ? { publishedAt: scheduledForDate!, scheduledFor: scheduledForDate! } : {}), - apId: `${siteUrl}/post/${slug}`, + apId: `${getSiteUrl()}/post/${slug}`, ...(parentPost ? { inReplyToPostId: parentPost.id } : {}), }, }); @@ -312,7 +312,7 @@ async function composeHandler(req: NextRequest) { tags, published: !isScheduled, publishedAt: post.publishedAt, - apId: `${siteUrl}/photography/${photoSlug}`, + apId: `${getSiteUrl()}/photography/${photoSlug}`, }, }).catch(() => { // Ignore duplicate slug errors @@ -339,7 +339,7 @@ async function composeHandler(req: NextRequest) { tags, published: !isScheduled, publishedAt: post.publishedAt, - apId: `${siteUrl}/videos/${videoSlug}`, + apId: `${getSiteUrl()}/videos/${videoSlug}`, }, }).catch(() => { // Ignore duplicate slug errors @@ -364,7 +364,7 @@ async function composeHandler(req: NextRequest) { tags, published: !isScheduled, publishedAt: post.publishedAt, - apId: `${siteUrl}/audio/${audioSlug}`, + apId: `${getSiteUrl()}/audio/${audioSlug}`, }, }).catch(() => { // Ignore duplicate slug errors @@ -381,7 +381,7 @@ async function composeHandler(req: NextRequest) { post: { id: post.id, slug: post.slug, - url: `${siteUrl}/post/${post.slug}`, + url: `${getSiteUrl()}/post/${post.slug}`, scheduledFor: post.scheduledFor?.toISOString() ?? null, }, }); @@ -413,7 +413,7 @@ async function composeHandler(req: NextRequest) { ? [imageAttachment(post.coverImage)] : []), ...(photos || []).map((p) => { - const url = p.url.startsWith("http") ? p.url : `${siteUrl}${p.url}`; + const url = p.url.startsWith("http") ? p.url : `${getSiteUrl()}${p.url}`; const ext = url.split(".").pop()?.toLowerCase() || "jpg"; const mimeMap: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", @@ -429,7 +429,7 @@ async function composeHandler(req: NextRequest) { ]; const apAudioAttachments = (audios || []).map((a) => { - const url = a.url.startsWith("http") ? a.url : `${siteUrl}${a.url}`; + const url = a.url.startsWith("http") ? a.url : `${getSiteUrl()}${a.url}`; return { type: "Document", mediaType: "audio/mpeg", @@ -457,22 +457,22 @@ async function composeHandler(req: NextRequest) { .map((m) => m.actorUri!); // Federation - const ccList = [`${siteUrl}/ap/followers`, ...mentionActorUris]; + const ccList = [`${getSiteUrl()}/ap/followers`, ...mentionActorUris]; const activity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/create/${post.id}`, + id: `${getSiteUrl()}/ap/create/${post.id}`, type: "Create", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, published: post.publishedAt.toISOString(), to: ["https://www.w3.org/ns/activitystreams#Public"], cc: ccList, object: { type: isArticle ? "Article" : "Note", id: post.apId, - attributedTo: `${siteUrl}/ap/actor`, + attributedTo: `${getSiteUrl()}/ap/actor`, ...(isArticle ? { name: title!.trim() } : {}), content: apContent, - url: `${siteUrl}/post/${slug}`, + url: `${getSiteUrl()}/post/${slug}`, published: post.publishedAt.toISOString(), to: ["https://www.w3.org/ns/activitystreams#Public"], cc: ccList, @@ -494,7 +494,7 @@ async function composeHandler(req: NextRequest) { } // Crossposting - const postUrl = `${siteUrl}/post/${slug}`; + const postUrl = `${getSiteUrl()}/post/${slug}`; const baseText = isArticle ? (description?.trim() || stripMarkdown(content).slice(0, 300)) : content; @@ -508,7 +508,7 @@ async function composeHandler(req: NextRequest) { if (crosspostBluesky !== false) { // Build image list with full URLs for Bluesky upload const bskyImages = (photos || []).map((p) => ({ - url: p.url.startsWith("http") ? p.url : `${siteUrl}${p.url}`, + url: p.url.startsWith("http") ? p.url : `${getSiteUrl()}${p.url}`, alt: p.alt || "", })); // When no images attached, send the first video as an external link card. @@ -579,7 +579,7 @@ async function composeHandler(req: NextRequest) { if (!parentPost && crosspostDayOne !== false) { const dayOneImages = (photos || []).map((p) => { - const url = p.url.startsWith("http") ? p.url : `${siteUrl}${p.url}`; + const url = p.url.startsWith("http") ? p.url : `${getSiteUrl()}${p.url}`; const localPath = url.includes("/uploads/") ? path.join(process.cwd(), "public", new URL(url).pathname) : null; @@ -694,7 +694,7 @@ async function updatePostHandler(postId: string, input: EditInput) { ? [imageAttachment(updated.coverImage)] : []), ...effPhotos.map((p) => { - const url = p.url.startsWith("http") ? p.url : `${siteUrl}${p.url}`; + const url = p.url.startsWith("http") ? p.url : `${getSiteUrl()}${p.url}`; const ext = url.split(".").pop()?.toLowerCase() || "jpg"; const mimeMap: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", @@ -704,7 +704,7 @@ async function updatePostHandler(postId: string, input: EditInput) { }), ]; const apAudioAttachments = effAudios.map((a) => { - const url = a.url.startsWith("http") ? a.url : `${siteUrl}${a.url}`; + const url = a.url.startsWith("http") ? a.url : `${getSiteUrl()}${a.url}`; return { type: "Document", mediaType: "audio/mpeg", url, name: a.title || "" }; }); const apAttachments = [...apImageAttachments, ...apAudioAttachments]; @@ -723,22 +723,22 @@ async function updatePostHandler(postId: string, input: EditInput) { .map((m) => m.actorUri!); const now = new Date(); - const ccList = [`${siteUrl}/ap/followers`, ...mentionActorUris]; + const ccList = [`${getSiteUrl()}/ap/followers`, ...mentionActorUris]; const activity = { "@context": "https://www.w3.org/ns/activitystreams", - id: `${siteUrl}/ap/update/${updated.id}/${Date.now()}`, + id: `${getSiteUrl()}/ap/update/${updated.id}/${Date.now()}`, type: "Update", - actor: `${siteUrl}/ap/actor`, + actor: `${getSiteUrl()}/ap/actor`, published: now.toISOString(), to: ["https://www.w3.org/ns/activitystreams#Public"], cc: ccList, object: { type: isArticle ? "Article" : "Note", id: updated.apId, - attributedTo: `${siteUrl}/ap/actor`, + attributedTo: `${getSiteUrl()}/ap/actor`, ...(isArticle ? { name: input.title!.trim() } : {}), content: apContent, - url: `${siteUrl}/post/${updated.slug}`, + url: `${getSiteUrl()}/post/${updated.slug}`, published: updated.publishedAt.toISOString(), updated: now.toISOString(), to: ["https://www.w3.org/ns/activitystreams#Public"], @@ -766,7 +766,7 @@ async function updatePostHandler(postId: string, input: EditInput) { return NextResponse.json({ success: true, - post: { id: updated.id, slug: updated.slug, url: `${siteUrl}/post/${updated.slug}` }, + post: { id: updated.id, slug: updated.slug, url: `${getSiteUrl()}/post/${updated.slug}` }, edited: true, }); } diff --git a/src/app/api/media/route.ts b/src/app/api/media/route.ts index 05aea95..9a906cc 100644 --- a/src/app/api/media/route.ts +++ b/src/app/api/media/route.ts @@ -4,6 +4,7 @@ import { writeFile, mkdir } from "fs/promises"; import path from "path"; import { parseBuffer as parseAudioMetadata } from "music-metadata"; import { saveUploadedImage, IMAGE_TYPES } from "@/lib/media"; +import { getSiteUrl } from "@/lib/identity"; // Audio cap (per-file) const MAX_AUDIO_SIZE = 100 * 1024 * 1024; // 100 MB @@ -43,7 +44,7 @@ export async function POST(req: NextRequest) { if (!result.ok) { return NextResponse.json({ error: result.error }, { status: 400 }); } - const siteUrl = process.env.SITE_URL || "http://localhost:3000"; + const siteUrl = getSiteUrl(); const url = `${siteUrl}${result.path}`; return NextResponse.json({ url }, { status: 201, headers: { Location: url } }); } @@ -80,7 +81,7 @@ async function handleAudioUpload(file: File): Promise { const filePath = path.join(audioDir, filename); await writeFile(filePath, buffer); - const siteUrl = process.env.SITE_URL || "http://localhost:3000"; + const siteUrl = getSiteUrl(); const url = `${siteUrl}/uploads/audio/${year}/${month}/${filename}`; return NextResponse.json( diff --git a/src/app/api/micropub/route.ts b/src/app/api/micropub/route.ts index c9f39dd..0dfced0 100644 --- a/src/app/api/micropub/route.ts +++ b/src/app/api/micropub/route.ts @@ -8,6 +8,7 @@ import { publishPost } from "@/lib/publish-post"; import { deletePostWithFederation } from "@/lib/delete-post"; import { getRuntimeSiteConfig } from "@/lib/site-settings"; import { categoryEntries } from "@/lib/categories"; +import { getSiteUrl } from "@/lib/identity"; /** * The resolved gallery categories for API discovery (#284) — the SAME source of @@ -62,7 +63,7 @@ export async function GET(req: NextRequest) { if (q === "config") { return NextResponse.json({ - "media-endpoint": `${process.env.SITE_URL || "http://localhost:3000"}/api/media`, + "media-endpoint": `${getSiteUrl()}/api/media`, "post-types": [ { type: "note", name: "Note" }, { type: "article", name: "Article" }, @@ -177,7 +178,7 @@ export async function POST(req: NextRequest) { } const slug = generateSlug(title, content); - const siteUrl = process.env.SITE_URL || "http://localhost:3000"; + const siteUrl = getSiteUrl(); const contentHtml = sanitizeHtml(marked.parse(content) as string); const post = await prisma.post.create({ diff --git a/src/app/api/oauth/token/route.ts b/src/app/api/oauth/token/route.ts index 75adedb..59be787 100644 --- a/src/app/api/oauth/token/route.ts +++ b/src/app/api/oauth/token/route.ts @@ -3,6 +3,7 @@ import { hashToken, generateToken } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { rateLimitKey } from "@/lib/client-ip"; import { getClient, verifyPkceS256, makeRateLimiter, sanitizeScope, bodyTooLarge } from "@/lib/oauth"; +import { getSiteUrl } from "@/lib/identity"; /** * OAuth 2.0 / IndieAuth token endpoint. Exchanges a single-use, PKCE-bound @@ -121,7 +122,7 @@ export async function POST(req: NextRequest) { access_token: accessToken, token_type: "Bearer", scope: grantedScope, - me: process.env.SITE_URL || "http://localhost:3000", + me: getSiteUrl(), }, { headers: { "Cache-Control": "no-store", Pragma: "no-cache" } } ); diff --git a/src/app/api/setup/route.ts b/src/app/api/setup/route.ts index 9c69924..afa09ef 100644 --- a/src/app/api/setup/route.ts +++ b/src/app/api/setup/route.ts @@ -7,6 +7,7 @@ import { verifyAdmin } from "@/lib/auth"; import { applySiteConfig } from "@/lib/site-settings"; import { verifySetupToken } from "@/lib/setup-token"; import { validateImagePath } from "@/lib/media"; +import { getConfiguredSiteUrl } from "@/lib/identity"; const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), @@ -61,6 +62,24 @@ function validateField(name: string, value: unknown): string { return value; } +/** + * Hosts that can't be reached from the Fediverse. Setting up against one bakes + * an unreachable identity into the actor id — and, because `Post.apId` stores + * absolute URLs, into every post published before the move. It's a legitimate + * thing to do while testing; it just has to be a decision rather than an + * accident, so the wizard requires an explicit acknowledgement (#326). + */ +function isLocalOrPrivateHost(hostname: string): boolean { + const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (h === "localhost" || h.endsWith(".localhost") || h === "::1") return true; + if (/^127\./.test(h)) return true; + if (/^(10\.|192\.168\.)/.test(h)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; + if (/^169\.254\./.test(h)) return true; // link-local + if (/^(fc|fd)[0-9a-f]{2}:/.test(h)) return true; // IPv6 unique-local + return false; +} + /** * Validate the canonical public origin. `SITE_URL` is baked into ActivityPub * ids, WebFinger, signature keyIds, RSS and CSRF checks — and once setup @@ -69,7 +88,7 @@ function validateField(name: string, value: unknown): string { * real host, no credentials, no path/query/fragment. Returns the normalized * origin (trailing slash dropped) plus the host used for `FEDI_DOMAIN`. */ -function validateSiteUrl(raw: unknown): { siteUrl: string; host: string } { +function validateSiteUrl(raw: unknown, allowLocal = false): { siteUrl: string; host: string } { const value = validateField("siteUrl", raw).trim(); if (!value) throw new SetupValidationError("siteUrl is required"); let u: URL; @@ -86,6 +105,13 @@ function validateSiteUrl(raw: unknown): { siteUrl: string; host: string } { if ((u.pathname && u.pathname !== "/") || u.search || u.hash) { throw new SetupValidationError("siteUrl must be a bare origin — no path, query or fragment"); } + if (!allowLocal && isLocalOrPrivateHost(u.hostname)) { + throw new SetupValidationError( + `"${u.host}" can't be reached from the Fediverse, so this address would become an identity nobody can follow — ` + + "and it gets written into every post you publish before you move. Use your real public domain, " + + "or confirm you're only testing locally to continue anyway.", + ); + } return { siteUrl: u.origin, host: u.host }; } @@ -159,7 +185,8 @@ export async function POST(request: Request) { // Prefer the value the wizard submitted (correct protocol AND port), then any // configured SITE_URL, then the request origin. const { siteUrl, host: fediDomain } = validateSiteUrl( - body.siteUrl || process.env.SITE_URL || new URL(request.url).origin + body.siteUrl || getConfiguredSiteUrl() || new URL(request.url).origin, + body.allowLocalIdentity === true, ); // Build .env.local content. All field values were validated above to diff --git a/src/app/rsd.xml/route.ts b/src/app/rsd.xml/route.ts index cebea8c..747207a 100644 --- a/src/app/rsd.xml/route.ts +++ b/src/app/rsd.xml/route.ts @@ -1,5 +1,6 @@ +import { getSiteUrl } from "@/lib/identity"; export async function GET() { - const siteUrl = process.env.SITE_URL || "http://localhost:3000"; + const siteUrl = getSiteUrl(); const rsd = ` diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 77c3c68..11f3d81 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -96,6 +96,10 @@ export default function SetupWizard() { const [adminSecret, setAdminSecret] = useState(""); const [savedPassword, setSavedPassword] = useState(false); const [setupToken, setSetupToken] = useState(""); + // An unreachable address is a legitimate choice while testing, but it bakes an + // identity nobody can follow into the actor id and into every post published + // before you move (#326) — so it has to be ticked, not stumbled into. + const [allowLocalIdentity, setAllowLocalIdentity] = useState(false); // Optional avatar/banner (#59) — relative /uploads/ paths from the setup-token- // gated upload; "" = keep the built-in default. const [avatarPath, setAvatarPath] = useState(""); @@ -168,16 +172,32 @@ export default function SetupWizard() { return ""; } })(); + /** + * Is this an address the Fediverse can't reach? Mirrors isLocalOrPrivateHost in + * /api/setup — the server enforces it, this only surfaces it. + */ + const siteUrlUnreachable = (() => { + if (!siteUrl || !siteUrlHost) return false; + const h = new URL(siteUrl).hostname.toLowerCase(); + return ( + h === "localhost" || + h.endsWith(".localhost") || + h === "::1" || + /^127\./.test(h) || + /^(10\.|192\.168\.)/.test(h) || + /^172\.(1[6-9]|2\d|3[01])\./.test(h) || + /^169\.254\./.test(h) || + /^(fc|fd)[0-9a-f]{2}:/.test(h) + ); + })(); + /** Inline warnings for origins that usually aren't the real public one. */ const siteUrlWarning = (() => { if (!siteUrl) return null; if (!siteUrlHost) return "That doesn't look like a valid URL — use a full origin, e.g. https://example.com"; const h = new URL(siteUrl).hostname; - if (h === "localhost" || h.startsWith("127.") || h === "::1") { - return "This is a local address — federation and links will break for anyone else. Use your real public domain."; - } - if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h)) { - return "This is a private network address — it won't be reachable from the Fediverse."; + if (siteUrlUnreachable) { + return "This address can't be reached from the Fediverse — nobody will be able to follow you, and it gets written into every post you publish before you move."; } if (siteUrl.startsWith("http://") && h !== "localhost") { return "Plain http:// — most Fediverse servers require https for federation."; @@ -235,6 +255,7 @@ export default function SetupWizard() { adminSecret, siteUrl, setupToken, + ...(siteUrlUnreachable ? { allowLocalIdentity } : {}), ...(avatarPath ? { avatarPath } : {}), ...(bannerPath ? { bannerPath } : {}), ...(Object.keys(siteConfig).length ? { siteConfig } : {}), @@ -756,6 +777,22 @@ export default function SetupWizard() {

Go back to Your Identity to change the public site address.

+ {siteUrlUnreachable && ( + + )} )} @@ -769,10 +806,18 @@ export default function SetupWizard() {