From 14f5456ef043d15709db4dcbfdbbf5eb58e256d6 Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 26 Aug 2025 14:12:39 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat(misskey-js):=20acct.parse=E3=81=8CUR?= =?UTF-8?q?L=E3=82=92=E5=8F=97=E3=81=91=E4=BB=98=E3=81=91=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB,=20acct.ts=E3=81=AE=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/misskey-js/src/acct.ts | 49 ++++++++++++++++++--- packages/misskey-js/test/acct.ts | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 packages/misskey-js/test/acct.ts diff --git a/packages/misskey-js/src/acct.ts b/packages/misskey-js/src/acct.ts index aa8658cdbd..8d73d3ea09 100644 --- a/packages/misskey-js/src/acct.ts +++ b/packages/misskey-js/src/acct.ts @@ -3,11 +3,50 @@ 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); - return { username: split[0], host: split[1] || null }; +/** + * 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 parse(acct: string, localHostname?: string): Acct { + //#region url style + if (acct.startsWith('https://') || acct.startsWith('http://')) { + const url = new URL(acct); + const path = url.pathname.split('/').find((p) => p.startsWith('@') && p.length >= 2); + if (!path) throw new Error('This url is not acct like.'); + + const split = path.split('@', 3); // ['', 'username', 'other.example.com'] + + let host: string | null = split[2] || url.hostname; + if (localHostname && host === localHostname) { + host = null; + } + + return { username: split[1], host: host }; + } + //#endregion + + //#region at-mark and acct: style + let acctWithNoPrefix = acct; + if (acct.startsWith('@')) { + acctWithNoPrefix = acct.substring(1); + } else if (acct.startsWith('acct:')) { + acctWithNoPrefix = acct.substring(5); + } + const split = acctWithNoPrefix.split('@', 2); + + let host: string | null = split[1] || null; + if (localHostname && host === localHostname) { + host = null; + } + + return { username: split[0], host }; + //#endregion } export function toString(acct: Acct): string { diff --git a/packages/misskey-js/test/acct.ts b/packages/misskey-js/test/acct.ts new file mode 100644 index 0000000000..7727565480 --- /dev/null +++ b/packages/misskey-js/test/acct.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import * as acct from '../src/acct.js'; + +describe('acct.parse', () => { + it('parses plain username', () => { + const res = acct.parse('alice'); + expect(res).toEqual({ username: 'alice', host: null }); + }); + + it('parses at-mark style without host', () => { + const res = acct.parse('@alice'); + expect(res).toEqual({ username: 'alice', host: null }); + }); + + it('parses at-mark style with host', () => { + const res = acct.parse('@alice@example.com'); + expect(res).toEqual({ username: 'alice', host: 'example.com' }); + }); + + it('nulls host for at-mark style when localHostname matches', () => { + const res = acct.parse('@alice@example.com', 'example.com'); + expect(res).toEqual({ username: 'alice', host: null }); + }); + + it('parses acct: style', () => { + const res = acct.parse('acct:alice@example.com'); + expect(res).toEqual({ username: 'alice', host: 'example.com' }); + }); + + it('nulls host for acct: style when localHostname matches', () => { + const res = acct.parse('acct:alice@example.com', 'example.com'); + expect(res).toEqual({ username: 'alice', host: null }); + }); + + it('parses url style https with same host -> host kept when localHostname not provided', () => { + const res = acct.parse('https://example.com/@alice'); + expect(res).toEqual({ username: 'alice', host: 'example.com' }); + }); + + it('parses url style http with same host and nulls host when localHostname matches', () => { + const res = acct.parse('http://example.com/@alice', 'example.com'); + expect(res).toEqual({ username: 'alice', host: null }); + }); + + it('parses url style with remote host contained in path', () => { + const res = acct.parse('https://self.example.com/@alice@other.example.com'); + expect(res).toEqual({ username: 'alice', host: 'other.example.com' }); + }); + + it('nulls host when localHostname matches the remote host in path', () => { + const res = acct.parse('https://self.example.com/@alice@other.example.com', 'other.example.com'); + expect(res).toEqual({ username: 'alice', host: null }); + }); + + it('throws on non-acct-like url path', () => { + expect(() => acct.parse('https://example.com/users/alice')).toThrowError(); + }); + + it('parses correctly Mr.http', () => { + const res = acct.parse('http'); + expect(res).toEqual({ username: 'http', host: null }); + }); +}); + +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'); + }); +}); + + From a8eff662e326d13861dd22cf5c04d1b4a9179eab Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 26 Aug 2025 14:28:43 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat(misskey-js):=20acct.parse=E3=81=8CUR?= =?UTF-8?q?L=E3=82=92=E5=8F=97=E3=81=91=E4=BB=98=E3=81=91=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB,=20acct.ts=E3=81=AE=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/misskey-js/src/acct.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/misskey-js/src/acct.ts b/packages/misskey-js/src/acct.ts index 8d73d3ea09..392c5ca947 100644 --- a/packages/misskey-js/src/acct.ts +++ b/packages/misskey-js/src/acct.ts @@ -3,6 +3,16 @@ export type Acct = { host: string | null; }; +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; +} + /** * Parses a string and returns an Acct object. * @param acct String to parse @@ -22,12 +32,7 @@ export function parse(acct: string, localHostname?: string): Acct { const split = path.split('@', 3); // ['', 'username', 'other.example.com'] - let host: string | null = split[2] || url.hostname; - if (localHostname && host === localHostname) { - host = null; - } - - return { username: split[1], host: host }; + return correctAcct({ username: split[1], host: split[2] || url.hostname }, localHostname); } //#endregion @@ -40,12 +45,7 @@ export function parse(acct: string, localHostname?: string): Acct { } const split = acctWithNoPrefix.split('@', 2); - let host: string | null = split[1] || null; - if (localHostname && host === localHostname) { - host = null; - } - - return { username: split[0], host }; + return correctAcct({ username: split[0], host: split[1] || null }, localHostname); //#endregion } From 0dbaa6f2ae1cf0c01b78c30801aaad4c28ea3e5c Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 26 Aug 2025 14:39:33 +0900 Subject: [PATCH 03/20] api --- packages/misskey-js/etc/misskey-js.api.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 170c20f163..ef4f3ae8c2 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -23,6 +23,7 @@ export type Acct = { declare namespace acct { export { + correctAcct, parse, toString_2 as toString, Acct @@ -1229,6 +1230,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; @@ -3253,7 +3257,7 @@ 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, localHostname?: string): Acct; // Warning: (ae-forgotten-export) The symbol "Values" needs to be exported by the entry point index.d.ts // From c7902feb49e39848c476a399d745f8dccddbfc64 Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 26 Aug 2025 14:46:50 +0900 Subject: [PATCH 04/20] clean up --- packages/misskey-js/test/acct.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/misskey-js/test/acct.ts b/packages/misskey-js/test/acct.ts index 7727565480..e0ebc0551b 100644 --- a/packages/misskey-js/test/acct.ts +++ b/packages/misskey-js/test/acct.ts @@ -71,5 +71,3 @@ describe('acct.toString', () => { expect(acct.toString({ username: 'alice', host: 'example.com' })).toBe('alice@example.com'); }); }); - - From 71783d8f063ee1216bf666d743fbd842e4a92da4 Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 26 Aug 2025 21:51:17 +0900 Subject: [PATCH 05/20] iikanji --- packages/misskey-js/src/acct.ts | 50 +++++++++++-------- packages/misskey-js/test/acct.ts | 86 ++++++++++++++++++++++---------- 2 files changed, 88 insertions(+), 48 deletions(-) diff --git a/packages/misskey-js/src/acct.ts b/packages/misskey-js/src/acct.ts index 392c5ca947..74e0ff06f0 100644 --- a/packages/misskey-js/src/acct.ts +++ b/packages/misskey-js/src/acct.ts @@ -13,6 +13,30 @@ export function correctAcct(acct: Acct, localHostname?: string): Acct { 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 function parseUrl(str: string): Acct { + const url = new URL(str); + const path = url.pathname.split('/').find((p) => p.startsWith('@') && p.length >= 2); + if (!path) throw new Error('This url is not acct like.'); + + 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 @@ -23,30 +47,14 @@ export function correctAcct(acct: Acct, localHostname?: string): Acct { * @param localHostname If provided and matches the host found in acct, the returned `host` will be set to `null`. * @returns Acct object */ -export function parse(acct: string, localHostname?: string): Acct { - //#region url style +export function parseAcctOrUrl(acct: string, localHostname?: string): Acct { if (acct.startsWith('https://') || acct.startsWith('http://')) { - const url = new URL(acct); - const path = url.pathname.split('/').find((p) => p.startsWith('@') && p.length >= 2); - if (!path) throw new Error('This url is not acct like.'); - - const split = path.split('@', 3); // ['', 'username', 'other.example.com'] - - return correctAcct({ username: split[1], host: split[2] || url.hostname }, localHostname); + // url style + return correctAcct(parseUrl(acct), localHostname); } - //#endregion - //#region at-mark and acct: style - 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 correctAcct({ username: split[0], host: split[1] || null }, localHostname); - //#endregion + // acct style + return correctAcct(parse(acct), localHostname); } export function toString(acct: Acct): string { diff --git a/packages/misskey-js/test/acct.ts b/packages/misskey-js/test/acct.ts index e0ebc0551b..fd59a82c9d 100644 --- a/packages/misskey-js/test/acct.ts +++ b/packages/misskey-js/test/acct.ts @@ -1,64 +1,96 @@ import { describe, it, expect } from 'vitest'; import * as acct from '../src/acct.js'; -describe('acct.parse', () => { +function testParseAcct(fn: (acct: string) => acct.Acct) { it('parses plain username', () => { - const res = acct.parse('alice'); + const res = fn('alice'); expect(res).toEqual({ username: 'alice', host: null }); }); it('parses at-mark style without host', () => { - const res = acct.parse('@alice'); + const res = fn('@alice'); expect(res).toEqual({ username: 'alice', host: null }); }); it('parses at-mark style with host', () => { - const res = acct.parse('@alice@example.com'); + const res = fn('@alice@example.com'); expect(res).toEqual({ username: 'alice', host: 'example.com' }); }); - it('nulls host for at-mark style when localHostname matches', () => { - const res = acct.parse('@alice@example.com', 'example.com'); - expect(res).toEqual({ username: 'alice', host: null }); - }); - it('parses acct: style', () => { - const res = acct.parse('acct:alice@example.com'); + const res = fn('acct:alice@example.com'); expect(res).toEqual({ username: 'alice', host: 'example.com' }); }); - it('nulls host for acct: style when localHostname matches', () => { - const res = acct.parse('acct:alice@example.com', 'example.com'); - expect(res).toEqual({ username: 'alice', host: null }); + 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 = acct.parse('https://example.com/@alice'); + const res = fn('https://example.com/@alice'); expect(res).toEqual({ username: 'alice', host: 'example.com' }); }); - it('parses url style http with same host and nulls host when localHostname matches', () => { - const res = acct.parse('http://example.com/@alice', 'example.com'); - expect(res).toEqual({ username: 'alice', host: null }); - }); - it('parses url style with remote host contained in path', () => { - const res = acct.parse('https://self.example.com/@alice@other.example.com'); + const res = fn('https://self.example.com/@alice@other.example.com'); expect(res).toEqual({ username: 'alice', host: 'other.example.com' }); }); - it('nulls host when localHostname matches the remote host in path', () => { - const res = acct.parse('https://self.example.com/@alice@other.example.com', 'other.example.com'); + it('throws on non-acct-like url path', () => { + expect(() => fn('https://example.com/users/alice')).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('throws on non-acct-like url path', () => { - expect(() => acct.parse('https://example.com/users/alice')).toThrowError(); + 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('parses correctly Mr.http', () => { - const res = acct.parse('http'); - expect(res).toEqual({ username: 'http', host: null }); + 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' }); }); }); From 22e8fa4cba1139bd8f8ffbc8ba93595c87dc8804 Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 26 Aug 2025 22:02:21 +0900 Subject: [PATCH 06/20] api --- packages/misskey-js/etc/misskey-js.api.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index ef4f3ae8c2..cd2ca0db32 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -25,6 +25,8 @@ declare namespace acct { export { correctAcct, parse, + parseUrl, + parseAcctOrUrl, toString_2 as toString, Acct } @@ -3257,7 +3259,13 @@ type PagesUnlikeRequest = operations['pages___unlike']['requestBody']['content'] type PagesUpdateRequest = operations['pages___update']['requestBody']['content']['application/json']; // @public (undocumented) -function parse(acct: string, localHostname?: 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 // From 3bf39a5c99d9eab087632b2d0395c59ed969c5a2 Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 28 Aug 2025 10:38:41 +0900 Subject: [PATCH 07/20] build-misskey-js-with-types --- packages/misskey-js/src/autogen/types.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index be24e3ec5f..908a9289f3 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -9329,8 +9329,10 @@ export interface operations { mcaptchaSecretKey: string | null; recaptchaSecretKey: string | null; turnstileSecretKey: string | null; - sensitiveMediaDetection: string; - sensitiveMediaDetectionSensitivity: string; + /** @enum {string} */ + sensitiveMediaDetection: 'none' | 'all' | 'local' | 'remote'; + /** @enum {string} */ + sensitiveMediaDetectionSensitivity: 'medium' | 'low' | 'high' | 'veryLow' | 'veryHigh'; setSensitiveFlagAutomatically: boolean; enableSensitiveMediaDetectionForVideos: boolean; /** Format: id */ From b1d8a092e56c9cbbbc30fb25cd38e3448268ac00 Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 28 Aug 2025 11:42:43 +0900 Subject: [PATCH 08/20] wip --- .../src/server/api/endpoints/ap/show.ts | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 4afed7dc5c..a7e6aa5b3f 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; +import * as misskey from 'misskey-js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiNote } from '@/models/Note.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -21,6 +22,9 @@ import { bindThis } from '@/decorators.js'; import { ApiError } from '../../error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { DI } from '@/di-symbols.js'; +import type { MiMeta } from '@/models/_.js'; export const meta = { tags: ['federation'], @@ -59,6 +63,11 @@ export const meta = { code: 'NO_SUCH_OBJECT', id: 'dc94d745-1262-4e63-a17d-fecaa57efc82', }, + somethingHappenedInFetchingUri: { + message: 'Something happened while fetching the URI.', + code: 'SOMETHING_HAPPENED_IN_FETCHING_URI', + id: '14d45054-9df7-4f85-9e60-343b22f16b05, + }, }, res: { @@ -102,6 +111,7 @@ export const paramDef = { type: 'object', properties: { uri: { type: 'string' }, + onlyUriFetch: { type: 'boolean', optional: true, nullable: false }, }, required: ['uri'], } as const; @@ -109,6 +119,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private utilityService: UtilityService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, @@ -116,9 +129,31 @@ export default class extends Endpoint { // eslint- private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, private apNoteService: ApNoteService, + private remoteUserResolveService: RemoteUserResolveService, ) { super(meta, paramDef, async (ps, me) => { - const object = await this.fetchAny(ps.uri, me); + let object: SchemaType | null; + + if (!ps.onlyUriFetch) { + try { + object = await this.fetchAcct(misskey.acct.parseAcctOrUrl(ps.uri), me); + } catch (err) { + if (err instanceof IdentifiableError && err.id === 'bddd9f4c-f0a8-4cac-9c0a-4e6d2fc43408') { + // Signin required + throw new ApiError(meta.errors.noSuchObject); + } + } + } + + try { + object = await this.fetchAnyUri(ps.uri, me); + } catch (err) { + if (err instanceof ApiError) { + throw err; + } + throw new ApiError(meta.errors.somethingHappenedInFetchingUri); + } + if (object) { return object; } else { @@ -127,11 +162,26 @@ export default class extends Endpoint { // eslint- }); } - /*** + private async fetchAcct(acct: misskey.acct.Acct, me: MiLocalUser | null | undefined): Promise | null> { + if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) { + throw new IdentifiableError('bddd9f4c-f0a8-4cac-9c0a-4e6d2fc43408', 'Signin required'); + } + + const user = await this.remoteUserResolveService.resolveUser(acct.username, acct.host); + + if (!user) return null; + + return { + type: 'User', + object: await this.userEntityService.pack(user, me, { schema: 'UserDetailed' }), + } + } + + /** * URIからUserかNoteを解決する */ @bindThis - private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise | null> { + private async fetchAnyUri(uri: string, me: MiLocalUser | null | undefined): Promise | null> { if (!this.utilityService.isFederationAllowedUri(uri)) { throw new ApiError(meta.errors.federationNotAllowed); } @@ -174,7 +224,6 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.responseInvalid); } } - throw new ApiError(meta.errors.requestFailed); }); From 70e57ccf32000f1a5b3f4241f6a8136fdeeab3e4 Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 28 Aug 2025 22:12:33 +0900 Subject: [PATCH 09/20] wip --- .../src/server/api/endpoints/ap/show.ts | 2 +- .../test-federation/test/api/ap/show.test.ts | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 packages/backend/test-federation/test/api/ap/show.test.ts diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index a7e6aa5b3f..f62baf79e1 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -66,7 +66,7 @@ export const meta = { somethingHappenedInFetchingUri: { message: 'Something happened while fetching the URI.', code: 'SOMETHING_HAPPENED_IN_FETCHING_URI', - id: '14d45054-9df7-4f85-9e60-343b22f16b05, + id: '14d45054-9df7-4f85-9e60-343b22f16b05', }, }, 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..7c35026269 --- /dev/null +++ b/packages/backend/test-federation/test/api/ap/show.test.ts @@ -0,0 +1,66 @@ +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; + let bobInA: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + // 事前に bob を a.test に解決してキャッシュ・同一性を取る + bobInA = await resolveRemoteUser('b.test', bob.id, alice); + }); + + describe('User resolution', () => { + test('resolve by acct (bob@b.test)', async () => { + const res = await alice.client.request('ap/show', { uri: `${bob.username}@b.test` }); + strictEqual(res.type, 'User'); + strictEqual(res.object.id, bobInA.id); + strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); + }); + + 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.id, bobInA.id); + strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); + }); + + test('resolve by cross-origin non-canonical 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'); + // 非正規URLから正規IDに追従して同一ユーザーになること + strictEqual(res.object.id, bobInA.id); + strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); + }); + + test('onlyUriFetch=true with acct string should fail with URI_INVALID', async () => { + await rejects( + async () => await alice.client.request('ap/show', { uri: `${bob.username}@b.test`, onlyUriFetch: true }), + (err: any) => { + strictEqual(err.code, 'URI_INVALID'); + return true; + }, + ); + }); + }); + + 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.userId, bobInA.id); + }); + }); +}); From 92dd1347c44a6517915111326c177efedbd7efea Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 28 Aug 2025 22:18:46 +0900 Subject: [PATCH 10/20] fix --- packages/backend/src/server/api/endpoints/ap/show.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index f62baf79e1..b89687acce 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -111,7 +111,7 @@ export const paramDef = { type: 'object', properties: { uri: { type: 'string' }, - onlyUriFetch: { type: 'boolean', optional: true, nullable: false }, + onlyUriFetch: { type: 'boolean' }, }, required: ['uri'], } as const; From e29fe18b93276734c67a8ee728288f28f4b99933 Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 28 Aug 2025 23:19:06 +0900 Subject: [PATCH 11/20] WIP --- .../src/server/api/endpoints/ap/show.ts | 39 ++++++++++++++----- .../test-federation/test/api/ap/show.test.ts | 4 +- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index b89687acce..867a2bc71e 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -25,6 +25,7 @@ import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-ur import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DI } from '@/di-symbols.js'; import type { MiMeta } from '@/models/_.js'; +import { ApiLoggerService } from '../../ApiLoggerService.js'; export const meta = { tags: ['federation'], @@ -68,6 +69,11 @@ export const meta = { code: 'SOMETHING_HAPPENED_IN_FETCHING_URI', id: '14d45054-9df7-4f85-9e60-343b22f16b05', }, + uriIsAcctLikeButThisIsOnlyUriFetchMode: { + message: 'URI is acct-like but onlyUriFetch is true.', + code: 'URI_IS_ACCT_LIKE_BUT_THIS_IS_ONLY_URI_FETCH_MODE', + id: 'b224ffe3-ae5c-44e2-9df4-f0b8662bb085', + }, }, res: { @@ -130,28 +136,43 @@ export default class extends Endpoint { // eslint- private apPersonService: ApPersonService, private apNoteService: ApNoteService, private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me) => { - let object: SchemaType | null; + let object: SchemaType | null = null; + let acct: misskey.acct.Acct | null = null; - if (!ps.onlyUriFetch) { + try { + acct = misskey.acct.parseAcctOrUrl(ps.uri); + } catch (err) { + // nothing to do + } + + if (!ps.onlyUriFetch && acct) { try { - object = await this.fetchAcct(misskey.acct.parseAcctOrUrl(ps.uri), me); + object = await this.fetchAcct(acct, me); } catch (err) { if (err instanceof IdentifiableError && err.id === 'bddd9f4c-f0a8-4cac-9c0a-4e6d2fc43408') { // Signin required throw new ApiError(meta.errors.noSuchObject); } + + this.apiLoggerService.logger.warn('ap/show: fetchAcct failed', { uri: ps.uri, error: err }); } } - try { - object = await this.fetchAnyUri(ps.uri, me); - } catch (err) { - if (err instanceof ApiError) { - throw err; + if (object == null) { + try { + object = await this.fetchAnyUri(ps.uri, me); + } catch (err) { + if (err instanceof ApiError) { + throw err; + } + if (acct) { + throw new ApiError(meta.errors.uriIsAcctLikeButThisIsOnlyUriFetchMode); + } + throw new ApiError(meta.errors.somethingHappenedInFetchingUri, err); } - throw new ApiError(meta.errors.somethingHappenedInFetchingUri); } if (object) { diff --git a/packages/backend/test-federation/test/api/ap/show.test.ts b/packages/backend/test-federation/test/api/ap/show.test.ts index 7c35026269..a7839f5d6e 100644 --- a/packages/backend/test-federation/test/api/ap/show.test.ts +++ b/packages/backend/test-federation/test/api/ap/show.test.ts @@ -39,11 +39,11 @@ describe('API ap/show', () => { strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); }); - test('onlyUriFetch=true with acct string should fail with URI_INVALID', async () => { + test('onlyUriFetch=true with acct string returns generic fetch error', async () => { await rejects( async () => await alice.client.request('ap/show', { uri: `${bob.username}@b.test`, onlyUriFetch: true }), (err: any) => { - strictEqual(err.code, 'URI_INVALID'); + strictEqual(err.code, 'URI_IS_ACCT_LIKE_BUT_THIS_IS_ONLY_URI_FETCH_MODE'); return true; }, ); From a30b029803b94822f9d08b3c8702aec86e066311 Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 28 Aug 2025 23:23:09 +0900 Subject: [PATCH 12/20] :v: --- .../test-federation/test/api/ap/show.test.ts | 100 ++++++++---------- 1 file changed, 47 insertions(+), 53 deletions(-) diff --git a/packages/backend/test-federation/test/api/ap/show.test.ts b/packages/backend/test-federation/test/api/ap/show.test.ts index a7839f5d6e..30dbc9e396 100644 --- a/packages/backend/test-federation/test/api/ap/show.test.ts +++ b/packages/backend/test-federation/test/api/ap/show.test.ts @@ -3,64 +3,58 @@ import * as Misskey from 'misskey-js'; import { createAccount, resolveRemoteUser, sleep, type LoginUser } from '../../utils.js'; describe('API ap/show', () => { - let alice: LoginUser, bob: LoginUser; - let bobInA: Misskey.entities.UserDetailedNotMe; + let alice: LoginUser, bob: LoginUser; - beforeAll(async () => { - [alice, bob] = await Promise.all([ - createAccount('a.test'), - createAccount('b.test'), - ]); + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + }); - // 事前に bob を a.test に解決してキャッシュ・同一性を取る - bobInA = await resolveRemoteUser('b.test', bob.id, alice); - }); + describe('User resolution', () => { + test('resolve by acct (bob@b.test)', async () => { + const res = await alice.client.request('ap/show', { uri: `${bob.username}@b.test` }); + strictEqual(res.type, 'User'); + strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); + }); - describe('User resolution', () => { - test('resolve by acct (bob@b.test)', async () => { - const res = await alice.client.request('ap/show', { uri: `${bob.username}@b.test` }); - strictEqual(res.type, 'User'); - strictEqual(res.object.id, bobInA.id); - strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); - }); + 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 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.id, bobInA.id); - strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); - }); + test('resolve by cross-origin non-canonical 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'); + // 非正規URLから正規IDに追従して同一ユーザーになること + strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); + }); - test('resolve by cross-origin non-canonical 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'); - // 非正規URLから正規IDに追従して同一ユーザーになること - strictEqual(res.object.id, bobInA.id); - strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); - }); + test('onlyUriFetch=true with acct string returns generic fetch error', async () => { + await rejects( + async () => await alice.client.request('ap/show', { uri: `${bob.username}@b.test`, onlyUriFetch: true }), + (err: any) => { + strictEqual(err.code, 'URI_IS_ACCT_LIKE_BUT_THIS_IS_ONLY_URI_FETCH_MODE'); + return true; + }, + ); + }); + }); - test('onlyUriFetch=true with acct string returns generic fetch error', async () => { - await rejects( - async () => await alice.client.request('ap/show', { uri: `${bob.username}@b.test`, onlyUriFetch: true }), - (err: any) => { - strictEqual(err.code, 'URI_IS_ACCT_LIKE_BUT_THIS_IS_ONLY_URI_FETCH_MODE'); - return true; - }, - ); - }); - }); + 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(); - 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.userId, bobInA.id); - }); - }); + 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'); + }); + }); }); From f2115a06c9cd4eb98d825cc9364bb20280d751f5 Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 28 Aug 2025 23:42:03 +0900 Subject: [PATCH 13/20] api --- packages/misskey-js/etc/misskey-js.api.md | 2 +- packages/misskey-js/src/autogen/types.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 6ddff405e4..70955a2a73 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2815,7 +2815,7 @@ type ModerationLog = { id: ID; createdAt: DateString; userId: User['id']; - user: UserDetailedNotMe | null; + user: UserDetailedNotMe; } & ({ type: 'updateServerSettings'; info: ModerationLogPayloads['updateServerSettings']; 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; }; }; }; From d90593693097e1fc02530ad0884498aab1a6d1f5 Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 28 Aug 2025 23:44:52 +0900 Subject: [PATCH 14/20] build-misskey-js-with-types --- packages/misskey-js/etc/misskey-js.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index ecce5bd8e6..3388a3d9a9 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2809,7 +2809,7 @@ type ModerationLog = { id: ID; createdAt: DateString; userId: User['id']; - user: UserDetailedNotMe | null; + user: UserDetailedNotMe; } & ({ type: 'updateServerSettings'; info: ModerationLogPayloads['updateServerSettings']; From 42946e3f68c79d88b298ca9136234b0034ab8aa0 Mon Sep 17 00:00:00 2001 From: tamaina Date: Fri, 29 Aug 2025 00:09:54 +0900 Subject: [PATCH 15/20] fix test --- packages/backend/test-federation/test/user.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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; }, ); From 60dd7864ce6c54ff71ca755c305e0a830d94d9b1 Mon Sep 17 00:00:00 2001 From: tamaina Date: Fri, 29 Aug 2025 00:11:29 +0900 Subject: [PATCH 16/20] fix lint --- packages/backend/src/server/api/endpoints/ap/show.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 867a2bc71e..a3dc795df3 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -195,7 +195,7 @@ export default class extends Endpoint { // eslint- return { type: 'User', object: await this.userEntityService.pack(user, me, { schema: 'UserDetailed' }), - } + }; } /** From 6fa73a2e26fe5eb282a2a0b3ee9d6e5c888a4525 Mon Sep 17 00:00:00 2001 From: tamaina Date: Fri, 29 Aug 2025 16:17:10 +0900 Subject: [PATCH 17/20] =?UTF-8?q?=E4=BB=95=E6=A7=98=E5=A4=89=E6=9B=B4:=20U?= =?UTF-8?q?RL=E3=81=AE=E6=9C=80=E5=BE=8C=E3=81=8C@username=E3=81=A7?= =?UTF-8?q?=E7=B5=82=E3=82=8F=E3=81=A3=E3=81=A6=E3=81=84=E3=82=8B=E5=A0=B4?= =?UTF-8?q?=E5=90=88=E3=81=AB=E3=81=A0=E3=81=91acct=20like=20url=E3=81=A8?= =?UTF-8?q?=E3=81=97=E3=81=A6=E8=AA=8D=E7=9F=A5=E3=81=99=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/misskey-js/src/acct.ts | 7 ++++++- packages/misskey-js/test/acct.ts | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/misskey-js/src/acct.ts b/packages/misskey-js/src/acct.ts index 74e0ff06f0..e30599b650 100644 --- a/packages/misskey-js/src/acct.ts +++ b/packages/misskey-js/src/acct.ts @@ -29,8 +29,13 @@ export function parse(acct: string): Acct { export function parseUrl(str: string): Acct { const url = new URL(str); - const path = url.pathname.split('/').find((p) => p.startsWith('@') && p.length >= 2); + const splited = url.pathname.split('/'); + let path = splited.pop(); + if (path === '') path = splited.pop(); // If the last segment is empty due to a trailing '/', use the previous segment + if (!path) throw new Error('This url is not acct like.'); + if (!path.startsWith('@')) throw new Error('This url is not acct like.'); + if (path.length <= 1) throw new Error('This url is not acct like.'); const split = path.split('@', 3); // ['', 'username', 'other.example.com'] diff --git a/packages/misskey-js/test/acct.ts b/packages/misskey-js/test/acct.ts index fd59a82c9d..510b106ad2 100644 --- a/packages/misskey-js/test/acct.ts +++ b/packages/misskey-js/test/acct.ts @@ -39,9 +39,26 @@ function testParseUrl(fn: (acct: string) => acct.Acct) { expect(res).toEqual({ username: 'alice', host: 'other.example.com' }); }); - it('throws on non-acct-like url path', () => { + 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(); + }); } describe('acct.parse', () => { From 289051b3149327a39920f507179c937d61eb84e2 Mon Sep 17 00:00:00 2001 From: tamaina Date: Fri, 29 Aug 2025 16:50:21 +0900 Subject: [PATCH 18/20] throws url with search params or hash --- packages/misskey-js/etc/misskey-js.api.md | 8 +++++++- packages/misskey-js/src/acct.ts | 16 +++++++++++++--- packages/misskey-js/test/acct.ts | 8 ++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 70955a2a73..423b3be916 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -28,7 +28,8 @@ declare namespace acct { parseUrl, parseAcctOrUrl, toString_2 as toString, - Acct + Acct, + UrlIsNotAcctLikeError } } export { acct } @@ -3640,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 e30599b650..e8c4524ec4 100644 --- a/packages/misskey-js/src/acct.ts +++ b/packages/misskey-js/src/acct.ts @@ -27,15 +27,25 @@ export function parse(acct: string): Acct { return { username: split[0], host: split[1] || null }; } +export class UrlIsNotAcctLikeError extends Error { + constructor() { + super('This url is not acct like.'); + } +} + 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('/'); let path = splited.pop(); if (path === '') path = splited.pop(); // If the last segment is empty due to a trailing '/', use the previous segment - if (!path) throw new Error('This url is not acct like.'); - if (!path.startsWith('@')) throw new Error('This url is not acct like.'); - if (path.length <= 1) throw new Error('This url is not acct like.'); + 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'] diff --git a/packages/misskey-js/test/acct.ts b/packages/misskey-js/test/acct.ts index 510b106ad2..4200e593de 100644 --- a/packages/misskey-js/test/acct.ts +++ b/packages/misskey-js/test/acct.ts @@ -59,6 +59,14 @@ function testParseUrl(fn: (acct: string) => acct.Acct) { 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', () => { From 5a698687036698a95330a5557b96b5899d41f0cf Mon Sep 17 00:00:00 2001 From: tamaina Date: Sat, 30 Aug 2025 17:15:01 +0900 Subject: [PATCH 19/20] revert ap/show changes --- .../src/server/api/endpoints/ap/show.ts | 78 +------------------ .../test-federation/test/api/ap/show.test.ts | 29 +++---- 2 files changed, 16 insertions(+), 91 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index a3dc795df3..4afed7dc5c 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -5,7 +5,6 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import * as misskey from 'misskey-js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiNote } from '@/models/Note.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -22,10 +21,6 @@ import { bindThis } from '@/decorators.js'; import { ApiError } from '../../error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { DI } from '@/di-symbols.js'; -import type { MiMeta } from '@/models/_.js'; -import { ApiLoggerService } from '../../ApiLoggerService.js'; export const meta = { tags: ['federation'], @@ -64,16 +59,6 @@ export const meta = { code: 'NO_SUCH_OBJECT', id: 'dc94d745-1262-4e63-a17d-fecaa57efc82', }, - somethingHappenedInFetchingUri: { - message: 'Something happened while fetching the URI.', - code: 'SOMETHING_HAPPENED_IN_FETCHING_URI', - id: '14d45054-9df7-4f85-9e60-343b22f16b05', - }, - uriIsAcctLikeButThisIsOnlyUriFetchMode: { - message: 'URI is acct-like but onlyUriFetch is true.', - code: 'URI_IS_ACCT_LIKE_BUT_THIS_IS_ONLY_URI_FETCH_MODE', - id: 'b224ffe3-ae5c-44e2-9df4-f0b8662bb085', - }, }, res: { @@ -117,7 +102,6 @@ export const paramDef = { type: 'object', properties: { uri: { type: 'string' }, - onlyUriFetch: { type: 'boolean' }, }, required: ['uri'], } as const; @@ -125,9 +109,6 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.meta) - private serverSettings: MiMeta, - private utilityService: UtilityService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, @@ -135,46 +116,9 @@ export default class extends Endpoint { // eslint- private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, private apNoteService: ApNoteService, - private remoteUserResolveService: RemoteUserResolveService, - private apiLoggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me) => { - let object: SchemaType | null = null; - let acct: misskey.acct.Acct | null = null; - - try { - acct = misskey.acct.parseAcctOrUrl(ps.uri); - } catch (err) { - // nothing to do - } - - if (!ps.onlyUriFetch && acct) { - try { - object = await this.fetchAcct(acct, me); - } catch (err) { - if (err instanceof IdentifiableError && err.id === 'bddd9f4c-f0a8-4cac-9c0a-4e6d2fc43408') { - // Signin required - throw new ApiError(meta.errors.noSuchObject); - } - - this.apiLoggerService.logger.warn('ap/show: fetchAcct failed', { uri: ps.uri, error: err }); - } - } - - if (object == null) { - try { - object = await this.fetchAnyUri(ps.uri, me); - } catch (err) { - if (err instanceof ApiError) { - throw err; - } - if (acct) { - throw new ApiError(meta.errors.uriIsAcctLikeButThisIsOnlyUriFetchMode); - } - throw new ApiError(meta.errors.somethingHappenedInFetchingUri, err); - } - } - + const object = await this.fetchAny(ps.uri, me); if (object) { return object; } else { @@ -183,26 +127,11 @@ export default class extends Endpoint { // eslint- }); } - private async fetchAcct(acct: misskey.acct.Acct, me: MiLocalUser | null | undefined): Promise | null> { - if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) { - throw new IdentifiableError('bddd9f4c-f0a8-4cac-9c0a-4e6d2fc43408', 'Signin required'); - } - - const user = await this.remoteUserResolveService.resolveUser(acct.username, acct.host); - - if (!user) return null; - - return { - type: 'User', - object: await this.userEntityService.pack(user, me, { schema: 'UserDetailed' }), - }; - } - - /** + /*** * URIからUserかNoteを解決する */ @bindThis - private async fetchAnyUri(uri: string, me: MiLocalUser | null | undefined): Promise | null> { + private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise | null> { if (!this.utilityService.isFederationAllowedUri(uri)) { throw new ApiError(meta.errors.federationNotAllowed); } @@ -245,6 +174,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.responseInvalid); } } + throw new ApiError(meta.errors.requestFailed); }); diff --git a/packages/backend/test-federation/test/api/ap/show.test.ts b/packages/backend/test-federation/test/api/ap/show.test.ts index 30dbc9e396..eb949754d7 100644 --- a/packages/backend/test-federation/test/api/ap/show.test.ts +++ b/packages/backend/test-federation/test/api/ap/show.test.ts @@ -13,33 +13,28 @@ describe('API ap/show', () => { }); describe('User resolution', () => { - test('resolve by acct (bob@b.test)', async () => { - const res = await alice.client.request('ap/show', { uri: `${bob.username}@b.test` }); - strictEqual(res.type, 'User'); - strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); - }); - 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 cross-origin non-canonical URL (https://a.test/@bob@b.test)', async () => { - const res = await alice.client.request('ap/show', { uri: `https://a.test/@${bob.username}@b.test` }); + 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'); - // 非正規URLから正規IDに追従して同一ユーザーになること strictEqual(res.object.uri, `https://b.test/users/${bob.id}`); }); - test('onlyUriFetch=true with acct string returns generic fetch error', async () => { - await rejects( - async () => await alice.client.request('ap/show', { uri: `${bob.username}@b.test`, onlyUriFetch: true }), - (err: any) => { - strictEqual(err.code, 'URI_IS_ACCT_LIKE_BUT_THIS_IS_ONLY_URI_FETCH_MODE'); - return true; - }, - ); + 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}`); }); }); From a3ed7d994b831a9103325b375c1f79a21117dd72 Mon Sep 17 00:00:00 2001 From: tamaina Date: Sat, 30 Aug 2025 17:22:22 +0900 Subject: [PATCH 20/20] =?UTF-8?q?pathname=E3=81=AE=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=81=8C@=E3=81=A7=E5=A7=8B=E3=81=BE=E3=82=8B?= =?UTF-8?q?=E3=81=A8=E3=81=8D=E3=81=A0=E3=81=91acct=20like=20url=E3=81=A8?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/misskey-js/src/acct.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/misskey-js/src/acct.ts b/packages/misskey-js/src/acct.ts index e8c4524ec4..06dcd6fa2c 100644 --- a/packages/misskey-js/src/acct.ts +++ b/packages/misskey-js/src/acct.ts @@ -33,6 +33,9 @@ export class UrlIsNotAcctLikeError extends Error { } } +/** + * 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); @@ -40,8 +43,7 @@ export function parseUrl(str: string): Acct { if (url.search.length > 0) throw new UrlIsNotAcctLikeError(); const splited = url.pathname.split('/'); - let path = splited.pop(); - if (path === '') path = splited.pop(); // If the last segment is empty due to a trailing '/', use the previous segment + const path = splited[1]; if (!path) throw new UrlIsNotAcctLikeError(); if (!path.startsWith('@')) throw new UrlIsNotAcctLikeError();