v4: a handle with this instance's own host names the local user — another instance writes bob from here as bob@gerbeur.example, so a mention or a link copied from there named no one here: /@bob@gerbeur.example was a 404, and the mention notified nobody. localHandle (shared/validation.ts) drops this instance's host, its API's or its pages', from a handle; resolveUsername reads names by it, so the page, the API and the Open Graph 301 reach bob at /@bob, and so do the mention notifications and the chat's highlight and chime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-09 22:11:39 +00:00
parent 6dfba07341
commit 9608520675
11 changed files with 79 additions and 12 deletions

4
API.md
View File

@@ -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

View File

@@ -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;

View File

@@ -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);
}
/**

View File

@@ -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);

View File

@@ -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<PublicUser>(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<SelfUser>("/api/me", { auth: user.auth });

View File

@@ -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<Comment>(`/api/dumps/${dumpId}/comments`, {

View File

@@ -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"), {

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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.

View File

@@ -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());
}