diff --git a/locales/en-US.yml b/locales/en-US.yml index a7e55192e..0503d5b58 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -317,6 +317,7 @@ lightThemes: "Light themes" darkThemes: "Dark themes" syncDeviceDarkMode: "Sync Dark Mode with your device settings" drive: "Drive" +driveSearchbarPlaceholder: "Search drive" fileName: "Filename" selectFile: "Select a file" selectFiles: "Select files" @@ -530,6 +531,7 @@ mediaListWithOneImageAppearance: "Height of media lists with one image only" limitTo: "Limit to {x}" noFollowRequests: "You don't have any pending follow requests" openImageInNewTab: "Open images in new tab" +warnForMissingAltText: "Warn you when you forget to put alt text" dashboard: "Dashboard" local: "Local" remote: "Remote" @@ -1054,6 +1056,9 @@ thisPostMayBeAnnoying: "This note may annoy others." thisPostMayBeAnnoyingHome: "Post to home timeline" thisPostMayBeAnnoyingCancel: "Cancel" thisPostMayBeAnnoyingIgnore: "Post anyway" +thisPostIsMissingAltTextCancel: "Cancel" +thisPostIsMissingAltTextIgnore: "Post anyway" +thisPostIsMissingAltText: "One of the files attached to this post is missing alt text. Please ensure all the attachments have alt text." collapseRenotes: "Collapse renotes you've already seen" internalServerError: "Internal Server Error" internalServerErrorDescription: "The server has run into an unexpected error." diff --git a/locales/index.d.ts b/locales/index.d.ts index 1bb2e3753..dc56ba98a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1292,6 +1292,10 @@ export interface Locale extends ILocale { * ドライブ */ "drive": string; + /** + * 検索ドライブ + */ + "driveSearchbarPlaceholder": string; /** * ファイル名 */ @@ -4239,6 +4243,18 @@ export interface Locale extends ILocale { * このまま投稿 */ "thisPostMayBeAnnoyingIgnore": string; + /** + * やめる + */ + "thisPostIsMissingAltTextCancel": string; + /** + * このまま投稿 + */ + "thisPostIsMissingAltTextIgnore": string; + /** + * この投稿に添付されたファイルの 1 つに代替テキストがありません。すべての添付ファイルに代替テキストが含まれていることを確認してください。 + */ + "thisPostIsMissingAltText": string; /** * 見たことのあるリノートを省略して表示 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 0ccedb88f..4af35892b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -319,6 +319,7 @@ lightThemes: "明るいテーマ" darkThemes: "暗いテーマ" syncDeviceDarkMode: "デバイスのダークモードと同期する" drive: "ドライブ" +driveSearchbarPlaceholder: "検索ドライブ" fileName: "ファイル名" selectFile: "ファイルを選択" selectFiles: "ファイルを選択" @@ -1055,6 +1056,9 @@ thisPostMayBeAnnoying: "この投稿は迷惑になる可能性があります thisPostMayBeAnnoyingHome: "ホームに投稿" thisPostMayBeAnnoyingCancel: "やめる" thisPostMayBeAnnoyingIgnore: "このまま投稿" +thisPostIsMissingAltTextCancel: "やめる" +thisPostIsMissingAltTextIgnore: "このまま投稿" +thisPostIsMissingAltText: "この投稿に添付されたファイルの 1 つに代替テキストがありません。すべての添付ファイルに代替テキストが含まれていることを確認してください。" collapseRenotes: "見たことのあるリノートを省略して表示" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。" 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/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index 3df7d3670..915d3edbc 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -4,11 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; export const meta = { tags: ['drive'], @@ -37,6 +39,7 @@ export const paramDef = { folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] }, + searchQuery: { type: 'string', default: '' }, }, required: [], } as const; @@ -60,6 +63,15 @@ export default class extends Endpoint { // eslint- query.andWhere('file.folderId IS NULL'); } + if (ps.searchQuery.length > 0) { + const args = { searchQuery: `%${sqlLikeEscape(ps.searchQuery)}%` }; + query.andWhere(new Brackets((qb) => { + qb + .where('file.name ILIKE :searchQuery', args) + .orWhere('file.comment ILIKE :searchQuery', args); + })); + } + if (ps.type) { if (ps.type.endsWith('/*')) { query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts index 8c4848f8e..7f9278c7f 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -9,6 +9,7 @@ import type { DriveFoldersRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; export const meta = { tags: ['drive'], @@ -35,6 +36,7 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, + searchQuery: { type: 'string', default: '' }, }, required: [], } as const; @@ -58,6 +60,10 @@ export default class extends Endpoint { // eslint- query.andWhere('folder.parentId IS NULL'); } + if (ps.searchQuery.length > 0) { + query.andWhere('folder.name ILIKE :searchQuery', { searchQuery: `%${sqlLikeEscape(ps.searchQuery)}%` }); + } + const folders = await query.limit(ps.limit).getMany(); return await Promise.all(folders.map(folder => this.driveFolderEntityService.pack(folder))); 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); + }); + }); }); diff --git a/packages/frontend/assets/kawaii/LICENSE_SAWARATSUKI_KawaiiLogos b/packages/frontend/assets/kawaii/LICENSE_SAWARATSUKI_KawaiiLogos deleted file mode 100644 index d09912f27..000000000 --- a/packages/frontend/assets/kawaii/LICENSE_SAWARATSUKI_KawaiiLogos +++ /dev/null @@ -1,18 +0,0 @@ -LICENSE -------- - -Copyright (c) by [さわらつき](https://github.com/SAWARATSUKI) - -The following files: - -- misskey-io.png -- about-icon.png - -are licensed under proprietary license. To view a copy of this license, visit https://github.com/SAWARATSUKI/KawaiiLogos/blob/main/README_EN.md#license -AND SHOULD NOT be considered as part of this project, which is licensed under AGPL-3.0-only. - -The following files: - -- about-icon.png (uwl.png) - -were copied from [misskey-dev/misskey-hub-next](https://github.com/misskey-dev/misskey-hub-next/tree/221a89760e80d75a00600c4e37e22c0f226a6017/public/img/uwu) diff --git a/packages/frontend/assets/kawaii/about-icon.png b/packages/frontend/assets/kawaii/about-icon.png deleted file mode 100644 index e85cabba7..000000000 Binary files a/packages/frontend/assets/kawaii/about-icon.png and /dev/null differ diff --git a/packages/frontend/assets/kawaii/misskey-io.png b/packages/frontend/assets/kawaii/misskey-io.png deleted file mode 100644 index 110c2d50b..000000000 Binary files a/packages/frontend/assets/kawaii/misskey-io.png and /dev/null differ diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 48a8e1679..a4a8901d5 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -5,6 +5,7 @@ import { computed, watch, version as vueVersion, App } from 'vue'; import { compareVersions } from 'compare-versions'; +import VueGtag, { bootstrap as gtagBootstrap, GtagConsent, GtagConsentParams } from 'vue-gtag'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; import components from '@/components/index.js'; @@ -24,7 +25,6 @@ import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; import { setupRouter } from '@/router/definition.js'; import { mainRouter } from '@/router/main.js'; -import VueGtag, { bootstrap as gtagBootstrap, GtagConsent, GtagConsentParams } from 'vue-gtag'; export async function common(createVue: () => App) { console.info(`Misskey v${version}`); @@ -144,17 +144,6 @@ export async function common(createVue: () => App) { } //#endregion - //#region kawaii - if (params.has('kawaii') || params.has('uwu')) { - const v = params.get('kawaii') ?? params.get('uwu'); - if (v === 'false' || v === '0' || v === 'no' || v === 'off') { - miLocalStorage.removeItem('kawaii'); - } else { - miLocalStorage.setItem('kawaii', 'true'); - } - } - //#endregion - // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) watch(defaultStore.reactiveState.darkMode, (darkMode) => { applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 19e2c67b0..7f9845144 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -30,7 +30,14 @@ SPDX-License-Identifier: AGPL-3.0-only {{ folder.name }} - +
+ + + + + + +
{ if (fetchedFolders.length === foldersMax + 1) { moreFolders.value = true; @@ -552,6 +563,7 @@ async function fetch() { folderId: folder.value ? folder.value.id : null, type: props.type, limit: filesMax + 1, + searchQuery: searchQuery.value.toString().trim(), }).then(fetchedFiles => { if (fetchedFiles.length === filesMax + 1) { moreFiles.value = true; @@ -578,6 +590,7 @@ function fetchMoreFolders() { type: props.type, untilId: folders.value[folders.value.length - 1].id, limit: max + 1, + searchQuery: searchQuery.value.toString().trim(), }).then(folders => { if (folders.length === max + 1) { moreFolders.value = true; @@ -601,6 +614,7 @@ function fetchMoreFiles() { type: props.type, untilId: files.value[files.value.length - 1].id, limit: max + 1, + searchQuery: searchQuery.value.toString().trim(), }).then(files => { if (files.length === max + 1) { moreFiles.value = true; @@ -747,8 +761,13 @@ onBeforeUnmount(() => { } .navMenu { + display: flex; margin-left: auto; - padding: 0 12px; + align-items: center; +} + +.navMenu > *:not(:last-child) { + padding-right: 12px; } .main { diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 00e5f3966..27dc8a5f4 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -101,7 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only