diff --git a/packages/backend/assets/transparent.png b/packages/backend/assets/transparent.png new file mode 100644 index 000000000..240ca4f8d Binary files /dev/null and b/packages/backend/assets/transparent.png differ diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index eaa828091..ef29b39fd 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -140,6 +140,7 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js'; import { QueueModule } from './QueueModule.js'; import { QueueService } from './QueueService.js'; import { LoggerService } from './LoggerService.js'; +import { MastodonUserConverterService } from './mastodon/MastodonUserConverterService.js'; import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) @@ -422,6 +423,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApQuestionService, QueueService, + // Mastodon + MastodonUserConverterService, + //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, $AccountMoveService, @@ -696,6 +700,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApQuestionService, QueueService, + // Mastodon + MastodonUserConverterService, + //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, $AccountMoveService, diff --git a/packages/backend/src/core/mastodon/MastodonUserConverterService.ts b/packages/backend/src/core/mastodon/MastodonUserConverterService.ts new file mode 100644 index 000000000..b5605e0e7 --- /dev/null +++ b/packages/backend/src/core/mastodon/MastodonUserConverterService.ts @@ -0,0 +1,208 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as mfm from 'mfm-js'; +import { MiLocalUser, MiUser } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { extractMentions } from '@/misc/extract-mentions.js'; +import { IMentionedRemoteUsers } from '@/models/Note.js'; +import { UserEntityService, UserRelation } from '../entities/UserEntityService.js'; +import { RoleService } from '../RoleService.js'; +import { MfmService } from '../MfmService.js'; +import { CustomEmojiService } from '../CustomEmojiService.js'; +import { ApPersonService } from '../activitypub/models/ApPersonService.js'; +import { IdService } from '../IdService.js'; + +export type Account = { + id: string; + username: string; + acct: string; + url: string; + uri: string; // undocumented + display_name: string; + note: string; + avatar: string; + avatar_static: string; + header: string; + header_static: string; + locked: boolean; + fields: Field[]; + emojis: Emoji[]; + bot: boolean; + group: boolean; + discoverable: boolean | null; + noindex?: boolean | null; + moved?: Account | null; + suspended?: true; + limited?: true; + created_at: string; + last_status_at: string | null; + statuses_count: number; + followers_count: number; + following_count: number; +}; + +export type MutedAccount = Account | { + mute_expires_at: string | null; +} + +export type SuggestedAccount = { + source: 'staff' | 'past_interactions' | 'global', + account: Account +} + +export type Emoji = { + shortcode: string; + static_url: string; + url: string; + visible_in_picker: boolean; + category?: string; +}; + +export type Field = { + name: string; + value: string; + verified_at: string | null; +}; + +export type Source = { + privacy: string | null; + sensitive: boolean | null; + language: string | null; + note: string; + fields: Field[]; +}; + +@Injectable() +export class MastodonUserConverterService { + public constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private userEntityService: UserEntityService, + private roleService: RoleService, + private mfmService: MfmService, + private customEmojiService: CustomEmojiService, + private apPersonService: ApPersonService, + private idService: IdService, + ) {} + + public async encode(u: MiUser, me: MiLocalUser | null): Promise { + return await this.#encodeInner(u, me, true); + } + + async #encodeInner(u: MiUser, me: MiLocalUser | null, recurse: boolean): Promise { + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: u.id }); + let acct = u.username; + let acctUrl = `https://${u.host ?? this.config.host}/@${u.username}`; + if (u.host) { + acct = `${u.username}@${u.host}`; + acctUrl = `https://${u.host}/@${u.username}`; + } + const meId = me ? me.id : null; + const isMe = meId === u.id; + const iAmModerator = me ? await this.roleService.isModerator(me) : false; + if (!iAmModerator && (u.isDeleted || u.isSuspended)) { + return null; + } + let relation: UserRelation | null = null; + if (meId && !isMe) { + relation = await this.userEntityService.getRelation(meId, u.id); + } + const followingCount = + (profile.followingVisibility === 'public') || isMe || iAmModerator ? u.followingCount : + (profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? u.followingCount : + 0; + const followersCount = + (profile.followersVisibility === 'public') || isMe || iAmModerator ? u.followersCount : + (profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? u.followersCount : + 0; + const avatarUrl = u.avatarUrl ?? this.userEntityService.getIdenticonUrl(u); + const bannerUrl = u.bannerUrl ?? `${this.config.url}/static-assets/transparent.png`; + const parsedDescription = mfm.parse(profile.description ?? ''); + const parsedFields = profile.fields.map(({ name, value }) => ({ name, value, parsed: mfm.parse(value) })); + const mentions = await this.#extractMentions([...parsedDescription, ...parsedFields.flatMap(field => field.parsed)]); + const verifiedLinks = new Set(profile.verifiedLinks); + const fields = parsedFields.map(({ name, value, parsed }) => { + let verifiedAt: string | null = null; + if (verifiedLinks.has(value)) { + verifiedAt = new Date().toISOString(); + } + return { + name, + value: this.mfmService.toHtml(parsed, mentions) ?? '', + verified_at: verifiedAt, + }; + }); + const emojis = Object.entries(await this.customEmojiService.populateEmojis(u.emojis, u.host)).map(([shortcode, url]) => ({ + shortcode, + url, + static_url: url, + visible_in_picker: true, + })); + const lastStatus = await this.notesRepository.createQueryBuilder() + .where({ userId: u.id }) + .orderBy('id', 'DESC') + .select('id') + .getOne(); + const lastStatusAt = lastStatus ? this.idService.parse(lastStatus.id).date.toDateString() : null; + let moved = undefined; + if (recurse && u.movedToUri) { + moved = await this.#encodeInner(await this.apPersonService.resolvePerson(u.movedToUri), me, false); + } + return { + id: u.id, + username: u.username, + acct, + display_name: u.name ?? u.username, + locked: u.isLocked, + bot: u.isBot, + discoverable: u.isExplorable, + group: false, // unimplemented + created_at: u.createdAt.toISOString(), + note: this.mfmService.toHtml(parsedDescription, mentions) ?? '', + url: profile.url ?? acctUrl, + uri: u.uri ?? acctUrl, + avatar: avatarUrl, + avatar_static: avatarUrl, + header: bannerUrl, + header_static: bannerUrl, + followers_count: followersCount || 0, + following_count: followingCount || 0, + statuses_count: u.notesCount, + noindex: profile.noCrawle, + emojis, + last_status_at: lastStatusAt, + suspended: u.isSuspended || undefined, + fields, + moved, + }; + } + + async #extractMentions(nodes: mfm.MfmNode[]): Promise { + const mentions = extractMentions(nodes).filter(mention => mention.host !== null); + if (mentions.length === 0) { + return []; + } + let query = this.usersRepository.createQueryBuilder('user'); + for (const { username, host } of mentions) { + query = query.orWhere( + '("user"."usernameLower" = :username AND host = :host)', + { username: username.toLowerCase(), host: host?.toLowerCase() }, + ); + } + return await query + .innerJoinAndSelect(this.userProfilesRepository.metadata.targetName, 'profile', '"user".id = profile."userId"') + .select(['uri', 'url', 'username', 'host']) + .getRawMany(); + } +} diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index aafbf8060..42eab0093 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -46,10 +46,12 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; +import { MastodonEndpointsModule } from './api/MastodonEndpointsModule.js'; @Module({ imports: [ EndpointsModule, + MastodonEndpointsModule, CoreModule, ], providers: [ diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index cd15721b0..9c0421849 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -19,10 +19,11 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { Config } from '@/config.js'; -import { ApiError } from './error.js'; +import { ApiError, MastodonApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; +import { IMastodonEndpoint } from './mastodon-endpoints.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; import type { OnApplicationShutdown } from '@nestjs/common'; import type { IEndpointMeta, IEndpoint } from './endpoints.js'; @@ -182,6 +183,32 @@ export class ApiCallService implements OnApplicationShutdown { }); } + @bindThis + public async handleMastodonRequest( + endpoint: IMastodonEndpoint & { exec: any }, + request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, + reply: FastifyReply, + ) { + const body = request.method === 'GET' + ? request.query + : request.body; + const token = request.headers.authorization?.startsWith('Bearer ') + ? request.headers.authorization.slice(7) + : null; + const [user] = await this.authenticateService.authenticate(token); + let res; + try { + res = await endpoint.exec(body, user, token, null, request.ip, request.headers); + } catch (e) { + if (e instanceof MastodonApiError) { + reply.status(e.httpStatusCode).send({ error: e.message }); + return; + } + throw e; + } + reply.send(res); + } + @bindThis private send(reply: FastifyReply, x?: any, y?: ApiError) { if (x == null) { diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 941d2f742..10e6272d6 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -17,6 +17,7 @@ import endpoints from './endpoints.js'; import { ApiCallService } from './ApiCallService.js'; import { SignupApiService } from './SignupApiService.js'; import { SigninApiService } from './SigninApiService.js'; +import { IMastodonEndpoint, mastodonEndpoints } from './mastodon-endpoints.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() @@ -105,6 +106,32 @@ export class ApiServerService { } } + const createEndpoint = (endpoint: IMastodonEndpoint): IMastodonEndpoint & { exec: any } => ({ + name: endpoint.name, + method: endpoint.method, + meta: endpoint.meta, + params: endpoint.params, + exec: this.moduleRef.get(`mep:${endpoint.method}:${endpoint.name}`, { strict: false }).exec, + }); + const groupedMastodonEndpoints = Array.from(Map.groupBy(mastodonEndpoints.map(createEndpoint), endpoint => endpoint.name)) + .map(([name, endpoints]) => ({ name, endpoints: new Map(endpoints.map(endpoint => [endpoint.method, endpoint])) })); + for (const { name, endpoints } of groupedMastodonEndpoints) { + fastify.all<{ + Params: { endpoint: string; }, + Body: Record, + Querystring: Record, + }>('/' + name, async (request, reply) => { + const ep = endpoints.get(request.method); + if (!ep) { + reply.code(405); + reply.send(); + return; + } + await this.apiCallService.handleMastodonRequest(ep, request, reply); + return reply; + }); + } + fastify.post<{ Body: { username: string; diff --git a/packages/backend/src/server/api/MastodonEndpointsModule.ts b/packages/backend/src/server/api/MastodonEndpointsModule.ts new file mode 100644 index 000000000..099da28af --- /dev/null +++ b/packages/backend/src/server/api/MastodonEndpointsModule.ts @@ -0,0 +1,19 @@ +import { Module, Provider } from '@nestjs/common'; + +import { CoreModule } from '@/core/CoreModule.js'; +import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js'; + +const $accounts_lookup_v1_get: Provider = { provide: 'mep:GET:v1/accounts/lookup', useClass: mep___accounts_lookup_v1_get.default }; + +@Module({ + imports: [ + CoreModule, + ], + providers: [ + $accounts_lookup_v1_get, + ], + exports: [ + $accounts_lookup_v1_get, + ], +}) +export class MastodonEndpointsModule {} diff --git a/packages/backend/src/server/api/ajv.ts b/packages/backend/src/server/api/ajv.ts new file mode 100644 index 000000000..d6be6ec0a --- /dev/null +++ b/packages/backend/src/server/api/ajv.ts @@ -0,0 +1,22 @@ +import _Ajv from 'ajv'; + +const Ajv = _Ajv.default; + +const ajv = new Ajv({ + useDefaults: true, +}); + +ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); +ajv.addFormat('url', { + type: 'string', + validate: (url: string) => { + try { + new URL(url); + return true; + } catch (e) { + return false; + } + }, +}); + +export { ajv }; diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index 68f3d4c0f..54b3ac3f1 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -4,32 +4,13 @@ */ import * as fs from 'node:fs'; -import _Ajv from 'ajv'; import type { Schema, SchemaType } from '@/misc/json-schema.js'; import type { MiLocalUser } from '@/models/User.js'; import type { MiAccessToken } from '@/models/AccessToken.js'; +import { ajv } from './ajv.js'; import { ApiError } from './error.js'; import type { IEndpointMeta } from './endpoints.js'; -const Ajv = _Ajv.default; - -const ajv = new Ajv({ - useDefaults: true, -}); - -ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); -ajv.addFormat('url', { - type: 'string', - validate: (url: string) => { - try { - new URL(url); - return true; - } catch (e) { - return false; - } - }, -}); - export type Response = Record | void; type File = { diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index b05447050..fcdf1b939 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -23,3 +23,15 @@ export class ApiError extends Error { this.info = info ?? undefined; } } + +export class MastodonApiError extends Error { + constructor(message: string | undefined, public httpStatusCode: number) { + super(message ?? 'no error message available'); + } +} + +export class MastodonNotFoundError extends MastodonApiError { + constructor() { + super('Record not found', 404); + } +} diff --git a/packages/backend/src/server/api/mastodon-endpoint-base.ts b/packages/backend/src/server/api/mastodon-endpoint-base.ts new file mode 100644 index 000000000..581485bcb --- /dev/null +++ b/packages/backend/src/server/api/mastodon-endpoint-base.ts @@ -0,0 +1,39 @@ +import type { Schema, SchemaType } from '@/misc/json-schema.js'; +import { MiLocalUser } from '@/models/User.js'; +import { MiAccessToken } from '@/models/AccessToken.js'; +import { IMastodonEndpointMeta } from './mastodon-endpoints.js'; +import { ajv } from './ajv.js'; +import { MastodonApiError } from './error.js'; + +type Executor = + (params: SchemaType, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null) => Promise; + +export abstract class MastodonEndpoint { + public exec: ( + params: SchemaType, + user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, + token: MiAccessToken | null, + file?: File, + ip?: string | null, + headers?: Record | null, + ) => Promise; + + constructor(meta: T, paramDef: Ps, exec: ( + params: SchemaType, + user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, + token: MiAccessToken | null, + file?: File, + ip?: string | null, + headers?: Record | null, + ) => Promise) { + const validate = ajv.compile(paramDef); + this.exec = (params, user, token, file, ip, headers) => { + const valid = validate(params); + if (!valid) { + const error = validate.errors![0]; + throw new MastodonApiError(`${error.schemaPath}: ${error.message}`, 400); + } + return exec(params, user, token, file, ip, headers); + }; + } +} diff --git a/packages/backend/src/server/api/mastodon-endpoints.ts b/packages/backend/src/server/api/mastodon-endpoints.ts new file mode 100644 index 000000000..6c138c206 --- /dev/null +++ b/packages/backend/src/server/api/mastodon-endpoints.ts @@ -0,0 +1,30 @@ +import { Schema } from '@/misc/json-schema.js'; +import * as mep___accounts_lookup_v1_get from './mastodon/accounts/lookup/v1/get.js'; + +const eps = [ + ['GET', 'v1/accounts/lookup', mep___accounts_lookup_v1_get], +]; + +export interface IMastodonEndpointMeta { + readonly requireCredential: boolean; +} + +export interface IMastodonEndpoint { + name: string; + method: string; + meta: IMastodonEndpointMeta; + params: Schema; +} + +export const mastodonEndpoints: IMastodonEndpoint[] = (eps as [string, string, any]).map(([method, name, ep]) => { + return { + name, + method, + get meta() { + return ep.meta; + }, + get params() { + return ep.schema; + }, + }; +}); diff --git a/packages/backend/src/server/api/mastodon/accounts/lookup/v1/get.ts b/packages/backend/src/server/api/mastodon/accounts/lookup/v1/get.ts new file mode 100644 index 000000000..6cdc7aad3 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/accounts/lookup/v1/get.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { MastodonEndpoint } from '@/server/api/mastodon-endpoint-base.js'; +import * as Acct from '@/misc/acct.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { MastodonUserConverterService } from '@/core/mastodon/MastodonUserConverterService.js'; +import { MastodonNotFoundError } from '@/server/api/error.js'; + +export const meta = { + requireCredential: false, +} as const; + +export const paramDef = { + type: 'object', + properties: { + acct: { type: 'string', minLength: 1 }, + }, + required: ['acct'], +} as const; + +@Injectable() +export default class extends MastodonEndpoint { // eslint-disable-line import/no-default-export + constructor( + private remoteUserResolveService: RemoteUserResolveService, + private mastodonUserConverterService: MastodonUserConverterService, + ) { + super(meta, paramDef, async (ps, me) => { + const { username, host } = Acct.parse(ps.acct); + let user; + try { + user = await this.remoteUserResolveService.resolveUser(username, host); + } catch (e) { + throw new MastodonNotFoundError(); + } + const encoded = await this.mastodonUserConverterService.encode(user, me); + if (!encoded) { + throw new MastodonNotFoundError(); + } + return encoded; + }); + } +} diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index e22f3b37a..2bde3dffb 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -10,7 +10,7 @@ import * as assert from 'assert'; // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; import { MiUser } from '@/models/_.js'; -import { api, initTestDb, post, sendEnvUpdateRequest, signup, simpleGet, uploadFile } from '../utils.js'; +import { api, initTestDb, post, relativeFetch, sendEnvUpdateRequest, signup, simpleGet, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Endpoints', () => { @@ -1062,4 +1062,16 @@ describe('Endpoints', () => { assert.strictEqual(resCarol.body.memo, memoCarolToBob); }); }); + + describe('v1/accounts/lookup', () => { + test('regular usage', async () => { + const response: any = await (await relativeFetch('api/v1/accounts/lookup?acct=alice')).json(); + assert.strictEqual(response.username, 'alice'); + }); + + test('404', async () => { + const response = await relativeFetch('api/v1/accounts/lookup?acct=fourohfour'); + assert.strictEqual(response.status, 404); + }); + }); });