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:
4
API.md
4
API.md
@@ -64,7 +64,9 @@ page to a comment.
|
|||||||
- **A user from another instance** is `name@host` here: their name at home and
|
- **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
|
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.
|
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
|
- **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
|
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
|
can take one. An item can take back one of its own. Deleting an item frees its
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||||
import { sendToUser } from "./ws-service.ts";
|
import { sendToUser } from "./ws-service.ts";
|
||||||
import { mentionedUsernames } from "../../shared/mention.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. */
|
/** Each id of a user a notification names, beside the field of their name. */
|
||||||
const USER_FIELDS = [
|
const USER_FIELDS = [
|
||||||
@@ -359,7 +361,7 @@ export function notifyMentions(
|
|||||||
for (const username of usernames) {
|
for (const username of usernames) {
|
||||||
const mentionedRow = db.prepare(
|
const mentionedRow = db.prepare(
|
||||||
`SELECT id FROM users WHERE lower(username) = ?;`,
|
`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 (!mentionedRow || mentionedRow.id === mentionerUserId) continue;
|
||||||
if (skipUserId?.(mentionedRow.id)) continue;
|
if (skipUserId?.(mentionedRow.id)) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ import { LIKE, likePattern } from "../lib/like.ts";
|
|||||||
import { parseRows } from "../model/rows.ts";
|
import { parseRows } from "../model/rows.ts";
|
||||||
import { isEmailEnabled, sendEmail } from "./email-service.ts";
|
import { isEmailEnabled, sendEmail } from "./email-service.ts";
|
||||||
import { renderWelcomeEmail } from "../mail/welcome.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";
|
import { EVERYONE } from "../../shared/mention.ts";
|
||||||
|
|
||||||
/** Selects full `UserRow`s from `users u`, with the inviter's name joined. */
|
/** 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
|
* Returns the user who is or was named `username`, in any case, or by the
|
||||||
* API path naming a user by a former name reaches them, and the caller sends
|
* handle another instance gives them (`localHandle`): a page or an API path
|
||||||
* the client on to the current one.
|
* 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.
|
* @throws {APIException} 404 when nobody is or was.
|
||||||
*/
|
*/
|
||||||
export function resolveUsername(username: string): User {
|
export function resolveUsername(username: string): User {
|
||||||
|
const name = localHandle(username, isSelfHost);
|
||||||
const alias = db.prepare(
|
const alias = db.prepare(
|
||||||
`SELECT user_id FROM user_aliases WHERE lower(username) = lower(?);`,
|
`SELECT user_id FROM user_aliases WHERE lower(username) = lower(?);`,
|
||||||
).get(username) as { user_id: string } | undefined;
|
).get(name) as { user_id: string } | undefined;
|
||||||
return alias ? getUserById(alias.user_id) : getUserByUsername(username);
|
return alias ? getUserById(alias.user_id) : getUserByUsername(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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/dumps/now`, dump.path],
|
||||||
[`/@old_name/follow/playlists`, `/@${user.username}/follow/playlists`],
|
[`/@old_name/follow/playlists`, `/@${user.username}/follow/playlists`],
|
||||||
[`/@${user.username.toUpperCase()}`, `/@${user.username}`],
|
[`/@${user.username.toUpperCase()}`, `/@${user.username}`],
|
||||||
|
[`/@${user.username}@gerbeur.test/upvoted`, `/@${user.username}/upvoted`],
|
||||||
]
|
]
|
||||||
) {
|
) {
|
||||||
const { res } = await handle(ogApp(), from);
|
const { res } = await handle(ogApp(), from);
|
||||||
|
|||||||
@@ -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}`,
|
||||||
`/api/users/@${user.username.toUpperCase()}`,
|
`/api/users/@${user.username.toUpperCase()}`,
|
||||||
`/api/users/${user.id}`,
|
`/api/users/${user.id}`,
|
||||||
|
// As another instance writes them.
|
||||||
|
`/api/users/@${user.username}@gerbeur.test`,
|
||||||
]
|
]
|
||||||
) {
|
) {
|
||||||
const res = await get<PublicUser>(path);
|
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);
|
assertFalse("passwordHash" in res.data);
|
||||||
}
|
}
|
||||||
assertEquals((await get(`/api/users/@no_one_here`)).status, 404);
|
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.
|
// Without the `@`, the key is an id.
|
||||||
assertEquals((await get(`/api/users/${user.username}`)).status, 404);
|
assertEquals((await get(`/api/users/${user.username}`)).status, 404);
|
||||||
const me = await get<SelfUser>("/api/me", { auth: user.auth });
|
const me = await get<SelfUser>("/api/me", { auth: user.auth });
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ test("mentions: notify the named, once per context, never the author or an email
|
|||||||
const named = await createUser();
|
const named = await createUser();
|
||||||
const { id: dumpId } = insertDump({ userId: author.id, title: "Topic" });
|
const { id: dumpId } = insertDump({ userId: author.id, title: "Topic" });
|
||||||
const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` +
|
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, ` +
|
`me @${author.username}, mail x@${named.username}.com, ` +
|
||||||
`[@${named.username}](link) @nobody_known`;
|
`[@${named.username}](link) @nobody_known`;
|
||||||
const c = await post<Comment>(`/api/dumps/${dumpId}/comments`, {
|
const c = await post<Comment>(`/api/dumps/${dumpId}/comments`, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
isFederationEvent,
|
isFederationEvent,
|
||||||
parseInstance,
|
parseInstance,
|
||||||
} from "../federation.ts";
|
} 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", () => {
|
Deno.test("parseInstance: a host or a URL names an instance's origin", () => {
|
||||||
assertEquals(parseInstance("music.example"), "https://music.example");
|
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");
|
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", () => {
|
Deno.test("handles: a name, and a host for a user from another instance", () => {
|
||||||
assertEquals(splitHandle("bob"), { name: "bob" });
|
assertEquals(splitHandle("bob"), { name: "bob" });
|
||||||
assertEquals(splitHandle("alice@music.example"), {
|
assertEquals(splitHandle("alice@music.example"), {
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ export function splitHandle(handle: string): { name: string; host?: string } {
|
|||||||
: { name: handle.slice(0, at), host: handle.slice(at + 1) };
|
: { 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
|
* 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
|
* the changes left: 3 times a day. Moderators are exempt. slug-service refuses
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { mentionsUser } from "../../../utils/chat/text.ts";
|
import { mentionsUser } from "../../../utils/chat/text.ts";
|
||||||
|
import { API_URL } from "../../../config/api.ts";
|
||||||
|
|
||||||
describe("mentionsUser", () => {
|
describe("mentionsUser", () => {
|
||||||
test("case-insensitive whole-word match", () => {
|
test("case-insensitive whole-word match", () => {
|
||||||
@@ -11,6 +12,14 @@ describe("mentionsUser", () => {
|
|||||||
expect(mentionsUser("[@alice](x)", "alice")).toBe(false);
|
expect(mentionsUser("[@alice](x)", "alice")).toBe(false);
|
||||||
expect(mentionsUser("(@alice)", "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", () => {
|
test("any of several mentions", () => {
|
||||||
expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true);
|
expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
import { API_URL } from "../../config/api.ts";
|
import { API_URL } from "../../config/api.ts";
|
||||||
import { splitHandle } from "../../../shared/validation.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
|
* Returns the host of `handle`'s home instance, or undefined for a local
|
||||||
* user.
|
* user.
|
||||||
|
|||||||
@@ -5,12 +5,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { mentionedUsernames } from "../../../shared/mention.ts";
|
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
|
* Checks whether `body` @mentions `username`, ignoring case, with the pattern
|
||||||
* the server uses for mention notifications. `WSProvider` uses it to pick the
|
* the server uses for mention notifications, this instance's own host
|
||||||
* chime, and `ChatModal` to highlight the message.
|
* included (`localHandle`). `WSProvider` uses it to pick the chime, and
|
||||||
|
* `ChatModal` to highlight the message.
|
||||||
*/
|
*/
|
||||||
export function mentionsUser(body: string, username: string): boolean {
|
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());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user