From 9608520675577c5c7d276fe80e6afe2221d7afef Mon Sep 17 00:00:00 2001 From: khannurien Date: Fri, 9 Oct 2026 22:11:39 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20a=20handle=20with=20this=20instance's=20o?= =?UTF-8?q?wn=20host=20names=20the=20local=20user=20=E2=80=94=20another=20?= =?UTF-8?q?instance=20writes=20bob=20from=20here=20as=20bob@gerbeur.exampl?= =?UTF-8?q?e,=20so=20a=20mention=20or=20a=20link=20copied=20from=20there?= =?UTF-8?q?=20named=20no=20one=20here:=20/@bob@gerbeur.example=20was=20a?= =?UTF-8?q?=20404,=20and=20the=20mention=20notified=20nobody.=20localHandl?= =?UTF-8?q?e=20(shared/validation.ts)=20drops=20this=20instance's=20host,?= =?UTF-8?q?=20its=20API's=20or=20its=20pages',=20from=20a=20handle;=20reso?= =?UTF-8?q?lveUsername=20reads=20names=20by=20it,=20so=20the=20page,=20the?= =?UTF-8?q?=20API=20and=20the=20Open=20Graph=20301=20reach=20bob=20at=20/@?= =?UTF-8?q?bob,=20and=20so=20do=20the=20mention=20notifications=20and=20th?= =?UTF-8?q?e=20chat's=20highlight=20and=20chime.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- API.md | 4 +++- api/services/notification-service.ts | 4 +++- api/services/user-service.ts | 19 +++++++++++++------ api/test/middleware/og.test.ts | 1 + api/test/routes/users.test.ts | 6 ++++++ .../services/notification-service.test.ts | 1 + shared/test/federation.test.ts | 9 ++++++++- shared/validation.ts | 16 ++++++++++++++++ src/test/utils/chat/text.test.ts | 9 +++++++++ src/utils/auth/home.ts | 11 +++++++++++ src/utils/chat/text.ts | 11 ++++++++--- 11 files changed, 79 insertions(+), 12 deletions(-) diff --git a/API.md b/API.md index 5c36e5eb..e5975895 100644 --- a/API.md +++ b/API.md @@ -64,7 +64,9 @@ page to a comment. - **A user from another instance** is `name@host` here: their name at home and their home's host, with its port if it has one (`alice@music.example`). Their name is their home's to change, which leaves the old handle an alias here too. - Mentions write the whole handle. + Mentions write the whole handle. A handle with this instance's own host, as + another instance writes a user from here (`bob@gerbeur.example`), names the + local `bob`, in a path, an API key or a mention. - **Aliases.** A slug or a name left behind stays an alias for good, which always names the item or user itself, so redirects never chain. Nobody else can take one. An item can take back one of its own. Deleting an item frees its diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index 6fd86a8f..e4224f8c 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -25,6 +25,8 @@ import { import { type Page, pageOf } from "../lib/pagination.ts"; import { sendToUser } from "./ws-service.ts"; import { mentionedUsernames } from "../../shared/mention.ts"; +import { localHandle } from "../../shared/validation.ts"; +import { isSelfHost } from "../lib/self-url.ts"; /** Each id of a user a notification names, beside the field of their name. */ const USER_FIELDS = [ @@ -359,7 +361,7 @@ export function notifyMentions( for (const username of usernames) { const mentionedRow = db.prepare( `SELECT id FROM users WHERE lower(username) = ?;`, - ).get(username) as { id: string } | undefined; + ).get(localHandle(username, isSelfHost)) as { id: string } | undefined; if (!mentionedRow || mentionedRow.id === mentionerUserId) continue; if (skipUserId?.(mentionedRow.id)) continue; diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 288c01b5..da4f916c 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -39,7 +39,12 @@ import { LIKE, likePattern } from "../lib/like.ts"; import { parseRows } from "../model/rows.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts"; import { renderWelcomeEmail } from "../mail/welcome.ts"; -import { USERNAME_RE, VALIDATION } from "../../shared/validation.ts"; +import { + localHandle, + USERNAME_RE, + VALIDATION, +} from "../../shared/validation.ts"; +import { isSelfHost } from "../lib/self-url.ts"; import { EVERYONE } from "../../shared/mention.ts"; /** Selects full `UserRow`s from `users u`, with the inviter's name joined. */ @@ -235,17 +240,19 @@ export function getUserByUsername(username: string): User { } /** - * Returns the user who is or was named `username`, in any case: a page or an - * API path naming a user by a former name reaches them, and the caller sends - * the client on to the current one. + * Returns the user who is or was named `username`, in any case, or by the + * handle another instance gives them (`localHandle`): a page or an API path + * naming a user so reaches them, and the caller sends the client on to their + * current name. * * @throws {APIException} 404 when nobody is or was. */ export function resolveUsername(username: string): User { + const name = localHandle(username, isSelfHost); const alias = db.prepare( `SELECT user_id FROM user_aliases WHERE lower(username) = lower(?);`, - ).get(username) as { user_id: string } | undefined; - return alias ? getUserById(alias.user_id) : getUserByUsername(username); + ).get(name) as { user_id: string } | undefined; + return alias ? getUserById(alias.user_id) : getUserByUsername(name); } /** diff --git a/api/test/middleware/og.test.ts b/api/test/middleware/og.test.ts index 0f623144..5d73773c 100644 --- a/api/test/middleware/og.test.ts +++ b/api/test/middleware/og.test.ts @@ -145,6 +145,7 @@ test("ogMiddleware: a former slug, a former name or another case gets a 301", as [`/@OLD_NAME/dumps/now`, dump.path], [`/@old_name/follow/playlists`, `/@${user.username}/follow/playlists`], [`/@${user.username.toUpperCase()}`, `/@${user.username}`], + [`/@${user.username}@gerbeur.test/upvoted`, `/@${user.username}/upvoted`], ] ) { const { res } = await handle(ogApp(), from); diff --git a/api/test/routes/users.test.ts b/api/test/routes/users.test.ts index 4a828e3c..da0f1126 100644 --- a/api/test/routes/users.test.ts +++ b/api/test/routes/users.test.ts @@ -59,6 +59,8 @@ test("public profile: by @name in any case or id, without private fields", async `/api/users/@${user.username}`, `/api/users/@${user.username.toUpperCase()}`, `/api/users/${user.id}`, + // As another instance writes them. + `/api/users/@${user.username}@gerbeur.test`, ] ) { const res = await get(path); @@ -68,6 +70,10 @@ test("public profile: by @name in any case or id, without private fields", async assertFalse("passwordHash" in res.data); } assertEquals((await get(`/api/users/@no_one_here`)).status, 404); + assertEquals( + (await get(`/api/users/@${user.username}@elsewhere.test`)).status, + 404, + ); // Without the `@`, the key is an id. assertEquals((await get(`/api/users/${user.username}`)).status, 404); const me = await get("/api/me", { auth: user.auth }); diff --git a/api/test/services/notification-service.test.ts b/api/test/services/notification-service.test.ts index bddd52be..a0ae87bf 100644 --- a/api/test/services/notification-service.test.ts +++ b/api/test/services/notification-service.test.ts @@ -15,6 +15,7 @@ test("mentions: notify the named, once per context, never the author or an email const named = await createUser(); const { id: dumpId } = insertDump({ userId: author.id, title: "Topic" }); const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` + + `@${named.username}@gerbeur.test as another instance writes them, ` + `me @${author.username}, mail x@${named.username}.com, ` + `[@${named.username}](link) @nobody_known`; const c = await post(`/api/dumps/${dumpId}/comments`, { diff --git a/shared/test/federation.test.ts b/shared/test/federation.test.ts index 416b9d82..2e84d404 100644 --- a/shared/test/federation.test.ts +++ b/shared/test/federation.test.ts @@ -4,7 +4,7 @@ import { isFederationEvent, parseInstance, } from "../federation.ts"; -import { HANDLE_RE, splitHandle } from "../validation.ts"; +import { HANDLE_RE, localHandle, splitHandle } from "../validation.ts"; Deno.test("parseInstance: a host or a URL names an instance's origin", () => { assertEquals(parseInstance("music.example"), "https://music.example"); @@ -21,6 +21,13 @@ Deno.test("parseInstance: a host or a URL names an instance's origin", () => { assertEquals(instanceHost("http://localhost:8001"), "localhost:8001"); }); +Deno.test("handles: this instance's host is dropped, another's kept", () => { + const self = (host: string) => host === "gerbeur.example"; + assertEquals(localHandle("bob@gerbeur.example", self), "bob"); + assertEquals(localHandle("bob@music.example", self), "bob@music.example"); + assertEquals(localHandle("bob", self), "bob"); +}); + Deno.test("handles: a name, and a host for a user from another instance", () => { assertEquals(splitHandle("bob"), { name: "bob" }); assertEquals(splitHandle("alice@music.example"), { diff --git a/shared/validation.ts b/shared/validation.ts index 12c2b68b..bc524c84 100644 --- a/shared/validation.ts +++ b/shared/validation.ts @@ -75,6 +75,22 @@ export function splitHandle(handle: string): { name: string; host?: string } { : { name: handle.slice(0, at), host: handle.slice(at + 1) }; } +/** + * Returns `handle` as this instance writes it, `isSelfHost` telling its own + * host. Another instance writes a user from here with this one's host, + * `bob@gerbeur.example`, who is `bob` here: a mention or a link copied from + * there names them so. Any other handle is returned as it is. The server + * resolves names by it (`resolveUsername`, the mention notifications), and + * the chat's highlight (`mentionsUser`) reads mentions by it. + */ +export function localHandle( + handle: string, + isSelfHost: (host: string) => boolean, +): string { + const { name, host } = splitHandle(handle); + return host && isSelfHost(host) ? name : handle; +} + /** * How often a dump's or a playlist's slug may change, counted from the aliases * the changes left: 3 times a day. Moderators are exempt. slug-service refuses diff --git a/src/test/utils/chat/text.test.ts b/src/test/utils/chat/text.test.ts index fe063dd5..96894f4b 100644 --- a/src/test/utils/chat/text.test.ts +++ b/src/test/utils/chat/text.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { mentionsUser } from "../../../utils/chat/text.ts"; +import { API_URL } from "../../../config/api.ts"; describe("mentionsUser", () => { test("case-insensitive whole-word match", () => { @@ -11,6 +12,14 @@ describe("mentionsUser", () => { expect(mentionsUser("[@alice](x)", "alice")).toBe(false); expect(mentionsUser("(@alice)", "alice")).toBe(false); }); + test("this instance's host, as another instance writes them, is them", () => { + const host = new URL(API_URL).host; + expect(mentionsUser(`hey @Alice@${host}`, "alice")).toBe(true); + expect(mentionsUser("hey @alice@elsewhere.example", "alice")).toBe(false); + expect( + mentionsUser("hey @alice@elsewhere.example", "alice@elsewhere.example"), + ).toBe(true); + }); test("any of several mentions", () => { expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true); }); diff --git a/src/utils/auth/home.ts b/src/utils/auth/home.ts index 52dd9773..55fff4af 100644 --- a/src/utils/auth/home.ts +++ b/src/utils/auth/home.ts @@ -10,6 +10,17 @@ import { API_URL } from "../../config/api.ts"; import { splitHandle } from "../../../shared/validation.ts"; +/** + * Checks whether `host`, as a handle writes it, is this instance's: its API's, + * which handles carry, or its pages'. Another instance writes this one's users + * with it. + */ +export function isSelfHost(host: string): boolean { + const h = host.toLowerCase(); + return h === new URL(API_URL || location.origin).host || + h === location.host; +} + /** * Returns the host of `handle`'s home instance, or undefined for a local * user. diff --git a/src/utils/chat/text.ts b/src/utils/chat/text.ts index 97f639ce..0b0d63a9 100644 --- a/src/utils/chat/text.ts +++ b/src/utils/chat/text.ts @@ -5,12 +5,17 @@ */ import { mentionedUsernames } from "../../../shared/mention.ts"; +import { localHandle } from "../../../shared/validation.ts"; +import { isSelfHost } from "../auth/home.ts"; /** * Checks whether `body` @mentions `username`, ignoring case, with the pattern - * the server uses for mention notifications. `WSProvider` uses it to pick the - * chime, and `ChatModal` to highlight the message. + * the server uses for mention notifications, this instance's own host + * included (`localHandle`). `WSProvider` uses it to pick the chime, and + * `ChatModal` to highlight the message. */ export function mentionsUser(body: string, username: string): boolean { - return mentionedUsernames(body).includes(username.toLowerCase()); + return mentionedUsernames(body) + .map((name) => localHandle(name, isSelfHost)) + .includes(username.toLowerCase()); }