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(/^@/, ""); +}