From 14f5456ef043d15709db4dcbfdbbf5eb58e256d6 Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 26 Aug 2025 14:12:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(misskey-js):=20acct.parse=E3=81=8CURL?= =?UTF-8?q?=E3=82=92=E5=8F=97=E3=81=91=E4=BB=98=E3=81=91=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB,=20acct.ts=E3=81=AE=E3=83=86=E3=82=B9?= =?UTF-8?q?=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'); + }); +}); + +