From 19744bf17c4475d78611b120f5ce2eee1724413e Mon Sep 17 00:00:00 2001 From: Freya Murphy Date: Sun, 8 Mar 2026 17:51:45 -0400 Subject: [PATCH] fix: store webfinger subject for remote users --- .../migration/1772983353696-user-acct.js | 20 ++++++++++ packages/backend/src/core/AntennaService.ts | 12 +++--- .../src/core/RemoteUserResolveService.ts | 40 ++++++++++++++----- .../backend/src/core/UserFollowingService.ts | 2 + packages/backend/src/core/UtilityService.ts | 9 ++++- .../backend/src/core/WebhookTestService.ts | 2 + .../activitypub/models/ApPersonService.ts | 34 +++++++++------- .../src/core/entities/UserEntityService.ts | 4 +- packages/backend/src/misc/acct.ts | 5 +++ packages/backend/src/models/User.ts | 11 +++++ .../backend/src/models/json-schema/user.ts | 4 ++ .../ExportAntennasProcessorService.ts | 2 +- .../ExportBlockingProcessorService.ts | 2 +- .../ExportFollowingProcessorService.ts | 2 +- .../ExportMutingProcessorService.ts | 2 +- .../ExportUserListsProcessorService.ts | 2 +- .../src/server/web/ClientServerService.ts | 4 +- .../backend/src/server/web/views/note.tsx | 3 +- .../backend/src/server/web/views/user.tsx | 3 +- packages/backend/test/e2e/users.ts | 2 + .../test/unit/ChannelFollowingService.ts | 4 +- .../frontend-embed/src/components/EmAcct.vue | 11 +++-- packages/frontend/.storybook/fakes.ts | 1 + .../src/components/MkAccountMoved.vue | 14 +++++-- .../src/components/MkFollowButton.vue | 8 +++- .../MkGalleryPostPreview.stories.impl.ts | 4 +- .../frontend/src/components/MkPostForm.vue | 38 ++++++++++-------- .../src/components/MkTutorialDialog.Note.vue | 1 + .../components/MkTutorialDialog.PostNote.vue | 1 + .../frontend/src/components/global/MkAcct.vue | 11 +++-- packages/frontend/src/pages/lookup.vue | 3 +- .../frontend/src/pages/search.stories.impl.ts | 6 ++- .../frontend/src/utility/get-user-menu.ts | 15 +++---- .../widgets/WidgetBirthdayFollowings.user.vue | 10 +++-- packages/misskey-js/etc/misskey-js.api.md | 8 ++++ packages/misskey-js/src/acct.ts | 11 +++++ packages/misskey-js/src/autogen/types.ts | 1 + 37 files changed, 227 insertions(+), 85 deletions(-) create mode 100644 packages/backend/migration/1772983353696-user-acct.js diff --git a/packages/backend/migration/1772983353696-user-acct.js b/packages/backend/migration/1772983353696-user-acct.js new file mode 100644 index 00000000000..f4897d70433 --- /dev/null +++ b/packages/backend/migration/1772983353696-user-acct.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UserAcct1772983353696 { + name = 'UserAcct1772983353696' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "acct" character varying(512)`); + await queryRunner.query(`COMMENT ON COLUMN "user"."acct" IS 'The last retrieved webfinger subject of the User. It will be null if the origin of the user is local.'`); + await queryRunner.query(`CREATE INDEX "IDX_0be9d7dcbac33e23aba1637a69" ON "user" ("acct") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_0be9d7dcbac33e23aba1637a69"`); + await queryRunner.query(`COMMENT ON COLUMN "user"."acct" IS 'The last retrieved webfinger subject of the User. It will be null if the origin of the user is local.'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "acct"`); + } +} diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index ec79675b064..6126f3c21ec 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -148,15 +148,15 @@ export class AntennaService implements OnApplicationShutdown { } else if (antenna.src === 'users') { const accts = antenna.users.map(x => { const { username, host } = Acct.parse(x); - return this.utilityService.getFullApAccount(username, host).toLowerCase(); + return this.utilityService.getFullApAccount({ username, host }).toLowerCase(); }); - if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + if (!accts.includes(this.utilityService.getFullApAccount(noteUser).toLowerCase())) return false; } else if (antenna.src === 'users_blacklist') { const accts = antenna.users.map(x => { const { username, host } = Acct.parse(x); - return this.utilityService.getFullApAccount(username, host).toLowerCase(); + return this.utilityService.getFullApAccount({ username, host }).toLowerCase(); }); - if (accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + if (accts.includes(this.utilityService.getFullApAccount(noteUser).toLowerCase())) return false; } const keywords = antenna.keywords @@ -225,11 +225,11 @@ export class AntennaService implements OnApplicationShutdown { // There is a possibility for users to add the srcUser to their antennas, but it's low, so we don't check it. // Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list - const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase(); + const srcUserAcct = this.utilityService.getFullApAccount(src).toLowerCase(); const antennasToMigrate = (await this.getAntennas()).filter(antenna => { return antenna.users.some(user => { const { username, host } = Acct.parse(user); - return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct; + return this.utilityService.getFullApAccount({ username, host }).toLowerCase() === srcUserAcct; }); }); diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index a2f1b73cdbd..5fcd4946a20 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -18,6 +18,7 @@ import { RemoteLoggerService } from '@/core/RemoteLoggerService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { bindThis } from '@/decorators.js'; +import * as Acct from '@/misc/acct.js'; @Injectable() export class RemoteUserResolveService { @@ -67,12 +68,15 @@ export class RemoteUserResolveService { }) as MiLocalUser; } - const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null; - const acctLower = `${usernameLower}@${host}`; + const user = await this.usersRepository.findOneBy([ + { usernameLower, host }, + { acct: acctLower }, + ]) as MiRemoteUser | null; + if (user == null) { - const self = await this.resolveSelf(acctLower); + const { self, subject } = await this.resolveWebfinger(acctLower); if (this.utilityService.isUriLocal(self.href)) { const local = this.apDbResolverService.parseUri(self.href); @@ -90,8 +94,20 @@ export class RemoteUserResolveService { } } - this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); - return await this.apPersonService.createPerson(self.href); + this.logger.succ(`return new remote user: ${chalk.magenta(subject)}`); + const newUser = await this.apPersonService.createPerson({ uri: self.href, acct: subject }); + + if (newUser.acct !== subject) { + await this.usersRepository.update({ + id: newUser.id, + }, { + acct: subject + }); + + newUser.acct = subject; + } + + return newUser; } // ユーザー情報が古い場合は、WebFingerからやりなおして返す @@ -102,7 +118,7 @@ export class RemoteUserResolveService { }); this.logger.info(`try resync: ${acctLower}`); - const self = await this.resolveSelf(acctLower); + const { self, subject } = await this.resolveWebfinger(acctLower); if (user.uri !== self.href) { // if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping. @@ -120,12 +136,13 @@ export class RemoteUserResolveService { host: host, }, { uri: self.href, + acct: subject, }); } else { this.logger.info(`uri is fine: ${acctLower}`); } - await this.apPersonService.updatePerson(self.href); + await this.apPersonService.updatePerson({ uri: self.href, acct: subject }); this.logger.info(`return resynced remote user: ${acctLower}`); return await this.usersRepository.findOneBy({ uri: self.href }).then(u => { @@ -142,7 +159,7 @@ export class RemoteUserResolveService { } @bindThis - private async resolveSelf(acctLower: string): Promise { + private async resolveWebfinger(acctLower: string): Promise<{ self: ILink, subject: string | null }> { this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); const finger = await this.webfingerService.webfinger(acctLower).catch(err => { this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`); @@ -153,6 +170,11 @@ export class RemoteUserResolveService { this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`); throw new Error('self link not found'); } - return self; + let subject = Acct.validate(finger.subject) ? finger.subject : + Acct.validate(acctLower) ? acctLower : null; + if (subject) { + subject = Acct.parse(subject).toString(); + } + return { self, subject }; } } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index e7a6be99fb2..9e3cbaade71 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -35,11 +35,13 @@ const logger = new Logger('following/create'); type Local = MiLocalUser | { id: MiLocalUser['id']; host: MiLocalUser['host']; + acct: MiLocalUser['acct']; uri: MiLocalUser['uri'] }; type Remote = MiRemoteUser | { id: MiRemoteUser['id']; host: MiRemoteUser['host']; + acct: MiRemoteUser['acct']; uri: MiRemoteUser['uri']; inbox: MiRemoteUser['inbox']; }; diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index e3ceebccaeb..efe820d87ee 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -12,6 +12,7 @@ import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; import { MiMeta, SoftwareSuspension } from '@/models/Meta.js'; import { MiInstance } from '@/models/Instance.js'; +import { MiUser } from '@/models/User.js'; @Injectable() export class UtilityService { @@ -25,8 +26,12 @@ export class UtilityService { } @bindThis - public getFullApAccount(username: string, host: string | null): string { - return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`; + public getFullApAccount(user: { username: string, host: string | null, acct?: string | null }): string { + if (user.acct) { + return user.acct; + } + + return user.host ? `${user.username}@${this.toPuny(user.host)}` : `${user.username}@${this.toPuny(this.config.host)}`; } @bindThis diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index b112912b1b6..309f7b629a7 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -63,6 +63,7 @@ function generateDummyUser(override?: Partial): MiUser { uri: null, followersUri: null, token: null, + acct: null, ...override, }; } @@ -411,6 +412,7 @@ export class WebhookTestService { name: user.name, username: user.username, host: user.host, + acct: user.acct, avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? '', avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash, avatarDecorations: user.avatarDecorations.map(it => ({ diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 39396cb7411..dcde7fe5768 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -301,10 +301,11 @@ export class ApPersonService implements OnModuleInit { * Personを作成します。 */ @bindThis - public async createPerson(uri: string, resolver?: Resolver): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); + public async createPerson(args: string | { uri: string, acct?: string | null }, resolver?: Resolver): Promise { + if (typeof args === 'string') args = { uri: args }; + if (typeof args.uri !== 'string') throw new Error('uri is not string'); - const host = this.utilityService.punyHost(uri); + const host = this.utilityService.punyHost(args.uri); if (host === this.utilityService.toPuny(this.config.host)) { throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); } @@ -312,10 +313,10 @@ export class ApPersonService implements OnModuleInit { // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = await this.apResolverService.createResolver(); - const object = await resolver.resolve(uri); + const object = await resolver.resolve(args.uri); if (object.id == null) throw new Error('invalid object.id: ' + object.id); - const person = this.validateActor(object, uri); + const person = this.validateActor(object, args.uri); this.logger.info(`Creating the Person: ${person.id}`); @@ -381,6 +382,7 @@ export class ApPersonService implements OnModuleInit { username: person.preferredUsername, usernameLower: person.preferredUsername?.toLowerCase(), host, + acct: args.acct ?? null, inbox: person.inbox, sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, followersUri: person.followers ? getApId(person.followers) : undefined, @@ -488,23 +490,24 @@ export class ApPersonService implements OnModuleInit { * @param movePreventUris ここに指定されたURIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない(無限ループ防止) */ @bindThis - public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise { - if (typeof uri !== 'string') throw new Error('uri is not string'); + public async updatePerson(args: string | { uri: string, acct?: string | null }, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise { + if (typeof args === 'string') args = { uri: args }; + if (typeof args.uri !== 'string') throw new Error('uri is not string'); // URIがこのサーバーを指しているならスキップ - if (this.utilityService.isUriLocal(uri)) return; + if (this.utilityService.isUriLocal(args.uri)) return; //#region このサーバーに既に登録されているか - const exist = await this.fetchPerson(uri) as MiRemoteUser | null; + const exist = await this.fetchPerson(args.uri) as MiRemoteUser | null; if (exist === null) return; //#endregion // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = await this.apResolverService.createResolver(); - const object = hint ?? await resolver.resolve(uri); + const object = hint ?? await resolver.resolve(args.uri); - const person = this.validateActor(object, uri); + const person = this.validateActor(object, args.uri); this.logger.info(`Updating the Person: ${person.id}`); @@ -557,6 +560,7 @@ export class ApPersonService implements OnModuleInit { const updates = { lastFetchedAt: new Date(), + acct: args.acct, inbox: person.inbox, sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, followersUri: person.followers ? getApId(person.followers) : undefined, @@ -639,7 +643,7 @@ export class ApPersonService implements OnModuleInit { const updated = { ...exist, ...updates }; - this.cacheService.uriPersonCache.set(uri, updated); + this.cacheService.uriPersonCache.set(args.uri, updated); // 移行処理を行う if (updated.movedAt && ( @@ -649,14 +653,14 @@ export class ApPersonService implements OnModuleInit { // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() )) { - this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); + this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${args.uri})`); return this.processRemoteMove(updated, movePreventUris) .then(result => { - this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`); + this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${args.uri})`); return result; }) .catch(e => { - this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e }); + this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${args.uri})`, { stack: e }); }); } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 996f0bad2e8..284d90aa9a4 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -387,7 +387,8 @@ export class UserEntityService implements OnModuleInit { if ((user.host == null || user.host === this.config.host) && user.username.includes('.') && this.meta.iconUrl) { // ローカルのシステムアカウントの場合 return this.meta.iconUrl; } else { - return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; + const acct = user.acct?.toLowerCase() ?? `${user.username.toLowerCase()}@${user.host ?? this.config.host}`; + return `${this.config.url}/identicon/${acct}`; } } @@ -488,6 +489,7 @@ export class UserEntityService implements OnModuleInit { name: user.name, username: user.username, host: user.host, + acct: user.acct, avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user), avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash), avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ diff --git a/packages/backend/src/misc/acct.ts b/packages/backend/src/misc/acct.ts index 3d729b11510..fc498dcba79 100644 --- a/packages/backend/src/misc/acct.ts +++ b/packages/backend/src/misc/acct.ts @@ -9,6 +9,7 @@ export type Acct = { }; export function parse(acct: string): Acct { + if (acct.startsWith('acct:')) acct = acct.substring(5); if (acct.startsWith('@')) acct = acct.substring(1); const split = acct.split('@', 2); return { username: split[0], host: split[1] ?? null }; @@ -17,3 +18,7 @@ export function parse(acct: string): Acct { export function toString(acct: Acct): string { return acct.host == null ? acct.username : `${acct.username}@${acct.host}`; } + +export function validate(acct: string): boolean { + return acct.match(/^(acct:)?[@]?[^@]+@[^@]+\.[^@]+$/) !== null; +} diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 084dd354850..0fc40a29add 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -247,6 +247,13 @@ export class MiUser { }) public host: string | null; + @Index() + @Column('varchar', { + length: 512, nullable: true, + comment: 'The last retrieved webfinger subject of the User. It will be null if the origin of the user is local.', + }) + public acct: string | null; + @Column('varchar', { length: 512, nullable: true, comment: 'The inbox URL of the User. It will be null if the origin of the user is local.', @@ -297,23 +304,27 @@ export class MiUser { export type MiLocalUser = MiUser & { host: null; uri: null; + acct: null; }; export type MiPartialLocalUser = Partial & { id: MiUser['id']; host: null; uri: null; + acct: null; }; export type MiRemoteUser = MiUser & { host: string; uri: string; + acct: string | null; }; export type MiPartialRemoteUser = Partial & { id: MiUser['id']; host: string; uri: string; + acct: string | null; }; export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f71ec1d023e..50cc2d58a91 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -191,6 +191,10 @@ export const packedUserLiteSchema = { }, }, }, + acct: { + type: 'string', + nullable: true, optional: false, + }, }, } as const; diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index 053ba99005a..6ec065f4785 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -80,7 +80,7 @@ export class ExportAntennasProcessorService { excludeKeywords: antenna.excludeKeywords, users: antenna.users, userListAccts: typeof users !== 'undefined' ? users.map((u) => { - return this.utilityService.getFullApAccount(u.username, u.host); // acct + return this.utilityService.getFullApAccount(u); // acct }) : null, caseSensitive: antenna.caseSensitive, localOnly: antenna.localOnly, diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index cca7cdf9dad..d60eb19b83f 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -87,7 +87,7 @@ export class ExportBlockingProcessorService { exportedCount++; continue; } - const content = this.utilityService.getFullApAccount(u.username, u.host); + const content = this.utilityService.getFullApAccount(u); await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 91c39cb7588..ec4f99aa1bd 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -94,7 +94,7 @@ export class ExportFollowingProcessorService { continue; } - const userAcct = this.utilityService.getFullApAccount(u.username, u.host); + const userAcct = this.utilityService.getFullApAccount(u); const content = `${userAcct},withReplies=${following.withReplies}`; await new Promise((res, rej) => { stream.write(content + '\n', err => { diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index 59448ccd34c..1f963211882 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -88,7 +88,7 @@ export class ExportMutingProcessorService { exportedCount++; continue; } - const content = this.utilityService.getFullApAccount(u.username, u.host); + const content = this.utilityService.getFullApAccount(u); await new Promise((res, rej) => { stream.write(content + '\n', err => { if (err) { diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index 733e75f65f1..1aa8e1d522a 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -70,7 +70,7 @@ export class ExportUserListsProcessorService { const usersWithReplies = new Set(memberships.filter(m => m.withReplies).map(m => m.userId)); for (const u of users) { - const acct = this.utilityService.getFullApAccount(u.username, u.host); + const acct = this.utilityService.getFullApAccount(u); // 3rd column and later will be key=value pairs const content = `${list.name},${acct},withReplies=${usersWithReplies.has(u.id)}`; await new Promise((res, rej) => { diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 13ef05e7851..42dabac7ca6 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -565,9 +565,11 @@ export class ClientServerService { return; } + const acct = user.acct ?? `@${user.username}${ user.host == null ? '' : '@' + user.host}`; + vary(reply.raw, 'Accept'); - reply.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); + reply.redirect(`/${acct}`); }); // Note diff --git a/packages/backend/src/server/web/views/note.tsx b/packages/backend/src/server/web/views/note.tsx index 803c3d25374..e45074bf516 100644 --- a/packages/backend/src/server/web/views/note.tsx +++ b/packages/backend/src/server/web/views/note.tsx @@ -14,7 +14,8 @@ export function NotePage(props: CommonProps<{ note: Packed<'Note'>; profile: MiUserProfile; }>) { - const title = props.note.user.name ? `${props.note.user.name} (@${props.note.user.username}${props.note.user.host ? `@${props.note.user.host}` : ''})` : `@${props.note.user.username}${props.note.user.host ? `@${props.note.user.host}` : ''}` + const acct = props.note.user.acct ?? `@${props.note.user.username}${props.note.user.host ? `@${props.note.user.host}` : ''}`; + const title = props.note.user.name ? `${props.note.user.name} (${acct})` : acct; const isRenote = isRenotePacked(props.note); const images = (props.note.files ?? []).filter(f => f.type.startsWith('image/')); const videos = (props.note.files ?? []).filter(f => f.type.startsWith('video/')); diff --git a/packages/backend/src/server/web/views/user.tsx b/packages/backend/src/server/web/views/user.tsx index 76c2633ab9b..154070c7305 100644 --- a/packages/backend/src/server/web/views/user.tsx +++ b/packages/backend/src/server/web/views/user.tsx @@ -13,7 +13,8 @@ export function UserPage(props: CommonProps<{ profile: MiUserProfile; sub?: string; }>) { - const title = props.user.name ? `${props.user.name} (@${props.user.username}${props.user.host ? `@${props.user.host}` : ''})` : `@${props.user.username}${props.user.host ? `@${props.user.host}` : ''}`; + const acct = props.user.acct ?? `@${props.user.username}${props.user.host ? `@${props.user.host}` : ''}`; + const title = props.user.name ? `${props.user.name} (${acct})` : acct; const me = props.profile.fields ? props.profile.fields .filter(field => field.value != null && field.value.match(/^https?:/)) diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index be5fb3b0a70..2f1ba91a002 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -36,6 +36,7 @@ describe('ユーザー', () => { name: user.name, username: user.username, host: user.host, + acct: user.acct, avatarUrl: user.avatarUrl, avatarBlurhash: user.avatarBlurhash, avatarDecorations: user.avatarDecorations, @@ -309,6 +310,7 @@ describe('ユーザー', () => { assert.strictEqual(response.name, null); assert.strictEqual(response.username, 'zoe'); assert.strictEqual(response.host, null); + assert.strictEqual(response.acct, null); response.avatarUrl && assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); assert.strictEqual(response.avatarBlurhash, null); assert.deepStrictEqual(response.avatarDecorations, []); diff --git a/packages/backend/test/unit/ChannelFollowingService.ts b/packages/backend/test/unit/ChannelFollowingService.ts index 3b1ad72287f..89129badc51 100644 --- a/packages/backend/test/unit/ChannelFollowingService.ts +++ b/packages/backend/test/unit/ChannelFollowingService.ts @@ -127,8 +127,8 @@ describe('ChannelFollowingService', () => { }); beforeEach(async () => { - alice = { ...await createUser({ username: 'alice' }), host: null, uri: null }; - bob = { ...await createUser({ username: 'bob' }), host: null, uri: null }; + alice = { ...await createUser({ username: 'alice' }), host: null, uri: null, acct: null }; + bob = { ...await createUser({ username: 'bob' }), host: null, uri: null, acct: null }; driveFile1 = await createDriveFile(); driveFile2 = await createDriveFile(); channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id }); diff --git a/packages/frontend-embed/src/components/EmAcct.vue b/packages/frontend-embed/src/components/EmAcct.vue index ff794d9b6e6..2b06e57a0a6 100644 --- a/packages/frontend-embed/src/components/EmAcct.vue +++ b/packages/frontend-embed/src/components/EmAcct.vue @@ -5,20 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 723255267b4..6d1e5344668 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -250,6 +250,7 @@ export function userLite(id = 'someuserid', username = 'miskist', host: entities id, username, host, + acct: host ? `@${username}@${host}` : null, name, onlineStatus: 'unknown', avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index cb8032c0196..5f53b621b4f 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -4,15 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/lookup.vue b/packages/frontend/src/pages/lookup.vue index 182b2f703d3..281a78080c2 100644 --- a/packages/frontend/src/pages/lookup.vue +++ b/packages/frontend/src/pages/lookup.vue @@ -69,9 +69,10 @@ function _fetch_() { uri = uri.slice(5); } promise = misskeyApi('users/show', Misskey.acct.parse(uri)).then(user => { + const acct = Misskey.acct.fromUser(user); mainRouter.replace('/@:acct/:page?', { params: { - acct: user.host != null ? `${user.username}@${user.host}` : user.username, + acct: Misskey.acct.toString(acct), }, }); }); diff --git a/packages/frontend/src/pages/search.stories.impl.ts b/packages/frontend/src/pages/search.stories.impl.ts index 27271615c29..0da16b37cec 100644 --- a/packages/frontend/src/pages/search.stories.impl.ts +++ b/packages/frontend/src/pages/search.stories.impl.ts @@ -8,8 +8,10 @@ import { HttpResponse, http } from 'msw'; import search_ from './search.vue'; import { userDetailed } from '@/../.storybook/fakes.js'; import { commonHandlers } from '@/../.storybook/mocks.js'; +import * as Misskey from 'misskey-js'; const localUser = userDetailed('someuserid', 'miskist', null, 'Local Misskey User'); +const localAcct = Misskey.acct.fromUser(localUser); export const Default = { render(args) { @@ -61,8 +63,8 @@ export const WithUsernameLocal = { args: { ...Default.args, - username: localUser.username, - host: localUser.host, + username: localAcct.username, + host: localAcct.host, }, parameters: { layout: 'fullscreen', diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index 9b2c53360cc..385e67c4827 100644 --- a/packages/frontend/src/utility/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -23,6 +23,7 @@ import { getPluginHandlers } from '@/plugin.js'; export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router = mainRouter) { const meId = $i ? $i.id : null; + const acct = Misskey.acct.fromUser(user); const cleanups = [] as (() => void)[]; @@ -171,7 +172,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router icon: 'ti ti-at', text: i18n.ts.copyUsername, action: () => { - copyToClipboard(`@${user.username}@${user.host ?? host}`); + copyToClipboard(`@${acct.username}@${acct.host ?? host}`); }, }); @@ -179,7 +180,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router icon: 'ti ti-share', text: i18n.ts.copyProfileUrl, action: () => { - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + const canonical = `@${Misskey.acct.toString(acct)}`; copyToClipboard(`${url}/${canonical}`); }, }); @@ -231,11 +232,11 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router text: i18n.ts.searchThisUsersNotes, action: () => { const query = { - username: user.username, + username: acct.username, } as { username: string, host?: string }; - if (user.host !== null) { - query.host = user.host; + if (acct.host !== null) { + query.host = acct.host; } router.push('/search', { @@ -289,7 +290,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router text: i18n.ts.addToAntenna, children: async () => { const antennas = await antennasCache.fetch(); - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + const canonical = `@${Misskey.acct.toString(acct)}`; return antennas.filter((a) => a.src === 'users').map(antenna => ({ text: antenna.name, action: async () => { @@ -384,7 +385,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router icon: 'ti ti-pencil-heart', text: i18n.ts.createUserSpecifiedNote, action: () => { - const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; + const canonical = `@${Misskey.acct.toString(acct)}`; os.post({ specified: user, initialText: `${canonical} ` }); }, }); diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue index 2b714c2f6c1..ece4c51b8e8 100644 --- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue @@ -10,11 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only - @@ -27,7 +27,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { useLowresTime } from '@/composables/use-lowres-time.js'; -import { userPage, acct } from '@/filters/user.js'; +import { userPage, acct as acctFilter } from '@/filters/user.js'; const props = defineProps<{ item: Misskey.entities.UsersGetFollowingUsersByBirthdayResponse[number]; @@ -54,6 +54,10 @@ const countdownDate = computed(() => { return i18n.tsx._ago.daysAgo({ n: Math.abs(days) }); } }); + +const acct = computed(() => { + return Misskey.acct.fromUser(props.item.user); +});