diff --git a/AGENTS.md b/AGENTS.md
index 3ff81d5e..371b5460 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -428,6 +428,8 @@ STORAGE_URL_BASE=https://your-bucket.s3.amazonaws.com
| `REMOTE_REPLIES_SCRAPE_COOLDOWN_SECONDS` | 300 | Completed scrape deduplication window |
| `MEDIA_PROXY` | off | Remote media proxy: `off`, `proxy`, `cache` (booleans accepted: `true`→`proxy`, `false`→`off`) |
| `REMOTE_MEDIA_THUMBNAILS` | on | Generate local sharp thumbnails for remote attachments (boolean) |
+| `HANDLE_HOST` | - | Split-domain WebFinger handle host (e.g. `example.com`); must be set together with `WEB_ORIGIN` |
+| `WEB_ORIGIN` | - | Split-domain ActivityPub server origin (e.g. `https://ap.example.com`); must be set together with `HANDLE_HOST` |
Adding new environment variables
diff --git a/CHANGES.md b/CHANGES.md
index 6b99e449..cee9b1b5 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -6,6 +6,24 @@ Version 0.9.0
To be released.
+ - Added optional split-domain WebFinger support. When the new
+ `HANDLE_HOST` and `WEB_ORIGIN` environment variables are set,
+ Hollo uses Fedify's `origin` configuration so that fediverse
+ handles (e.g. `@alice@example.com`) and ActivityPub actor URIs
+ (e.g. `https://ap.example.com/@alice`) can live on different
+ domains. The Mastodon-compatible `/api/v1/instance` and
+ `/api/v2/instance` endpoints expose `HANDLE_HOST` as the instance
+ domain when this is configured, so clients display the correct
+ `@user@HANDLE_HOST` handle. Both variables must be set together;
+ setting only one is a startup error. They must be configured
+ *before* the first account is created — changing the handle
+ domain after federation has begun breaks remote follow
+ relationships; on startup Hollo logs a warning when the
+ configured `HANDLE_HOST` does not match the existing account's
+ stored handle. Operators must also configure their reverse
+ proxy on the handle domain to redirect `/.well-known/webfinger`
+ to `WEB_ORIGIN`. See the [Split-domain WebFinger guide]. [[#161], [#484]]
+
- Added a media proxy that re-serves remote avatars, headers, post
attachments, custom emojis, and preview-card images from Hollo's own
origin. This sidesteps CORS configurations on remote object stores
@@ -187,6 +205,8 @@ To be released.
- Upgraded Fedify to 2.2.1.
[FEP-044f]: https://w3id.org/fep/044f
+[Split-domain WebFinger guide]: https://docs.hollo.social/install/split-domain/
+[#161]: https://github.com/fedify-dev/hollo/issues/161
[@hollo@hollo.social]: https://hollo.social/@hollo
[#67]: https://github.com/fedify-dev/hollo/issues/67
[#127]: https://github.com/fedify-dev/hollo/issues/127
@@ -200,6 +220,7 @@ To be released.
[#481]: https://github.com/fedify-dev/hollo/issues/481
[#482]: https://github.com/fedify-dev/hollo/pull/482
[#483]: https://github.com/fedify-dev/hollo/pull/483
+[#484]: https://github.com/fedify-dev/hollo/pull/484
Version 0.8.4
diff --git a/bin/server.ts b/bin/server.ts
index a53273e3..1354d167 100644
--- a/bin/server.ts
+++ b/bin/server.ts
@@ -4,6 +4,7 @@ import { serve } from "@hono/node-server";
import { behindProxy } from "x-forwarded-fetch";
import "../src/logging";
+import { checkHandleHostConsistency } from "../src/handle-host-check";
import { configureSentry } from "../src/sentry";
// oxlint-disable-next-line typescript/dot-notation
@@ -40,6 +41,9 @@ if (!["all", "web", "worker"].includes(NODE_TYPE)) {
process.exit(1);
}
+// Warn if the configured HANDLE_HOST disagrees with an existing account.
+await checkHandleHostConsistency();
+
// Start web server if running as web or all node
if (NODE_TYPE === "web" || NODE_TYPE === "all") {
const { default: app } = await import("../src/index");
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index 0f3653ad..a57290c1 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -108,6 +108,15 @@ export default defineConfig({
},
slug: "install/workers",
},
+ {
+ label: "Split-domain WebFinger",
+ translations: {
+ ko: "도메인 분리 WebFinger",
+ ja: "ドメイン分離 WebFinger",
+ "zh-CN": "分域 WebFinger",
+ },
+ slug: "install/split-domain",
+ },
{
label: "Setting up",
translations: {
diff --git a/docs/src/content/docs/install/env.mdx b/docs/src/content/docs/install/env.mdx
index 444612a3..d07e6bf5 100644
--- a/docs/src/content/docs/install/env.mdx
+++ b/docs/src/content/docs/install/env.mdx
@@ -80,6 +80,34 @@ Turned off by default.
security reasons.
+### `HANDLE_HOST`
+
+The fediverse handle domain when running a [split-domain WebFinger
+setup][split-domain]. When set, fediverse handles take the form
+`@user@HANDLE_HOST` even though Hollo itself is served at
+[`WEB_ORIGIN`](#web_origin).
+
+Must be set together with `WEB_ORIGIN`; setting only one is a startup
+error. Configure both *before* creating the first account — changing
+the handle domain after federation has begun breaks remote follow
+relationships.
+
+Not set by default.
+
+[split-domain]: /install/split-domain/
+
+### `WEB_ORIGIN`
+
+The origin (scheme + host) where Hollo's ActivityPub server actually
+runs in a [split-domain WebFinger setup][split-domain], e.g.
+`https://ap.example.com`. Actor URIs, inbox URLs, and other federation
+endpoints are built under this origin.
+
+Must be set together with [`HANDLE_HOST`](#handle_host); setting only
+one is a startup error.
+
+Not set by default.
+
### `ALLOW_PRIVATE_ADDRESS`
Setting this to `true` disables SSRF (Server-Side Request Forgery) protection.
diff --git a/docs/src/content/docs/install/split-domain.mdx b/docs/src/content/docs/install/split-domain.mdx
new file mode 100644
index 00000000..0d5a0847
--- /dev/null
+++ b/docs/src/content/docs/install/split-domain.mdx
@@ -0,0 +1,177 @@
+---
+title: Split-domain WebFinger
+description: Run Hollo at one domain while addressing accounts under another.
+---
+
+import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components';
+
+Hollo supports a *split-domain WebFinger* setup: your fediverse handles
+live under one domain (e.g. `@alice@example.com`) while the actual
+ActivityPub server runs under another (e.g. `https://ap.example.com`).
+This is the same pattern Mastodon documents under "[hosting WebFinger
+at the root domain][mastodon-webfinger]" and that GoToSocial calls
+*host-meta*-based host swapping.
+
+This is implemented on top of [Fedify's `origin` configuration option].
+
+[mastodon-webfinger]: https://docs.joinmastodon.org/admin/config/#web_domain
+[Fedify's `origin` configuration option]: https://fedify.dev/manual/federation#separating-webfinger-host-from-the-server-origin
+
+
+Why split the domain?
+---------------------
+
+The typical reason is that you already own a nice short domain
+(`example.com`) and want your handles to look like
+`@alice@example.com`, but you don't want to host Hollo at the apex of
+that domain — the apex is reserved for a homepage, a different web app,
+or an existing service.
+
+In a split-domain setup:
+
+ - Users address you as `@alice@example.com` everywhere in the
+ fediverse.
+ - Hollo itself is served from `https://ap.example.com`. The web
+ UI, the Mastodon-compatible API, OAuth, and the actor URIs all
+ live there.
+ - The apex domain `example.com` only needs to handle one thing:
+ redirecting `/.well-known/webfinger` requests over to
+ `ap.example.com`.
+
+
+
+
+Configuration
+-------------
+
+Set both of these environment variables on your Hollo instance:
+
+ - [`HANDLE_HOST`](/install/env/#handle_host) — the bare hostname
+ used in handles (no scheme, no path), e.g. `example.com`.
+ - [`WEB_ORIGIN`](/install/env/#web_origin) — the scheme + host
+ where Hollo runs, e.g. `https://ap.example.com`.
+
+Both must be set together; setting only one causes Hollo to refuse to
+start.
+
+~~~~ env
+HANDLE_HOST=example.com
+WEB_ORIGIN=https://ap.example.com
+~~~~
+
+With this in place, Fedify takes care of the rest:
+
+ - WebFinger responses use `acct:alice@example.com` as the subject.
+ - Actor URIs are built under `https://ap.example.com/@alice`.
+ - The Mastodon-compatible `/api/v1/instance` and `/api/v2/instance`
+ endpoints report `example.com` as the instance domain, so clients
+ display the correct handle.
+
+
+Reverse proxy redirect
+----------------------
+
+Hollo itself only listens on the `WEB_ORIGIN` host. Remote servers
+that look up `@alice@example.com` will send their WebFinger query to
+`https://example.com/.well-known/webfinger`, so you must configure
+that domain's reverse proxy to redirect those requests to Hollo.
+
+A 301 redirect that preserves the query string is enough. Send
+`/.well-known/nodeinfo` and `/.well-known/host-meta` along as well,
+since some implementations probe those during discovery.
+
+
+
+~~~~ nginx
+server {
+ listen 443 ssl;
+ server_name example.com;
+ # … your normal site config …
+
+ location /.well-known/webfinger {
+ return 301 https://ap.example.com$request_uri;
+ }
+ location /.well-known/nodeinfo {
+ return 301 https://ap.example.com$request_uri;
+ }
+ location /.well-known/host-meta {
+ return 301 https://ap.example.com$request_uri;
+ }
+}
+~~~~
+
+
+~~~~ caddy
+example.com {
+ # … your normal site config …
+
+ redir /.well-known/webfinger* https://ap.example.com{uri} permanent
+ redir /.well-known/nodeinfo* https://ap.example.com{uri} permanent
+ redir /.well-known/host-meta* https://ap.example.com{uri} permanent
+}
+~~~~
+
+
+
+You do **not** need to redirect the `/@username` path or any actor
+URLs — those URLs live on `ap.example.com` and remote servers will go
+straight there after resolving the WebFinger response.
+
+
+Verifying the setup
+-------------------
+
+After deploying, three quick checks confirm everything is wired up:
+
+ 1. WebFinger from the handle domain redirects:
+
+ ~~~~ sh frame="none"
+ curl -i "https://example.com/.well-known/webfinger?resource=acct:alice@example.com"
+ ~~~~
+
+ should return a 301 to `https://ap.example.com/.well-known/webfinger?...`.
+
+ 2. WebFinger from the server domain responds:
+
+ ~~~~ sh frame="none"
+ curl "https://ap.example.com/.well-known/webfinger?resource=acct:alice@example.com"
+ ~~~~
+
+ should return a JRD whose `subject` is `acct:alice@example.com`
+ and whose `self` link points at `https://ap.example.com/@alice`.
+
+ 3. The instance endpoint reports the handle domain:
+
+ ~~~~ sh frame="none"
+ curl https://ap.example.com/api/v2/instance | jq .domain
+ ~~~~
+
+ should print `"example.com"`.
+
+For a more thorough audit, point Julian Fietkau's [WebFinger Canary]
+at your handle domain. Mastodon and Fedify-based servers handle
+split-domain setups correctly today; Misskey and Pixelfed currently
+do not.
+
+[WebFinger Canary]: https://correct.webfinger-canary.fietkau.software/
+
+
+
diff --git a/docs/src/content/docs/ja/install/env.mdx b/docs/src/content/docs/ja/install/env.mdx
index 70ed591d..16891190 100644
--- a/docs/src/content/docs/ja/install/env.mdx
+++ b/docs/src/content/docs/ja/install/env.mdx
@@ -80,6 +80,34 @@ HolloがL7ロードバランサーの後ろにある場合(通常はそうす
この動作はセキュリティ上注意が必要です。
+### `HANDLE_HOST`
+
+[ドメイン分離 WebFinger 設定][split-domain]で使用するフェディバースの
+ハンドルドメインです。このオプションを設定すると、フェディバースの
+ハンドルは`@user@HANDLE_HOST`の形式になりますが、Hollo自体は
+[`WEB_ORIGIN`](#web_origin)で動作します。
+
+`WEB_ORIGIN`と一緒に設定する必要があり、片方だけを設定すると
+Holloは起動に失敗します。**最初のアカウントを作成する前に**
+両方の変数を設定してください。連合が始まった後にハンドルドメインを
+変更すると、リモートフォロー関係が壊れます。
+
+デフォルトでは設定されていません。
+
+[split-domain]: /ja/install/split-domain/
+
+### `WEB_ORIGIN`
+
+[ドメイン分離 WebFinger 設定][split-domain]でHolloの ActivityPub
+サーバーが実際に動作するオリジン(スキーム + ホスト)です。例:
+`https://ap.example.com`。アクターURI、インボックスURLなどの
+連合エンドポイントは、すべてこのオリジンを基に構築されます。
+
+[`HANDLE_HOST`](#handle_host)と一緒に設定する必要があり、
+片方だけを設定するとHolloは起動に失敗します。
+
+デフォルトでは設定されていません。
+
### `ALLOW_PRIVATE_ADDRESS`
このオプションを`true`に設定すると、サーバーサイドリクエストフォージェリ(SSRF)攻撃の防止を解除します。
diff --git a/docs/src/content/docs/ja/install/split-domain.mdx b/docs/src/content/docs/ja/install/split-domain.mdx
new file mode 100644
index 00000000..cf39077a
--- /dev/null
+++ b/docs/src/content/docs/ja/install/split-domain.mdx
@@ -0,0 +1,180 @@
+---
+title: ドメイン分離 WebFinger
+description: Holloをあるドメインで運用しつつ、別のドメインでアカウントハンドルを公開する方法。
+---
+
+import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components';
+
+Holloは*ドメイン分離 WebFinger*の設定をサポートしています。
+フェディバースのハンドルは1つのドメイン(例: `@alice@example.com`)
+にあり、実際のActivityPubサーバーは別のドメイン
+(例: `https://ap.example.com`)で動作する形式です。
+Mastodonが「[ルートドメインでのWebFingerホスティング][mastodon-webfinger]」
+として説明し、GoToSocialが*host-meta*ベースのホストスワッピングと
+呼ぶパターンと同じです。
+
+この機能は [Fedifyの `origin` オプション] の上に実装されています。
+
+[mastodon-webfinger]: https://docs.joinmastodon.org/admin/config/#web_domain
+[Fedifyの `origin` オプション]: https://fedify.dev/manual/federation#separating-webfinger-host-from-the-server-origin
+
+
+なぜドメインを分離するのか?
+----------------------------
+
+典型的な動機は、すでに短くて良いドメイン(`example.com`)を持っていて、
+ハンドルを`@alice@example.com`のように見せたいけれども、そのドメインの
+ルートにHolloを置くわけにはいかない場合です。ルートドメインは
+ホームページや別のWebアプリ、既存サービスが使っていることが多いものです。
+
+ドメイン分離設定では:
+
+ - ユーザーはフェディバースのどこでも`@alice@example.com`という
+ 形で呼んでくれます。
+ - Hollo自体は`https://ap.example.com`で動作します。Web UI、
+ Mastodon互換 API、OAuth、アクターURIはすべてこちらにあります。
+ - ルートドメイン`example.com`はたった一つの仕事をすればよいです:
+ `/.well-known/webfinger`リクエストを`ap.example.com`に
+ リダイレクトすることです。
+
+
+
+
+設定方法
+--------
+
+以下の2つの環境変数をHolloインスタンスに設定してください:
+
+ - [`HANDLE_HOST`](/ja/install/env/#handle_host) — ハンドルに使う
+ ホスト名のみ(スキームなし、パスなし)。例: `example.com`。
+ - [`WEB_ORIGIN`](/ja/install/env/#web_origin) — Holloが実際に
+ 動作するスキーム+ホスト。例: `https://ap.example.com`。
+
+両方を一緒に設定する必要があります。片方だけ設定するとHolloは
+起動に失敗します。
+
+~~~~ env
+HANDLE_HOST=example.com
+WEB_ORIGIN=https://ap.example.com
+~~~~
+
+これで残りはFedifyが処理してくれます:
+
+ - WebFingerレスポンスの`subject`は`acct:alice@example.com`。
+ - アクターURIは`https://ap.example.com/@alice`の形式で構築されます。
+ - Mastodon互換の`/api/v1/instance`、`/api/v2/instance`エンドポイントは
+ インスタンスドメインを`example.com`として返すため、クライアントは
+ 正しいハンドルを表示します。
+
+
+リバースプロキシのリダイレクト
+------------------------------
+
+Hollo自体は`WEB_ORIGIN`ホストでしかリッスンしません。
+リモートサーバーが`@alice@example.com`を解決する際は
+`https://example.com/.well-known/webfinger`にWebFingerクエリを
+送るので、そのドメインのリバースプロキシでHolloへリダイレクト
+させる必要があります。
+
+クエリ文字列を保持する301リダイレクトで十分です。
+一部の実装はディスカバリ時に`/.well-known/nodeinfo`と
+`/.well-known/host-meta`も探るので、これらも一緒に
+リダイレクトしておくのが良いでしょう。
+
+
+
+~~~~ nginx
+server {
+ listen 443 ssl;
+ server_name example.com;
+ # … 通常のサイト設定 …
+
+ location /.well-known/webfinger {
+ return 301 https://ap.example.com$request_uri;
+ }
+ location /.well-known/nodeinfo {
+ return 301 https://ap.example.com$request_uri;
+ }
+ location /.well-known/host-meta {
+ return 301 https://ap.example.com$request_uri;
+ }
+}
+~~~~
+
+
+~~~~ caddy
+example.com {
+ # … 通常のサイト設定 …
+
+ redir /.well-known/webfinger* https://ap.example.com{uri} permanent
+ redir /.well-known/nodeinfo* https://ap.example.com{uri} permanent
+ redir /.well-known/host-meta* https://ap.example.com{uri} permanent
+}
+~~~~
+
+
+
+`/@username`パスやアクターURLはリダイレクト**不要**です。
+これらのURLは`ap.example.com`上にあり、リモートサーバーは
+WebFingerレスポンスを解決した後そちらに直接アクセスします。
+
+
+設定の検証
+----------
+
+デプロイ後、次の3つを確認すればすべて正しく接続されていることが
+わかります:
+
+ 1. ハンドルドメインからのWebFingerがリダイレクトされるか:
+
+ ~~~~ sh frame="none"
+ curl -i "https://example.com/.well-known/webfinger?resource=acct:alice@example.com"
+ ~~~~
+
+ `https://ap.example.com/.well-known/webfinger?...`への301を
+ 返す必要があります。
+
+ 2. サーバードメインからのWebFingerが応答するか:
+
+ ~~~~ sh frame="none"
+ curl "https://ap.example.com/.well-known/webfinger?resource=acct:alice@example.com"
+ ~~~~
+
+ `subject`が`acct:alice@example.com`で、`self`リンクが
+ `https://ap.example.com/@alice`を指すJRDが返ってくる必要があります。
+
+ 3. インスタンスエンドポイントがハンドルドメインを返すか:
+
+ ~~~~ sh frame="none"
+ curl https://ap.example.com/api/v2/instance | jq .domain
+ ~~~~
+
+ `"example.com"`が出力されるはずです。
+
+より入念に監査したい場合は、Julian Fietkau の [WebFinger Canary] を
+ハンドルドメインに対して実行してみてください。現在 Mastodon と
+Fedify ベースのサーバーはドメイン分離設定を正しく扱えますが、
+Misskey と Pixelfed は今のところそうではありません。
+
+[WebFinger Canary]: https://correct.webfinger-canary.fietkau.software/
+
+
+
diff --git a/docs/src/content/docs/ko/install/env.mdx b/docs/src/content/docs/ko/install/env.mdx
index 2b5dc914..be923267 100644
--- a/docs/src/content/docs/ko/install/env.mdx
+++ b/docs/src/content/docs/ko/install/env.mdx
@@ -78,6 +78,33 @@ Hollo가 L7 로드 밸런서 뒤에 위치할 경우 (일반적으로 그래야
이 동작은 보안상 주의를 기울여야 합니다.
+### `HANDLE_HOST`
+
+[도메인 분리 WebFinger 설정][split-domain]에서 사용할 페디버스 핸들
+도메인입니다. 이 옵션을 설정하면 페디버스 핸들이
+`@user@HANDLE_HOST` 형식이 되지만, Hollo 자체는 여전히
+[`WEB_ORIGIN`](#web_origin)에서 동작합니다.
+
+`WEB_ORIGIN`과 함께 설정해야 하며, 한 쪽만 설정하면 Hollo가 시작에
+실패합니다. **계정을 처음 만들기 전에** 두 변수를 설정해야 합니다.
+연합이 시작된 뒤 핸들 도메인을 바꾸면 원격 팔로워 관계가 끊어집니다.
+
+기본적으로 설정되어 있지 않습니다.
+
+[split-domain]: /ko/install/split-domain/
+
+### `WEB_ORIGIN`
+
+[도메인 분리 WebFinger 설정][split-domain]에서 Hollo의 ActivityPub
+서버가 실제로 동작하는 오리진(스킴 + 호스트)입니다. 예:
+`https://ap.example.com`. 액터 URI, 인박스 URL을 비롯한 모든
+연합 엔드포인트가 이 오리진을 기반으로 만들어집니다.
+
+[`HANDLE_HOST`](#handle_host)와 함께 설정해야 하며, 한 쪽만
+설정하면 Hollo가 시작에 실패합니다.
+
+기본적으로 설정되어 있지 않습니다.
+
### `ALLOW_PRIVATE_ADDRESS`
이 옵션을 `true`로 설정하면 서버 측 요청 위조(SSRF) 공격 방지를 풉니다.
diff --git a/docs/src/content/docs/ko/install/split-domain.mdx b/docs/src/content/docs/ko/install/split-domain.mdx
new file mode 100644
index 00000000..715180dc
--- /dev/null
+++ b/docs/src/content/docs/ko/install/split-domain.mdx
@@ -0,0 +1,176 @@
+---
+title: 도메인 분리 WebFinger
+description: Hollo를 한 도메인에서 운영하면서 다른 도메인으로 계정 핸들을 노출하는 방법.
+---
+
+import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components';
+
+Hollo는 *도메인 분리 WebFinger* 설정을 지원합니다. 페디버스
+핸들은 한 도메인 아래(예: `@alice@example.com`)에 있고,
+실제 ActivityPub 서버는 다른 도메인(예: `https://ap.example.com`)
+에서 동작하는 방식입니다. Mastodon이 "[루트 도메인에서
+WebFinger 호스팅하기][mastodon-webfinger]"라는 문서로 설명하는
+패턴, 그리고 GoToSocial이 *host-meta* 기반 호스트 스왑이라고
+부르는 것과 같은 패턴입니다.
+
+이 기능은 [Fedify의 `origin` 옵션] 위에 구현되어 있습니다.
+
+[mastodon-webfinger]: https://docs.joinmastodon.org/admin/config/#web_domain
+[Fedify의 `origin` 옵션]: https://fedify.dev/manual/federation#separating-webfinger-host-from-the-server-origin
+
+
+왜 도메인을 분리할까요?
+-----------------------
+
+전형적인 동기는 이미 짧고 좋은 도메인(`example.com`)을 가지고 있고
+핸들을 `@alice@example.com`처럼 보여주고 싶지만, 그 도메인의 루트
+자체에는 Hollo를 두기 어려운 경우입니다. 루트 도메인은 홈페이지,
+다른 웹 앱, 또는 기존 서비스가 차지하고 있기 마련이지요.
+
+도메인 분리 설정에서는:
+
+ - 사용자는 페디버스 어디서든 `@alice@example.com`이라는 형태로
+ 여러분을 부릅니다.
+ - Hollo 자체는 `https://ap.example.com`에서 동작합니다. 웹 UI,
+ Mastodon 호환 API, OAuth, 액터 URI가 모두 이 도메인에 있습니다.
+ - 루트 도메인 `example.com`은 단 한 가지 일만 하면 됩니다:
+ `/.well-known/webfinger` 요청을 `ap.example.com`으로
+ 리다이렉트하는 것.
+
+
+
+
+설정 방법
+---------
+
+다음 두 환경 변수를 Hollo 인스턴스에 설정하세요:
+
+ - [`HANDLE_HOST`](/ko/install/env/#handle_host) — 핸들에 사용할
+ 호스트명만(스킴 없음, 경로 없음). 예: `example.com`.
+ - [`WEB_ORIGIN`](/ko/install/env/#web_origin) — Hollo가 실제로
+ 동작하는 스킴+호스트. 예: `https://ap.example.com`.
+
+두 변수는 반드시 함께 설정해야 합니다. 한 쪽만 설정하면 Hollo가
+시작에 실패합니다.
+
+~~~~ env
+HANDLE_HOST=example.com
+WEB_ORIGIN=https://ap.example.com
+~~~~
+
+이렇게 설정하면 나머지는 Fedify가 알아서 처리합니다:
+
+ - WebFinger 응답의 `subject`는 `acct:alice@example.com`.
+ - 액터 URI는 `https://ap.example.com/@alice` 형식으로 만들어집니다.
+ - Mastodon 호환 `/api/v1/instance`, `/api/v2/instance` 엔드포인트는
+ 인스턴스 도메인을 `example.com`으로 알려주므로, 클라이언트가
+ 정확한 핸들을 표시합니다.
+
+
+리버스 프록시 리다이렉트
+------------------------
+
+Hollo는 `WEB_ORIGIN` 호스트에서만 응답합니다. 원격 서버가
+`@alice@example.com`을 조회할 때는 `https://example.com/.well-known/webfinger`로
+WebFinger 요청을 보내므로, 해당 도메인의 리버스 프록시가 그 요청을
+Hollo로 리다이렉트하도록 설정해야 합니다.
+
+쿼리스트링을 보존하는 301 리다이렉트면 충분합니다. 일부 구현체가
+디스커버리 중에 `/.well-known/nodeinfo`와 `/.well-known/host-meta`도
+조회하므로 함께 리다이렉트해 두는 것이 좋습니다.
+
+
+
+~~~~ nginx
+server {
+ listen 443 ssl;
+ server_name example.com;
+ # … 일반적인 사이트 설정 …
+
+ location /.well-known/webfinger {
+ return 301 https://ap.example.com$request_uri;
+ }
+ location /.well-known/nodeinfo {
+ return 301 https://ap.example.com$request_uri;
+ }
+ location /.well-known/host-meta {
+ return 301 https://ap.example.com$request_uri;
+ }
+}
+~~~~
+
+
+~~~~ caddy
+example.com {
+ # … 일반적인 사이트 설정 …
+
+ redir /.well-known/webfinger* https://ap.example.com{uri} permanent
+ redir /.well-known/nodeinfo* https://ap.example.com{uri} permanent
+ redir /.well-known/host-meta* https://ap.example.com{uri} permanent
+}
+~~~~
+
+
+
+`/@username` 경로나 액터 URL은 리다이렉트하지 **않아도** 됩니다.
+이 URL들은 `ap.example.com`에 있고, 원격 서버는 WebFinger 응답을
+해석한 뒤 곧장 그쪽으로 갑니다.
+
+
+설정 검증
+---------
+
+배포 후 다음 세 가지를 확인하면 모든 것이 제대로 연결됐는지 알 수 있습니다:
+
+ 1. 핸들 도메인의 WebFinger가 리다이렉트되는지:
+
+ ~~~~ sh frame="none"
+ curl -i "https://example.com/.well-known/webfinger?resource=acct:alice@example.com"
+ ~~~~
+
+ `https://ap.example.com/.well-known/webfinger?...`로 가는
+ 301 응답이 반환되어야 합니다.
+
+ 2. 서버 도메인의 WebFinger가 응답하는지:
+
+ ~~~~ sh frame="none"
+ curl "https://ap.example.com/.well-known/webfinger?resource=acct:alice@example.com"
+ ~~~~
+
+ `subject`가 `acct:alice@example.com`이고 `self` 링크가
+ `https://ap.example.com/@alice`를 가리키는 JRD 응답이
+ 반환되어야 합니다.
+
+ 3. 인스턴스 엔드포인트가 핸들 도메인을 알려주는지:
+
+ ~~~~ sh frame="none"
+ curl https://ap.example.com/api/v2/instance | jq .domain
+ ~~~~
+
+ `"example.com"`이 출력되어야 합니다.
+
+더 꼼꼼히 확인하고 싶다면 Julian Fietkau가 만든 [WebFinger Canary]를
+핸들 도메인에 돌려보세요. 현재 Mastodon과 Fedify 기반 서버는
+도메인 분리 설정을 잘 처리하지만, Misskey와 Pixelfed는 아직 그렇지
+않습니다.
+
+[WebFinger Canary]: https://correct.webfinger-canary.fietkau.software/
+
+
+
diff --git a/docs/src/content/docs/zh-cn/install/env.mdx b/docs/src/content/docs/zh-cn/install/env.mdx
index 70f7fa34..d9797702 100644
--- a/docs/src/content/docs/zh-cn/install/env.mdx
+++ b/docs/src/content/docs/zh-cn/install/env.mdx
@@ -71,6 +71,32 @@ openssl rand -hex 32
启用此选项后,Hollo将信任来自反向代理的`X-Forwarded-For`、`X-Forwarded-Proto`和`X-Forwarded-Host`头。这对于安全来说非常重要。
+### `HANDLE_HOST`
+
+在[分域 WebFinger 配置][split-domain]中用作联邦宇宙账号句柄的域名。
+设置后,联邦宇宙账号的形式为 `@user@HANDLE_HOST`,但 Hollo 本身
+仍在 [`WEB_ORIGIN`](#web_origin) 上运行。
+
+必须与 `WEB_ORIGIN` 一起设置;只设置其中一个会导致 Hollo 启动失败。
+**请务必在创建第一个账号之前**完成配置——一旦联邦开始后再更改
+句柄域名,会破坏所有远程关注关系。
+
+默认情况下不设置。
+
+[split-domain]: /zh-cn/install/split-domain/
+
+### `WEB_ORIGIN`
+
+在[分域 WebFinger 配置][split-domain]中 Hollo 的 ActivityPub
+服务器实际运行的来源(scheme + host),例如
+`https://ap.example.com`。所有 actor URI、收件箱 URL 等联邦端点
+都将基于此来源构建。
+
+必须与 [`HANDLE_HOST`](#handle_host) 一起设置;
+只设置其中一个会导致 Hollo 启动失败。
+
+默认情况下不设置。
+
### `ALLOW_PRIVATE_ADDRESS`
将此选项设置为`true`将禁用 SSRF(服务器端请求伪造)保护。
diff --git a/docs/src/content/docs/zh-cn/install/split-domain.mdx b/docs/src/content/docs/zh-cn/install/split-domain.mdx
new file mode 100644
index 00000000..fff0b401
--- /dev/null
+++ b/docs/src/content/docs/zh-cn/install/split-domain.mdx
@@ -0,0 +1,164 @@
+---
+title: 分域 WebFinger
+description: 在一个域名运行 Hollo 的同时,使用另一个域名作为账号句柄。
+---
+
+import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components';
+
+Hollo 支持*分域 WebFinger* 配置:联邦宇宙账号句柄使用一个域名
+(例如 `@alice@example.com`),而实际的 ActivityPub 服务器运行在
+另一个域名(例如 `https://ap.example.com`)上。这与 Mastodon 文档中
+"[在根域名上托管 WebFinger][mastodon-webfinger]" 描述的模式,以及
+GoToSocial 所称的基于 *host-meta* 的主机互换模式相同。
+
+此功能基于 [Fedify 的 `origin` 配置选项] 实现。
+
+[mastodon-webfinger]: https://docs.joinmastodon.org/admin/config/#web_domain
+[Fedify 的 `origin` 配置选项]: https://fedify.dev/manual/federation#separating-webfinger-host-from-the-server-origin
+
+
+为什么要分域?
+--------------
+
+最常见的动机是:你已经拥有一个简短好记的域名(`example.com`),
+希望句柄看起来像 `@alice@example.com`,但又不想把 Hollo 部署在该
+域名的根上——根域名往往被首页、其他 Web 应用或既有服务占用。
+
+在分域配置下:
+
+ - 用户在联邦宇宙的任何地方都以 `@alice@example.com` 称呼你。
+ - Hollo 本身运行在 `https://ap.example.com`。Web UI、
+ Mastodon 兼容 API、OAuth 和 actor URI 都位于此域名。
+ - 根域名 `example.com` 只需做一件事:把
+ `/.well-known/webfinger` 请求重定向到 `ap.example.com`。
+
+
+
+
+配置方法
+--------
+
+在 Hollo 实例上设置以下两个环境变量:
+
+ - [`HANDLE_HOST`](/zh-cn/install/env/#handle_host) — 用于句柄的
+ 主机名(不含 scheme,也不含路径),例如 `example.com`。
+ - [`WEB_ORIGIN`](/zh-cn/install/env/#web_origin) — Hollo 实际运行的
+ scheme + host,例如 `https://ap.example.com`。
+
+两者必须同时设置;只设置其中一个会导致 Hollo 启动失败。
+
+~~~~ env
+HANDLE_HOST=example.com
+WEB_ORIGIN=https://ap.example.com
+~~~~
+
+完成此配置后,剩下的由 Fedify 处理:
+
+ - WebFinger 响应的 `subject` 为 `acct:alice@example.com`。
+ - Actor URI 形如 `https://ap.example.com/@alice`。
+ - Mastodon 兼容的 `/api/v1/instance` 与 `/api/v2/instance`
+ 端点将实例域名报告为 `example.com`,因此客户端会显示正确的句柄。
+
+
+反向代理重定向
+--------------
+
+Hollo 自身只监听 `WEB_ORIGIN` 主机。解析 `@alice@example.com` 的
+远程服务器会向 `https://example.com/.well-known/webfinger` 发送
+WebFinger 请求,因此你需要在该域名的反向代理上把这些请求重定向到
+Hollo。
+
+保留查询字符串的 301 重定向即可。一些实现会在发现阶段探测
+`/.well-known/nodeinfo` 和 `/.well-known/host-meta`,建议一并
+重定向。
+
+
+
+~~~~ nginx
+server {
+ listen 443 ssl;
+ server_name example.com;
+ # … 你的常规站点配置 …
+
+ location /.well-known/webfinger {
+ return 301 https://ap.example.com$request_uri;
+ }
+ location /.well-known/nodeinfo {
+ return 301 https://ap.example.com$request_uri;
+ }
+ location /.well-known/host-meta {
+ return 301 https://ap.example.com$request_uri;
+ }
+}
+~~~~
+
+
+~~~~ caddy
+example.com {
+ # … 你的常规站点配置 …
+
+ redir /.well-known/webfinger* https://ap.example.com{uri} permanent
+ redir /.well-known/nodeinfo* https://ap.example.com{uri} permanent
+ redir /.well-known/host-meta* https://ap.example.com{uri} permanent
+}
+~~~~
+
+
+
+**不需要**重定向 `/@username` 路径或任何 actor URL;这些 URL 位于
+`ap.example.com`,远程服务器在解析 WebFinger 响应后会直接访问那里。
+
+
+验证配置
+--------
+
+部署后,以下三项检查可以快速确认是否一切正常:
+
+ 1. 句柄域名上的 WebFinger 是否被重定向:
+
+ ~~~~ sh frame="none"
+ curl -i "https://example.com/.well-known/webfinger?resource=acct:alice@example.com"
+ ~~~~
+
+ 应返回 `https://ap.example.com/.well-known/webfinger?...` 的
+ 301 响应。
+
+ 2. 服务器域名上的 WebFinger 是否响应:
+
+ ~~~~ sh frame="none"
+ curl "https://ap.example.com/.well-known/webfinger?resource=acct:alice@example.com"
+ ~~~~
+
+ 应返回 JRD,其中 `subject` 为 `acct:alice@example.com`,
+ `self` 链接指向 `https://ap.example.com/@alice`。
+
+ 3. 实例端点是否报告句柄域名:
+
+ ~~~~ sh frame="none"
+ curl https://ap.example.com/api/v2/instance | jq .domain
+ ~~~~
+
+ 应输出 `"example.com"`。
+
+若需更全面的审计,可以用 Julian Fietkau 的 [WebFinger Canary] 对
+你的句柄域名进行检查。目前 Mastodon 与基于 Fedify 的服务器能够
+正确处理分域配置;Misskey 和 Pixelfed 尚不支持。
+
+[WebFinger Canary]: https://correct.webfinger-canary.fietkau.software/
+
+
+
diff --git a/src/api/v1/accounts.ts b/src/api/v1/accounts.ts
index 95ea162c..b1d58d26 100644
--- a/src/api/v1/accounts.ts
+++ b/src/api/v1/accounts.ts
@@ -39,6 +39,7 @@ import {
REMOTE_ACTOR_FETCH_POSTS,
unfollowAccount,
} from "../../federation/account";
+import { normalizeHandleForLookup } from "../../instance-host";
import {
scopeRequired,
tokenRequired,
@@ -335,7 +336,10 @@ app.get(
),
async (c) => {
const query = c.req.valid("query");
- const acct = query.acct;
+ const handleLookup = normalizeHandleForLookup(
+ query.acct,
+ new URL(c.req.url),
+ );
let account:
| (Account & {
owner: AccountOwner | null;
@@ -343,12 +347,7 @@ app.get(
})
| null =
(await db.query.accounts.findFirst({
- where: eq(
- accounts.handle,
- acct.includes("@")
- ? `@${acct}`
- : `@${acct}@${new URL(c.req.url).host}`,
- ),
+ where: eq(accounts.handle, handleLookup),
with: { owner: true, successor: true },
})) ?? null;
if (account == null) {
@@ -357,7 +356,7 @@ app.get(
}
const fedCtx = federation.createContext(c.req.raw, undefined);
const options = fedCtx;
- const actor = await lookupObject(acct, options);
+ const actor = await lookupObject(normalizeHandle(query.acct), options);
if (!isActor(actor)) return c.json({ error: "Record not found" }, 404);
const loaded = await persistAccount(db, actor, c.req.url, options);
if (loaded != null) {
@@ -383,7 +382,7 @@ app.get(
},
);
-import { HANDLE_PATTERN } from "../../patterns";
+import { HANDLE_PATTERN, normalizeHandle } from "../../patterns";
app.get(
"/search",
@@ -413,9 +412,13 @@ app.get(
),
async (c) => {
const query = c.req.valid("query");
+ const requestUrl = new URL(c.req.url);
+ const handleLookup = HANDLE_PATTERN.test(query.q)
+ ? normalizeHandleForLookup(query.q, requestUrl)
+ : `@${normalizeHandle(query.q)}`;
if (query.resolve && HANDLE_PATTERN.test(query.q) && query.offset < 1) {
const exactMatch = await db.query.accounts.findFirst({
- where: ilike(accounts.handle, `@${query.q.replace(/^@/, "")}`),
+ where: ilike(accounts.handle, handleLookup),
});
if (exactMatch != null) {
const fedCtx = federation.createContext(c.req.raw, undefined);
@@ -432,13 +435,14 @@ app.get(
const accountList = await db.query.accounts.findMany({
where: or(
ilike(accounts.handle, `%${query.q}%`),
+ ilike(accounts.handle, `%${handleLookup}%`),
ilike(accounts.name, `%${query.q}%`),
),
with: { owner: true, successor: true },
orderBy: [
- desc(ilike(accounts.handle, `@${query.q.replace(/^@/, "")}`)),
+ desc(ilike(accounts.handle, handleLookup)),
desc(ilike(accounts.name, query.q)),
- desc(ilike(accounts.handle, `@${query.q.replace(/^@/, "")}%`)),
+ desc(ilike(accounts.handle, `${handleLookup}%`)),
desc(ilike(accounts.name, `${query.q}%`)),
],
offset: query.offset,
diff --git a/src/api/v1/instance.ts b/src/api/v1/instance.ts
index 922969a5..92ef8043 100644
--- a/src/api/v1/instance.ts
+++ b/src/api/v1/instance.ts
@@ -4,12 +4,14 @@ import { Hono } from "hono";
import metadata from "../../../package.json" with { type: "json" };
import { db } from "../../db";
import { serializeAccountOwner } from "../../entities/account";
+import { getInstanceHost } from "../../instance-host";
import { accountOwners, instances, posts } from "../../schema";
const app = new Hono();
app.get("/", async (c) => {
const url = new URL(c.req.url);
+ const instanceHost = getInstanceHost(url);
const credential = await db.query.credentials.findFirst();
if (credential == null) return c.notFound();
const accountOwner = await db.query.accountOwners.findFirst({
@@ -40,10 +42,10 @@ app.get("/", async (c) => {
.select({ domainCount: count() })
.from(instances);
return c.json({
- uri: url.host,
- title: url.host,
- short_description: `A Hollo instance at ${url.host}`,
- description: `A Hollo instance at ${url.host}`,
+ uri: instanceHost,
+ title: instanceHost,
+ short_description: `A Hollo instance at ${instanceHost}`,
+ description: `A Hollo instance at ${instanceHost}`,
email: credential.email,
version: metadata.version,
urls: {}, // TODO: streaming_api URL
diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts
index 4948b2d8..ede8040d 100644
--- a/src/api/v1/statuses.ts
+++ b/src/api/v1/statuses.ts
@@ -45,6 +45,7 @@ import {
} from "../../federation/post";
import { appendPostToTimelines } from "../../federation/timeline";
import { requestBody } from "../../helpers";
+import { isLocalHost } from "../../instance-host";
import { getAccessToken } from "../../oauth/helpers";
import {
scopeRequired,
@@ -52,6 +53,7 @@ import {
withAccountOwner,
type AccountOwnerVariables,
} from "../../oauth/middleware";
+import { normalizeHandle } from "../../patterns";
import { fetchPreviewCard, type PreviewCard } from "../../previewcard";
import {
accountOwners,
@@ -1379,9 +1381,12 @@ async function addEmojiReaction(
const fedCtx = federation.createContext(c.req.raw, undefined);
const postId = c.req.param("id");
if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404);
- let emoji = c.req.param("emoji");
+ let emoji = normalizeHandle(c.req.param("emoji"));
const url = new URL(c.req.url);
- if (emoji.endsWith(`@${url.host}`)) emoji = emoji.replace(/@[^@]+$/, "");
+ const emojiAt = emoji.lastIndexOf("@");
+ if (emojiAt >= 0 && isLocalHost(emoji.slice(emojiAt + 1), url)) {
+ emoji = emoji.slice(0, emojiAt);
+ }
let emojiCode = "";
let tag: Emoji | null = null;
if (emoji.includes("@")) {
@@ -1525,9 +1530,12 @@ async function removeEmojiReaction(
const fedCtx = federation.createContext(c.req.raw, undefined);
const postId = c.req.param("id");
if (!isUuid(postId)) return c.json({ error: "Record not found" }, 404);
- let emoji = c.req.param("emoji");
+ let emoji = normalizeHandle(c.req.param("emoji"));
const url = new URL(c.req.url);
- if (emoji.endsWith(`@${url.host}`)) emoji = emoji.replace(/@[^@]+$/, "");
+ const emojiAt = emoji.lastIndexOf("@");
+ if (emojiAt >= 0 && isLocalHost(emoji.slice(emojiAt + 1), url)) {
+ emoji = emoji.slice(0, emojiAt);
+ }
const unicode = /^[\p{Emoji}]+$/u.test(emoji);
const deleted = await db
.delete(reactions)
diff --git a/src/api/v2/instance.ts b/src/api/v2/instance.ts
index dc4eea47..1ff53428 100644
--- a/src/api/v2/instance.ts
+++ b/src/api/v2/instance.ts
@@ -4,12 +4,14 @@ import { Hono } from "hono";
import metadata from "../../../package.json" with { type: "json" };
import { db } from "../../db";
import { serializeAccountOwner } from "../../entities/account";
+import { getInstanceHost } from "../../instance-host";
import { accountOwners, posts } from "../../schema";
const app = new Hono();
app.get("/", async (c) => {
const url = new URL(c.req.url);
+ const instanceHost = getInstanceHost(url);
const credential = await db.query.credentials.findFirst();
if (credential == null) return c.notFound();
const accountOwner = await db.query.accountOwners.findFirst({
@@ -34,11 +36,11 @@ app.get("/", async (c) => {
api_versions: {
mastodon: 7,
},
- domain: url.host,
- title: url.host,
+ domain: instanceHost,
+ title: instanceHost,
version: metadata.version,
source_url: "https://github.com/fedify-dev/hollo",
- description: `A Hollo instance at ${url.host}`,
+ description: `A Hollo instance at ${instanceHost}`,
usage: {
users: {
// TODO: Track active users in the past 4 weeks
diff --git a/src/entities/account.ts b/src/entities/account.ts
index e1b925c7..1018c193 100644
--- a/src/entities/account.ts
+++ b/src/entities/account.ts
@@ -1,3 +1,4 @@
+import { getInstanceHost } from "../instance-host";
import { proxyUrl } from "../media-proxy";
import type { Account, AccountOwner, Block, Follow, Mute } from "../schema";
import type { Uuid } from "../uuid";
@@ -19,7 +20,7 @@ export function serializeAccount(
baseUrl,
).href;
let acct = account.handle.replace(/^@/, "");
- if (acct.endsWith(`@${baseUrl.host}`)) {
+ if (acct.endsWith(`@${getInstanceHost(baseUrl)}`)) {
acct = acct.replace(/@[^@]+$/, "");
}
const avatar = proxyUrl(account.avatarUrl, baseUrl) ?? defaultAvatarUrl;
diff --git a/src/env.ts b/src/env.ts
index 8139760a..bb06b19d 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -14,3 +14,72 @@ if (secretKey.length < SECRET_KEY_MINIMUM_LENGTH) {
}
export const SECRET_KEY = secretKey;
+
+// oxlint-disable-next-line typescript/dot-notation
+const rawHandleHost = process.env["HANDLE_HOST"]?.trim().toLowerCase();
+// oxlint-disable-next-line typescript/dot-notation
+const rawWebOrigin = process.env["WEB_ORIGIN"]?.trim().replace(/\/+$/, "");
+
+const handleHostSet = rawHandleHost != null && rawHandleHost !== "";
+const webOriginSet = rawWebOrigin != null && rawWebOrigin !== "";
+
+if (handleHostSet !== webOriginSet) {
+ throw new Error(
+ "HANDLE_HOST and WEB_ORIGIN must be set together (or both unset).",
+ );
+}
+
+// Syntax-level check only; we don't resolve DNS or contact the host.
+// HANDLE_HOST is the bare hostname used in fediverse handles (the part
+// after the second `@`), so it must not carry a scheme, port, or path.
+if (rawHandleHost != null && rawHandleHost !== "") {
+ if (rawHandleHost.includes("/") || rawHandleHost.includes(":")) {
+ throw new Error(
+ "HANDLE_HOST must be a bare hostname (e.g. example.com) with no scheme, port, or path.",
+ );
+ }
+ // Use URL.canParse on a synthesized URL to catch other malformed
+ // hostnames (whitespace, control characters, empty labels, etc.).
+ // canParse is Unicode-aware, so IDN domains pass through.
+ if (!URL.canParse(`https://${rawHandleHost}/`)) {
+ throw new Error("HANDLE_HOST must be a valid hostname (e.g. example.com).");
+ }
+}
+
+// Syntax-level checks only; we don't resolve DNS or contact the host.
+// Fedify enforces the same shape when the origin is wired into
+// createFederation, but checking up front gives the operator a clear
+// error pointing at the env variable instead of a downstream TypeError.
+let normalizedWebOrigin: string | undefined;
+if (rawWebOrigin != null && rawWebOrigin !== "") {
+ if (!URL.canParse(rawWebOrigin)) {
+ throw new Error(
+ "WEB_ORIGIN must be a valid URL (e.g. https://ap.example.com).",
+ );
+ }
+ const webOriginUrl = new URL(rawWebOrigin);
+ if (webOriginUrl.protocol !== "http:" && webOriginUrl.protocol !== "https:") {
+ throw new Error(
+ "WEB_ORIGIN must use the http or https scheme (e.g. https://ap.example.com).",
+ );
+ }
+ if (
+ (webOriginUrl.pathname !== "/" && webOriginUrl.pathname !== "") ||
+ webOriginUrl.search !== "" ||
+ webOriginUrl.hash !== ""
+ ) {
+ throw new Error(
+ "WEB_ORIGIN must be a bare origin (scheme and host only) with no path, query string, or fragment.",
+ );
+ }
+ // URL.origin yields the canonical form (lowercased host, no trailing
+ // slash), so consumers can rely on a normalized value.
+ normalizedWebOrigin = webOriginUrl.origin;
+}
+
+export const HANDLE_HOST = handleHostSet ? rawHandleHost : undefined;
+export const WEB_ORIGIN = normalizedWebOrigin;
+export const FEDIFY_ORIGIN =
+ HANDLE_HOST != null && WEB_ORIGIN != null
+ ? { handleHost: HANDLE_HOST, webOrigin: WEB_ORIGIN }
+ : undefined;
diff --git a/src/federation/federation.ts b/src/federation/federation.ts
index 7f47c69b..46dab86e 100644
--- a/src/federation/federation.ts
+++ b/src/federation/federation.ts
@@ -17,6 +17,7 @@ import {
import metadata from "../../package.json" with { type: "json" };
import { postgres } from "../db";
+import { FEDIFY_ORIGIN } from "../env";
// oxlint-disable-next-line typescript/dot-notation
const nodeType = process.env["NODE_TYPE"] ?? "all";
@@ -54,6 +55,7 @@ let federation: Federation & { sink?: Sink } = createFederation({
// oxlint-disable-next-line typescript/dot-notation
allowPrivateAddress: process.env["ALLOW_PRIVATE_ADDRESS"] === "true",
tracerProvider,
+ origin: FEDIFY_ORIGIN,
});
if (fedifyDebug && exporter != null) {
diff --git a/src/handle-host-check.ts b/src/handle-host-check.ts
new file mode 100644
index 00000000..66d16a46
--- /dev/null
+++ b/src/handle-host-check.ts
@@ -0,0 +1,31 @@
+import { getLogger } from "@logtape/logtape";
+
+import { db } from "./db";
+import { HANDLE_HOST } from "./env";
+
+const logger = getLogger(["hollo", "config"]);
+
+export async function checkHandleHostConsistency(): Promise {
+ if (HANDLE_HOST == null) return;
+ const owners = await db.query.accountOwners.findMany({
+ with: { account: { columns: { handle: true } } },
+ });
+ for (const owner of owners) {
+ const handle = owner.account.handle;
+ // Local handles are stored in canonical "@user@host" form; ignore
+ // anything that doesn't have both the leading "@" and a host segment.
+ if (!handle.startsWith("@")) continue;
+ const at = handle.lastIndexOf("@");
+ if (at <= 0) continue;
+ const existingHost = handle.slice(at + 1).toLowerCase();
+ if (existingHost === "") continue;
+ if (existingHost === HANDLE_HOST) continue;
+ logger.warn(
+ "Configured HANDLE_HOST ({configured}) does not match existing " +
+ "account handle host ({existing}) for {handle}. Changing the " +
+ "handle domain after federation has begun breaks remote follow " +
+ "relationships. See https://docs.hollo.social/install/split-domain/",
+ { configured: HANDLE_HOST, existing: existingHost, handle },
+ );
+ }
+}
diff --git a/src/instance-host.ts b/src/instance-host.ts
new file mode 100644
index 00000000..0577385f
--- /dev/null
+++ b/src/instance-host.ts
@@ -0,0 +1,51 @@
+import { HANDLE_HOST, WEB_ORIGIN } from "./env";
+import { normalizeHandle } from "./patterns";
+
+// Use hostname (not host) so a non-default port on WEB_ORIGIN doesn't end
+// up in the comparison value; fediverse handles never carry ports.
+const WEB_ORIGIN_HOST =
+ WEB_ORIGIN != null ? new URL(WEB_ORIGIN).hostname.toLowerCase() : undefined;
+
+export function getInstanceHost(fallback: URL | string): string {
+ if (HANDLE_HOST != null) return HANDLE_HOST;
+ return typeof fallback === "string" ? fallback : fallback.host;
+}
+
+// String-equality check between hostnames; no DNS resolution and no
+// authority over the request itself. Used by lookup paths to decide
+// whether a handle's host segment refers to this instance.
+export function isLocalHost(host: string, requestUrl: URL): boolean {
+ const lower = host.toLowerCase();
+ // Accept both request URL forms: .host (with port) covers
+ // non-split-domain deployments whose stored handles include the
+ // port (e.g. local dev at localhost:3000), and .hostname (no port)
+ // covers split-domain setups where HANDLE_HOST never carries one.
+ if (lower === requestUrl.host.toLowerCase()) return true;
+ if (lower === requestUrl.hostname.toLowerCase()) return true;
+ if (HANDLE_HOST != null && lower === HANDLE_HOST) return true;
+ if (WEB_ORIGIN_HOST != null && lower === WEB_ORIGIN_HOST) return true;
+ return false;
+}
+
+/**
+ * Canonicalize a user-supplied handle-like string for an `accounts.handle`
+ * lookup. Strips a leading `@`, fills in the configured handle host for
+ * bare usernames, and rewrites local-host aliases (the request host, the
+ * configured `WEB_ORIGIN` host) to the canonical `HANDLE_HOST` form that
+ * local accounts are stored under. Remote handles are returned with a
+ * leading `@` but otherwise untouched.
+ */
+export function normalizeHandleForLookup(
+ handle: string,
+ requestUrl: URL,
+): string {
+ const acct = normalizeHandle(handle);
+ const at = acct.lastIndexOf("@");
+ if (at < 0) {
+ return `@${acct}@${getInstanceHost(requestUrl)}`;
+ }
+ if (isLocalHost(acct.slice(at + 1), requestUrl)) {
+ return `@${acct.slice(0, at)}@${getInstanceHost(requestUrl)}`;
+ }
+ return `@${acct}`;
+}
diff --git a/src/pages/accounts.tsx b/src/pages/accounts.tsx
index 6b3fa50b..2ee0a9e6 100644
--- a/src/pages/accounts.tsx
+++ b/src/pages/accounts.tsx
@@ -35,6 +35,7 @@ import {
REMOTE_ACTOR_FETCH_POSTS,
unfollowAccount,
} from "../federation/account.ts";
+import { getInstanceHost } from "../instance-host.ts";
import { loginRequired } from "../login.ts";
import {
type Account,
@@ -163,7 +164,7 @@ accounts.post("/", async (c) => {
: undefined,
}}
officialAccount={HOLLO_OFFICIAL_ACCOUNT}
- host={new URL(c.req.url).host}
+ host={getInstanceHost(new URL(c.req.url))}
/>,
400,
);
@@ -190,7 +191,7 @@ accounts.post("/", async (c) => {
}}
errors={{ avatar: "Avatar must be a JPEG, PNG, or GIF." }}
officialAccount={HOLLO_OFFICIAL_ACCOUNT}
- host={new URL(c.req.url).host}
+ host={getInstanceHost(new URL(c.req.url))}
/>,
400,
);
@@ -217,7 +218,7 @@ accounts.post("/", async (c) => {
}}
errors={{ header: "Header image must be a JPEG, PNG, or GIF." }}
officialAccount={HOLLO_OFFICIAL_ACCOUNT}
- host={new URL(c.req.url).host}
+ host={getInstanceHost(new URL(c.req.url))}
/>,
400,
);
@@ -269,11 +270,12 @@ accounts.post("/", async (c) => {
uploadedPaths.push(result.path);
}
}
+ const handleHost = getInstanceHost(fedCtx.host);
dbResult = await db.transaction(async (tx) => {
await tx
.insert(instances)
.values({
- host: fedCtx.host,
+ host: handleHost,
software: "hollo",
softwareVersion: null,
})
@@ -283,11 +285,11 @@ accounts.post("/", async (c) => {
.values({
id: accountId,
iri: fedCtx.getActorUri(username).href,
- instanceHost: fedCtx.host,
+ instanceHost: handleHost,
type: "Person",
name,
emojis,
- handle: `@${username}@${fedCtx.host}`,
+ handle: `@${username}@${handleHost}`,
bioHtml: bioResult.html,
url: fedCtx.getActorUri(username).href,
protected: protected_,
@@ -399,7 +401,7 @@ accounts.get("/new", (c) => {
expandSpoilers: false,
}}
officialAccount={HOLLO_OFFICIAL_ACCOUNT}
- host={new URL(c.req.url).host}
+ host={getInstanceHost(new URL(c.req.url))}
/>,
);
});
@@ -429,7 +431,7 @@ accounts.get("/:id", async (c) => {
accountOwner={accountOwner}
news={news != null}
officialAccount={HOLLO_OFFICIAL_ACCOUNT}
- host={new URL(c.req.url).host}
+ host={getInstanceHost(new URL(c.req.url))}
/>,
);
});
@@ -549,7 +551,7 @@ accounts.post("/:id", async (c) => {
name: name == null || name === "" ? "Display name is required." : "",
}}
officialAccount={HOLLO_OFFICIAL_ACCOUNT}
- host={new URL(c.req.url).host}
+ host={getInstanceHost(new URL(c.req.url))}
/>,
400,
);
@@ -579,7 +581,7 @@ accounts.post("/:id", async (c) => {
}}
errors={{ avatar: "Avatar must be a JPEG, PNG, or GIF." }}
officialAccount={HOLLO_OFFICIAL_ACCOUNT}
- host={new URL(c.req.url).host}
+ host={getInstanceHost(new URL(c.req.url))}
/>,
400,
);
@@ -609,7 +611,7 @@ accounts.post("/:id", async (c) => {
}}
errors={{ header: "Header image must be a JPEG, PNG, or GIF." }}
officialAccount={HOLLO_OFFICIAL_ACCOUNT}
- host={new URL(c.req.url).host}
+ host={getInstanceHost(new URL(c.req.url))}
/>,
400,
);
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx
index 564fd377..04d0d3f7 100644
--- a/src/pages/home/index.tsx
+++ b/src/pages/home/index.tsx
@@ -4,6 +4,7 @@ import { Hono } from "hono";
import { Layout } from "../../components/Layout.tsx";
import { renderCustomEmojis } from "../../custom-emoji.ts";
import db from "../../db.ts";
+import { getInstanceHost } from "../../instance-host.ts";
import { proxyUrl } from "../../media-proxy.ts";
const homePage = new Hono().basePath("/");
@@ -25,7 +26,7 @@ homePage.get("/", async (c) => {
// oxlint-disable-next-line typescript/dot-notation
return c.redirect(process.env["HOME_URL"]);
}
- const host = new URL(c.req.url).host;
+ const host = getInstanceHost(new URL(c.req.url));
const themeColor = owners[0]?.themeColor;
return c.html(
diff --git a/src/patterns.ts b/src/patterns.ts
index 480cce3f..0ab30644 100644
--- a/src/patterns.ts
+++ b/src/patterns.ts
@@ -10,3 +10,15 @@
*/
export const HANDLE_PATTERN =
/^@?[\p{L}\p{N}._-]+@(?:[\p{L}\p{N}][\p{L}\p{N}_-]*\.)+[\p{L}\p{N}]{2,}$/u;
+
+/**
+ * Strip a single leading `@` from a handle-like value.
+ *
+ * Use this on values that came from user input before further parsing: many
+ * fediverse clients send handles in the user-typed `@user@domain` form, but
+ * Hollo's lookup paths build their own leading `@`, which would otherwise
+ * produce `@@user@domain` and miss every stored handle.
+ */
+export function normalizeHandle(handle: string): string {
+ return handle.replace(/^@/, "");
+}