mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-25 17:10:43 +00:00
fix: store webfinger subject for remote users
This commit is contained in:
parent
079ec865e0
commit
19744bf17c
37 changed files with 227 additions and 85 deletions
20
packages/backend/migration/1772983353696-user-acct.js
Normal file
20
packages/backend/migration/1772983353696-user-acct.js
Normal file
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ILink> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ function generateDummyUser(override?: Partial<MiUser>): 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 => ({
|
||||
|
|
|
|||
|
|
@ -301,10 +301,11 @@ export class ApPersonService implements OnModuleInit {
|
|||
* Personを作成します。
|
||||
*/
|
||||
@bindThis
|
||||
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
public async createPerson(args: string | { uri: string, acct?: string | null }, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||
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<string | void> {
|
||||
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<string | void> {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MiUser> & {
|
||||
id: MiUser['id'];
|
||||
host: null;
|
||||
uri: null;
|
||||
acct: null;
|
||||
};
|
||||
|
||||
export type MiRemoteUser = MiUser & {
|
||||
host: string;
|
||||
uri: string;
|
||||
acct: string | null;
|
||||
};
|
||||
|
||||
export type MiPartialRemoteUser = Partial<MiUser> & {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -191,6 +191,10 @@ export const packedUserLiteSchema = {
|
|||
},
|
||||
},
|
||||
},
|
||||
acct: {
|
||||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void>((res, rej) => {
|
||||
stream.write(content + '\n', err => {
|
||||
if (err) {
|
||||
|
|
|
|||
|
|
@ -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<void>((res, rej) => {
|
||||
stream.write(content + '\n', err => {
|
||||
|
|
|
|||
|
|
@ -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<void>((res, rej) => {
|
||||
stream.write(content + '\n', err => {
|
||||
if (err) {
|
||||
|
|
|
|||
|
|
@ -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<void>((res, rej) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/'));
|
||||
|
|
|
|||
|
|
@ -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?:/))
|
||||
|
|
|
|||
|
|
@ -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, []);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -5,20 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<span>
|
||||
<span>@{{ user.username }}</span>
|
||||
<span v-if="user.host || detail" style="opacity: 0.5;">@{{ user.host || host }}</span>
|
||||
<span>@{{ acct.username }}</span>
|
||||
<span v-if="acct.host || detail" style="opacity: 0.5;">@{{ acct.host || host }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { toUnicode } from 'punycode.js';
|
||||
import { host as hostRaw } from '@@/js/config.js';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
user: Misskey.entities.UserLite;
|
||||
detail?: boolean;
|
||||
}>();
|
||||
|
||||
const host = toUnicode(hostRaw);
|
||||
const acct = computed(() => {
|
||||
return Misskey.acct.fromUser(props.user);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="user" :class="$style.root">
|
||||
<div v-if="acct" :class="$style.root">
|
||||
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
|
||||
{{ i18n.ts.accountMoved }}
|
||||
<MkMention :class="$style.link" :username="user.username" :host="user.host ?? localHost"/>
|
||||
<MkMention :class="$style.link" :username="acct.username" :host="acct.host ?? localHost"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkMention from './MkMention.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
@ -21,6 +21,14 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
|||
|
||||
const user = ref<Misskey.entities.UserLite>();
|
||||
|
||||
const acct = computed(() => {
|
||||
if (!user.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Misskey.acct.fromUser(user.value);
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
movedTo: string; // user id
|
||||
}>();
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { onBeforeUnmount, onMounted, ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import * as os from '@/os.js';
|
||||
|
|
@ -66,6 +66,10 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro
|
|||
const wait = ref(false);
|
||||
const connection = useStream().useChannel('main');
|
||||
|
||||
const acct = computed(() => {
|
||||
return Misskey.acct.fromUser(props.user);
|
||||
});
|
||||
|
||||
if (props.user.isFollowing == null && $i) {
|
||||
misskeyApi('users/show', {
|
||||
userId: props.user.id,
|
||||
|
|
@ -84,7 +88,7 @@ async function onClick() {
|
|||
const isLoggedIn = await pleaseLogin({
|
||||
openOnRemote: {
|
||||
type: 'web',
|
||||
path: `/@${props.user.username}@${props.user.host ?? host}`,
|
||||
path: `/@${acct.value.username}@${acct.value.host ?? host}`,
|
||||
},
|
||||
});
|
||||
if (!isLoggedIn) return;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { expect, userEvent, waitFor, within } from '@storybook/test';
|
|||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { galleryPost } from '../../.storybook/fakes.js';
|
||||
import MkGalleryPostPreview from './MkGalleryPostPreview.vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
|
|
@ -30,11 +31,12 @@ export const Default = {
|
|||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const acct = Misskey.acct.fromUser(galleryPost().user);
|
||||
const canvas = within(canvasElement);
|
||||
const links = canvas.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toHaveAttribute('href', `/gallery/${galleryPost().id}`);
|
||||
expect(links[1]).toHaveAttribute('href', `/@${galleryPost().user.username}@${galleryPost().user.host}`);
|
||||
expect(links[1]).toHaveAttribute('href', `/@${Misskey.acct.toString(acct)}`);
|
||||
const images = canvas.getAllByRole<HTMLImageElement>('img');
|
||||
await waitFor(() => expect(Promise.all(images.map((image) => image.decode()))).resolves.toBeDefined());
|
||||
},
|
||||
|
|
|
|||
|
|
@ -355,28 +355,32 @@ if (props.mention) {
|
|||
text.value += ' ';
|
||||
}
|
||||
|
||||
if (replyTargetNote.value && (replyTargetNote.value.user.username !== $i.username || (replyTargetNote.value.user.host != null && replyTargetNote.value.user.host !== host))) {
|
||||
text.value = `@${replyTargetNote.value.user.username}${replyTargetNote.value.user.host != null ? '@' + toASCII(replyTargetNote.value.user.host) : ''} `;
|
||||
}
|
||||
if (replyTargetNote.value) {
|
||||
const replyUserAcct = Misskey.acct.fromUser(replyTargetNote.value.user);
|
||||
|
||||
if (replyTargetNote.value && replyTargetNote.value.text != null) {
|
||||
const ast = mfm.parse(replyTargetNote.value.text);
|
||||
const otherHost = replyTargetNote.value.user.host;
|
||||
if (replyTargetNote.value.user.username !== $i.username || (replyUserAcct.host != null && replyUserAcct.host !== host)) {
|
||||
text.value = `@${replyUserAcct.username}${replyUserAcct.host != null ? '@' + toASCII(replyUserAcct.host) : ''} `;
|
||||
}
|
||||
|
||||
for (const x of extractMentions(ast)) {
|
||||
const mention = x.host ?
|
||||
`@${x.username}@${toASCII(x.host)}` :
|
||||
(otherHost == null || otherHost === host) ?
|
||||
`@${x.username}` :
|
||||
`@${x.username}@${toASCII(otherHost)}`;
|
||||
if (replyTargetNote.value.text != null) {
|
||||
const ast = mfm.parse(replyTargetNote.value.text);
|
||||
const otherHost = replyUserAcct.host;
|
||||
|
||||
// 自分は除外
|
||||
if ($i.username === x.username && (x.host == null || x.host === host)) continue;
|
||||
for (const x of extractMentions(ast)) {
|
||||
const mention = x.host ?
|
||||
`@${x.username}@${toASCII(x.host)}` :
|
||||
(otherHost == null || otherHost === host) ?
|
||||
`@${x.username}` :
|
||||
`@${x.username}@${toASCII(otherHost)}`;
|
||||
|
||||
// 重複は除外
|
||||
if (text.value.includes(`${mention} `)) continue;
|
||||
// 自分は除外
|
||||
if ($i.username === x.username && (x.host == null || x.host === host)) continue;
|
||||
|
||||
text.value += `${mention} `;
|
||||
// 重複は除外
|
||||
if (text.value.includes(`${mention} `)) continue;
|
||||
|
||||
text.value += `${mention} `;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
|
|||
name: '藍',
|
||||
username: 'ai',
|
||||
host: null,
|
||||
acct: null,
|
||||
avatarDecorations: [],
|
||||
avatarUrl: '/client-assets/tutorial/ai.webp',
|
||||
avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({
|
|||
name: '藍',
|
||||
username: 'ai',
|
||||
host: null,
|
||||
acct: null,
|
||||
avatarDecorations: [],
|
||||
avatarUrl: '/client-assets/tutorial/ai.webp',
|
||||
avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',
|
||||
|
|
|
|||
|
|
@ -5,20 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<span>
|
||||
<span>@{{ user.username }}</span>
|
||||
<span v-if="user.host || detail" style="opacity: 0.5;">@{{ user.host || host }}</span>
|
||||
<span>@{{ acct.username }}</span>
|
||||
<span v-if="acct.host || detail" style="opacity: 0.5;">@{{ acct.host || host }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { toUnicode } from 'punycode.js';
|
||||
import { host as hostRaw } from '@@/js/config.js';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
user: Misskey.entities.UserLite;
|
||||
detail?: boolean;
|
||||
}>();
|
||||
|
||||
const host = toUnicode(hostRaw);
|
||||
const acct = computed(() => {
|
||||
return Misskey.acct.fromUser(props.user);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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} ` });
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #sub>
|
||||
<span>{{ countdownDate }}</span>
|
||||
<span> / </span>
|
||||
<span class="_monospace">@{{ acct(item.user) }}</span>
|
||||
<span class="_monospace">@{{ acctFilter(item.user) }}</span>
|
||||
</template>
|
||||
</MkUserCardMini>
|
||||
</MkA>
|
||||
<button v-tooltip.noDelay="i18n.ts.note" class="_button" :class="$style.post" @click="os.post({initialText: `@${item.user.username}${item.user.host ? `@${item.user.host}` : ''} `})">
|
||||
<button v-tooltip.noDelay="i18n.ts.note" class="_button" :class="$style.post" @click="os.post({initialText: `@${Misskey.acct.toString(acct)}`})">
|
||||
<i class="ti-fw ti ti-confetti" :class="$style.postIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ declare namespace acct {
|
|||
export {
|
||||
parse,
|
||||
toString_2 as toString,
|
||||
fromUser,
|
||||
validate,
|
||||
Acct
|
||||
}
|
||||
}
|
||||
|
|
@ -2457,6 +2459,9 @@ type FollowingUpdateResponse = operations['following___update']['responses']['20
|
|||
// @public (undocumented)
|
||||
export const followingVisibilities: readonly ["public", "followers", "private"];
|
||||
|
||||
// @public (undocumented)
|
||||
function fromUser(u: UserLite): Acct;
|
||||
|
||||
// @public (undocumented)
|
||||
type GalleryFeaturedRequest = operations['gallery___featured']['requestBody']['content']['application/json'];
|
||||
|
||||
|
|
@ -3910,6 +3915,9 @@ type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestB
|
|||
// @public (undocumented)
|
||||
type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
function validate(acct: string): boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
type VerifyEmailRequest = operations['verify-email']['requestBody']['content']['application/json'];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { UserLite } from './entities.js';
|
||||
|
||||
export type Acct = {
|
||||
username: string;
|
||||
host: string | null;
|
||||
|
|
@ -5,6 +7,7 @@ export type Acct = {
|
|||
|
||||
export function parse(_acct: string): Acct {
|
||||
let acct = _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 };
|
||||
|
|
@ -13,3 +16,11 @@ export function parse(_acct: string): Acct {
|
|||
export function toString(acct: Acct): string {
|
||||
return acct.host == null ? acct.username : `${acct.username}@${acct.host}`;
|
||||
}
|
||||
|
||||
export function fromUser(u: UserLite): Acct {
|
||||
return u.acct ? parse(u.acct) : { username: u.username, host: u.host };
|
||||
}
|
||||
|
||||
export function validate(acct: string): boolean {
|
||||
return acct.match(/^(acct:)?[@]?[^@]+@[^@]+\.[^@]+$/) !== null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4056,6 +4056,7 @@ export type components = {
|
|||
iconUrl: string | null;
|
||||
displayOrder: number;
|
||||
}[];
|
||||
acct: string | null;
|
||||
};
|
||||
UserDetailedNotMeOnly: {
|
||||
/** Format: url */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue