diff --git a/packages/backend/test-federation/test/api/ap/show.test.ts b/packages/backend/test-federation/test/api/ap/show.test.ts new file mode 100644 index 0000000000..eb949754d7 --- /dev/null +++ b/packages/backend/test-federation/test/api/ap/show.test.ts @@ -0,0 +1,55 @@ +import { strictEqual, rejects } from 'node:assert'; +import * as Misskey from 'misskey-js'; +import { createAccount, resolveRemoteUser, sleep, type LoginUser } from '../../utils.js'; + +describe('API ap/show', () => { + let alice: LoginUser, bob: LoginUser; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + }); + + describe('User resolution', () => { + test('resolve by canonical user URL (https://b.test/users/:id)', async () => { + const res = await alice.client.request('ap/show', { uri: `https://b.test/users/${bob.id}` }); + strictEqual(res.type, 'User'); + strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); + }); + + test('resolve by user profile URL (https://b.test/@bob)', async () => { + const res = await alice.client.request('ap/show', { uri: `https://b.test/@${bob.username}` }); + strictEqual(res.type, 'User'); + strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); + }); + + test('resolve local user by local profile url', async () => { + const res = await alice.client.request('ap/show', { uri: `https://a.test/@${alice.username}` }); + strictEqual(res.type, 'User'); + strictEqual(res.object.id, alice.id); + }); + + test('resolve remote user by local profile URL (https://a.test/@bob@b.test)', async () => { + const res = await alice.client.request('ap/show', { uri: `https://a.test/@${bob.username}@b.test` }); + strictEqual(res.type, 'User'); + strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); + }); + }); + + describe('Note resolution', () => { + test('resolve by note URL (https://b.test/notes/:id)', async () => { + const note = (await bob.client.request('notes/create', { text: 'hello from Bob' })).createdNote; + // 伝搬待ち + await sleep(); + + const res = await alice.client.request('ap/show', { uri: `https://b.test/notes/${note.id}` }); + strictEqual(res.type, 'Note'); + strictEqual(res.object.uri, `https://b.test/notes/${note.id}`); + // 投稿者が a.test 側で解決済みの Bob になること + strictEqual(res.object.user.username, bob.username); + strictEqual(res.object.user.host, 'b.test'); + }); + }); +}); diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts index ebbe9ff5ba..3b9722975d 100644 --- a/packages/backend/test-federation/test/user.test.ts +++ b/packages/backend/test-federation/test/user.test.ts @@ -516,7 +516,7 @@ describe('User', () => { await rejects( async () => await resolveRemoteUser('a.test', alice.id, bob), (err: any) => { - strictEqual(err.code, 'INTERNAL_ERROR'); + strictEqual(err.code, 'SOMETHING_HAPPENED_IN_FETCHING_URI'); return true; }, ); @@ -551,7 +551,7 @@ describe('User', () => { await rejects( async () => await resolveRemoteUser('a.test', alice.id, bob), (err: any) => { - strictEqual(err.code, 'INTERNAL_ERROR'); + strictEqual(err.code, 'SOMETHING_HAPPENED_IN_FETCHING_URI'); return true; }, ); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 3388a3d9a9..423b3be916 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -23,9 +23,13 @@ export type Acct = { declare namespace acct { export { + correctAcct, parse, + parseUrl, + parseAcctOrUrl, toString_2 as toString, - Acct + Acct, + UrlIsNotAcctLikeError } } export { acct } @@ -1229,6 +1233,9 @@ type ClipsUpdateRequest = operations['clips___update']['requestBody']['content'] // @public (undocumented) type ClipsUpdateResponse = operations['clips___update']['responses']['200']['content']['application/json']; +// @public (undocumented) +function correctAcct(acct: Acct, localHostname?: string): Acct; + // @public (undocumented) type DateString = string; @@ -3261,7 +3268,13 @@ type PagesUnlikeRequest = operations['pages___unlike']['requestBody']['content'] type PagesUpdateRequest = operations['pages___update']['requestBody']['content']['application/json']; // @public (undocumented) -function parse(_acct: string): Acct; +function parse(acct: string): Acct; + +// @public (undocumented) +function parseAcctOrUrl(acct: string, localHostname?: string): Acct; + +// @public (undocumented) +function parseUrl(str: string): Acct; // Warning: (ae-forgotten-export) The symbol "Values" needs to be exported by the entry point index.d.ts // @@ -3628,6 +3641,11 @@ type TestResponse = operations['test']['responses']['200']['content']['applicati // @public (undocumented) function toString_2(acct: Acct): string; +// @public (undocumented) +class UrlIsNotAcctLikeError extends Error { + constructor(); +} + // @public (undocumented) type User = components['schemas']['User']; diff --git a/packages/misskey-js/src/acct.ts b/packages/misskey-js/src/acct.ts index aa8658cdbd..06dcd6fa2c 100644 --- a/packages/misskey-js/src/acct.ts +++ b/packages/misskey-js/src/acct.ts @@ -3,13 +3,77 @@ export type Acct = { host: string | null; }; -export function parse(_acct: string): Acct { - let acct = _acct; - if (acct.startsWith('@')) acct = acct.substring(1); - const split = acct.split('@', 2); +export function correctAcct(acct: Acct, localHostname?: string): Acct { + const result = { ...acct }; + if (!acct.host) { + result.host = null; + } else if (localHostname && acct.host === localHostname) { + result.host = null; + } + return result; +} + +export function parse(acct: string): Acct { + let acctWithNoPrefix = acct; + + if (acct.startsWith('@')) { + acctWithNoPrefix = acct.substring(1); + } else if (acct.startsWith('acct:')) { + acctWithNoPrefix = acct.substring(5); + } + + const split = acctWithNoPrefix.split('@', 2); + return { username: split[0], host: split[1] || null }; } +export class UrlIsNotAcctLikeError extends Error { + constructor() { + super('This url is not acct like.'); + } +} + +/** + * Only supports `https?://example.com/@username` or `https?://example.com/@username@other.example.com` + */ +export function parseUrl(str: string): Acct { + const url = new URL(str); + + if (url.hash.length > 0) throw new UrlIsNotAcctLikeError(); + if (url.search.length > 0) throw new UrlIsNotAcctLikeError(); + + const splited = url.pathname.split('/'); + const path = splited[1]; + + if (!path) throw new UrlIsNotAcctLikeError(); + if (!path.startsWith('@')) throw new UrlIsNotAcctLikeError(); + if (path.length <= 1) throw new UrlIsNotAcctLikeError(); + + const split = path.split('@', 3); // ['', 'username', 'other.example.com'] + + return { username: split[1], host: split[2] || url.hostname }; +} + +/** + * Parses a string and returns an Acct object. + * @param acct String to parse + * The string should be in one of the following formats: + * * At-mark style: `@username@example.com` + * * Starts with `acct:`: `acct:username@example.com` + * * URL style: `https://example.com/@username`, `https://self.example.com/@username@other.example.com` + * @param localHostname If provided and matches the host found in acct, the returned `host` will be set to `null`. + * @returns Acct object + */ +export function parseAcctOrUrl(acct: string, localHostname?: string): Acct { + if (acct.startsWith('https://') || acct.startsWith('http://')) { + // url style + return correctAcct(parseUrl(acct), localHostname); + } + + // acct style + return correctAcct(parse(acct), localHostname); +} + export function toString(acct: Acct): string { return acct.host == null ? acct.username : `${acct.username}@${acct.host}`; } diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index d92ef599f3..da04e975d6 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -13554,6 +13554,7 @@ export interface operations { content: { 'application/json': { uri: string; + onlyUriFetch?: boolean; }; }; }; diff --git a/packages/misskey-js/test/acct.ts b/packages/misskey-js/test/acct.ts new file mode 100644 index 0000000000..4200e593de --- /dev/null +++ b/packages/misskey-js/test/acct.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import * as acct from '../src/acct.js'; + +function testParseAcct(fn: (acct: string) => acct.Acct) { + it('parses plain username', () => { + const res = fn('alice'); + expect(res).toEqual({ username: 'alice', host: null }); + }); + + it('parses at-mark style without host', () => { + const res = fn('@alice'); + expect(res).toEqual({ username: 'alice', host: null }); + }); + + it('parses at-mark style with host', () => { + const res = fn('@alice@example.com'); + expect(res).toEqual({ username: 'alice', host: 'example.com' }); + }); + + it('parses acct: style', () => { + const res = fn('acct:alice@example.com'); + expect(res).toEqual({ username: 'alice', host: 'example.com' }); + }); + + it('parse Mr.http', () => { + const res = fn('http'); + expect(res).toEqual({ username: 'http', host: null }); + }); +} + +function testParseUrl(fn: (acct: string) => acct.Acct) { + it('parses url style https with same host -> host kept when localHostname not provided', () => { + const res = fn('https://example.com/@alice'); + expect(res).toEqual({ username: 'alice', host: 'example.com' }); + }); + + it('parses url style with remote host contained in path', () => { + const res = fn('https://self.example.com/@alice@other.example.com'); + expect(res).toEqual({ username: 'alice', host: 'other.example.com' }); + }); + + it('throws on non-acct-like url path (root)', () => { + expect(() => fn('https://example.com')).toThrowError(); + }); + + it('throws on non-acct-like url path (users/alice)', () => { + expect(() => fn('https://example.com/users/alice')).toThrowError(); + }); + + it('throws ended with @', () => { + expect(() => fn('https://example.com/@')).toThrowError(); + }); + + it('parses url ended with /', () => { + const res = fn('https://example.com/@alice/'); + expect(res).toEqual({ username: 'alice', host: 'example.com' }); + }); + + it('throws url have @username path but ended with sub directory', () => { + expect(() => fn('https://example.com/@alice/subdir')).toThrowError(); + }); + + it('throws url with search params', () => { + expect(() => fn('https://example.com/@alice?foo=bar')).toThrowError(); + }); + + it('throws url with hash', () => { + expect(() => fn('https://example.com/@alice#fragment')).toThrowError(); + }); +} + +describe('acct.parse', () => { + testParseAcct(acct.parse); +}); + +describe('acct.parseUrl', () => { + testParseUrl(acct.parseUrl); +}); + +describe('acct.parseAcctOrUrl', () => { + testParseAcct(acct.parseAcctOrUrl); + testParseUrl(acct.parseAcctOrUrl); + + it('parse url with localHostname', () => { + const res = acct.parseAcctOrUrl('https://example.com/@alice', 'example.com'); + expect(res).toEqual({ username: 'alice', host: null }); + }); + + it('parse @username with localHostname', () => { + const res = acct.parseAcctOrUrl('@alice', 'example.com'); + expect(res).toEqual({ username: 'alice', host: null }); + }); +}); + +describe('acct.correctAcct', () => { + it('returns host=null when acct.host is null', () => { + const input: acct.Acct = { username: 'alice', host: null }; + const out = acct.correctAcct(input); + expect(out).toEqual({ username: 'alice', host: null }); + expect(out).not.toBe(input); // immutability + }); + + it('keeps host when localHostname not provided', () => { + const input: acct.Acct = { username: 'bob', host: 'example.com' }; + const out = acct.correctAcct(input); + expect(out).toEqual({ username: 'bob', host: 'example.com' }); + }); + + it('nulls host when it matches localHostname', () => { + const input: acct.Acct = { username: 'carol', host: 'example.com' }; + const out = acct.correctAcct(input, 'example.com'); + expect(out).toEqual({ username: 'carol', host: null }); + }); + + it('keeps host when it differs from localHostname', () => { + const input: acct.Acct = { username: 'dave', host: 'other.example.com' }; + const out = acct.correctAcct(input, 'example.com'); + expect(out).toEqual({ username: 'dave', host: 'other.example.com' }); + }); +}); + +describe('acct.toString', () => { + it('returns username when host is null', () => { + expect(acct.toString({ username: 'alice', host: null })).toBe('alice'); + }); + + it('returns username@host when host exists', () => { + expect(acct.toString({ username: 'alice', host: 'example.com' })).toBe('alice@example.com'); + }); +});