diff --git a/locales/index.d.ts b/locales/index.d.ts index 966c2224fb64..f8c4971655ee 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4894,7 +4894,7 @@ export interface Locale extends ILocale { */ "readConfirmText": ParameterizedString<"title">; /** - * 特に新規ユーザーのUXを損ねる可能性が高いため、ストック情報ではなくフロー情報の掲示にお知らせを使用することを推奨します。 + * 特に新規ユーザーのUXを損ねる可能性が高いため、常時掲示するための情報ではなく、即時性が求められる情報の掲示のためにお知らせを使用することを推奨します。 */ "shouldNotBeUsedToPresentPermanentInfo": string; /** diff --git a/packages/backend/migration/1706599230317-support-micropub.js b/packages/backend/migration/1706599230317-support-micropub.js new file mode 100644 index 000000000000..3df528520b85 --- /dev/null +++ b/packages/backend/migration/1706599230317-support-micropub.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SupportMicropub1706599230317 { + name = 'SupportMicropub1706599230317' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "drive_file" ADD "downloadedFrom" character varying(512)`); + await queryRunner.query(`ALTER TABLE "drive_file" ADD "createdByMicropub" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_209c886ff6705aa121d29f13c1" ON "drive_file" ("downloadedFrom") `); + await queryRunner.query(`CREATE INDEX "IDX_aa00d9f0d9c8c99c5dd17ecca8" ON "drive_file" ("createdByMicropub") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_aa00d9f0d9c8c99c5dd17ecca8"`); + await queryRunner.query(`DROP INDEX "public"."IDX_209c886ff6705aa121d29f13c1"`); + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "createdByMicropub"`); + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "downloadedFrom"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 9551991b34e4..17e90f53defb 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -75,6 +75,7 @@ "@fastify/cookie": "9.3.1", "@fastify/cors": "8.5.0", "@fastify/express": "2.3.0", + "@fastify/formbody": "^7.4.0", "@fastify/http-proxy": "9.3.0", "@fastify/multipart": "8.1.0", "@fastify/static": "6.12.0", diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 075bc9d7f72c..ad4791b74d4e 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -68,6 +68,8 @@ type AddFileArgs = { /** Extension to force */ ext?: string | null; + micropub?: boolean; + downloadedFrom?: string | null; requestIp?: string | null; requestHeaders?: Record | null; }; @@ -453,6 +455,8 @@ export class DriveService { url = null, uri = null, sensitive = null, + micropub = false, + downloadedFrom = null, requestIp = null, requestHeaders = null, ext = null, @@ -572,6 +576,8 @@ export class DriveService { file.properties = properties; file.blurhash = info.blurhash ?? null; file.isLink = isLink; + file.createdByMicropub = micropub; + file.downloadedFrom = downloadedFrom; file.requestIp = requestIp; file.requestHeaders = requestHeaders; file.maybeSensitive = info.sensitive; diff --git a/packages/backend/src/models/DriveFile.ts b/packages/backend/src/models/DriveFile.ts index 24e6be9c90c2..180c31ad86dd 100644 --- a/packages/backend/src/models/DriveFile.ts +++ b/packages/backend/src/models/DriveFile.ts @@ -188,4 +188,16 @@ export class MiDriveFile { length: 128, nullable: true, }) public requestIp: string | null; + + @Index() + @Column('varchar', { + length: 512, nullable: true, + }) + public downloadedFrom: string | null; + + @Index() + @Column('boolean', { + default: false, + }) + public createdByMicropub: boolean; } diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index aed352d15e28..5041d4349ea5 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -27,6 +27,7 @@ import { ClientServerService } from './web/ClientServerService.js'; import { FeedService } from './web/FeedService.js'; import { UrlPreviewService } from './web/UrlPreviewService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js'; +import { MicropubServerService } from './micropub/MicropubServerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; import { MainChannelService } from './api/stream/channels/main.js'; @@ -89,6 +90,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js ServerStatsChannelService, UserListChannelService, OpenApiServerService, + MicropubServerService, OAuth2ProviderService, ], exports: [ diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 632a7692cdba..48e604c575d3 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -31,6 +31,7 @@ import { WellKnownServerService } from './WellKnownServerService.js'; import { FileServerService } from './FileServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; +import { MicropubServerService } from './micropub/MicropubServerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; const _dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -65,6 +66,7 @@ export class ServerService implements OnApplicationShutdown { private clientServerService: ClientServerService, private globalEventService: GlobalEventService, private loggerService: LoggerService, + private micropubServerService: MicropubServerService, private oauth2ProviderService: OAuth2ProviderService, ) { this.logger = this.loggerService.getLogger('server', 'gray', false); @@ -107,6 +109,7 @@ export class ServerService implements OnApplicationShutdown { fastify.register(this.activityPubServerService.createServer); fastify.register(this.nodeinfoServerService.createServer); fastify.register(this.wellKnownServerService.createServer); + fastify.register(this.micropubServerService.createServer, { prefix: '/micropub' }); fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' }); fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' }); diff --git a/packages/backend/src/server/micropub/MicropubServerService.ts b/packages/backend/src/server/micropub/MicropubServerService.ts new file mode 100644 index 000000000000..82eb66fe3d1c --- /dev/null +++ b/packages/backend/src/server/micropub/MicropubServerService.ts @@ -0,0 +1,684 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as stream from 'node:stream/promises'; +import * as querystring from 'node:querystring'; +import * as parse5 from 'parse5'; +import * as mfm from 'mfm-js'; +import ms from 'ms'; +import Ajv from 'ajv'; +import { In } from 'typeorm'; +import { Injectable, Inject } from '@nestjs/common'; +import formbody from '@fastify/formbody'; +import multipart from '@fastify/multipart'; +import { format as dateFormat } from 'date-fns'; +import Logger from '@/logger.js'; +import { type Config } from '@/config.js'; +import type { MiDriveFile, MiUser, MiNote, MiAccessToken, BlockingsRepository, UsersRepository, ChannelsRepository, NotesRepository, DriveFilesRepository } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import { MfmService } from '@/core/MfmService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { FileInfoService } from '@/core/FileInfoService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NoteDeleteService } from '@/core/NoteDeleteService.js'; +import { RateLimiterService } from '@/server/api/RateLimiterService.js'; +import { AuthenticateService } from '@/server/api/AuthenticateService.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { createTemp } from '@/misc/create-temp.js'; +import { isPureRenote } from '@/misc/is-pure-renote.js'; +import type { SchemaType } from '@/misc/json-schema.js'; +import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { DI } from '@/di-symbols.js'; +import * as TreeAdapter from '../../../node_modules/parse5/dist/tree-adapters/default.js'; +import type { FastifyInstance, FastifyReply } from 'fastify'; + +type ValueOf = T[keyof T]; +type MediaImage = { source: string, alt: string } | string; +type MediaRawSource = { path: string, filename: string }; +type MediaRawSources = { photo: MediaRawSource[], audio: MediaRawSource[], video: MediaRawSource[] }; +type MediaSource = { photos: MediaImage[], audios: string[], videos: string[] }; +type MicropubRequest = { h?: string, media?: MediaRawSources, action?: string, url?: string, body: unknown }; +type NoteCreateOptions = { + content: (string | { html: string })[], + cw?: string, + photos?: ({ alt: string, value: string } | string)[], + audios?: string[], + videos?: string[], + renote?: { id?: string }, + channel?: { id?: string }, + published?: string[], + category?: string[], + visibility?: string, + localOnly?: boolean, + reactionAcceptance?: MiNote['reactionAcceptance'], + visibleUsers?: string[], +} + +const errorSymbols = { + FORBIDDEN: Symbol('FORBIDDEN'), + UNAUTHORIZED: Symbol('UNAUTHORIZED'), + BAD_REQUEST: Symbol('BAD_REQUEST'), + INSUFFICIENT_SCOPE: Symbol('INSUFFICIENT_SCOPE'), +}; + +const mergeSymbols = { + MERGE_ADD: Symbol('MERGE_ADD'), + MERGE_REPLACE: Symbol('MERGE_REPLACE'), + MERGE_DELETE: Symbol('MERGE_DELETE'), +}; + +const misskeyGeneralSchema = { + published: { type: 'array', items: { type: 'string', format: 'iso-date-time' } }, + content: { type: 'array', items: { oneOf: [ + { type: 'object', properties: { html: { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH } }, required: ['html'] }, + { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH }, + ] } }, + photos: { type: 'array', items: { oneOf: [ + { type: 'object', properties: { alt: { type: 'string' }, value: { type: 'string', format: 'url' } }, required: ['alt', 'value'] }, + { type: 'string', format: 'url' }, + ] } }, + audios: { type: 'array', items: { type: 'string', format: 'url' } }, + videos: { type: 'array', items: { type: 'string', format: 'url' } }, + category: { type: 'array', items: { type: 'string' } }, + 'misskey-visible-users': { type: 'array', items: { type: 'string' } }, + 'misskey-cw': { type: 'array', minItems: 1, maxItems: 1, items: { type: 'string' } }, + 'misskey-reaction-acceptance': { type: 'array', minItems: 1, maxItems: 1, items: { type: 'string', enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'] } }, + 'misskey-channel-id': { type: 'array', minItems: 1, maxItems: 1, items: { type: 'string' } }, + 'misskey-renote-id': { type: 'array', minItems: 1, maxItems: 1, items: { type: 'string' } }, + 'misskey-local-only': { type: 'array', minItems: 1, maxItems: 1, items: { type: 'boolean' } }, + 'misskey-visibility': { type: 'array', minItems: 1, maxItems: 1, items: { type: 'string' } }, +} as const; + +const IS_X_WWW_FORM_URLENCODED = Symbol('IS_X_WWW_FORM_URLENCODED'); +const treeAdapter = TreeAdapter.defaultTreeAdapter; +const validator = new Ajv.default({ coerceTypes: 'array' }); +validator.addFormat('url', { type: 'string', validate: URL.canParse }); +// Taken from https://github.com/ajv-validator/ajv-formats/blob/master/src/formats.ts#L112-L115 +validator.addFormat('iso-date-time', /^\d\d\d\d-[0-1]\d-[0-3]\d[t\s](?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i); +const createNoteSchema = { type: 'object', properties: misskeyGeneralSchema, required: ['content'] } as const; +const updateNoteSchema = { type: 'object', properties: misskeyGeneralSchema, required: [] } as const; +const validateIsCreatingNote = validator.compile(createNoteSchema); +const validateIsUpdatingNote = validator.compile(updateNoteSchema); + +class MicropubError extends Error { + public errorTypes: ValueOf; + public description: string | null; + + constructor(errorTypes: ValueOf, description?: string) { + super(description); + this.errorTypes = errorTypes; + this.description = description ?? null; + } +} + +@Injectable() +export class MicropubServerService { + #logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + private idService: IdService, + private mfmService: MfmService, + private roleService: RoleService, + private driveService: DriveService, + private loggerService: LoggerService, + private downloadService: DownloadService, + private fileInfoService: FileInfoService, + private noteCreateService: NoteCreateService, + private noteDeleteService: NoteDeleteService, + private rateLimiterService: RateLimiterService, + private authenticateService: AuthenticateService, + ) { + this.#logger = this.loggerService.getLogger('micropub'); + } + + // Retrieving media sources from HTML content. + @bindThis + private getMediaSourcesFromHTML(html: string): MediaSource { + function analyzeNodes(nodes: TreeAdapter.Node[]): [MediaImage[], string[], string[]] { + let photos: MediaImage[] = []; + let audios: string[] = []; + let videos: string[] = []; + + for (const node of nodes) { + if (!treeAdapter.isElementNode(node)) continue; + switch (node.nodeName) { + case 'img': { + const source = node.attrs.find(x => x.name === 'src'); + const alt = node.attrs.find(x => x.name === 'alt'); + if (!source) continue; + photos = [...photos, alt ? { source: source.value, alt: alt.value } : source.value]; + break; + } + case 'video': { + const source = node.attrs.find(x => x.name === 'src'); + if (!source) continue; + videos = [...videos, source.value]; + break; + } + case 'audio': { + const source = node.attrs.find(x => x.name === 'src'); + if (!source) continue; + audios = [...audios, source.value]; + break; + } + default: { + const result = analyzeNodes(node.childNodes); + photos = [...photos, ...result[0]]; + audios = [...audios, ...result[1]]; + videos = [...videos, ...result[2]]; + break; + } + } + } + return [photos, audios, videos]; + } + + const rootNode = parse5.parseFragment(html); + const [photos, audios, videos] = analyzeNodes(rootNode.childNodes); + return { photos, audios, videos }; + } + + @bindThis + private serializeNoteFromRequest(request: Partial>): Partial { + return { + published: request.published, + content: request.content, + photos: request.photos, + audios: request.audios, + videos: request.videos, + category: request.category, + cw: request['misskey-cw']?.at(0), + renote: { id: request['misskey-renote-id']?.at(0) }, + channel: { id: request['misskey-channel-id']?.at(0) }, + visibility: request['misskey-visibility']?.at(0), + localOnly: request['misskey-local-only']?.at(0), + reactionAcceptance: request['misskey-reaction-acceptance']?.at(0), + visibleUsers: request['misskey-visible-users'], + }; + } + + @bindThis + private serializeRequestFromNote(note: NoteCreateOptions): SchemaType { + return { + published: note.published, + // @ts-expect-error error: 'The expected type comes from property 'content' which is declared here on type {...}' + content: note.content, + // @ts-expect-error error: 'The expected type comes from property 'photos' which is declared here on type {...}' + photos: note.photos, + audios: note.audios, + videos: note.videos, + category: note.category, + 'misskey-cw': note.cw ? [note.cw] : undefined, + 'misskey-renote-id': note.renote?.id ? [note.renote.id] : undefined, + 'misskey-channel-id': note.channel?.id ? [note.channel.id] : undefined, + 'misskey-visibility': note.visibility ? [note.visibility] : undefined, + 'misskey-local-only': note.localOnly ? [note.localOnly] : undefined, + 'misskey-reaction-acceptance': note.reactionAcceptance ? [note.reactionAcceptance] : undefined, + 'misskey-visible-users': note.visibleUsers, + }; + } + + @bindThis + private async serializeOptionsFromDatabase(note: MiNote, verbose = false): Promise { + const markdown = note.text ? mfm.parse(note.text) : undefined; + const text = markdown ? mfm.toString(mfm.extract(markdown, (node) => node.type !== 'hashtag')) : undefined; + const driveFiles = await this.driveFilesRepository.findBy({ id: In(note.fileIds) }); + const files = driveFiles.map(file => ({ + mime: file.type, + alt: file.comment, + url: file.downloadedFrom ?? new URL('/files/' + file.accessKey, this.config.url).toString(), + })); + + return { + ...(verbose ? { published: [this.idService.parse(note.id).date.toISOString()] } : {}), + cw: note.cw ?? undefined, + content: text ? [text.trimEnd()] : [], + renote: { id: note.renoteId ?? undefined }, + channel: { id: note.channelId ?? undefined }, + category: note.tags, + visibility: note.visibility, + localOnly: note.localOnly, + reactionAcceptance: note.reactionAcceptance ?? undefined, + visibleUsers: note.visibleUserIds.length > 0 ? note.visibleUserIds : undefined, + photos: files.filter(file => file.mime.startsWith('image')).map(file => file.alt ? { alt: file.alt, value: file.url } : file.url), + audios: files.filter(file => file.mime.startsWith('audio')).map(file => file.url), + videos: files.filter(file => file.mime.startsWith('video')).map(file => file.url), + }; + } + + @bindThis + private sendMicropubApiError(error: unknown, reply: FastifyReply) { + function sendApiError(code: number, error: string, description: string | null, reply: FastifyReply) { + reply.code(code); + return reply.send({ error, ...(description ? { error_description: description } : {}) }); + } + + if (error instanceof MicropubError) { + switch (error.errorTypes) { + case errorSymbols.FORBIDDEN: return sendApiError(403, 'forbidden', error.description, reply); + case errorSymbols.UNAUTHORIZED: return sendApiError(401, 'unauthorized', error.description, reply); + case errorSymbols.BAD_REQUEST: return sendApiError(400, 'invalid_request', error.description, reply); + case errorSymbols.INSUFFICIENT_SCOPE: return sendApiError(403, 'insufficient_scope', error.description, reply); + } + } + + if (process.env.NODE_ENV === 'production') return sendApiError(400, 'invalid_request', 'Intenal server error occurred', reply); + return sendApiError(400, 'invalid_request', `Intenal server error occurred: ${JSON.stringify(error, Object.getOwnPropertyNames(error))}`, reply); + } + + @bindThis + private async createNote(user: MiUser, options: NoteCreateOptions, rawMedia?: MediaRawSources): Promise<{ noteId: MiNote['id'] }> { + let message = options.content.map(m => typeof m === 'object' && 'html' in m ? this.mfmService.fromHtml(m.html) : m).join('\n'); + const published = options.published && options.published.length > 0 ? new Date(options.published[0]) : new Date(); + const html = options.content.reduce((a, b) => typeof b === 'object' && 'html' in b ? a + b.html : a, ''); + let { photos, audios, videos } = html ? this.getMediaSourcesFromHTML(html) : { photos: [], audios: [], videos: [] }; + photos = [...photos, ...(options.photos ?? []).map(photo => typeof photo === 'string' ? photo : { source: photo.value, alt: photo.alt })]; + audios = [...audios, ...(options.audios ?? [])]; + videos = [...videos, ...(options.videos ?? [])]; + message += ' ' + (options.category ?? []).map(hashtag => `#${hashtag}`).join(' '); + const media = [...photos, ...audios, ...videos]; + let attachedMedia: MiDriveFile[] = []; + + for (const medium of media) { + const source = typeof medium === 'string' ? medium : medium.source; + const mediaUrl = new URL(source); + if (mediaUrl.origin === this.config.url && mediaUrl.pathname.startsWith('/files/')) { + const accessKey = mediaUrl.pathname.slice(7); + const driveFile = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.accessKey = :accessKey', { accessKey }) + .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: accessKey }) + .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: accessKey }) + .getOne(); + if (driveFile === null) throw new MicropubError(errorSymbols.BAD_REQUEST, `Cannot access to ${medium}`); + attachedMedia = [...attachedMedia, driveFile]; + } else { + const [destPath] = await createTemp(); + await this.downloadService.downloadUrl(source, destPath); + const extension = await this.fileInfoService.detectType(destPath); + const filename = correctFilename('micropub-' + dateFormat(new Date(), 'yyyy-MM-ddHH-mm-ss'), extension.ext); + const driveFile = await this.driveService.addFile({ + user, + path: destPath, + name: filename, + force: true, + micropub: true, + ext: extension.ext, + comment: typeof medium === 'object' && 'alt' in medium ? medium.alt : undefined, + downloadedFrom: mediaUrl.toString(), + }); + attachedMedia = [...attachedMedia, driveFile]; + } + } + + if (typeof rawMedia !== 'undefined') { + for (const medium of [...rawMedia.photo, ...rawMedia.audio, ...rawMedia.video]) { + const driveFile = await this.driveService.addFile({ user, path: medium.path, name: path.parse(medium.filename).name }); + attachedMedia = [...attachedMedia, driveFile]; + } + } + + const visibleUsers = options.visibleUsers ? await this.usersRepository.findBy({ id: In(options.visibleUsers) }) : null; + const renote = options.renote?.id ? await this.notesRepository.findOneBy({ id: options.renote.id }) : null; + const channel = options.channel?.id ? await this.channelsRepository.findOneBy({ id: options.channel.id }) : null; + if (options.renote?.id && !renote) throw new MicropubError(errorSymbols.BAD_REQUEST, 'Cannot find channel'); + if (options.channel?.id && !channel) throw new MicropubError(errorSymbols.BAD_REQUEST, `Cannot find note (id: ${options.channel.id})`); + + if (renote !== null) { + if (renote.userId !== user.id) { + const blockExists = await this.blockingsRepository.exists({ where: { blockerId: renote.userId, blockeeId: user.id } }); + if (blockExists) throw new MicropubError(errorSymbols.BAD_REQUEST, 'You have been blocked by this user'); + } + if (isPureRenote(renote)) throw new MicropubError(errorSymbols.BAD_REQUEST, 'Cannot renote a pure renote'); + if (renote.visibility === 'followers' && renote.userId !== user.id) throw new MicropubError(errorSymbols.BAD_REQUEST); + if (renote.visibility === 'specified') throw new MicropubError(errorSymbols.BAD_REQUEST); + if (renote.channelId && renote.userId !== options.channel?.id) { + const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId }); + if (renoteChannel === null) throw new MicropubError(errorSymbols.BAD_REQUEST, 'Cannot find channel'); + if (!renoteChannel.allowRenoteToExternal) throw new MicropubError(errorSymbols.BAD_REQUEST, 'Cannot renote outside of channel'); + } + } + + const note = await this.noteCreateService.create(user, { + ...options, + createdAt: published, + files: attachedMedia, + text: message, + renote, + channel, + visibleUsers, + }); + + return { noteId: note.id }; + } + + @bindThis + private mergeNote>(strategy: ValueOf, firstNote: T, secondNote: Partial): T { + const outputNote: T = firstNote; + + if (strategy === mergeSymbols.MERGE_ADD) { + for (const [key, value] of Object.entries(secondNote)) { + if (Array.isArray(value)) { + if (!Array.isArray(firstNote[key])) continue; + Reflect.set(outputNote, key, [...firstNote[key], ...value]); + } else if (typeof value === 'object') { + if (typeof firstNote[key] !== 'object' && value !== null) continue; + Reflect.set(outputNote, key, this.mergeNote(mergeSymbols.MERGE_ADD, >firstNote[key], value)); + } else if (typeof value !== 'undefined') { + if (typeof firstNote[key] !== 'undefined') continue; + Reflect.set(outputNote, key, value); + } + } + } else if (strategy === mergeSymbols.MERGE_REPLACE) { + for (const [key, value] of Object.entries(secondNote)) { + if (typeof value === 'object' && !Array.isArray(value)) { + if (typeof firstNote[key] !== 'object' || Array.isArray(firstNote[key])) continue; + Reflect.set(outputNote, key, this.mergeNote(mergeSymbols.MERGE_REPLACE, >firstNote[key], value)); + } else if (typeof value !== 'undefined') { + if (typeof firstNote[key] === 'undefined' || typeof firstNote[key] !== typeof value) continue; + Reflect.set(outputNote, key, value); + } + } + } else if (strategy === mergeSymbols.MERGE_DELETE) { + for (const [key, value] of Object.entries(secondNote)) { + if (typeof value === 'object' && !Array.isArray(value)) { + if (typeof firstNote[key] !== 'object') continue; + Reflect.set(outputNote, key, this.mergeNote(mergeSymbols.MERGE_DELETE, >firstNote[key], value)); + } else if (Array.isArray(value)) { + if (!Array.isArray(firstNote[key])) continue; + Reflect.set(outputNote, key, (firstNote[key]).filter(v => { + const normalized = value.map(v => URL.canParse(v) ? new URL(v).toString() : v); + return !normalized.includes(v); + })); + } + } + } + + return outputNote; + } + + @bindThis + private async deleteNote(user: MiUser, note: MiNote) { + const files = await this.driveFilesRepository.findBy({ + id: In(note.fileIds), + userId: user.id, + createdByMicropub: true, + }); + + await Promise.all(files.map(file => this.driveService.deleteFile(file))); + await this.noteDeleteService.delete(user, note); + } + + @bindThis + private async handleRequest(request: MicropubRequest, user: MiUser, app: MiAccessToken | null, reply: FastifyReply) { + if (request.h === 'entry') { + const valid = validateIsCreatingNote(request.body); + const message = `${validateIsCreatingNote.errors?.at(0)?.schemaPath}: ${validateIsCreatingNote.errors?.at(0)?.message}`; + if (!valid) throw new MicropubError(errorSymbols.BAD_REQUEST, message); + if (app && !app.permission.includes('write:notes')) throw new MicropubError(errorSymbols.INSUFFICIENT_SCOPE); + const validated = request.body as SchemaType; + const { noteId } = await this.createNote(user, { ...this.serializeNoteFromRequest(validated), content: validated.content }, request.media); + reply.code(201 /* Created */); + reply.header('Location', new URL('/notes/' + noteId, this.config.url)); + return await reply.send(); + } else if (typeof request.h !== 'undefined') { + throw new MicropubError(errorSymbols.BAD_REQUEST, 'Request format should be h-entry'); + } else if (request.action === 'delete') { + if (typeof request.url !== 'string') throw new MicropubError(errorSymbols.BAD_REQUEST); + const noteUrl = new URL(request.url); + if (app && !app.permission.includes('write:notes')) throw new MicropubError(errorSymbols.INSUFFICIENT_SCOPE); + if (noteUrl.origin !== this.config.url || !noteUrl.pathname.startsWith('/notes/')) throw new MicropubError(errorSymbols.BAD_REQUEST); + const noteId = noteUrl.pathname.slice(7); + const note = await this.notesRepository.findOneBy({ id: noteId, userId: user.id }); + if (note === null) throw new MicropubError(errorSymbols.BAD_REQUEST); + await this.deleteNote(user, note); + return await reply.send(); + } else if (request.action === 'update') { + if (typeof request.url !== 'string') throw new MicropubError(errorSymbols.BAD_REQUEST); + if (app && !app.permission.includes('write:notes')) throw new MicropubError(errorSymbols.INSUFFICIENT_SCOPE); + const noteUrl = new URL(request.url); + if (noteUrl.origin !== this.config.url || !noteUrl.pathname.startsWith('/notes/')) throw new MicropubError(errorSymbols.BAD_REQUEST); + const noteId = noteUrl.pathname.slice(7); + const note = await this.notesRepository.findOneBy({ id: noteId, userId: user.id }); + if (note === null) throw new MicropubError(errorSymbols.BAD_REQUEST, `Cannot find note (id: ${noteId}`); + const body = request.body as { add?: unknown, replace?: unknown, delete?: unknown }; + let options = await this.serializeOptionsFromDatabase(note); + + this.#logger.info(`Validating user input... [add: ${!!body.add}, replace: ${!!body.replace}, delete: ${!!body.delete}]`); + + if (typeof body.add !== 'undefined') { + const valid = validateIsUpdatingNote(body.add); + const message = `${validateIsUpdatingNote.errors?.at(0)?.schemaPath}: ${validateIsUpdatingNote.errors?.at(0)?.message}`; + if (!valid) throw new MicropubError(errorSymbols.BAD_REQUEST, message); + const overrideOptions = this.serializeNoteFromRequest(body.add as SchemaType); + options = this.mergeNote(mergeSymbols.MERGE_ADD, options, overrideOptions); + } + + if (typeof body.replace !== 'undefined') { + const valid = validateIsUpdatingNote(body.replace); + const message = `${validateIsUpdatingNote.errors?.at(0)?.schemaPath}: ${validateIsUpdatingNote.errors?.at(0)?.message}`; + if (!valid) throw new MicropubError(errorSymbols.BAD_REQUEST, message); + const overrideOptions = this.serializeNoteFromRequest(body.replace as SchemaType); + options = this.mergeNote(mergeSymbols.MERGE_REPLACE, options, overrideOptions); + } + + if (typeof body.delete === 'object' && !Array.isArray(body.delete)) { + const valid = validateIsUpdatingNote(body.delete); + const message = `${validateIsUpdatingNote.errors?.at(0)?.schemaPath}: ${validateIsUpdatingNote.errors?.at(0)?.message}`; + if (!valid) throw new MicropubError(errorSymbols.BAD_REQUEST, message); + const overrideOptions = this.serializeNoteFromRequest(body.delete as SchemaType); + options = this.mergeNote(mergeSymbols.MERGE_DELETE, options, overrideOptions); + } else if (Array.isArray(body.delete)) { + for (const key of body.delete) { + if (key in options && key !== 'content') delete options[key as keyof NoteCreateOptions]; + } + } + + this.#logger.info('Validating combined note...'); + const valid = validateIsCreatingNote(options); + const message = `${validateIsCreatingNote.errors?.at(0)?.schemaPath}: ${validateIsCreatingNote.errors?.at(0)?.message}`; + if (!valid) throw new MicropubError(errorSymbols.BAD_REQUEST, message); + await this.deleteNote(user, note); + const { noteId: createdNoteId } = await this.createNote(user, options); + reply.code(201 /* Created */); + reply.header('Location', new URL('/notes/' + createdNoteId, this.config.url)); + return await reply.send(); + } + throw new MicropubError(errorSymbols.BAD_REQUEST); + } + + @bindThis + public async createServer(fastify: FastifyInstance) { + fastify.register(formbody, { + parser: (request) => { + const params = querystring.parse(request); + const transformed = {} as Record; + for (const [key, value] of Object.entries(params)) { + if (typeof value !== 'undefined') { + if (key.endsWith('[]')) { + const strippedKey = key.slice(0, -2); + transformed[strippedKey] = Array.isArray(value) ? value : [value]; + } else { + transformed[key] = value; + } + } + } + return { ...transformed, [IS_X_WWW_FORM_URLENCODED]: true }; + }, + }); + + fastify.register(multipart, { + limits: { + fileSize: this.config.maxFileSize ?? 262144000, + files: 16, + }, + }); + + fastify.post<{ + Body?: { + h?: string, + url?: string, + type?: string[], + action?: string, + access_token?: string | string[], + [key: symbol]: unknown, + [key: string]: unknown, + }, + Headers: { authorization?: string } + }>('/micropub', async (request, reply) => { + try { + const media: MediaRawSources = { photo: [], audio: [], video: [] }; + let params: any = {}; + + if (request.isMultipart()) { + for await (const part of request.parts()) { + if (part.type === 'file') { + if (/^(photo|audio|video)$/.test(part.fieldname)) { + const fieldname = part.fieldname as keyof MediaRawSources; + const [destPath] = await createTemp(); + await stream.pipeline(part.file, fs.createWriteStream(destPath)); + media[fieldname] = [...media[fieldname], { path: destPath, filename: part.filename }]; + } + } else { + for (const [key, value] of Object.entries(part.fields)) { + if (typeof value !== 'undefined' && 'value' in value && typeof value.value === 'string') { + if (key.endsWith('[]')) { + const strippedKey = key.slice(0, -2); + const previous = Array.isArray(params[strippedKey]) ? params[strippedKey] : + typeof params[strippedKey] !== 'undefined' ? [params[strippedKey]] : []; + params[strippedKey] = [...previous, value.value]; + } else { + params[key] = value.value; + } + } + } + } + } + } else if (request.body) { + params = request.body.action === 'update' ? request.body : + request.body.type ? request.body.properties : + request.body[IS_X_WWW_FORM_URLENCODED] ? request.body : {}; + } + + const token = request.headers.authorization?.startsWith('Bearer ') ? request.headers.authorization.slice(7) : + typeof request.body?.access_token !== 'undefined' ? request.body.access_token : params.access_token; + if (typeof token !== 'string') throw new MicropubError(errorSymbols.UNAUTHORIZED); + const [user, app] = await this.authenticateService.authenticate(token); + if (user === null) throw new MicropubError(errorSymbols.UNAUTHORIZED); + if (request.body?.type && request.body.type.at(0) !== 'h-entry') throw new MicropubError(errorSymbols.BAD_REQUEST); + const factor = (await this.roleService.getUserPolicies(user.id)).rateLimitFactor; + + if (factor > 0) { + await this.rateLimiterService.limit({ + key: 'micropub', + max: 300, + duration: ms('1 hour'), + }, user.id, factor); + } + + return await this.handleRequest({ + media, + h: request.body?.type ? 'entry' : request.body?.h ?? params.h, + action: request.body?.action ?? params.action, + url: request.body?.url ?? params.url, + body: params as unknown, + }, user, app, reply); + } catch (err) { + return await this.sendMicropubApiError(err, reply); + } + }); + + fastify.get<{ + Querystring: { + q?: string, + url?: string, + [key: string]: unknown, + }, + Headers: { authorization?: string }, + }>('/micropub', async (request, reply) => { + try { + if (request.query.q === 'config') { + return await reply.send({ + 'media-endpoint': new URL('/micropub/media', this.config.url).toString(), + 'syndicate-to': [], + }); + } else if (request.query.q === 'syndicate-to') { + return await reply.send({ 'syndicate-to': [] }); + } else if (request.query.q === 'source') { + const token = request.headers.authorization?.startsWith('Bearer ') ? request.headers.authorization.slice(7) : null; + const properties = typeof request.query.properties === 'string' ? [request.query.properties] : + Array.isArray(request.query['properties[]']) ? request.query['properties[]'] : null; + if (typeof request.query.url !== 'string') throw new MicropubError(errorSymbols.BAD_REQUEST); + if (token === null) throw new MicropubError(errorSymbols.BAD_REQUEST); + const [user, app] = await this.authenticateService.authenticate(token); + if (user === null) throw new MicropubError(errorSymbols.UNAUTHORIZED); + if (app && !app.permission.includes('read:account')) throw new MicropubError(errorSymbols.INSUFFICIENT_SCOPE); + const noteUrl = new URL(request.query.url); + if (noteUrl.origin !== this.config.url || !noteUrl.pathname.startsWith('/notes/')) throw new MicropubError(errorSymbols.BAD_REQUEST); + const noteId = noteUrl.pathname.slice(7); + const note = await this.notesRepository.findOneBy({ id: noteId, userId: user.id }); + if (note === null) throw new MicropubError(errorSymbols.BAD_REQUEST, `Cannot find note (id: ${noteId}`); + const options = await this.serializeOptionsFromDatabase(note, true); + const query = this.serializeRequestFromNote(options); + const response = Object.keys(query) + .filter(key => properties == null || properties.includes(key)) + .reduce((a, b) => ({ ...a, [b]: query[b as keyof typeof query] }), {}); + + return await reply.send({ + ...(properties === null ? { type: ['h-entry'] } : {}), + properties: response, + }); + } else { + throw new MicropubError(errorSymbols.BAD_REQUEST); + } + } catch (err) { + return await this.sendMicropubApiError(err, reply); + } + }); + + fastify.post<{ Headers: { authorization?: string} }>('/media', async (request, reply) => { + try { + const multipartData = await request.file(); + const fields = {} as Record; + if (typeof multipartData === 'undefined') throw new MicropubError(errorSymbols.BAD_REQUEST); + for (const [key, value] of Object.entries(multipartData.fields)) { + fields[key] = typeof value === 'object' && 'value' in value ? value.value : undefined; + } + const token = request.headers.authorization?.startsWith('Bearer ') ? request.headers.authorization.slice(7) : + typeof fields.access_token === 'string' ? fields.access_token : null; + if (token === null) throw new MicropubError(errorSymbols.UNAUTHORIZED); + const [user, app] = await this.authenticateService.authenticate(token); + if (user === null) throw new MicropubError(errorSymbols.UNAUTHORIZED); + if (app && !app.permission.includes('write:drive')) throw new MicropubError(errorSymbols.INSUFFICIENT_SCOPE); + const [destPath] = await createTemp(); + await stream.pipeline(multipartData.file, fs.createWriteStream(destPath)); + if (multipartData.fieldname !== 'file') throw new MicropubError(errorSymbols.BAD_REQUEST); + const driveFile = await this.driveService.addFile({ user, path: destPath, name: path.parse(multipartData.filename).name, force: true }); + reply.code(201 /* Created */); + reply.header('Location', new URL('/files/' + driveFile.webpublicAccessKey, this.config.url)); + return await reply.send(); + } catch (err) { + return await this.sendMicropubApiError(err, reply); + } + }); + } +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 8e1a89d55f9a..a4de3ff24257 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -181,6 +181,7 @@ export class ClientServerService { infoImageUrl: meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg', notFoundImageUrl: meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg', instanceUrl: this.config.url, + micropubEndpointUrl: new URL('/micropub/micropub', this.config.url).toString(), }; } diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index d167afe1e8e9..d5a985359933 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -35,6 +35,7 @@ html link(rel='prefetch' href=serverErrorImageUrl) link(rel='prefetch' href=infoImageUrl) link(rel='prefetch' href=notFoundImageUrl) + link(rel='micropub' href=micropubEndpointUrl) //- https://github.com/misskey-dev/misskey/issues/9842 link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.44.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) diff --git a/packages/backend/test/e2e/micropub.ts b/packages/backend/test/e2e/micropub.ts new file mode 100644 index 000000000000..e577469066d1 --- /dev/null +++ b/packages/backend/test/e2e/micropub.ts @@ -0,0 +1,490 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as fs from 'node:fs/promises'; +import * as assert from 'node:assert'; +import { Repository } from 'typeorm'; +import { JSDOM } from 'jsdom'; +import * as misskey from 'misskey-js'; +import { MiDriveFile } from '@/models/DriveFile.js'; +import { port, origin, api, initTestDb, signup, uploadFile, createAppToken, failedApiCall } from '../utils.js'; + +const host = new URL(`http://127.0.0.1:${port}`); + +describe('Micropub', () => { + let driveFiles: Repository; + let alice: misskey.entities.SignupResponse; + + beforeAll(async () => { + const connection = await initTestDb(true); + driveFiles = connection.getRepository(MiDriveFile); + alice = await signup({ username: 'alice' }); + }, 1000 * 60 * 2); + + // https://micropub.spec.indieweb.org/#x3-3-create + test('Posting note', async () => { + const lenna = (await uploadFile(alice)).body; + if (!lenna) return; + const file = await driveFiles.findOneByOrFail({ id: lenna.id }); + const response = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: JSON.stringify({ + type: ['h-entry'], + properties: { + content: ['Hello world'], + category: ['foo', 'bar'], + photos: [new URL('/files/' + file.accessKey, origin).toString(), 'https://assets.misskey-hub.net/public/icon.png'], + }, + }), + headers: { + 'Authorization': 'Bearer ' + alice.token, + 'Content-Type': 'application/json', + }, + }); + + const createdNote = response.headers.get('Location'); + const noteUrl = createdNote ? new URL(createdNote) : null; + + assert.strictEqual(response.status, 201 /* Created */); + assert.strictEqual(noteUrl?.origin, origin); + assert.ok(noteUrl.pathname.startsWith('/notes/')); + + const note = (await api('notes/show', { noteId: noteUrl!.pathname.slice(7) })).body; + assert.strictEqual(note.text, 'Hello world #foo #bar'); + assert.deepStrictEqual(note.tags, ['foo', 'bar']); + assert.strictEqual(note.fileIds.length, 2); + assert.ok(note.fileIds.includes(file.id)); + }); + + // https://micropub.spec.indieweb.org/#x3-3-create + test('Posting note using HTML', async () => { + const lenna = (await uploadFile(alice)).body; + if (!lenna) return; + const file = await driveFiles.findOneByOrFail({ id: lenna.id }); + const response = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: JSON.stringify({ + type: ['h-entry'], + properties: { + content: [{ html: `Hello!` }], + }, + }), + headers: { + 'Authorization': 'Bearer ' + alice.token, + 'Content-Type': 'application/json', + }, + }); + + const createdNote = response.headers.get('Location'); + const noteUrl = createdNote ? new URL(createdNote) : null; + + assert.strictEqual(response.status, 201 /* Created */); + assert.strictEqual(noteUrl?.origin, origin); + assert.ok(noteUrl.pathname.startsWith('/notes/')); + + const note = (await api('notes/show', { noteId: noteUrl!.pathname.slice(7) })).body; + assert.strictEqual(note.text, 'Hello!'); + assert.strictEqual(note.fileIds.length, 1); + assert.ok(note.fileIds.includes(file.id)); + }); + + // https://micropub.spec.indieweb.org/#x3-3-create + test('Posting note using application/x-www-form-urlencoded', async () => { + const params = new URLSearchParams(); + params.append('h', 'entry'); + params.append('access_token', alice.token); + params.append('content', 'Hello world'); + params.append('category[]', 'foo'); + params.append('category[]', 'bar'); + + const response = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + + const createdNote = response.headers.get('Location'); + const noteUrl = createdNote ? new URL(createdNote) : null; + + assert.strictEqual(response.status, 201 /* Created */); + assert.strictEqual(noteUrl?.origin, origin); + assert.ok(noteUrl.pathname.startsWith('/notes/')); + }); + + // // https://micropub.spec.indieweb.org/#x3-3-create + test('Posting note using multipart/form-data', async () => { + const lenna = new URL('../resources/Lenna.jpg', import.meta.url); + const form = new FormData(); + form.append('h', 'entry'); + form.append('content', 'Hello world'); + form.append('access_token', alice.token); + form.append('photo', new File([await fs.readFile(lenna)], 'Lenna.png')); + + const response = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: form, + }); + + const createdNote = response.headers.get('Location'); + const noteUrl = createdNote ? new URL(createdNote) : null; + + assert.strictEqual(response.status, 201 /* Created */); + assert.strictEqual(noteUrl?.origin, origin); + assert.ok(noteUrl.pathname.startsWith('/notes/')); + + const note = (await api('notes/show', { noteId: noteUrl!.pathname.slice(7) })).body; + assert.strictEqual(note.text, 'Hello world'); + assert.strictEqual(note.fileIds.length, 1); + }); + + describe('Updating note', () => { + // https://micropub.spec.indieweb.org/#add + test('Add fields to note', async () => { + const params = new URLSearchParams(); + params.append('h', 'entry'); + params.append('access_token', alice.token); + params.append('content', 'Hello world'); + params.append('category[]', 'foo'); + + const response = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + + const createdNote = response.headers.get('Location'); + assert.strictEqual(response.status, 201 /* Created */); + assert.ok(createdNote); + + const response2 = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: JSON.stringify({ + action: 'update', + url: createdNote, + add: { category: ['bar', 'bazz'] }, + }), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + alice.token, + }, + }); + + const createdNote2 = response2.headers.get('Location'); + const noteUrl = createdNote2 ? new URL(createdNote2) : null; + assert.strictEqual(response2.status, 201 /* Created */); + assert.strictEqual(noteUrl?.origin, origin); + assert.ok(noteUrl.pathname.startsWith('/notes/')); + + const note = (await api('notes/show', { noteId: noteUrl!.pathname.slice(7) })).body; + assert.strictEqual(note.text, 'Hello world #foo #bar #bazz'); + assert.deepStrictEqual(note.tags, ['foo', 'bar', 'bazz']); + }); + + // https://micropub.spec.indieweb.org/#replace + test('Replace fields in note', async () => { + const params = new URLSearchParams(); + params.append('h', 'entry'); + params.append('access_token', alice.token); + params.append('content', 'Hello world'); + + const response = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + + const createdNote = response.headers.get('Location'); + assert.strictEqual(response.status, 201 /* Created */); + assert.ok(createdNote); + + const response2 = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: JSON.stringify({ + action: 'update', + url: createdNote, + replace: { content: ['Changed'] }, + }), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + alice.token, + }, + }); + + const createdNote2 = response2.headers.get('Location'); + const noteUrl = createdNote2 ? new URL(createdNote2) : null; + assert.strictEqual(response2.status, 201 /* Created */); + assert.strictEqual(noteUrl?.origin, origin); + assert.ok(noteUrl.pathname.startsWith('/notes/')); + + const note = (await api('notes/show', { noteId: noteUrl!.pathname.slice(7) })).body; + assert.strictEqual(note.text, 'Changed'); + }); + + // https://micropub.spec.indieweb.org/#remove + test('Remove fields from note', async () => { + const params = new URLSearchParams(); + params.append('h', 'entry'); + params.append('access_token', alice.token); + params.append('content', 'Hello world'); + params.append('category[]', 'foo'); + params.append('photos[]', 'https://assets.misskey-hub.net/public/icon.png'); + + const response = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + + const createdNote = response.headers.get('Location'); + assert.strictEqual(response.status, 201 /* Created */); + assert.ok(createdNote); + + const response2 = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: JSON.stringify({ + action: 'update', + url: createdNote, + delete: ['category'], + }), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + alice.token, + }, + }); + + const createdNote2 = response2.headers.get('Location'); + const noteUrl = createdNote2 ? new URL(createdNote2) : null; + assert.strictEqual(response2.status, 201 /* Created */); + assert.strictEqual(noteUrl?.origin, origin); + assert.ok(noteUrl.pathname.startsWith('/notes/')); + + const note = (await api('notes/show', { noteId: noteUrl!.pathname.slice(7) })).body; + assert.strictEqual(note.tags, undefined); + + const response3 = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: JSON.stringify({ + action: 'update', + url: createdNote2, + delete: { photos: ['https://assets.misskey-hub.net/public/icon.png'] }, + }), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + alice.token, + }, + }); + + const createdNote3 = response3.headers.get('Location'); + const noteUrl2 = createdNote3 ? new URL(createdNote3) : null; + assert.strictEqual(response3.status, 201 /* Created */); + assert.strictEqual(noteUrl2?.origin, origin); + assert.ok(noteUrl2.pathname.startsWith('/notes/')); + + const note2 = (await api('notes/show', { noteId: noteUrl2!.pathname.slice(7) })).body; + assert.strictEqual(note2.fileIds?.length, 0); + }); + }); + + // https://micropub.spec.indieweb.org/#delete + test('Deleting note', async () => { + const params = new URLSearchParams(); + params.append('h', 'entry'); + params.append('access_token', alice.token); + params.append('content', 'Hello world'); + params.append('category[]', 'foo'); + + const response = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + + const createdNote = response.headers.get('Location'); + assert.strictEqual(response.status, 201 /* Created */); + assert.ok(createdNote); + + const noteId = new URL(createdNote).pathname.slice(7); + const params2 = new URLSearchParams(); + params2.append('access_token', alice.token); + params2.append('action', 'delete'); + params2.append('url', createdNote); + + const response2 = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: params2, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + + assert.strictEqual(response2.status, 200 /* OK */); + + await failedApiCall({ + endpoint: '/notes/show', + parameters: { noteId }, + user: alice, + }, { + status: 400, + code: 'NO_SUCH_NOTE', + id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', + }); + }); + + test('Authorization', async () => { + const application = await createAppToken(alice, []); + const lenna = new URL('../resources/Lenna.jpg', import.meta.url); + const form = new FormData(); + form.append('access_token', application); + form.append('file', new File([await fs.readFile(lenna)], 'Lenna.png')); + + const response = await fetch(new URL('/micropub/media', host), { + method: 'POST', + body: form, + }).then(async res => ({ body: await res.json(), status: res.status })); + + assert.strictEqual(response.status, 403 /* Forbidden */); + assert.strictEqual(response.body.error, 'insufficient_scope'); + + const params = new URLSearchParams(); + params.append('h', 'entry'); + params.append('access_token', application); + params.append('content', 'Hello world'); + + const response2 = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }).then(async res => ({ body: await res.json(), status: res.status })); + + assert.strictEqual(response2.status, 403 /* Forbidden */); + assert.strictEqual(response2.body.error, 'insufficient_scope'); + }); + + // https://micropub.spec.indieweb.org/#media-endpoint + test('Uploading file', async () => { + const lenna = new URL('../resources/Lenna.jpg', import.meta.url); + const form = new FormData(); + form.append('access_token', alice.token); + form.append('file', new File([await fs.readFile(lenna)], 'Lenna.png')); + + const response = await fetch(new URL('/micropub/media', host), { + method: 'POST', + body: form, + }); + + const createdFile = response.headers.get('Location'); + const createdFileUrl = createdFile ? new URL(createdFile) : null; + + assert.strictEqual(response.status, 201 /* Created */); + assert.strictEqual(createdFileUrl?.origin, origin); + assert.ok(createdFileUrl.pathname.startsWith('/files/')); + + const driveFile = await driveFiles.findOneBy({ webpublicAccessKey: createdFileUrl.pathname.slice(7) }); + assert.ok(driveFile); + }); + + // https://micropub.spec.indieweb.org/#media-endpoint + test('Uploading file with authorization header', async () => { + const lenna = new URL('../resources/Lenna.jpg', import.meta.url); + const form = new FormData(); + form.append('file', new File([await fs.readFile(lenna)], 'Lenna.png')); + + const response = await fetch(new URL('/micropub/media', host), { + method: 'POST', + body: form, + headers: { authorization: `Bearer ${alice.token}` }, + }); + + const createdFile = response.headers.get('Location'); + const createdFileUrl = createdFile ? new URL(createdFile) : null; + + assert.strictEqual(response.status, 201 /* Created */); + assert.strictEqual(createdFileUrl?.origin, origin); + assert.ok(createdFileUrl.pathname.startsWith('/files/')); + }); + + // https://micropub.spec.indieweb.org/#querying + describe('Querying', () => { + // https://micropub.spec.indieweb.org/#configuration + // https://micropub.spec.indieweb.org/#syndication-targets + test('Configuration, Syndication targets', async () => { + const configUrl = new URL('/micropub/micropub', host); + configUrl.search = new URLSearchParams({ q: 'config' }).toString(); + + const config = await fetch(configUrl, { method: 'GET' }).then(res => res.json()); + const mediaEndpoint = config['media-endpoint'] ? new URL(config['media-endpoint']) : null; + + assert.strictEqual(config['syndicate-to']?.length, 0); + assert.strictEqual(mediaEndpoint?.origin, origin); + assert.strictEqual(mediaEndpoint.pathname, '/micropub/media'); + + configUrl.search = new URLSearchParams({ q: 'syndicate-to' }).toString(); + const syndicateTo = await fetch(configUrl, { method: 'GET' }).then(res => res.json()); + assert.strictEqual(syndicateTo['syndicate-to']?.length, 0); + }); + + // https://micropub.spec.indieweb.org/#source-content + test('Source content', async () => { + const params = new URLSearchParams(); + params.append('h', 'entry'); + params.append('access_token', alice.token); + params.append('content', 'Hello world'); + params.append('category[]', 'foo'); + params.append('category[]', 'bar'); + + const response = await fetch(new URL('/micropub/micropub', host), { + method: 'POST', + body: params, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + + const createdNote = response.headers.get('Location'); + const noteUrl = createdNote ? new URL(createdNote) : null; + + assert.strictEqual(response.status, 201 /* Created */); + assert.strictEqual(noteUrl?.origin, origin); + assert.ok(noteUrl.pathname.startsWith('/notes/')); + + const configUrl = new URL('/micropub/micropub', host); + configUrl.search = new URLSearchParams({ q: 'source', url: noteUrl.toString() }).toString(); + + const response2 = await fetch(configUrl, { + method: 'GET', + headers: { authorization: 'Bearer ' + alice.token }, + }).then(async res => ({ status: res.status, body: await res.json() })); + + assert.strictEqual(response2.status, 200 /* OK */); + assert.deepEqual(response2.body.type, ['h-entry']); + assert.deepEqual(response2.body.properties.content, ['Hello world']); + assert.deepEqual(response2.body.properties.category, ['foo', 'bar']); + + configUrl.search = new URLSearchParams({ + q: 'source', + url: noteUrl.toString(), + properties: 'category' + }).toString(); + + const response3 = await fetch(configUrl, { + method: 'GET', + headers: { authorization: 'Bearer ' + alice.token }, + }).then(async res => ({ status: res.status, body: await res.json() })); + + assert.strictEqual(response3.status, 200 /* OK */); + assert.deepEqual(response3.body, { properties: { category: ['foo', 'bar'] }}); + }); + }); + + // https://micropub.spec.indieweb.org/#x5-3-endpoint-discovery + test('Endpoint discovery', async () => { + const html = await fetch(host).then(response => response.text()); + const fragment = JSDOM.fragment(html); + const micropubEndpoint = new URL('/micropub/micropub', origin); + const rawEndpoint = fragment.querySelector('link[rel="micropub"]')?.getAttribute('href'); + const maybeEndpoint = rawEndpoint ? new URL(rawEndpoint) : null; + + assert.strictEqual(maybeEndpoint?.origin, micropubEndpoint.origin); + assert.strictEqual(maybeEndpoint.pathname, micropubEndpoint.pathname); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa466569efbe..18b78cdef319 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@fastify/express': specifier: 2.3.0 version: 2.3.0 + '@fastify/formbody': + specifier: ^7.4.0 + version: 7.4.0 '@fastify/http-proxy': specifier: 9.3.0 version: 9.3.0(bufferutil@4.0.7)(utf-8-validate@6.0.3) @@ -831,7 +834,7 @@ importers: version: 1.7.2(vue@3.4.15) vite: specifier: 5.0.12 - version: 5.0.12(@types/node@20.11.5)(sass@1.70.0) + version: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) vue: specifier: 3.4.15 version: 3.4.15(typescript@5.3.3) @@ -1009,7 +1012,7 @@ importers: version: 1.0.3 vitest: specifier: 0.34.6 - version: 0.34.6(happy-dom@10.0.3)(sass@1.70.0) + version: 0.34.6(happy-dom@10.0.3)(sass@1.70.0)(terser@5.27.0) vitest-fetch-mock: specifier: 0.2.2 version: 0.2.2(vitest@0.34.6) @@ -1906,7 +1909,7 @@ packages: '@babel/traverse': 7.22.11 '@babel/types': 7.22.17 convert-source-map: 1.9.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -1929,7 +1932,7 @@ packages: '@babel/traverse': 7.23.5 '@babel/types': 7.23.5 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -2031,7 +2034,7 @@ packages: '@babel/core': 7.23.5 '@babel/helper-compilation-targets': 7.22.15 '@babel/helper-plugin-utils': 7.22.5 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -3430,7 +3433,7 @@ packages: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.23.5 '@babel/types': 7.22.17 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3448,7 +3451,7 @@ packages: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.23.6 '@babel/types': 7.23.5 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -4155,7 +4158,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) espree: 9.6.1 globals: 13.19.0 ignore: 5.2.4 @@ -4172,7 +4175,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) espree: 9.6.1 globals: 13.19.0 ignore: 5.2.4 @@ -4266,6 +4269,13 @@ packages: fast-json-stringify: 5.8.0 dev: false + /@fastify/formbody@7.4.0: + resolution: {integrity: sha512-H3C6h1GN56/SMrZS8N2vCT2cZr7mIHzBHzOBa5OPpjfB/D6FzP9mMpE02ZzrFX0ANeh0BAJdoXKOF2e7IbV+Og==} + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 4.5.0 + dev: false + /@fastify/http-proxy@9.3.0(bufferutil@4.0.7)(utf-8-validate@6.0.3): resolution: {integrity: sha512-fQkdgwco8q7eI2PQA8lH++y3Q+hNlIByBYsphl+r4FKRbmrU7ey4WOA/CA9tBhe4oEojGpa3eTU4jXvqf2DBuQ==} dependencies: @@ -4407,7 +4417,7 @@ packages: engines: {node: '>=10.10.0'} dependencies: '@humanwhocodes/object-schema': 2.0.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -4711,7 +4721,7 @@ packages: magic-string: 0.27.0 react-docgen-typescript: 2.2.2(typescript@5.3.3) typescript: 5.3.3 - vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0) + vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) dev: true /@jridgewell/gen-mapping@0.3.2: @@ -4735,7 +4745,6 @@ packages: dependencies: '@jridgewell/gen-mapping': 0.3.2 '@jridgewell/trace-mapping': 0.3.18 - dev: false /@jridgewell/sourcemap-codec@1.4.14: resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} @@ -6772,7 +6781,7 @@ packages: magic-string: 0.30.5 rollup: 3.29.4 typescript: 5.3.3 - vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0) + vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) transitivePeerDependencies: - encoding - supports-color @@ -6977,7 +6986,7 @@ packages: util: 0.12.5 util-deprecate: 1.0.2 watchpack: 2.4.0 - ws: 8.16.0 + ws: 8.16.0(bufferutil@4.0.7)(utf-8-validate@6.0.3) transitivePeerDependencies: - bufferutil - encoding @@ -7146,7 +7155,7 @@ packages: react: 18.2.0 react-docgen: 7.0.1 react-dom: 18.2.0(react@18.2.0) - vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0) + vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) transitivePeerDependencies: - '@preact/preset-vite' - encoding @@ -7272,7 +7281,7 @@ packages: '@storybook/vue3': 7.6.10(vue@3.4.15) '@vitejs/plugin-vue': 4.5.2(vite@5.0.12)(vue@3.4.15) magic-string: 0.30.5 - vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0) + vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) vue-docgen-api: 4.64.1(vue@3.4.15) transitivePeerDependencies: - '@preact/preset-vite' @@ -7772,7 +7781,7 @@ packages: dom-accessibility-api: 0.5.16 lodash: 4.17.21 redent: 3.0.0 - vitest: 0.34.6(happy-dom@10.0.3)(sass@1.70.0) + vitest: 0.34.6(happy-dom@10.0.3)(sass@1.70.0)(terser@5.27.0) dev: true /@testing-library/user-event@14.4.3(@testing-library/dom@9.2.0): @@ -8446,7 +8455,7 @@ packages: '@typescript-eslint/type-utils': 6.11.0(eslint@8.53.0)(typescript@5.3.3) '@typescript-eslint/utils': 6.11.0(eslint@8.53.0)(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.11.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.53.0 graphemer: 1.4.0 ignore: 5.2.4 @@ -8475,7 +8484,7 @@ packages: '@typescript-eslint/type-utils': 6.18.1(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/utils': 6.18.1(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.18.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.56.0 graphemer: 1.4.0 ignore: 5.2.4 @@ -8501,7 +8510,7 @@ packages: '@typescript-eslint/types': 6.11.0 '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.11.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.53.0 typescript: 5.3.3 transitivePeerDependencies: @@ -8522,7 +8531,7 @@ packages: '@typescript-eslint/types': 6.18.1 '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.18.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.56.0 typescript: 5.3.3 transitivePeerDependencies: @@ -8557,7 +8566,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3) '@typescript-eslint/utils': 6.11.0(eslint@8.53.0)(typescript@5.3.3) - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.53.0 ts-api-utils: 1.0.1(typescript@5.3.3) typescript: 5.3.3 @@ -8577,7 +8586,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 6.18.1(typescript@5.3.3) '@typescript-eslint/utils': 6.18.1(eslint@8.56.0)(typescript@5.3.3) - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.56.0 ts-api-utils: 1.0.1(typescript@5.3.3) typescript: 5.3.3 @@ -8606,7 +8615,7 @@ packages: dependencies: '@typescript-eslint/types': 6.11.0 '@typescript-eslint/visitor-keys': 6.11.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -8627,7 +8636,7 @@ packages: dependencies: '@typescript-eslint/types': 6.18.1 '@typescript-eslint/visitor-keys': 6.18.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -8707,7 +8716,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.19.6(@babel/core@7.23.5) magic-string: 0.27.0 react-refresh: 0.14.0 - vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0) + vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) transitivePeerDependencies: - supports-color dev: true @@ -8719,7 +8728,7 @@ packages: vite: ^4.0.0 || ^5.0.0 vue: ^3.2.25 dependencies: - vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0) + vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) vue: 3.4.15(typescript@5.3.3) dev: true @@ -8730,7 +8739,7 @@ packages: vite: ^5.0.0 vue: ^3.2.25 dependencies: - vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0) + vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) vue: 3.4.15(typescript@5.3.3) dev: false @@ -8750,7 +8759,7 @@ packages: std-env: 3.7.0 test-exclude: 6.0.0 v8-to-istanbul: 9.2.0 - vitest: 0.34.6(happy-dom@10.0.3)(sass@1.70.0) + vitest: 0.34.6(happy-dom@10.0.3)(sass@1.70.0)(terser@5.27.0) transitivePeerDependencies: - supports-color dev: true @@ -9091,7 +9100,7 @@ packages: engines: {node: '>= 6.0.0'} requiresBuild: true dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -9099,7 +9108,7 @@ packages: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} engines: {node: '>= 14'} dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color dev: false @@ -9485,7 +9494,7 @@ packages: resolution: {integrity: sha512-TAlMYvOuwGyLK3PfBb5WKBXZmXz2fVCgv23d6zZFdle/q3gPjmxBaeuC0pY0Dzs5PWMSgfqqEZkrye19GlDTgw==} dependencies: archy: 1.0.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) fastq: 1.15.0 transitivePeerDependencies: - supports-color @@ -9889,7 +9898,6 @@ packages: requiresBuild: true dependencies: node-gyp-build: 4.6.0 - dev: false /bullmq@5.1.4: resolution: {integrity: sha512-j/AjaPc8BhyrH7b2MyZpi4cUtGH8TJTxonZUmXEefmKU8z5DcldzmlXPief0P4+qvN0A7qwWZH3n0F+GsWgQkg==} @@ -10434,7 +10442,6 @@ packages: /commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - dev: false /commander@6.2.1: resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} @@ -10931,6 +10938,7 @@ packages: dependencies: ms: 2.1.2 supports-color: 5.5.0 + dev: true /debug@4.3.4(supports-color@8.1.1): resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} @@ -10943,7 +10951,6 @@ packages: dependencies: ms: 2.1.2 supports-color: 8.1.1 - dev: true /decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} @@ -11160,7 +11167,7 @@ packages: hasBin: true dependencies: address: 1.2.2 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color dev: true @@ -11484,7 +11491,7 @@ packages: peerDependencies: esbuild: '>=0.12 <1' dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) esbuild: 0.18.20 transitivePeerDependencies: - supports-color @@ -11793,7 +11800,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -11840,7 +11847,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -12471,7 +12478,7 @@ packages: debug: optional: true dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -13027,6 +13034,7 @@ packages: /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} + dev: true /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} @@ -13164,7 +13172,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color dev: false @@ -13224,7 +13232,7 @@ packages: engines: {node: '>= 6.0.0'} dependencies: agent-base: 5.1.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color dev: true @@ -13234,7 +13242,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -13243,7 +13251,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color dev: false @@ -13403,7 +13411,7 @@ packages: dependencies: '@ioredis/commands': 1.2.0 cluster-key-slot: 1.1.2 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -13849,7 +13857,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -15624,7 +15632,6 @@ packages: resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==} hasBin: true requiresBuild: true - dev: false /node-gyp@10.0.1: resolution: {integrity: sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==} @@ -17159,7 +17166,7 @@ packages: engines: {node: '>=8.16.0'} dependencies: '@types/mime-types': 2.1.4 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) extract-zip: 1.7.0 https-proxy-agent: 4.0.0 mime: 2.6.0 @@ -18159,7 +18166,7 @@ packages: dependencies: '@hapi/hoek': 10.0.1 '@hapi/wreck': 18.0.1 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) joi: 17.7.0 transitivePeerDependencies: - supports-color @@ -18359,7 +18366,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) socks: 2.7.1 transitivePeerDependencies: - supports-color @@ -18512,7 +18519,7 @@ packages: arg: 5.0.2 bluebird: 3.7.2 check-more-types: 2.24.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) execa: 5.1.1 lazy-ass: 1.6.0 ps-tree: 1.2.0 @@ -18770,6 +18777,7 @@ packages: engines: {node: '>=4'} dependencies: has-flag: 3.0.0 + dev: true /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} @@ -18932,7 +18940,6 @@ packages: acorn: 8.11.3 commander: 2.20.3 source-map-support: 0.5.21 - dev: false /test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} @@ -19391,7 +19398,7 @@ packages: chalk: 4.1.2 cli-highlight: 2.1.11 dayjs: 1.11.10 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) dotenv: 16.0.3 glob: 10.3.10 ioredis: 5.3.2 @@ -19654,7 +19661,6 @@ packages: requiresBuild: true dependencies: node-gyp-build: 4.6.0 - dev: false /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -19746,17 +19752,17 @@ packages: core-util-is: 1.0.2 extsprintf: 1.3.0 - /vite-node@0.34.6(@types/node@20.11.5)(sass@1.70.0): + /vite-node@0.34.6(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0): resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==} engines: {node: '>=v14.18.0'} hasBin: true dependencies: cac: 6.7.14 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) mlly: 1.5.0 pathe: 1.1.2 picocolors: 1.0.0 - vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0) + vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) transitivePeerDependencies: - '@types/node' - less @@ -19772,7 +19778,7 @@ packages: resolution: {integrity: sha512-p4D8CFVhZS412SyQX125qxyzOgIFouwOcvjZWk6bQbNPR1wtaEzFT6jZxAjf1dejlGqa6fqHcuCvQea6EWUkUA==} dev: true - /vite@5.0.12(@types/node@20.11.5)(sass@1.70.0): + /vite@5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0): resolution: {integrity: sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -19805,6 +19811,7 @@ packages: postcss: 8.4.33 rollup: 4.9.6 sass: 1.70.0 + terser: 5.27.0 optionalDependencies: fsevents: 2.3.3 @@ -19815,12 +19822,12 @@ packages: vitest: '>=0.16.0' dependencies: cross-fetch: 3.1.5 - vitest: 0.34.6(happy-dom@10.0.3)(sass@1.70.0) + vitest: 0.34.6(happy-dom@10.0.3)(sass@1.70.0)(terser@5.27.0) transitivePeerDependencies: - encoding dev: true - /vitest@0.34.6(happy-dom@10.0.3)(sass@1.70.0): + /vitest@0.34.6(happy-dom@10.0.3)(sass@1.70.0)(terser@5.27.0): resolution: {integrity: sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==} engines: {node: '>=v14.18.0'} hasBin: true @@ -19863,7 +19870,7 @@ packages: acorn-walk: 8.3.2 cac: 6.7.14 chai: 4.3.10 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) happy-dom: 10.0.3 local-pkg: 0.4.3 magic-string: 0.30.5 @@ -19873,8 +19880,8 @@ packages: strip-literal: 1.3.0 tinybench: 2.6.0 tinypool: 0.7.0 - vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0) - vite-node: 0.34.6(@types/node@20.11.5)(sass@1.70.0) + vite: 5.0.12(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) + vite-node: 0.34.6(@types/node@20.11.5)(sass@1.70.0)(terser@5.27.0) why-is-node-running: 2.2.2 transitivePeerDependencies: - less @@ -19945,7 +19952,7 @@ packages: peerDependencies: eslint: '>=6.0.0' dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.56.0 eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 @@ -20275,19 +20282,6 @@ packages: async-limiter: 1.0.1 dev: true - /ws@8.16.0: - resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: true - /ws@8.16.0(bufferutil@4.0.7)(utf-8-validate@6.0.3): resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} engines: {node: '>=10.0.0'} @@ -20302,7 +20296,6 @@ packages: dependencies: bufferutil: 4.0.7 utf-8-validate: 6.0.3 - dev: false /xev@3.0.2: resolution: {integrity: sha512-8kxuH95iMXzHZj+fwqfA4UrPcYOy6bGIgfWzo9Ji23JoEc30ge/Z++Ubkiuy8c0+M64nXmmxrmJ7C8wnuBhluw==}