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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 7 additions & 4 deletions site.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getIdentity } from "./src/lib/identity";

/**
* FediHome — Site Configuration
*
Expand All @@ -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
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/app/(public)/audio/feed.xml/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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).
Expand Down
14 changes: 6 additions & 8 deletions src/app/(public)/users/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,23 @@ 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,
}: {
params: Promise<{ username: string }>;
}): Promise<Metadata> {
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 }],
},
Expand All @@ -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 } }),
Expand Down Expand Up @@ -67,7 +65,7 @@ export default async function UserProfilePage({
{profile.authorName}
</h1>
<p className="text-neutral-500 dark:text-neutral-400 text-sm">
@{fediHandle}@{siteDomain}
@{getIdentity().fediHandle}@{getIdentity().fediDomain}
</p>

<p className="mt-3 text-neutral-700 dark:text-neutral-300 text-sm leading-relaxed">
Expand Down
3 changes: 2 additions & 1 deletion src/app/.well-known/oauth-authorization-server/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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
* this to discover the authorize/token/revoke endpoints and confirm that only
* 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(
{
Expand Down
17 changes: 10 additions & 7 deletions src/app/.well-known/webfinger/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/app/ap/followers/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,7 +15,7 @@ export async function GET() {

const collection: Record<string, unknown> = {
"@context": "https://www.w3.org/ns/activitystreams",
id: `${siteUrl}/ap/followers`,
id: `${getSiteUrl()}/ap/followers`,
type: "OrderedCollection",
totalItems,
};
Expand Down
4 changes: 2 additions & 2 deletions src/app/ap/following/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,7 +11,7 @@ export async function GET() {

const collection: Record<string, unknown> = {
"@context": "https://www.w3.org/ns/activitystreams",
id: `${siteUrl}/ap/following`,
id: `${getSiteUrl()}/ap/following`,
type: "OrderedCollection",
totalItems,
};
Expand Down
8 changes: 4 additions & 4 deletions src/app/ap/inbox/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -189,9 +189,9 @@ async function handleFollow(actorUri: string, activity: Record<string, unknown>)
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) {
Expand Down Expand Up @@ -571,7 +571,7 @@ async function handleNote(actorUri: string, note: Record<string, unknown>) {
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
Expand Down
6 changes: 3 additions & 3 deletions src/app/ap/outbox/route.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -14,15 +14,15 @@ 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),
}));

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,
Expand Down
16 changes: 8 additions & 8 deletions src/app/api/admin/_actions/bluesky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextResponse> {
const { content: bskyReplyContent, blueskyUri: parentUri, crosspostFedi } = body;
Expand Down Expand Up @@ -54,33 +54,33 @@ export async function bskyReply(body: AdminBody): Promise<NextResponse> {
(url: string) => `<a href="${url}" rel="nofollow noopener noreferrer" target="_blank">${url}</a>`
);
const fediHtml = `<p>${linkedContent}</p>`;
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 } : {}),
},
};
deliverToFollowers(fediActivity).catch((err) =>
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,
Expand Down
15 changes: 7 additions & 8 deletions src/app/api/admin/_actions/comments.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
const { commentId } = body;
Expand All @@ -20,30 +19,30 @@ export async function approveComment(body: AdminBody): Promise<NextResponse> {
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const noteContent = `<p><strong>${escape(comment.guestName)}</strong> (via ${escape(new URL(siteUrl).hostname)}):</p><p>${escape(comment.content)}</p>`;
const noteContent = `<p><strong>${escape(comment.guestName)}</strong> (via ${escape(new URL(getSiteUrl()).hostname)}):</p><p>${escape(comment.content)}</p>`;

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`],
},
};

Expand Down
Loading
Loading