Merge branch Sharkey:develop into trackeropt

This commit is contained in:
Vavency 2025-05-09 12:34:05 +00:00
commit 000609b43d
68 changed files with 1316 additions and 902 deletions

View file

@ -349,6 +349,9 @@ attachLdSignatureForRelays: true
# # Disable query truncation. If set to true, the full text of the query will be output to the log.
# # default: false
# disableQueryTruncation: false
# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable.
# # default: false in production, true otherwise.
# #verbose: false
# Settings for the activity logger, which records inbound activities to the database.
# Disabled by default due to the large volume of data it saves.

View file

@ -295,6 +295,9 @@ allowedPrivateNetworks: [
# # Disable query truncation. If set to true, the full text of the query will be output to the log.
# # default: false
# disableQueryTruncation: false
# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable.
# # default: false in production, true otherwise.
# #verbose: false
# Settings for the activity logger, which records inbound activities to the database.
# Disabled by default due to the large volume of data it saves.

View file

@ -411,6 +411,9 @@ attachLdSignatureForRelays: true
# # Disable query truncation. If set to true, the full text of the query will be output to the log.
# # default: false
# disableQueryTruncation: false
# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable.
# # default: false in production, true otherwise.
# #verbose: false
# Settings for the activity logger, which records inbound activities to the database.
# Disabled by default due to the large volume of data it saves.

View file

@ -417,6 +417,9 @@ attachLdSignatureForRelays: true
# # Disable query truncation. If set to true, the full text of the query will be output to the log.
# # default: false
# disableQueryTruncation: false
# # Shows debug log messages after instance startup. To capture earlier debug logs, set the MK_VERBOSE environment variable.
# # default: false in production, true otherwise.
# #verbose: false
# Settings for the activity logger, which records inbound activities to the database.
# Disabled by default due to the large volume of data it saves.

10
locales/index.d.ts vendored
View file

@ -12947,6 +12947,16 @@ export interface Locale extends ILocale {
* If disabled, then the proxy account will not be used. User lists will only include notes from local or followed users.
*/
"enableProxyAccountDescription": string;
"_confirmPollEdit": {
/**
* Are you sure you want to edit this poll?
*/
"title": string;
/**
* Editing this poll will cause it to lose all previous votes
*/
"text": string;
};
}
declare const locales: {
[lang: string]: Locale;

View file

@ -1,6 +1,6 @@
{
"name": "sharkey",
"version": "2025.4.1",
"version": "2025.4.2-rc",
"codename": "shonk",
"repository": {
"type": "git",

View file

@ -91,12 +91,11 @@
"@swc/core": "1.11.24",
"@transfem-org/sfm-js": "0.24.6",
"@twemoji/parser": "15.1.1",
"@types/redis-info": "3.0.3",
"@types/psl": "^1.1.3",
"accepts": "1.3.8",
"ajv": "8.17.1",
"archiver": "7.0.1",
"argon2": "^0.40.1",
"axios": "1.7.4",
"async-mutex": "0.5.0",
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
@ -107,6 +106,7 @@
"cbor": "9.0.2",
"chalk": "5.4.1",
"chalk-template": "1.1.0",
"cheerio": "1.0.0",
"chokidar": "3.6.0",
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
@ -115,7 +115,6 @@
"deep-email-validator": "0.1.21",
"fast-xml-parser": "4.4.1",
"fastify": "5.3.2",
"fastify-multer": "^2.0.3",
"fastify-raw-body": "5.0.0",
"feed": "4.2.2",
"file-type": "19.6.0",
@ -132,7 +131,6 @@
"ipaddr.js": "2.2.0",
"is-svg": "5.1.0",
"js-yaml": "4.1.0",
"jsdom": "26.1.0",
"json5": "2.2.3",
"jsonld": "8.3.3",
"jsrsasign": "11.1.0",
@ -209,7 +207,6 @@
"@types/http-link-header": "1.0.7",
"@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.7",
"@types/jsonld": "1.5.15",
"@types/jsrsasign": "10.5.15",
"@types/mime-types": "2.1.4",
@ -221,10 +218,12 @@
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.11.14",
"@types/proxy-addr": "^2.0.3",
"@types/psl": "^1.1.3",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.5",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/redis-info": "3.0.3",
"@types/rename": "1.0.7",
"@types/sanitize-html": "2.15.0",
"@types/semver": "7.7.0",

View file

@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as os from 'node:os';
import cluster from 'node:cluster';
import * as net from 'node:net';
import chalk from 'chalk';
import chalkTemplate from 'chalk-template';
import * as Sentry from '@sentry/node';
@ -18,7 +19,6 @@ import type { Config } from '@/config.js';
import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
import { jobQueue, server } from './common.js';
import * as net from 'node:net';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);

View file

@ -135,7 +135,8 @@ type Source = {
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
}
};
verbose?: boolean;
}
activityLogging?: {
@ -220,7 +221,8 @@ export type Config = {
sql?: {
disableQueryTruncation?: boolean,
enableQueryParamLogging?: boolean,
}
};
verbose?: boolean;
}
version: string;
@ -585,6 +587,7 @@ function applyEnvOverrides(config: Source) {
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]);
_apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]);
_apply_top(['logging', ['verbose']]);
_apply_top(['activityLogging', ['enabled', 'preSave', 'maxAge']]);
_apply_top(['customHtml', ['head']]);
}

View file

@ -45,6 +45,7 @@ import { isMimeImage } from '@/misc/is-mime-image.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { BunnyService } from '@/core/BunnyService.js';
import { LoggerService } from './LoggerService.js';
type AddFileArgs = {
/** User who wish to add file */
@ -133,8 +134,10 @@ export class DriveService {
private perUserDriveChart: PerUserDriveChart,
private instanceChart: InstanceChart,
private utilityService: UtilityService,
loggerService: LoggerService,
) {
const logger = new Logger('drive', 'blue');
const logger = loggerService.getLogger('drive', 'blue');
this.registerLogger = logger.createSubLogger('register', 'yellow');
this.downloaderLogger = logger.createSubLogger('downloader');
this.deleteLogger = logger.createSubLogger('delete');

View file

@ -5,9 +5,9 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
import tinycolor from 'tinycolor2';
import * as Redis from 'ioredis';
import { load as cheerio } from 'cheerio';
import type { MiInstance } from '@/models/Instance.js';
import type Logger from '@/logger.js';
import { DI } from '@/di-symbols.js';
@ -15,7 +15,7 @@ import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { DOMWindow } from 'jsdom';
import type { CheerioAPI } from 'cheerio';
type NodeInfo = {
openRegistrations?: unknown;
@ -181,17 +181,14 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async fetchDom(instance: MiInstance): Promise<Document> {
private async fetchDom(instance: MiInstance): Promise<CheerioAPI> {
this.logger.info(`Fetching HTML of ${instance.host} ...`);
const url = 'https://' + instance.host;
const html = await this.httpRequestService.getHtml(url);
const { window } = new JSDOM(html);
const doc = window.document;
return doc;
return cheerio(html);
}
@bindThis
@ -206,12 +203,15 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise<string | null> {
private async fetchFaviconUrl(instance: MiInstance, doc: CheerioAPI | null): Promise<string | null> {
const url = 'https://' + instance.host;
if (doc) {
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href;
const href = doc('link[rel][href]')
.filter((_, link) => link.attribs.rel.split(' ').includes('icon'))
.last()
.attr('href');
if (href) {
return (new URL(href, url)).href;
@ -232,7 +232,7 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
private async fetchIconUrl(instance: MiInstance, doc: CheerioAPI | null, manifest: Record<string, any> | null): Promise<string | null> {
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
const url = 'https://' + instance.host;
return (new URL(manifest.icons[0].src, url)).href;
@ -242,13 +242,16 @@ export class FetchInstanceMetadataService {
const url = 'https://' + instance.host;
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const links = Array.from(doc.getElementsByTagName('link')).reverse();
const links = Array.from(doc('link[rel][href]')).reverse().map(link => ({
rel: link.attribs.rel.split(' '),
href: link.attribs.href,
}));
// https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559
const href =
[
links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href,
links.find(link => link.relList.contains('apple-touch-icon'))?.href,
links.find(link => link.relList.contains('icon'))?.href,
links.find(link => link.rel.includes('apple-touch-icon-precomposed'))?.href,
links.find(link => link.rel.includes('apple-touch-icon'))?.href,
links.find(link => link.rel.includes('icon'))?.href,
]
.find(href => href);
@ -261,8 +264,8 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
private async getThemeColor(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record<string, any> | null): Promise<string | null> {
const themeColor = info?.metadata?.themeColor ?? doc?.('meta[name="theme-color"][content]').attr('content') ?? manifest?.theme_color;
if (themeColor) {
const color = new tinycolor(themeColor);
@ -273,7 +276,7 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
private async getSiteName(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (typeof info.metadata.nodeName === 'string') {
return info.metadata.nodeName;
@ -283,7 +286,7 @@ export class FetchInstanceMetadataService {
}
if (doc) {
const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content');
const og = doc('meta[property="og:title"][content]').attr('content');
if (og) {
return og;
@ -298,7 +301,7 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
private async getDescription(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (typeof info.metadata.nodeDescription === 'string') {
return info.metadata.nodeDescription;
@ -308,12 +311,12 @@ export class FetchInstanceMetadataService {
}
if (doc) {
const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content');
const meta = doc('meta[name="description"][content]').attr('content');
if (meta) {
return meta;
}
const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
const og = doc('meta[property="og:description"][content]').attr('content');
if (og) {
return og;
}

View file

@ -3,19 +3,25 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import type { KEYWORD } from 'color-convert/conversions.js';
import { envOption } from '@/env.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@Injectable()
export class LoggerService {
constructor(
@Inject(DI.config)
private config: Config,
) {
}
@bindThis
public getLogger(domain: string, color?: KEYWORD | undefined) {
return new Logger(domain, color);
const verbose = this.config.logging?.verbose || envOption.verbose;
return new Logger(domain, color, verbose);
}
}

View file

@ -539,7 +539,8 @@ export class NoteCreateService implements OnApplicationShutdown {
await this.notesRepository.insert(insert);
}
return insert;
// Re-fetch note to get the default values of null / unset fields.
return await this.notesRepository.findOneByOrFail({ id: insert.id });
} catch (e) {
// duplicate key error
if (isDuplicateKeyValueError(e)) {

View file

@ -574,12 +574,15 @@ export class NoteEditService implements OnApplicationShutdown {
await this.notesRepository.update(oldnote.id, note);
}
// Re-fetch note to get the default values of null / unset fields.
const edited = await this.notesRepository.findOneByOrFail({ id: note.id });
setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
() => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!),
() => this.postNoteEdited(edited, oldnote, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
);
return note;
return edited;
} else {
return oldnote;
}

View file

@ -52,6 +52,7 @@ export const QUEUE_TYPES = [
'objectStorage',
'userWebhookDeliver',
'systemWebhookDeliver',
'scheduleNotePost',
] as const;
@Injectable()
@ -783,6 +784,7 @@ export class QueueService {
case 'objectStorage': return this.objectStorageQueue;
case 'userWebhookDeliver': return this.userWebhookDeliverQueue;
case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue;
case 'scheduleNotePost': return this.ScheduleNotePostQueue;
default: throw new Error(`Unrecognized queue type: ${type}`);
}
}

View file

@ -28,9 +28,8 @@ import type { Config } from '@/config.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { ThinUser } from '@/queue/types.js';
import Logger from '../logger.js';
const logger = new Logger('following/create');
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '../logger.js';
type Local = MiLocalUser | {
id: MiLocalUser['id'];
@ -48,6 +47,7 @@ type Both = Local | Remote;
@Injectable()
export class UserFollowingService implements OnModuleInit {
private userBlockingService: UserBlockingService;
private readonly logger: Logger;
constructor(
private moduleRef: ModuleRef,
@ -86,7 +86,10 @@ export class UserFollowingService implements OnModuleInit {
private accountMoveService: AccountMoveService,
private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart,
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('following/create');
}
onModuleInit() {
@ -254,7 +257,7 @@ export class UserFollowingService implements OnModuleInit {
followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null,
}).catch(err => {
if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
this.logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
alreadyFollowed = true;
} else {
throw err;
@ -372,7 +375,7 @@ export class UserFollowingService implements OnModuleInit {
});
if (following === null || !following.follower || !following.followee) {
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
this.logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
return;
}

View file

@ -176,4 +176,14 @@ export class UtilityService {
const host = this.extractDbHost(uri);
return this.isFederationAllowedHost(host);
}
@bindThis
public getUrlScheme(url: string): string {
try {
// Returns in the format "https:" or an empty string
return new URL(url).protocol;
} catch {
return '';
}
}
}

View file

@ -470,7 +470,7 @@ export class ApInboxService {
}
@bindThis
private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver): Promise<string | void> {
private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver, silent = false): Promise<string | void> {
const uri = getApId(activity);
this.logger.info(`Create: ${uri}`);
@ -505,7 +505,7 @@ export class ApInboxService {
});
if (isPost(object)) {
await this.createNote(resolver, actor, object, false);
await this.createNote(resolver, actor, object, silent);
} else {
return `skip: Unsupported type for Create: ${getApType(object)} ${getNullableApId(object)}`;
}
@ -889,7 +889,7 @@ export class ApInboxService {
} else if (getApType(object) === 'Question') {
// If we get an Update(Question) for a note that doesn't exist, then create it instead
if (!await this.apNoteService.hasNote(object)) {
return await this.create(actor, activity, resolver);
return await this.create(actor, activity, resolver, true);
}
await this.apQuestionService.updateQuestion(object, actor, resolver);
@ -897,7 +897,7 @@ export class ApInboxService {
} else if (isPost(object)) {
// If we get an Update(Note) for a note that doesn't exist, then create it instead
if (!await this.apNoteService.hasNote(object)) {
return await this.create(actor, activity, resolver);
return await this.create(actor, activity, resolver, true);
}
await this.apNoteService.updateNote(object, actor, resolver);

View file

@ -402,7 +402,8 @@ export class NoteEntityService implements OnModuleInit {
bufferedReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
myReactions: Map<MiNote['id'], string | null>;
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
mentionHandles: Record<string, string | undefined>;
};
},
): Promise<Packed<'Note'>> {
@ -476,7 +477,8 @@ export class NoteEntityService implements OnModuleInit {
allowRenoteToExternal: channel.allowRenoteToExternal,
userId: channel.userId,
} : undefined,
mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined,
mentions: note.mentions.length > 0 ? note.mentions : undefined,
mentionHandles: note.mentions.length > 0 ? this.getUserHandles(note.mentions, options?._hint_?.mentionHandles) : undefined,
uri: note.uri ?? undefined,
url: note.url ?? undefined,
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
@ -529,6 +531,25 @@ export class NoteEntityService implements OnModuleInit {
) {
if (notes.length === 0) return [];
const targetNotes: MiNote[] = [];
for (const note of notes) {
if (isPureRenote(note)) {
// we may need to fetch 'my reaction' for renote target.
targetNotes.push(note.renote);
if (note.renote.reply) {
// idem if the renote is also a reply.
targetNotes.push(note.renote.reply);
}
} else {
if (note.reply) {
// idem for OP of a regular reply.
targetNotes.push(note.reply);
}
targetNotes.push(note);
}
}
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
const meId = me ? me.id : null;
@ -536,25 +557,6 @@ export class NoteEntityService implements OnModuleInit {
if (meId) {
const idsNeedFetchMyReaction = new Set<MiNote['id']>();
const targetNotes: MiNote[] = [];
for (const note of notes) {
if (isPureRenote(note)) {
// we may need to fetch 'my reaction' for renote target.
targetNotes.push(note.renote);
if (note.renote.reply) {
// idem if the renote is also a reply.
targetNotes.push(note.renote.reply);
}
} else {
if (note.reply) {
// idem for OP of a regular reply.
targetNotes.push(note.reply);
}
targetNotes.push(note);
}
}
for (const note of targetNotes) {
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
if (reactionsCount === 0) {
@ -594,6 +596,15 @@ export class NoteEntityService implements OnModuleInit {
const packedUsers = await this.userEntityService.packMany(users, me)
.then(users => new Map(users.map(u => [u.id, u])));
// Recursively add all mentioned users from all notes + replies + renotes
const allMentionedUsers = targetNotes.reduce((users, note) => {
for (const user of note.mentions) {
users.add(user);
}
return users;
}, new Set<string>());
const mentionHandles = await this.getUserHandles(Array.from(allMentionedUsers));
return await Promise.all(notes.map(n => this.pack(n, me, {
...options,
_hint_: {
@ -601,6 +612,7 @@ export class NoteEntityService implements OnModuleInit {
myReactions: myReactionsMap,
packedFiles,
packedUsers,
mentionHandles,
},
})));
}
@ -636,4 +648,36 @@ export class NoteEntityService implements OnModuleInit {
relations: ['user'],
});
}
private async getUserHandles(userIds: string[], hint?: Record<string, string | undefined>): Promise<Record<string, string | undefined>> {
if (userIds.length < 1) return {};
// Hint is provided by packMany to avoid N+1 queries.
// It should already include all existing mentioned users.
if (hint) {
const handles = {} as Record<string, string | undefined>;
for (const id of userIds) {
handles[id] = hint[id];
}
return handles;
}
const users = await this.usersRepository.find({
select: {
id: true,
username: true,
host: true,
},
where: {
id: In(userIds),
},
});
return users.reduce((map, user) => {
map[user.id] = user.host
? `@${user.username}@${user.host}`
: `@${user.username}`;
return map;
}, {} as Record<string, string | undefined>);
}
}

View file

@ -27,17 +27,19 @@ export type DataObject = Record<string, unknown> | (object & { length?: never; }
export default class Logger {
private context: Context;
private parentLogger: Logger | null = null;
public readonly verbose: boolean;
constructor(context: string, color?: KEYWORD) {
constructor(context: string, color?: KEYWORD, verbose?: boolean) {
this.context = {
name: context,
color: color,
};
this.verbose = verbose ?? envOption.verbose;
}
@bindThis
public createSubLogger(context: string, color?: KEYWORD): Logger {
const logger = new Logger(context, color);
const logger = new Logger(context, color, this.verbose);
logger.parentLogger = this;
return logger;
}
@ -110,7 +112,7 @@ export default class Logger {
@bindThis
public debug(message: string, data?: Data, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報)
if (process.env.NODE_ENV !== 'production' || envOption.verbose) {
if (process.env.NODE_ENV !== 'production' || this.verbose) {
this.log('debug', message, data, important);
}
}

View file

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { pipeline } from 'node:stream/promises';
import fs from 'node:fs';
import * as tmp from 'tmp';
export function createTemp(): Promise<[string, () => void]> {
@ -27,3 +29,21 @@ export function createTempDir(): Promise<[string, () => void]> {
);
});
}
export async function saveToTempFile(stream: NodeJS.ReadableStream & { truncated?: boolean }): Promise<[string, () => void]> {
const [filepath, cleanup] = await createTemp();
try {
await pipeline(stream, fs.createWriteStream(filepath));
} catch (e) {
cleanup();
throw e;
}
if (stream.truncated) {
cleanup();
throw new Error('Read failed: input stream truncated');
}
return [filepath, cleanup];
}

View file

@ -3,32 +3,29 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { JSDOM } from 'jsdom';
import { load as cheerio } from 'cheerio';
import type { HttpRequestService } from '@/core/HttpRequestService.js';
type Field = { name: string, value: string };
export async function verifyFieldLinks(fields: Field[], profile_url: string, httpRequestService: HttpRequestService): Promise<string[]> {
const verified_links = [];
for (const field_url of fields
.filter(x => URL.canParse(x.value) && ['http:', 'https:'].includes((new URL(x.value).protocol)))) {
for (const field_url of fields.filter(x => URL.canParse(x.value) && ['http:', 'https:'].includes((new URL(x.value).protocol)))) {
try {
const html = await httpRequestService.getHtml(field_url.value);
const { window } = new JSDOM(html);
const doc: Document = window.document;
const doc = cheerio(html);
const aEls = Array.from(doc.getElementsByTagName('a'));
const linkEls = Array.from(doc.getElementsByTagName('link'));
const links = doc('a[rel~="me"][href], link[rel~="me"][href]').toArray();
const includesProfileLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === profile_url);
if (includesProfileLinks) { verified_links.push(field_url.value); }
window.close();
} catch (err) {
const includesProfileLinks = links.some(link => link.attribs.href === profile_url);
if (includesProfileLinks) {
verified_links.push(field_url.value);
}
} catch {
// don't do anything.
continue;
}
}
return verified_links;
}

View file

@ -85,6 +85,16 @@ export const packedNoteSchema = {
format: 'id',
},
},
mentionHandles: {
type: 'object',
optional: true, nullable: false,
additionalProperties: {
anyOf: [{
type: 'string',
}],
optional: true, nullable: false,
},
},
visibleUserIds: {
type: 'array',
optional: true, nullable: false,

View file

@ -16,6 +16,7 @@ import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js
import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js';
import { ApiInstanceMastodon } from '@/server/api/mastodon/endpoints/instance.js';
import { ApiStatusMastodon } from '@/server/api/mastodon/endpoints/status.js';
import { ServerUtilityService } from '@/server/ServerUtilityService.js';
import { ApiCallService } from './api/ApiCallService.js';
import { FileServerService } from './FileServerService.js';
import { HealthServerService } from './HealthServerService.js';
@ -126,6 +127,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
ApiSearchMastodon,
ApiStatusMastodon,
ApiTimelineMastodon,
ServerUtilityService,
],
exports: [
ServerService,

View file

@ -7,7 +7,7 @@ import cluster from 'node:cluster';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import Fastify, { FastifyInstance } from 'fastify';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyStatic from '@fastify/static';
import fastifyRawBody from 'fastify-raw-body';
import { IsNull } from 'typeorm';
@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown {
}
@bindThis
public async launch() {
public async launch(): Promise<void> {
const fastify = Fastify({
trustProxy: true,
logger: false,
@ -135,8 +135,8 @@ export class ServerService implements OnApplicationShutdown {
reply.header('content-type', 'text/plain; charset=utf-8');
reply.header('link', `<${encodeURI(location)}>; rel="canonical"`);
done(null, [
'Refusing to relay remote ActivityPub object lookup.',
'',
"Refusing to relay remote ActivityPub object lookup.",
"",
`Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`,
].join('\n'));
});
@ -304,7 +304,6 @@ export class ServerService implements OnApplicationShutdown {
}
await fastify.ready();
return fastify;
}
@bindThis
@ -313,6 +312,13 @@ export class ServerService implements OnApplicationShutdown {
await this.#fastify.close();
}
/**
* Get the Fastify instance for testing.
*/
public get fastify(): FastifyInstance {
return this.#fastify;
}
@bindThis
async onApplicationShutdown(signal: string): Promise<void> {
await this.dispose();

View file

@ -0,0 +1,162 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import querystring from 'querystring';
import multipart from '@fastify/multipart';
import { Inject, Injectable } from '@nestjs/common';
import { FastifyInstance } from 'fastify';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { saveToTempFile } from '@/misc/create-temp.js';
@Injectable()
export class ServerUtilityService {
constructor(
@Inject(DI.config)
private readonly config: Config,
) {}
public addMultipartFormDataContentType(fastify: FastifyInstance): void {
fastify.register(multipart, {
limits: {
fileSize: this.config.maxFileSize,
files: 1,
},
});
// Default behavior saves files to memory - we don't want that!
// Store to temporary file instead, and copy the body fields while we're at it.
fastify.addHook<{ Body?: Record<string, string | string[] | undefined> }>('preValidation', async request => {
if (request.isMultipart()) {
// We can't use saveRequestFiles() because it erases all the data fields.
// Instead, recreate it manually.
// https://github.com/fastify/fastify-multipart/issues/549
for await (const part of request.parts()) {
if (part.type === 'field') {
const k = part.fieldname;
const v = part.value;
const body = request.body ??= {};
// Value can be string, buffer, or undefined.
// We only support the first one.
if (typeof(v) !== 'string') continue;
// This is just progressive conversion from undefined -> string -> string[]
if (!body[k]) {
body[k] = v;
} else if (Array.isArray(body[k])) {
body[k].push(v);
} else {
body[k] = [body[k], v];
}
} else { // Otherwise it's a file
try {
const [filepath] = await saveToTempFile(part.file);
const tmpUploads = (request.tmpUploads ??= []);
tmpUploads.push(filepath);
const requestSavedFiles = (request.savedRequestFiles ??= []);
requestSavedFiles.push({
...part,
filepath,
});
} catch (e) {
// Cleanup to avoid file leak in case of errors
await request.cleanRequestFiles();
request.tmpUploads = null;
request.savedRequestFiles = null;
throw e;
}
}
}
}
});
}
public addFormUrlEncodedContentType(fastify: FastifyInstance) {
fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => {
let body = '';
payload.on('data', (data) => {
body += data;
});
payload.on('end', () => {
try {
const parsed = querystring.parse(body);
done(null, parsed);
} catch (e) {
done(e as Error);
}
});
payload.on('error', done);
});
}
public addCORS(fastify: FastifyInstance) {
fastify.addHook('preHandler', (_, reply, done) => {
// Allow web-based clients to connect from other origins.
reply.header('Access-Control-Allow-Origin', '*');
// Mastodon uses all types of request methods.
reply.header('Access-Control-Allow-Methods', '*');
// Allow web-based clients to access Link header - required for mastodon pagination.
// https://stackoverflow.com/a/54928828
// https://docs.joinmastodon.org/api/guidelines/#pagination
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers
reply.header('Access-Control-Expose-Headers', 'Link');
// Cache to avoid extra pre-flight requests
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age
reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds
done();
});
}
public addFlattenedQueryType(fastify: FastifyInstance) {
// Remove trailing "[]" from query params
fastify.addHook<{ Querystring?: Record<string, string | string[] | undefined> }>('preValidation', (request, _reply, done) => {
if (!request.query || typeof(request.query) !== 'object') {
return done();
}
for (const key of Object.keys(request.query)) {
if (!key.endsWith('[]')) {
continue;
}
if (request.query[key] == null) {
continue;
}
const newKey = key.substring(0, key.length - 2);
const newValue = request.query[key];
const oldValue = request.query[newKey];
// Move the value to the correct key
if (oldValue != null) {
if (Array.isArray(oldValue)) {
// Works for both array and single values
request.query[newKey] = oldValue.concat(newValue);
} else if (Array.isArray(newValue)) {
// Preserve order
request.query[newKey] = [oldValue, ...newValue];
} else {
// Preserve order
request.query[newKey] = [oldValue, newValue];
}
} else {
request.query[newKey] = newValue;
}
// Remove the invalid key
delete request.query[key];
}
return done();
});
}
}

View file

@ -6,11 +6,8 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as stream from 'node:stream/promises';
import { Transform } from 'node:stream';
import { type MultipartFile } from '@fastify/multipart';
import { Inject, Injectable } from '@nestjs/common';
import * as Sentry from '@sentry/node';
import { AttachmentFile } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
@ -19,7 +16,7 @@ import type Logger from '@/logger.js';
import type { MiMeta, UserIpsRepository } from '@/models/_.js';
import { createTemp } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js';
import { type RolePolicies, RoleService } from '@/core/RoleService.js';
import { RoleService } from '@/core/RoleService.js';
import type { Config } from '@/config.js';
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
@ -194,6 +191,18 @@ export class ApiCallService implements OnApplicationShutdown {
return;
}
const [path, cleanup] = await createTemp();
await stream.pipeline(multipartData.file, fs.createWriteStream(path));
// ファイルサイズが制限を超えていた場合
// なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある
if (multipartData.file.truncated) {
cleanup();
reply.code(413);
reply.send();
return;
}
const fields = {} as Record<string, unknown>;
for (const [k, v] of Object.entries(multipartData.fields)) {
fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined;
@ -204,13 +213,18 @@ export class ApiCallService implements OnApplicationShutdown {
? request.headers.authorization.slice(7)
: fields['i'];
if (token != null && typeof token !== 'string') {
cleanup();
reply.code(400);
return;
}
this.authenticateService.authenticate(token).then(([user, app]) => {
this.call(endpoint, user, app, fields, multipartData, request, reply).then((res) => {
this.call(endpoint, user, app, fields, {
name: multipartData.filename,
path: path,
}, request, reply).then((res) => {
this.send(reply, res);
}).catch((err: ApiError) => {
cleanup();
this.#sendApiError(reply, err);
});
@ -218,6 +232,7 @@ export class ApiCallService implements OnApplicationShutdown {
this.logIp(request, user);
}
}).catch(err => {
cleanup();
this.#sendAuthenticationError(reply, err);
});
}
@ -278,7 +293,10 @@ export class ApiCallService implements OnApplicationShutdown {
user: MiLocalUser | null | undefined,
token: MiAccessToken | null | undefined,
data: any,
multipartFile: MultipartFile | null,
file: {
name: string;
path: string;
} | null,
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
) {
@ -354,37 +372,6 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
// Cast non JSON input
if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)) {
const param = ep.params.properties![k];
if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') {
try {
data[k] = JSON.parse(data[k]);
} catch (e) {
throw new ApiError({
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',
}, {
param: k,
reason: `cannot cast to ${param.type}`,
});
}
}
}
}
if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind))
|| (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) {
throw new ApiError({
message: 'Your app does not have the necessary permissions to use this endpoint.',
code: 'PERMISSION_DENIED',
kind: 'permission',
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
});
}
if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) {
const myRoles = await this.roleService.getUserRoles(user!.id);
if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) {
@ -418,91 +405,49 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
let attachmentFile: AttachmentFile | null = null;
let cleanup = () => {};
if (ep.meta.requireFile && request.method === 'POST' && multipartFile) {
const policies = await this.roleService.getUserPolicies(user!.id);
const result = await this.handleAttachmentFile(
Math.min((policies.maxFileSizeMb * 1024 * 1024), this.config.maxFileSize),
multipartFile,
);
attachmentFile = result.attachmentFile;
cleanup = result.cleanup;
if (token && ((ep.meta.kind && !token.permission.some(p => p === ep.meta.kind))
|| (!ep.meta.kind && (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin)))) {
throw new ApiError({
message: 'Your app does not have the necessary permissions to use this endpoint.',
code: 'PERMISSION_DENIED',
kind: 'permission',
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
});
}
// Cast non JSON input
if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)) {
const param = ep.params.properties![k];
if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') {
try {
data[k] = JSON.parse(data[k]);
} catch (e) {
throw new ApiError({
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '0b5f1631-7c1a-41a6-b399-cce335f34d85',
}, {
param: k,
reason: `cannot cast to ${param.type}`,
});
}
}
}
}
// API invoking
if (this.config.sentryForBackend) {
return await Sentry.startSpan({
name: 'API: ' + ep.name,
}, () => {
return ep.exec(data, user, token, attachmentFile, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id))
.finally(() => cleanup());
});
}, () => ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id)));
} else {
return await ep.exec(data, user, token, attachmentFile, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id))
.finally(() => cleanup());
return await ep.exec(data, user, token, file, request.ip, request.headers)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id));
}
}
@bindThis
private async handleAttachmentFile(
fileSizeLimit: number,
multipartFile: MultipartFile,
) {
function createTooLongError() {
return new ApiError({
httpStatusCode: 413,
kind: 'client',
message: 'File size is too large.',
code: 'FILE_SIZE_TOO_LARGE',
id: 'ff827ce8-9b4b-4808-8511-422222a3362f',
});
}
function createLimitStream(limit: number) {
let total = 0;
return new Transform({
transform(chunk, _, callback) {
total += chunk.length;
if (total > limit) {
callback(createTooLongError());
} else {
callback(null, chunk);
}
},
});
}
const [path, cleanup] = await createTemp();
try {
await stream.pipeline(
multipartFile.file,
createLimitStream(fileSizeLimit),
fs.createWriteStream(path),
);
// ファイルサイズが制限を超えていた場合
// なお truncated はストリームを読み切ってからでないと機能しないため、stream.pipeline より後にある必要がある
if (multipartFile.file.truncated) {
throw createTooLongError();
}
} catch (err) {
cleanup();
throw err;
}
return {
attachmentFile: {
name: multipartFile.filename,
path,
},
cleanup,
};
}
@bindThis
public dispose(): void {
clearInterval(this.userIpHistoriesClearIntervalId);

View file

@ -84,6 +84,8 @@ export class AuthenticateService implements OnApplicationShutdown {
return [user, {
id: accessToken.id,
permission: app.permission,
appId: app.id,
app,
} as MiAccessToken];
} else {
return [user, accessToken];

View file

@ -21,23 +21,23 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
export type Response = Record<string, any> | void;
export type AttachmentFile = {
type File = {
name: string | null;
path: string;
};
// TODO: paramsの型をT['params']のスキーマ定義から推論する
type Executor<T extends IEndpointMeta, Ps extends Schema> =
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) {
const validate = ajv.compile(paramDef);
this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => {
this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => {
let cleanup: undefined | (() => void) = undefined;
if (meta.requireFile) {

View file

@ -128,6 +128,7 @@ export * as 'antennas/update' from './endpoints/antennas/update.js';
export * as 'ap/get' from './endpoints/ap/get.js';
export * as 'ap/show' from './endpoints/ap/show.js';
export * as 'app/create' from './endpoints/app/create.js';
export * as 'app/current' from './endpoints/app/current.js';
export * as 'app/show' from './endpoints/app/show.js';
export * as 'auth/accept' from './endpoints/auth/accept.js';
export * as 'auth/session/generate' from './endpoints/auth/session/generate.js';

View file

@ -20,7 +20,7 @@ export const paramDef = {
type: 'object',
properties: {
queue: { type: 'string', enum: QUEUE_TYPES },
state: { type: 'array', items: { type: 'string', enum: ['active', 'wait', 'delayed', 'completed', 'failed'] } },
state: { type: 'array', items: { type: 'string', enum: ['active', 'paused', 'wait', 'delayed', 'completed', 'failed'] } },
search: { type: 'string' },
},
required: ['queue', 'state'],

View file

@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AppsRepository } from '@/models/_.js';
import { AppEntityService } from '@/core/entities/AppEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['app'],
errors: {
credentialRequired: {
message: 'Credential required.',
code: 'CREDENTIAL_REQUIRED',
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
httpStatusCode: 401,
},
noAppLogin: {
message: 'Not logged in with an app.',
code: 'NO_APP_LOGIN',
id: '339a4ad2-48c3-47fc-bd9d-2408f05120f8',
},
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'App',
},
// 10 calls per 5 seconds
limit: {
duration: 1000 * 5,
max: 10,
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.appsRepository)
private appsRepository: AppsRepository,
private appEntityService: AppEntityService,
) {
super(meta, paramDef, async (_, user, token) => {
if (!user) {
throw new ApiError(meta.errors.credentialRequired);
}
if (!token || !token.appId) {
throw new ApiError(meta.errors.noAppLogin);
}
const app = token.app ?? await this.appsRepository.findOneByOrFail({ id: token.appId });
return await this.appEntityService.pack(app, user, {
detail: true,
includeSecret: false,
});
});
}
}

View file

@ -67,6 +67,7 @@ export const meta = {
message: 'Cannot upload the file because it exceeds the maximum file size.',
code: 'MAX_FILE_SIZE_EXCEEDED',
id: 'b9d8c348-33f0-4673-b9a9-5d4da058977a',
httpStatusCode: 413,
},
},
} as const;

View file

@ -6,7 +6,6 @@
import * as mfm from '@transfem-org/sfm-js';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { JSDOM } from 'jsdom';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import * as Acct from '@/misc/acct.js';
@ -622,6 +621,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
// this function is superseded by '@/misc/verify-field-link.ts'
/*
private async verifyLink(url: string, user: MiLocalUser) {
if (!safeForSql(url)) return;
@ -653,6 +653,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// なにもしない
}
}
*/
// these two methods need to be kept in sync with
// `ApRendererService.renderPerson`

View file

@ -3,13 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import querystring from 'querystring';
import multer from 'fastify-multer';
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { Config } from '@/config.js';
import { getErrorData, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js';
import { getErrorData, getErrorException, getErrorStatus, MastodonLogger } from '@/server/api/mastodon/MastodonLogger.js';
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
import { ApiAccountMastodon } from '@/server/api/mastodon/endpoints/account.js';
import { ApiAppsMastodon } from '@/server/api/mastodon/endpoints/apps.js';
@ -20,6 +16,7 @@ import { ApiNotificationsMastodon } from '@/server/api/mastodon/endpoints/notifi
import { ApiTimelineMastodon } from '@/server/api/mastodon/endpoints/timeline.js';
import { ApiSearchMastodon } from '@/server/api/mastodon/endpoints/search.js';
import { ApiError } from '@/server/api/error.js';
import { ServerUtilityService } from '@/server/ServerUtilityService.js';
import { parseTimelineArgs, TimelineArgs, toBoolean } from './argsUtils.js';
import { convertAnnouncement, convertAttachment, MastodonConverters, convertRelationship } from './MastodonConverters.js';
import type { Entity } from 'megalodon';
@ -28,9 +25,6 @@ import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
@Injectable()
export class MastodonApiServerService {
constructor(
@Inject(DI.config)
private readonly config: Config,
private readonly mastoConverters: MastodonConverters,
private readonly logger: MastodonLogger,
private readonly clientService: MastodonClientService,
@ -42,115 +36,47 @@ export class MastodonApiServerService {
private readonly apiSearchMastodon: ApiSearchMastodon,
private readonly apiStatusMastodon: ApiStatusMastodon,
private readonly apiTimelineMastodon: ApiTimelineMastodon,
private readonly serverUtilityService: ServerUtilityService,
) {}
@bindThis
public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) {
const upload = multer({
storage: multer.diskStorage({}),
limits: {
fileSize: this.config.maxFileSize || 262144000,
files: 1,
},
});
fastify.addHook('onRequest', (_, reply, done) => {
// Allow web-based clients to connect from other origins.
reply.header('Access-Control-Allow-Origin', '*');
// Mastodon uses all types of request methods.
reply.header('Access-Control-Allow-Methods', '*');
// Allow web-based clients to access Link header - required for mastodon pagination.
// https://stackoverflow.com/a/54928828
// https://docs.joinmastodon.org/api/guidelines/#pagination
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers
reply.header('Access-Control-Expose-Headers', 'Link');
// Cache to avoid extra pre-flight requests
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age
reply.header('Access-Control-Max-Age', 60 * 60 * 24); // 1 day in seconds
done();
});
fastify.addContentTypeParser('application/x-www-form-urlencoded', (_, payload, done) => {
let body = '';
payload.on('data', (data) => {
body += data;
});
payload.on('end', () => {
try {
const parsed = querystring.parse(body);
done(null, parsed);
} catch (e) {
done(e as Error);
}
});
payload.on('error', done);
});
// Remove trailing "[]" from query params
fastify.addHook('preValidation', (request, _reply, done) => {
if (!request.query || typeof(request.query) !== 'object') {
return done();
}
// Same object aliased with a different type
const query = request.query as Record<string, string | string[] | undefined>;
for (const key of Object.keys(query)) {
if (!key.endsWith('[]')) {
continue;
}
if (query[key] == null) {
continue;
}
const newKey = key.substring(0, key.length - 2);
const newValue = query[key];
const oldValue = query[newKey];
// Move the value to the correct key
if (oldValue != null) {
if (Array.isArray(oldValue)) {
// Works for both array and single values
query[newKey] = oldValue.concat(newValue);
} else if (Array.isArray(newValue)) {
// Preserve order
query[newKey] = [oldValue, ...newValue];
} else {
// Preserve order
query[newKey] = [oldValue, newValue];
}
} else {
query[newKey] = newValue;
}
// Remove the invalid key
delete query[key];
}
return done();
});
this.serverUtilityService.addMultipartFormDataContentType(fastify);
this.serverUtilityService.addFormUrlEncodedContentType(fastify);
this.serverUtilityService.addCORS(fastify);
this.serverUtilityService.addFlattenedQueryType(fastify);
// Convert JS exceptions into error responses
fastify.setErrorHandler((error, request, reply) => {
const data = getErrorData(error);
const status = getErrorStatus(error);
const exception = getErrorException(error);
this.logger.error(request, data, status);
if (exception) {
this.logger.exception(request, exception);
}
reply.code(status).send(data);
return reply.code(status).send(data);
});
fastify.register(multer.contentParser);
// Log error responses (including converted JSON exceptions)
fastify.addHook('onSend', (request, reply, payload, done) => {
if (reply.statusCode >= 400) {
if (typeof(payload) === 'string' && String(reply.getHeader('content-type')).toLowerCase().includes('application/json')) {
const body = JSON.parse(payload);
const data = getErrorData(body);
this.logger.error(request, data, reply.statusCode);
}
}
done();
});
// External endpoints
this.apiAccountMastodon.register(fastify, upload);
this.apiAppsMastodon.register(fastify, upload);
this.apiFilterMastodon.register(fastify, upload);
this.apiAccountMastodon.register(fastify);
this.apiAppsMastodon.register(fastify);
this.apiFilterMastodon.register(fastify);
this.apiInstanceMastodon.register(fastify);
this.apiNotificationsMastodon.register(fastify, upload);
this.apiNotificationsMastodon.register(fastify);
this.apiSearchMastodon.register(fastify);
this.apiStatusMastodon.register(fastify);
this.apiTimelineMastodon.register(fastify);
@ -158,7 +84,7 @@ export class MastodonApiServerService {
fastify.get('/v1/custom_emojis', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.getInstanceCustomEmojis();
reply.send(data.data);
return reply.send(data.data);
});
fastify.get('/v1/announcements', async (_request, reply) => {
@ -166,7 +92,7 @@ export class MastodonApiServerService {
const data = await client.getInstanceAnnouncements();
const response = data.data.map((announcement) => convertAnnouncement(announcement));
reply.send(response);
return reply.send(response);
});
fastify.post<{ Body: { id?: string } }>('/v1/announcements/:id/dismiss', async (_request, reply) => {
@ -175,64 +101,62 @@ export class MastodonApiServerService {
const client = this.clientService.getClient(_request);
const data = await client.dismissInstanceAnnouncement(_request.body.id);
reply.send(data.data);
return reply.send(data.data);
});
fastify.post('/v1/media', { preHandler: upload.single('file') }, async (_request, reply) => {
const multipartData = await _request.file();
fastify.post('/v1/media', async (_request, reply) => {
const multipartData = _request.savedRequestFiles?.[0];
if (!multipartData) {
reply.code(401).send({ error: 'No image' });
return;
return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' });
}
const client = this.clientService.getClient(_request);
const data = await client.uploadMedia(multipartData);
const response = convertAttachment(data.data as Entity.Attachment);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', { preHandler: upload.single('file') }, async (_request, reply) => {
const multipartData = await _request.file();
fastify.post<{ Body: { description?: string; focus?: string } }>('/v2/media', async (_request, reply) => {
const multipartData = _request.savedRequestFiles?.[0];
if (!multipartData) {
reply.code(401).send({ error: 'No image' });
return;
return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'No image' });
}
const client = this.clientService.getClient(_request);
const data = await client.uploadMedia(multipartData, _request.body);
const response = convertAttachment(data.data as Entity.Attachment);
reply.send(response);
return reply.send(response);
});
fastify.get('/v1/trends', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.getInstanceTrends();
reply.send(data.data);
return reply.send(data.data);
});
fastify.get('/v1/trends/tags', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.getInstanceTrends();
reply.send(data.data);
return reply.send(data.data);
});
fastify.get('/v1/trends/links', async (_request, reply) => {
// As we do not have any system for news/links this will just return empty
reply.send([]);
return reply.send([]);
});
fastify.get('/v1/preferences', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.getPreferences();
reply.send(data.data);
return reply.send(data.data);
});
fastify.get('/v1/followed_tags', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.getFollowedTags();
reply.send(data.data);
return reply.send(data.data);
});
fastify.get<{ Querystring: TimelineArgs }>('/v1/bookmarks', async (_request, reply) => {
@ -241,7 +165,7 @@ export class MastodonApiServerService {
const data = await client.getBookmarks(parseTimelineArgs(_request.query));
const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me)));
reply.send(response);
return reply.send(response);
});
fastify.get<{ Querystring: TimelineArgs }>('/v1/favourites', async (_request, reply) => {
@ -263,7 +187,7 @@ export class MastodonApiServerService {
const data = await client.getFavourites(args);
const response = await Promise.all(data.data.map((status) => this.mastoConverters.convertStatus(status, me)));
reply.send(response);
return reply.send(response);
});
fastify.get<{ Querystring: TimelineArgs }>('/v1/mutes', async (_request, reply) => {
@ -272,7 +196,7 @@ export class MastodonApiServerService {
const data = await client.getMutes(parseTimelineArgs(_request.query));
const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account)));
reply.send(response);
return reply.send(response);
});
fastify.get<{ Querystring: TimelineArgs }>('/v1/blocks', async (_request, reply) => {
@ -281,7 +205,7 @@ export class MastodonApiServerService {
const data = await client.getBlocks(parseTimelineArgs(_request.query));
const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account)));
reply.send(response);
return reply.send(response);
});
fastify.get<{ Querystring: { limit?: string } }>('/v1/follow_requests', async (_request, reply) => {
@ -291,27 +215,27 @@ export class MastodonApiServerService {
const data = await client.getFollowRequests(limit);
const response = await Promise.all(data.data.map((account) => this.mastoConverters.convertAccount(account as Entity.Account)));
reply.send(response);
return reply.send(response);
});
fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/authorize', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/authorize', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
const data = await client.acceptFollowRequest(_request.params.id);
const response = convertRelationship(data.data);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Querystring: TimelineArgs, Params: { id?: string } }>('/v1/follow_requests/:id/reject', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<{ Params: { id?: string } }>('/v1/follow_requests/:id/reject', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
const data = await client.rejectFollowRequest(_request.params.id);
const response = convertRelationship(data.data);
reply.send(response);
return reply.send(response);
});
//#endregion
@ -325,7 +249,7 @@ export class MastodonApiServerService {
focus?: string,
is_sensitive?: string,
},
}>('/v1/media/:id', { preHandler: upload.none() }, async (_request, reply) => {
}>('/v1/media/:id', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const options = {
@ -336,7 +260,7 @@ export class MastodonApiServerService {
const data = await client.updateMedia(_request.params.id, options);
const response = convertAttachment(data.data);
reply.send(response);
return reply.send(response);
});
done();

View file

@ -4,7 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { Entity, MastodonEntity } from 'megalodon';
import { Entity, MastodonEntity, MisskeyEntity } from 'megalodon';
import mfm from '@transfem-org/sfm-js';
import { MastodonNotificationType } from 'megalodon/lib/src/mastodon/notification.js';
import { NotificationType } from 'megalodon/lib/src/notification.js';
@ -369,6 +369,15 @@ export class MastodonConverters {
type: convertNotificationType(notification.type as NotificationType),
};
}
public convertApplication(app: MisskeyEntity.App): MastodonEntity.Application {
return {
name: app.name,
scopes: app.permission,
redirect_uri: app.callbackUrl,
redirect_uris: [app.callbackUrl],
};
}
}
function simpleConvert<T>(data: T): T {
@ -459,4 +468,3 @@ export function convertRelationship(relationship: Partial<Entity.Relationship> &
note: relationship.note ?? '',
};
}

View file

@ -3,33 +3,49 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { FastifyRequest } from 'fastify';
import Logger from '@/logger.js';
import { Injectable } from '@nestjs/common';
import { isAxiosError } from 'axios';
import type Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
import { ApiError } from '@/server/api/error.js';
import { EnvService } from '@/core/EnvService.js';
import { getBaseUrl } from '@/server/api/mastodon/MastodonClientService.js';
import { AuthenticationError } from '@/server/api/AuthenticateService.js';
import type { FastifyRequest } from 'fastify';
@Injectable()
export class MastodonLogger {
public readonly logger: Logger;
constructor(
@Inject(EnvService)
private readonly envService: EnvService,
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('masto-api');
}
public error(request: FastifyRequest, error: MastodonError, status: number): void {
if ((status < 400 && status > 499) || this.envService.env.NODE_ENV === 'development') {
const path = new URL(request.url, getBaseUrl(request)).pathname;
const path = getPath(request);
if (status >= 400 && status <= 499) { // Client errors
this.logger.debug(`Error in mastodon endpoint ${request.method} ${path}:`, error);
} else { // Server errors
this.logger.error(`Error in mastodon endpoint ${request.method} ${path}:`, error);
}
}
public exception(request: FastifyRequest, ex: Error): void {
const path = getPath(request);
// Exceptions are always server errors, and should therefore always be logged.
this.logger.error(`Exception in mastodon endpoint ${request.method} ${path}:`, ex);
}
}
function getPath(request: FastifyRequest): string {
try {
return new URL(request.url, getBaseUrl(request)).pathname;
} catch {
return request.url;
}
}
// TODO move elsewhere
@ -38,6 +54,43 @@ export interface MastodonError {
error_description?: string;
}
export function getErrorException(error: unknown): Error | null {
if (!(error instanceof Error)) {
return null;
}
// AxiosErrors need special decoding
if (isAxiosError(error)) {
// Axios errors with a response are from the remote
if (error.response) {
return null;
}
// This is the inner exception, basically
if (error.cause && !isAxiosError(error.cause)) {
if (!error.cause.stack) {
error.cause.stack = error.stack;
}
return error.cause;
}
const ex = new Error();
ex.name = error.name;
ex.stack = error.stack;
ex.message = error.message;
ex.cause = error.cause;
return ex;
}
// AuthenticationError is a client error
if (error instanceof AuthenticationError) {
return null;
}
return error;
}
export function getErrorData(error: unknown): MastodonError {
// Axios wraps errors from the backend
error = unpackAxiosError(error);
@ -59,17 +112,33 @@ export function getErrorData(error: unknown): MastodonError {
}
}
if ('error' in error && typeof (error.error) === 'string') {
if ('message' in error && typeof (error.message) === 'string') {
return convertErrorMessageError(error as { error: string, message: string });
}
}
if (error instanceof Error) {
return convertGenericError(error);
}
return convertUnknownError(error);
if ('error' in error && typeof(error.error) === 'string') {
// "error_description" is string, undefined, or not present.
if (!('error_description' in error) || typeof(error.error_description) === 'string' || typeof(error.error_description) === 'undefined') {
return convertMastodonError(error as MastodonError);
}
}
return {
error: 'INTERNAL_ERROR',
error_description: 'Internal error occurred. Please contact us if the error persists.',
};
}
function unpackAxiosError(error: unknown): unknown {
if (error && typeof(error) === 'object') {
if ('response' in error && error.response && typeof (error.response) === 'object') {
if ('data' in error.response && error.response.data && typeof (error.response.data) === 'object') {
if (isAxiosError(error)) {
if (error.response) {
if (error.response.data && typeof(error.response.data) === 'object') {
if ('error' in error.response.data && error.response.data.error && typeof(error.response.data.error) === 'object') {
return error.response.data.error;
}
@ -80,46 +149,48 @@ function unpackAxiosError(error: unknown): unknown {
// No data - this is a fallback to avoid leaking request/response details in the error
return undefined;
}
if (error.cause && !isAxiosError(error.cause)) {
if (!error.cause.stack) {
error.cause.stack = error.stack;
}
return error.cause;
}
// No data - this is a fallback to avoid leaking request/response details in the error
return String(error);
}
return error;
}
function convertApiError(apiError: ApiError): MastodonError {
const mastoError: MastodonError & Partial<ApiError> = {
return {
error: apiError.code,
error_description: apiError.message,
...apiError,
};
delete mastoError.code;
delete mastoError.message;
delete mastoError.httpStatusCode;
return mastoError;
}
function convertUnknownError(data: object = {}): MastodonError {
return Object.assign({}, data, {
error: 'INTERNAL_ERROR',
error_description: 'Internal error occurred. Please contact us if the error persists.',
id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac',
kind: 'server',
});
function convertErrorMessageError(error: { error: string, message: string }): MastodonError {
return {
error: error.error,
error_description: error.message,
};
}
function convertGenericError(error: Error): MastodonError {
const mastoError: MastodonError & Partial<Error> = {
return {
error: 'INTERNAL_ERROR',
error_description: String(error),
...error,
};
}
delete mastoError.name;
delete mastoError.message;
delete mastoError.stack;
return mastoError;
function convertMastodonError(error: MastodonError): MastodonError {
return {
error: error.error,
error_description: error.error_description,
};
}
export function getErrorStatus(error: unknown): number {
@ -134,6 +205,10 @@ export function getErrorStatus(error: unknown): number {
if ('httpStatusCode' in error && typeof(error.httpStatusCode) === 'number') {
return error.httpStatusCode;
}
if ('statusCode' in error && typeof(error.statusCode) === 'number') {
return error.statusCode;
}
}
return 500;

View file

@ -11,7 +11,6 @@ import { DI } from '@/di-symbols.js';
import type { AccessTokensRepository, UserProfilesRepository } from '@/models/_.js';
import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
import { MastodonConverters, convertRelationship, convertFeaturedTag, convertList } from '../MastodonConverters.js';
import type multer from 'fastify-multer';
import type { FastifyInstance } from 'fastify';
interface ApiAccountMastodonRoute {
@ -34,7 +33,7 @@ export class ApiAccountMastodon {
private readonly driveService: DriveService,
) {}
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
public register(fastify: FastifyInstance): void {
fastify.get<ApiAccountMastodonRoute>('/v1/accounts/verify_credentials', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.verifyAccountCredentials();
@ -48,7 +47,7 @@ export class ApiAccountMastodon {
language: '',
},
});
reply.send(response);
return reply.send(response);
});
fastify.patch<{
@ -70,60 +69,50 @@ export class ApiAccountMastodon {
value: string,
}[],
},
}>('/v1/accounts/update_credentials', { preHandler: upload.any() }, async (_request, reply) => {
}>('/v1/accounts/update_credentials', async (_request, reply) => {
const accessTokens = _request.headers.authorization;
const client = this.clientService.getClient(_request);
// Check if there is a Header or Avatar being uploaded, if there is proceed to upload it to the drive of the user and then set it.
if (_request.files.length > 0 && accessTokens) {
if (_request.savedRequestFiles?.length && accessTokens) {
const tokeninfo = await this.accessTokensRepository.findOneBy({ token: accessTokens.replace('Bearer ', '') });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const avatar = (_request.files as any).find((obj: any) => {
const avatar = _request.savedRequestFiles.find(obj => {
return obj.fieldname === 'avatar';
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const header = (_request.files as any).find((obj: any) => {
const header = _request.savedRequestFiles.find(obj => {
return obj.fieldname === 'header';
});
if (tokeninfo && avatar) {
const upload = await this.driveService.addFile({
user: { id: tokeninfo.userId, host: null },
path: avatar.path,
name: avatar.originalname !== null && avatar.originalname !== 'file' ? avatar.originalname : undefined,
path: avatar.filepath,
name: avatar.filename && avatar.filename !== 'file' ? avatar.filename : undefined,
sensitive: false,
});
if (upload.type.startsWith('image/')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_request.body as any).avatar = upload.id;
_request.body.avatar = upload.id;
}
} else if (tokeninfo && header) {
const upload = await this.driveService.addFile({
user: { id: tokeninfo.userId, host: null },
path: header.path,
name: header.originalname !== null && header.originalname !== 'file' ? header.originalname : undefined,
path: header.filepath,
name: header.filename && header.filename !== 'file' ? header.filename : undefined,
sensitive: false,
});
if (upload.type.startsWith('image/')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_request.body as any).header = upload.id;
_request.body.header = upload.id;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((_request.body as any).fields_attributes) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fields = (_request.body as any).fields_attributes.map((field: any) => {
if (_request.body.fields_attributes) {
for (const field of _request.body.fields_attributes) {
if (!(field.name.trim() === '' && field.value.trim() === '')) {
if (field.name.trim() === '') return reply.code(400).send('Field name can not be empty');
if (field.value.trim() === '') return reply.code(400).send('Field value can not be empty');
}
return {
...field,
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_request.body as any).fields_attributes = fields.filter((field: any) => field.name.trim().length > 0 && field.value.length > 0);
}
_request.body.fields_attributes = _request.body.fields_attributes.filter(field => field.name.trim().length > 0 && field.value.length > 0);
}
const options = {
@ -139,7 +128,7 @@ export class ApiAccountMastodon {
const data = await client.updateCredentials(options);
const response = await this.mastoConverters.convertAccount(data.data);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Querystring: { acct?: string } }>('/v1/accounts/lookup', async (_request, reply) => {
@ -151,7 +140,7 @@ export class ApiAccountMastodon {
data.data.accounts[0].fields = profile?.fields.map(f => ({ ...f, verified_at: null })) ?? [];
const response = await this.mastoConverters.convertAccount(data.data.accounts[0]);
reply.send(response);
return reply.send(response);
});
fastify.get<ApiAccountMastodonRoute & { Querystring: { id?: string | string[] } }>('/v1/accounts/relationships', async (_request, reply) => {
@ -161,7 +150,7 @@ export class ApiAccountMastodon {
const data = await client.getRelationships(_request.query.id);
const response = data.data.map(relationship => convertRelationship(relationship));
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id', async (_request, reply) => {
@ -171,7 +160,7 @@ export class ApiAccountMastodon {
const data = await client.getAccount(_request.params.id);
const account = await this.mastoConverters.convertAccount(data.data);
reply.send(account);
return reply.send(account);
});
fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/statuses', async (request, reply) => {
@ -183,7 +172,7 @@ export class ApiAccountMastodon {
const response = await Promise.all(data.data.map(async (status) => await this.mastoConverters.convertStatus(status, me)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/featured_tags', async (_request, reply) => {
@ -193,7 +182,7 @@ export class ApiAccountMastodon {
const data = await client.getFeaturedTags();
const response = data.data.map((tag) => convertFeaturedTag(tag));
reply.send(response);
return reply.send(response);
});
fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/followers', async (request, reply) => {
@ -207,7 +196,7 @@ export class ApiAccountMastodon {
const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/following', async (request, reply) => {
@ -221,7 +210,7 @@ export class ApiAccountMastodon {
const response = await Promise.all(data.data.map(async (account) => await this.mastoConverters.convertAccount(account)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string } }>('/v1/accounts/:id/lists', async (_request, reply) => {
@ -231,10 +220,10 @@ export class ApiAccountMastodon {
const data = await client.getAccountLists(_request.params.id);
const response = data.data.map((list) => convertList(list));
reply.send(response);
return reply.send(response);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/follow', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/follow', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -242,10 +231,10 @@ export class ApiAccountMastodon {
const acct = convertRelationship(data.data);
acct.following = true; // TODO this is wrong, follow may not have processed immediately
reply.send(acct);
return reply.send(acct);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unfollow', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unfollow', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -253,20 +242,20 @@ export class ApiAccountMastodon {
const acct = convertRelationship(data.data);
acct.following = false;
reply.send(acct);
return reply.send(acct);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/block', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/block', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
const data = await client.blockAccount(_request.params.id);
const response = convertRelationship(data.data);
reply.send(response);
return reply.send(response);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unblock', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unblock', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -276,7 +265,7 @@ export class ApiAccountMastodon {
return reply.send(response);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/mute', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/mute', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
@ -286,17 +275,17 @@ export class ApiAccountMastodon {
);
const response = convertRelationship(data.data);
reply.send(response);
return reply.send(response);
});
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unmute', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiAccountMastodonRoute & { Params: { id?: string } }>('/v1/accounts/:id/unmute', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
const data = await client.unmuteAccount(_request.params.id);
const response = convertRelationship(data.data);
reply.send(response);
return reply.send(response);
});
}
}

View file

@ -5,8 +5,8 @@
import { Injectable } from '@nestjs/common';
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js';
import type { FastifyInstance } from 'fastify';
import type multer from 'fastify-multer';
const readScope = [
'read:account',
@ -48,9 +48,9 @@ const writeScope = [
export interface AuthPayload {
scopes?: string | string[],
redirect_uris?: string,
client_name?: string,
website?: string,
redirect_uris?: string | string[],
client_name?: string | string[],
website?: string | string[],
}
// Not entirely right, but it gets TypeScript to work so *shrug*
@ -60,14 +60,18 @@ type AuthMastodonRoute = { Body?: AuthPayload, Querystring: AuthPayload };
export class ApiAppsMastodon {
constructor(
private readonly clientService: MastodonClientService,
private readonly mastoConverters: MastodonConverters,
) {}
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
fastify.post<AuthMastodonRoute>('/v1/apps', { preHandler: upload.single('none') }, async (_request, reply) => {
public register(fastify: FastifyInstance): void {
fastify.post<AuthMastodonRoute>('/v1/apps', async (_request, reply) => {
const body = _request.body ?? _request.query;
if (!body.scopes) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "scopes"' });
if (!body.redirect_uris) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "redirect_uris"' });
if (Array.isArray(body.redirect_uris)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "redirect_uris": only one value is allowed' });
if (!body.client_name) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "client_name"' });
if (Array.isArray(body.client_name)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "client_name": only one value is allowed' });
if (Array.isArray(body.website)) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Invalid payload "website": only one value is allowed' });
let scope = body.scopes;
if (typeof scope === 'string') {
@ -88,12 +92,10 @@ export class ApiAppsMastodon {
}
}
const red = body.redirect_uris;
const client = this.clientService.getClient(_request);
const appData = await client.registerApp(body.client_name, {
scopes: Array.from(pushScope),
redirect_uris: red,
redirect_uri: body.redirect_uris,
website: body.website,
});
@ -101,12 +103,19 @@ export class ApiAppsMastodon {
id: Math.floor(Math.random() * 100).toString(),
name: appData.name,
website: body.website,
redirect_uri: red,
redirect_uri: body.redirect_uris,
client_id: Buffer.from(appData.url || '').toString('base64'),
client_secret: appData.clientSecret,
};
reply.send(response);
return reply.send(response);
});
fastify.get('/v1/apps/verify_credentials', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.verifyAppCredentials();
const response = this.mastoConverters.convertApplication(data.data);
return reply.send(response);
});
}
}

View file

@ -8,7 +8,6 @@ import { toBoolean } from '@/server/api/mastodon/argsUtils.js';
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
import { convertFilter } from '../MastodonConverters.js';
import type { FastifyInstance } from 'fastify';
import type multer from 'fastify-multer';
interface ApiFilterMastodonRoute {
Params: {
@ -29,14 +28,14 @@ export class ApiFilterMastodon {
private readonly clientService: MastodonClientService,
) {}
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
public register(fastify: FastifyInstance): void {
fastify.get('/v1/filters', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.getFilters();
const response = data.data.map((filter) => convertFilter(filter));
reply.send(response);
return reply.send(response);
});
fastify.get<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
@ -46,10 +45,10 @@ export class ApiFilterMastodon {
const data = await client.getFilter(_request.params.id);
const response = convertFilter(data.data);
reply.send(response);
return reply.send(response);
});
fastify.post<ApiFilterMastodonRoute>('/v1/filters', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiFilterMastodonRoute>('/v1/filters', async (_request, reply) => {
if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' });
if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' });
@ -65,10 +64,10 @@ export class ApiFilterMastodon {
const data = await client.createFilter(_request.body.phrase, _request.body.context, options);
const response = convertFilter(data.data);
reply.send(response);
return reply.send(response);
});
fastify.post<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
if (!_request.body.phrase) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "phrase"' });
if (!_request.body.context) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required payload "context"' });
@ -85,7 +84,7 @@ export class ApiFilterMastodon {
const data = await client.updateFilter(_request.params.id, _request.body.phrase, _request.body.context, options);
const response = convertFilter(data.data);
reply.send(response);
return reply.send(response);
});
fastify.delete<ApiFilterMastodonRoute & { Params: { id?: string } }>('/v1/filters/:id', async (_request, reply) => {
@ -94,7 +93,7 @@ export class ApiFilterMastodon {
const client = this.clientService.getClient(_request);
const data = await client.deleteFilter(_request.params.id);
reply.send(data.data);
return reply.send(data.data);
});
}
}

View file

@ -87,7 +87,7 @@ export class ApiInstanceMastodon {
rules: instance.rules ?? [],
};
reply.send(response);
return reply.send(response);
});
}
}

View file

@ -10,7 +10,6 @@ import { MastodonConverters } from '@/server/api/mastodon/MastodonConverters.js'
import { attachMinMaxPagination } from '@/server/api/mastodon/pagination.js';
import { MastodonClientService } from '../MastodonClientService.js';
import type { FastifyInstance } from 'fastify';
import type multer from 'fastify-multer';
interface ApiNotifyMastodonRoute {
Params: {
@ -26,7 +25,7 @@ export class ApiNotificationsMastodon {
private readonly clientService: MastodonClientService,
) {}
public register(fastify: FastifyInstance, upload: ReturnType<typeof multer>): void {
public register(fastify: FastifyInstance): void {
fastify.get<ApiNotifyMastodonRoute>('/v1/notifications', async (request, reply) => {
const { client, me } = await this.clientService.getAuthClient(request);
const data = await client.getNotifications(parseTimelineArgs(request.query));
@ -46,7 +45,7 @@ export class ApiNotificationsMastodon {
}
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id', async (_request, reply) => {
@ -63,23 +62,23 @@ export class ApiNotificationsMastodon {
});
}
reply.send(response);
return reply.send(response);
});
fastify.post<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id/dismiss', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiNotifyMastodonRoute & { Params: { id?: string } }>('/v1/notification/:id/dismiss', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const client = this.clientService.getClient(_request);
const data = await client.dismissNotification(_request.params.id);
reply.send(data.data);
return reply.send(data.data);
});
fastify.post<ApiNotifyMastodonRoute>('/v1/notifications/clear', { preHandler: upload.single('none') }, async (_request, reply) => {
fastify.post<ApiNotifyMastodonRoute>('/v1/notifications/clear', async (_request, reply) => {
const client = this.clientService.getClient(_request);
const data = await client.dismissNotifications();
reply.send(data.data);
return reply.send(data.data);
});
}
}

View file

@ -62,7 +62,7 @@ export class ApiSearchMastodon {
attachMinMaxPagination(request, reply, response[type]);
}
reply.send(response);
return reply.send(response);
});
fastify.get<ApiSearchMastodonRoute>('/v2/search', async (request, reply) => {
@ -103,7 +103,7 @@ export class ApiSearchMastodon {
// Offset pagination is the only possible option
attachOffsetPagination(request, reply, longestResult);
reply.send(response);
return reply.send(response);
});
fastify.get<ApiSearchMastodonRoute>('/v1/trends/statuses', async (request, reply) => {
@ -112,7 +112,7 @@ export class ApiSearchMastodon {
{
method: 'POST',
headers: {
...request.headers as HeadersInit,
...request.headers,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
@ -126,7 +126,7 @@ export class ApiSearchMastodon {
const response = await Promise.all(data.map(status => this.mastoConverters.convertStatus(status, me)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<ApiSearchMastodonRoute>('/v2/suggestions', async (request, reply) => {
@ -135,7 +135,7 @@ export class ApiSearchMastodon {
{
method: 'POST',
headers: {
...request.headers as HeadersInit,
...request.headers,
'Accept': 'application/json',
'Content-Type': 'application/json',
},
@ -158,7 +158,7 @@ export class ApiSearchMastodon {
}));
attachOffsetPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
}
}

View file

@ -38,7 +38,7 @@ export class ApiStatusMastodon {
response.media_attachments = [];
}
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/source', async (_request, reply) => {
@ -47,7 +47,7 @@ export class ApiStatusMastodon {
const client = this.clientService.getClient(_request);
const data = await client.getStatusSource(_request.params.id);
reply.send(data.data);
return reply.send(data.data);
});
fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/statuses/:id/context', async (_request, reply) => {
@ -59,7 +59,7 @@ export class ApiStatusMastodon {
const descendants = await Promise.all(data.descendants.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)));
const response = { ancestors, descendants };
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/history', async (_request, reply) => {
@ -68,7 +68,7 @@ export class ApiStatusMastodon {
const user = await this.clientService.getAuth(_request);
const edits = await this.mastoConverters.getEdits(_request.params.id, user);
reply.send(edits);
return reply.send(edits);
});
fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/reblogged_by', async (_request, reply) => {
@ -78,7 +78,7 @@ export class ApiStatusMastodon {
const data = await client.getStatusRebloggedBy(_request.params.id);
const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)));
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string } }>('/v1/statuses/:id/favourited_by', async (_request, reply) => {
@ -88,7 +88,7 @@ export class ApiStatusMastodon {
const data = await client.getStatusFavouritedBy(_request.params.id);
const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)));
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string } }>('/v1/media/:id', async (_request, reply) => {
@ -98,7 +98,7 @@ export class ApiStatusMastodon {
const data = await client.getMedia(_request.params.id);
const response = convertAttachment(data.data);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string } }>('/v1/polls/:id', async (_request, reply) => {
@ -108,7 +108,7 @@ export class ApiStatusMastodon {
const data = await client.getPoll(_request.params.id);
const response = convertPoll(data.data);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string }, Body: { choices?: number[] } }>('/v1/polls/:id/votes', async (_request, reply) => {
@ -119,7 +119,7 @@ export class ApiStatusMastodon {
const data = await client.votePoll(_request.params.id, _request.body.choices);
const response = convertPoll(data.data);
reply.send(response);
return reply.send(response);
});
fastify.post<{
@ -161,14 +161,14 @@ export class ApiStatusMastodon {
body.in_reply_to_id,
removed,
);
reply.send(a.data);
return reply.send(a.data);
}
if (body.in_reply_to_id && removed === '/unreact') {
const id = body.in_reply_to_id;
const post = await client.getStatus(id);
const react = post.data.emoji_reactions.filter((e: Entity.Emoji) => e.me)[0].name;
const data = await client.deleteEmojiReaction(id, react);
reply.send(data.data);
return reply.send(data.data);
}
if (!body.media_ids) body.media_ids = undefined;
if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
@ -194,7 +194,7 @@ export class ApiStatusMastodon {
const data = await client.postStatus(text, options);
const response = await this.mastoConverters.convertStatus(data.data as Entity.Status, me);
reply.send(response);
return reply.send(response);
});
fastify.put<{
@ -233,7 +233,7 @@ export class ApiStatusMastodon {
const data = await client.editStatus(_request.params.id, options);
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/favourite', async (_request, reply) => {
@ -243,7 +243,7 @@ export class ApiStatusMastodon {
const data = await client.createEmojiReaction(_request.params.id, '❤');
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unfavourite', async (_request, reply) => {
@ -253,7 +253,7 @@ export class ApiStatusMastodon {
const data = await client.deleteEmojiReaction(_request.params.id, '❤');
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/reblog', async (_request, reply) => {
@ -263,7 +263,7 @@ export class ApiStatusMastodon {
const data = await client.reblogStatus(_request.params.id);
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unreblog', async (_request, reply) => {
@ -273,7 +273,7 @@ export class ApiStatusMastodon {
const data = await client.unreblogStatus(_request.params.id);
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/bookmark', async (_request, reply) => {
@ -283,7 +283,7 @@ export class ApiStatusMastodon {
const data = await client.bookmarkStatus(_request.params.id);
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unbookmark', async (_request, reply) => {
@ -293,7 +293,7 @@ export class ApiStatusMastodon {
const data = await client.unbookmarkStatus(_request.params.id);
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/pin', async (_request, reply) => {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
@ -302,7 +302,7 @@ export class ApiStatusMastodon {
const data = await client.pinStatus(_request.params.id);
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string } }>('/v1/statuses/:id/unpin', async (_request, reply) => {
@ -312,7 +312,7 @@ export class ApiStatusMastodon {
const data = await client.unpinStatus(_request.params.id);
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/react/:name', async (_request, reply) => {
@ -323,7 +323,7 @@ export class ApiStatusMastodon {
const data = await client.createEmojiReaction(_request.params.id, _request.params.name);
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string, name?: string } }>('/v1/statuses/:id/unreact/:name', async (_request, reply) => {
@ -334,7 +334,7 @@ export class ApiStatusMastodon {
const data = await client.deleteEmojiReaction(_request.params.id, _request.params.name);
const response = await this.mastoConverters.convertStatus(data.data, me);
reply.send(response);
return reply.send(response);
});
fastify.delete<{ Params: { id?: string } }>('/v1/statuses/:id', async (_request, reply) => {
@ -343,7 +343,7 @@ export class ApiStatusMastodon {
const client = this.clientService.getClient(_request);
const data = await client.deleteStatus(_request.params.id);
reply.send(data.data);
return reply.send(data.data);
});
}
}

View file

@ -28,7 +28,7 @@ export class ApiTimelineMastodon {
const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Querystring: TimelineArgs }>('/v1/timelines/home', async (request, reply) => {
@ -38,7 +38,7 @@ export class ApiTimelineMastodon {
const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { hashtag?: string }, Querystring: TimelineArgs }>('/v1/timelines/tag/:hashtag', async (request, reply) => {
@ -50,7 +50,7 @@ export class ApiTimelineMastodon {
const response = await Promise.all(data.data.map((status: Entity.Status) => this.mastoConverters.convertStatus(status, me)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/timelines/list/:id', async (request, reply) => {
@ -62,7 +62,7 @@ export class ApiTimelineMastodon {
const response = await Promise.all(data.data.map(async (status: Entity.Status) => await this.mastoConverters.convertStatus(status, me)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Querystring: TimelineArgs }>('/v1/conversations', async (request, reply) => {
@ -72,7 +72,7 @@ export class ApiTimelineMastodon {
const response = await Promise.all(data.data.map((conversation: Entity.Conversation) => this.mastoConverters.convertConversation(conversation, me)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
@ -82,7 +82,7 @@ export class ApiTimelineMastodon {
const data = await client.getList(_request.params.id);
const response = convertList(data.data);
reply.send(response);
return reply.send(response);
});
fastify.get('/v1/lists', async (request, reply) => {
@ -91,7 +91,7 @@ export class ApiTimelineMastodon {
const response = data.data.map((list: Entity.List) => convertList(list));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.get<{ Params: { id?: string }, Querystring: TimelineArgs }>('/v1/lists/:id/accounts', async (request, reply) => {
@ -102,7 +102,7 @@ export class ApiTimelineMastodon {
const response = await Promise.all(data.data.map((account: Entity.Account) => this.mastoConverters.convertAccount(account)));
attachMinMaxPagination(request, reply, response);
reply.send(response);
return reply.send(response);
});
fastify.post<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
@ -112,7 +112,7 @@ export class ApiTimelineMastodon {
const client = this.clientService.getClient(_request);
const data = await client.addAccountsToList(_request.params.id, _request.query.accounts_id);
reply.send(data.data);
return reply.send(data.data);
});
fastify.delete<{ Params: { id?: string }, Querystring: { accounts_id?: string[] } }>('/v1/lists/:id/accounts', async (_request, reply) => {
@ -122,7 +122,7 @@ export class ApiTimelineMastodon {
const client = this.clientService.getClient(_request);
const data = await client.deleteAccountsFromList(_request.params.id, _request.query.accounts_id);
reply.send(data.data);
return reply.send(data.data);
});
fastify.post<{ Body: { title?: string } }>('/v1/lists', async (_request, reply) => {
@ -132,7 +132,7 @@ export class ApiTimelineMastodon {
const data = await client.createList(_request.body.title);
const response = convertList(data.data);
reply.send(response);
return reply.send(response);
});
fastify.put<{ Params: { id?: string }, Body: { title?: string } }>('/v1/lists/:id', async (_request, reply) => {
@ -143,7 +143,7 @@ export class ApiTimelineMastodon {
const data = await client.updateList(_request.params.id, _request.body.title);
const response = convertList(data.data);
reply.send(response);
return reply.send(response);
});
fastify.delete<{ Params: { id?: string } }>('/v1/lists/:id', async (_request, reply) => {
@ -152,7 +152,7 @@ export class ApiTimelineMastodon {
const client = this.clientService.getClient(_request);
await client.deleteList(_request.params.id);
reply.send({});
return reply.send({});
});
}
}

View file

@ -3,15 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import querystring from 'querystring';
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import multer from 'fastify-multer';
import { bindThis } from '@/decorators.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
import { getErrorData } from '@/server/api/mastodon/MastodonLogger.js';
import { ServerUtilityService } from '@/server/ServerUtilityService.js';
import type { FastifyInstance } from 'fastify';
const kinds = [
@ -56,6 +55,7 @@ export class OAuth2ProviderService {
private config: Config,
private readonly mastodonClientService: MastodonClientService,
private readonly serverUtilityService: ServerUtilityService,
) { }
// https://datatracker.ietf.org/doc/html/rfc8414.html
@ -92,36 +92,10 @@ export class OAuth2ProviderService {
});
}); */
const upload = multer({
storage: multer.diskStorage({}),
limits: {
fileSize: this.config.maxFileSize || 262144000,
files: 1,
},
});
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Access-Control-Allow-Origin', '*');
done();
});
fastify.addContentTypeParser('application/x-www-form-urlencoded', (request, payload, done) => {
let body = '';
payload.on('data', (data) => {
body += data;
});
payload.on('end', () => {
try {
const parsed = querystring.parse(body);
done(null, parsed);
} catch (e: unknown) {
done(e instanceof Error ? e : new Error(String(e)));
}
});
payload.on('error', done);
});
fastify.register(multer.contentParser);
this.serverUtilityService.addMultipartFormDataContentType(fastify);
this.serverUtilityService.addFormUrlEncodedContentType(fastify);
this.serverUtilityService.addCORS(fastify);
this.serverUtilityService.addFlattenedQueryType(fastify);
for (const url of ['/authorize', '/authorize/']) {
fastify.get<{ Querystring: Record<string, string | string[] | undefined> }>(url, async (request, reply) => {
@ -132,11 +106,11 @@ export class OAuth2ProviderService {
if (request.query.state) redirectUri.searchParams.set('state', String(request.query.state));
if (request.query.redirect_uri) redirectUri.searchParams.set('redirect_uri', String(request.query.redirect_uri));
reply.redirect(redirectUri.toString());
return reply.redirect(redirectUri.toString());
});
}
fastify.post<{ Body?: Record<string, string | string[] | undefined>, Querystring: Record<string, string | string[] | undefined> }>('/token', { preHandler: upload.none() }, async (request, reply) => {
fastify.post<{ Body?: Record<string, string | string[] | undefined>, Querystring: Record<string, string | string[] | undefined> }>('/token', async (request, reply) => {
const body = request.body ?? request.query;
if (body.grant_type === 'client_credentials') {
@ -146,7 +120,7 @@ export class OAuth2ProviderService {
scope: 'read',
created_at: Math.floor(new Date().getTime() / 1000),
};
reply.send(ret);
return reply.send(ret);
}
try {
@ -163,13 +137,13 @@ export class OAuth2ProviderService {
const ret = {
access_token: atData.accessToken,
token_type: 'Bearer',
scope: body.scope || 'read write follow push',
created_at: Math.floor(new Date().getTime() / 1000),
scope: atData.scope || body.scope || 'read write follow push',
created_at: atData.createdAt || Math.floor(new Date().getTime() / 1000),
};
reply.send(ret);
return reply.send(ret);
} catch (e: unknown) {
const data = getErrorData(e);
reply.code(401).send(data);
return reply.code(401).send(data);
}
});
}

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { summaly } from '@misskey-dev/summaly';
import { SummalyResult } from '@misskey-dev/summaly/built/summary.js';
import * as Redis from 'ioredis';
import { IsNull, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@ -18,31 +19,48 @@ import { ApiError } from '@/server/api/error.js';
import { MiMeta } from '@/models/Meta.js';
import { RedisKVCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import type { NotesRepository } from '@/models/_.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
export type LocalSummalyResult = SummalyResult & {
haveNoteLocally?: boolean;
};
// Increment this to invalidate cached previews after a major change.
const cacheFormatVersion = 2;
@Injectable()
export class UrlPreviewService {
private logger: Logger;
private previewCache: RedisKVCache<SummalyResult>;
private previewCache: RedisKVCache<LocalSummalyResult>;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
private readonly redisClient: Redis.Redis,
@Inject(DI.meta)
private meta: MiMeta,
private readonly meta: MiMeta,
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
private utilityService: UtilityService,
private apDbResolverService: ApDbResolverService,
private readonly utilityService: UtilityService,
private readonly apUtilityService: ApUtilityService,
private readonly apDbResolverService: ApDbResolverService,
private readonly apRequestService: ApRequestService,
private readonly systemAccountService: SystemAccountService,
) {
this.logger = this.loggerService.getLogger('url-preview');
this.previewCache = new RedisKVCache<SummalyResult>(this.redisClient, 'summaly', {
this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', {
lifetime: 1000 * 60 * 60 * 24, // 1d
memoryCacheLifetime: 1000 * 60 * 10, // 10m
fetcher: () => { throw new Error('the UrlPreview cache should never fetch'); },
@ -53,17 +71,21 @@ export class UrlPreviewService {
@bindThis
private wrap(url?: string | null): string | null {
return url != null
? `${this.config.mediaProxy}/preview.webp?${query({
url,
preview: '1',
})}`
: null;
if (url == null) return null;
// Don't proxy our own media
if (this.utilityService.isUriLocal(url)) {
return url;
}
// But proxy everything else!
const mediaQuery = query({ url, preview: '1' });
return `${this.config.mediaProxy}/preview.webp?${mediaQuery}`;
}
@bindThis
public async handle(
request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>,
request: FastifyRequest<{ Querystring: { url?: string; lang?: string; } }>,
reply: FastifyReply,
): Promise<object | undefined> {
const url = request.query.url;
@ -89,8 +111,7 @@ export class UrlPreviewService {
};
}
const host = new URL(url).host;
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, host)) {
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) {
reply.code(403);
return {
error: new ApiError({
@ -101,12 +122,11 @@ export class UrlPreviewService {
};
}
const key = `${url}@${lang}`;
const cached = await this.previewCache.get(key) as SummalyResult & { haveNoteLocally?: boolean };
const cacheKey = `${url}@${lang}@${cacheFormatVersion}`;
const cached = await this.previewCache.get(cacheKey);
if (cached !== undefined) {
this.logger.info(`Returning cache preview of ${key}`);
// Cache 7days
reply.header('Cache-Control', 'max-age=604800, immutable');
// Cache 1 day (matching redis)
reply.header('Cache-Control', 'public, max-age=86400');
if (cached.activityPub) {
cached.haveNoteLocally = !! await this.apDbResolverService.getNoteFromApId(cached.activityPub);
@ -115,43 +135,52 @@ export class UrlPreviewService {
return cached;
}
this.logger.info(this.meta.urlPreviewSummaryProxyUrl
? `(Proxy) Getting preview of ${key} ...`
: `Getting preview of ${key} ...`);
try {
const summary: SummalyResult & { haveNoteLocally?: boolean } = this.meta.urlPreviewSummaryProxyUrl
const summary: LocalSummalyResult = this.meta.urlPreviewSummaryProxyUrl
? await this.fetchSummaryFromProxy(url, this.meta, lang)
: await this.fetchSummary(url, this.meta, lang);
this.logger.succ(`Got preview of ${url}: ${summary.title}`);
this.validateUrls(summary);
if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
// Repeat check, since redirects are allowed.
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) {
reply.code(403);
return {
error: new ApiError({
message: 'URL is blocked',
code: 'URL_PREVIEW_BLOCKED',
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
}),
};
}
if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
}
this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`);
summary.icon = this.wrap(summary.icon);
summary.thumbnail = this.wrap(summary.thumbnail);
this.previewCache.set(key, summary);
if (summary.activityPub) {
summary.haveNoteLocally = !! await this.apDbResolverService.getNoteFromApId(summary.activityPub);
// Summaly cannot always detect links to a fedi post, so do some additional tests to try and find missed cases.
if (!summary.activityPub) {
await this.inferActivityPubLink(summary);
}
// Cache 7days
reply.header('Cache-Control', 'max-age=604800, immutable');
if (summary.activityPub) {
// Avoid duplicate checks in case inferActivityPubLink already set this.
summary.haveNoteLocally ||= !!await this.apDbResolverService.getNoteFromApId(summary.activityPub);
}
// Await this to avoid hammering redis when a bunch of URLs are fetched at once
await this.previewCache.set(cacheKey, summary);
// Cache 1 day (matching redis)
reply.header('Cache-Control', 'public, max-age=86400');
return summary;
} catch (err) {
this.logger.warn(`Failed to get preview of ${url}: ${err}`);
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`);
reply.code(422);
reply.header('Cache-Control', 'max-age=86400, immutable');
reply.header('Cache-Control', 'max-age=3600');
return {
error: new ApiError({
message: 'Failed to get preview',
@ -171,7 +200,7 @@ export class UrlPreviewService {
: undefined;
return summaly(url, {
followRedirects: false,
followRedirects: true,
lang: lang ?? 'ja-JP',
agent: agent,
userAgent: meta.urlPreviewUserAgent ?? undefined,
@ -184,6 +213,7 @@ export class UrlPreviewService {
private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
const proxy = meta.urlPreviewSummaryProxyUrl!;
const queryStr = query({
followRedirects: true,
url: url,
lang: lang ?? 'ja-JP',
userAgent: meta.urlPreviewUserAgent ?? undefined,
@ -192,6 +222,84 @@ export class UrlPreviewService {
contentLengthRequired: meta.urlPreviewRequireContentLength,
});
return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
return this.httpRequestService.getJson<LocalSummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
}
private validateUrls(summary: LocalSummalyResult) {
const urlScheme = this.utilityService.getUrlScheme(summary.url);
if (urlScheme !== 'http:' && urlScheme !== 'https:') {
throw new Error(`unsupported scheme in preview URL: "${urlScheme}"`);
}
if (summary.player.url) {
const playerScheme = this.utilityService.getUrlScheme(summary.player.url);
if (playerScheme !== 'http:' && playerScheme !== 'https:') {
this.logger.warn(`Redacting preview for ${summary.url}: player URL has unsupported scheme "${playerScheme}"`);
summary.player.url = null;
}
}
if (summary.icon) {
const iconScheme = this.utilityService.getUrlScheme(summary.icon);
if (iconScheme !== 'http:' && iconScheme !== 'https:') {
this.logger.warn(`Redacting preview for ${summary.url}: icon URL has unsupported scheme "${iconScheme}"`);
summary.icon = null;
}
}
if (summary.thumbnail) {
const thumbnailScheme = this.utilityService.getUrlScheme(summary.thumbnail);
if (thumbnailScheme !== 'http:' && thumbnailScheme !== 'https:') {
this.logger.warn(`Redacting preview for ${summary.url}: thumbnail URL has unsupported scheme "${thumbnailScheme}"`);
summary.thumbnail = null;
}
}
if (summary.activityPub) {
const activityPubScheme = this.utilityService.getUrlScheme(summary.activityPub);
if (activityPubScheme !== 'http:' && activityPubScheme !== 'https:') {
this.logger.warn(`Redacting preview for ${summary.url}: ActivityPub URL has unsupported scheme "${activityPubScheme}"`);
summary.activityPub = null;
}
}
}
private async inferActivityPubLink(summary: LocalSummalyResult) {
// Match canonical URI first.
// This covers local and remote links.
const isCanonicalUri = !!await this.apDbResolverService.getNoteFromApId(summary.url);
if (isCanonicalUri) {
summary.activityPub = summary.url;
summary.haveNoteLocally = true;
return;
}
// Try public URL next.
// This is necessary for Mastodon and other software with a different public URL.
const urlMatches = await this.notesRepository.find({
select: {
uri: true,
},
where: {
url: summary.url,
uri: Not(IsNull()),
},
}) as { uri: string }[];
// Older versions did not validate URL, so do it now to avoid impersonation.
const matchByUrl = urlMatches.find(({ uri }) => this.apUtilityService.haveSameAuthority(uri, summary.url));
if (matchByUrl) {
summary.activityPub = matchByUrl.uri;
summary.haveNoteLocally = true;
return;
}
// Finally, attempt a signed GET in case it's a direct link to an instance with authorized fetch.
const instanceActor = await this.systemAccountService.getInstanceActor();
const remoteObject = await this.apRequestService.signedGet(summary.url, instanceActor).catch(() => null);
if (remoteObject && this.apUtilityService.haveSameAuthority(remoteObject.id, summary.url)) {
summary.activityPub = remoteObject.id;
return;
}
}
}

View file

@ -159,8 +159,8 @@ describe('API', () => {
user: { token: application3 },
}, {
status: 403,
code: 'PERMISSION_DENIED',
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
code: 'ROLE_PERMISSION_DENIED',
id: 'c3d38592-54c0-429d-be96-5636b0431a61',
});
await failedApiCall({

View file

@ -19,7 +19,7 @@ import {
ResourceOwnerPassword,
} from 'simple-oauth2';
import pkceChallenge from 'pkce-challenge';
import { JSDOM } from 'jsdom';
import { load as cheerio } from 'cheerio';
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
import { api, port, sendEnvUpdateRequest, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
@ -73,11 +73,11 @@ const clientConfig: ModuleOptions<'client_id'> = {
};
function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined, clientLogo: string | undefined } {
const fragment = JSDOM.fragment(html);
const fragment = cheerio(html);
return {
transactionId: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]')?.content,
clientName: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content,
clientLogo: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-logo"]')?.content,
transactionId: fragment('meta[name="misskey:oauth:transaction-id"][content]').attr('content'),
clientName: fragment('meta[name="misskey:oauth:client-name"][content]').attr('content'),
clientLogo: fragment('meta[name="misskey:oauth:client-logo"][content]').attr('content'),
};
}

View file

@ -3,29 +3,31 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { S3Client } from '@aws-sdk/client-s3';
import { Test, TestingModule } from '@nestjs/testing';
import { mockClient } from 'aws-sdk-client-mock';
import { FastifyInstance } from 'fastify';
import request from 'supertest';
import { randomString } from '../../../../../utils.js';
import { CoreModule } from '@/core/CoreModule.js';
import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js';
import { GlobalModule } from '@/GlobalModule.js';
import { MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { DriveFoldersRepository, MiDriveFolder, MiRole, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { MiUser } from '@/models/User.js';
import { ServerModule } from '@/server/ServerModule.js';
import { ServerService } from '@/server/ServerService.js';
import { IdService } from '@/core/IdService.js';
describe('/drive/files/create', () => {
let module: TestingModule;
let server: FastifyInstance;
const s3Mock = mockClient(S3Client);
let roleService: RoleService;
let idService: IdService;
let root: MiUser;
let role_tinyAttachment: MiRole;
let folder: MiDriveFolder;
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [GlobalModule, CoreModule, ServerModule],
@ -33,21 +35,34 @@ describe('/drive/files/create', () => {
module.enableShutdownHooks();
const serverService = module.get<ServerService>(ServerService);
server = await serverService.launch();
await serverService.launch();
server = serverService.fastify;
idService = module.get(IdService);
const usersRepository = module.get<UsersRepository>(DI.usersRepository);
await usersRepository.delete({});
root = await usersRepository.insert({
id: 'root',
id: idService.gen(),
username: 'root',
usernameLower: 'root',
token: '1234567890123456',
}).then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
const userProfilesRepository = module.get<UserProfilesRepository>(DI.userProfilesRepository);
await userProfilesRepository.delete({});
await userProfilesRepository.insert({
userId: root.id,
});
const driveFoldersRepository = module.get<DriveFoldersRepository>(DI.driveFoldersRepository);
folder = await driveFoldersRepository.insertOne({
id: idService.gen(),
name: 'root-folder',
parentId: null,
userId: root.id,
});
roleService = module.get<RoleService>(RoleService);
role_tinyAttachment = await roleService.create({
name: 'test-role001',
@ -65,8 +80,8 @@ describe('/drive/files/create', () => {
});
beforeEach(async () => {
s3Mock.reset();
await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => {});
await roleService.unassign(root.id, role_tinyAttachment.id).catch(() => {
});
});
afterAll(async () => {
@ -74,35 +89,76 @@ describe('/drive/files/create', () => {
await module.close();
});
test('200 ok', async () => {
const result = await request(server.server)
async function postFile(props: {
name: string,
comment: string,
isSensitive: boolean,
force: boolean,
fileContent: Buffer | string,
}) {
const { name, comment, isSensitive, force, fileContent } = props;
return await request(server.server)
.post('/api/drive/files/create')
.set('Content-Type', 'multipart/form-data')
.set('Authorization', `Bearer ${root.token}`)
.attach('file', Buffer.from('a'.repeat(1024 * 1024)));
.attach('file', fileContent)
.field('name', name)
.field('comment', comment)
.field('isSensitive', isSensitive)
.field('force', force)
.field('folderId', folder.id)
.field('i', root.token ?? '');
}
test('200 ok', async () => {
const name = randomString();
const comment = randomString();
const result = await postFile({
name: name,
comment: comment,
isSensitive: true,
force: true,
fileContent: Buffer.from('a'.repeat(1000 * 1000)),
});
expect(result.statusCode).toBe(200);
expect(result.body.name).toBe(name + '.unknown');
expect(result.body.comment).toBe(comment);
expect(result.body.isSensitive).toBe(true);
expect(result.body.folderId).toBe(folder.id);
});
test('200 ok(with role)', async () => {
await roleService.assign(root.id, role_tinyAttachment.id);
const result = await request(server.server)
.post('/api/drive/files/create')
.set('Content-Type', 'multipart/form-data')
.set('Authorization', `Bearer ${root.token}`)
.attach('file', Buffer.from('a'.repeat(10)));
const name = randomString();
const comment = randomString();
const result = await postFile({
name: name,
comment: comment,
isSensitive: true,
force: true,
fileContent: Buffer.from('a'.repeat(10)),
});
expect(result.statusCode).toBe(200);
expect(result.body.name).toBe(name + '.unknown');
expect(result.body.comment).toBe(comment);
expect(result.body.isSensitive).toBe(true);
expect(result.body.folderId).toBe(folder.id);
});
test('413 too large', async () => {
await roleService.assign(root.id, role_tinyAttachment.id);
const result = await request(server.server)
.post('/api/drive/files/create')
.set('Content-Type', 'multipart/form-data')
.set('Authorization', `Bearer ${root.token}`)
.attach('file', Buffer.from('a'.repeat(11)));
const name = randomString();
const comment = randomString();
const result = await postFile({
name: name,
comment: comment,
isSensitive: true,
force: true,
fileContent: Buffer.from('a'.repeat(11)),
});
expect(result.statusCode).toBe(413);
expect(result.body.error.code).toBe('FILE_SIZE_TOO_LARGE');
expect(result.body.error.code).toBe('MAX_FILE_SIZE_EXCEEDED');
});
});

View file

@ -11,11 +11,12 @@ import { inspect } from 'node:util';
import WebSocket, { ClientOptions } from 'ws';
import fetch, { File, RequestInit, type Headers } from 'node-fetch';
import { DataSource } from 'typeorm';
import { JSDOM } from 'jsdom';
import { load as cheerio } from 'cheerio';
import { type Response } from 'node-fetch';
import Fastify from 'fastify';
import { entities } from '../src/postgres.js';
import { loadConfig } from '../src/config.js';
import type { CheerioAPI } from 'cheerio';
import type * as misskey from 'misskey-js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
@ -464,7 +465,7 @@ export function makeStreamCatcher<T>(
export type SimpleGetResponse = {
status: number,
body: any | JSDOM | null,
body: any | CheerioAPI | null,
type: string | null,
location: string | null
};
@ -495,7 +496,7 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde
const body =
jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() :
htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) :
htmlTypes.includes(res.headers.get('content-type') ?? '') ? cheerio(await res.text()) :
await bodyExtractor(res);
return {

View file

@ -1,7 +1,17 @@
This logo text was made in Inkscape by @sneexy@booping.synth.download
# Sharkey Logo
If you edit it, you can use
[svgo](https://jakearchibald.github.io/svgomg/) to generate a
browser-compatible file, to replace `../sharkey.svg`
This logo was made in Inkscape by [@sneexy@booping.synth.download](https://booping.synth.download/@sneexy).
The font used is Lilita One ([Google Fonts](https://fonts.google.com/specimen/Lilita+One)).
## Using Inkscape
Preferably, you should use Inkscape when wanting to make modifications to the logo. Once you've made your edits, save the SVG here as is from Inkscape directly, then use [svgo](https://github.com/svg/svgo) ([Web UI](https://jakearchibald.github.io/svgomg), ensure `Multipass` is enabled) to produce an SVG compatible to be viewed in web browsers, which that file will then replace [`../sharkey.svg`](../sharkey.svg).
## Not using Inkscape
The logo should preferably be modified using Inkscape, but should work in any other software. If any weird issues appear, try opening it with Inkscape first or try using the version under [`../sharkey.svg`](../sharkey.svg) if you prefer to work with your software. Follow the instructions for Using Inkscape.
## Licence
This logo is distributed under the same licence as Sharkey (AGPL ≥3).

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

View file

@ -92,7 +92,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button>
<button v-tooltip="i18n.ts.otherSettings" :class="['_button', $style.footerButton]" @click="showOtherMenu"><i class="ti ti-dots"></i></button>
</div>
<div :class="$style.footerRight">
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
@ -187,6 +186,7 @@ const posted = ref(false);
const text = ref(props.initialText ?? '');
const files = ref(props.initialFiles ?? []);
const poll = ref<PollEditorModelValue | null>(null);
const initialPoll = ref<PollEditorModelValue | null>(null);
const useCw = ref<boolean>(!!props.initialCw);
const showPreview = ref(store.s.showPreview);
watch(showPreview, () => store.set('showPreview', showPreview.value));
@ -317,19 +317,10 @@ if (props.reply && (props.reply.user.username !== $i.username || (props.reply.us
text.value = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
}
if (props.reply && props.reply.text != null) {
const ast = mfm.parse(props.reply.text);
const otherHost = props.reply.user.host;
for (const x of extractMentions(ast)) {
const mention = x.host ?
`@${x.username}@${toASCII(x.host)}` :
(otherHost == null || otherHost === host) ?
`@${x.username}` :
`@${x.username}@${toASCII(otherHost)}`;
//
if ($i.username === x.username && (x.host == null || x.host === host)) continue;
if (props.reply && props.reply.mentionHandles) {
for (const [user, mention] of Object.entries(props.reply.mentionHandles)) {
// Don't mention ourself
if (user === $i.id) continue;
//
if (text.value.includes(`${mention} `)) continue;
@ -425,7 +416,7 @@ function checkMissingMention() {
const ast = mfm.parse(text.value);
for (const x of extractMentions(ast)) {
if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) {
if (!visibleUsers.value.some(u => (u.username.toLowerCase() === x.username.toLowerCase()) && (u.host === x.host))) {
hasNotSpecifiedMentions.value = true;
return;
}
@ -438,7 +429,7 @@ function addMissingMention() {
const ast = mfm.parse(text.value);
for (const x of extractMentions(ast)) {
if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) {
if (!visibleUsers.value.some(u => (u.username.toLowerCase() === x.username.toLowerCase()) && (u.host === x.host))) {
misskeyApi('users/show', { username: x.username, host: x.host }).then(user => {
pushVisibleUser(user);
});
@ -629,12 +620,32 @@ function showOtherSettings() {
},
}] satisfies MenuItem[];
if ($i.policies.scheduleNoteMax > 0) {
menuItems.push({ type: 'divider' }, {
type: 'button',
text: i18n.ts.schedulePost,
icon: 'ti ti-calendar-time',
action: toggleScheduleNote,
}, {
type: 'button',
text: i18n.ts.schedulePostList,
icon: 'ti ti-calendar-event',
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, {
closed: () => {
dispose();
},
});
},
});
}
os.popupMenu(menuItems, otherSettingsButton.value);
}
//#endregion
function pushVisibleUser(user: Misskey.entities.UserDetailed) {
if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) {
if (!visibleUsers.value.some(u => u.username.toLowerCase() === user.username.toLowerCase() && u.host === user.host)) {
visibleUsers.value.push(user);
}
}
@ -959,6 +970,15 @@ async function post(ev?: MouseEvent) {
token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token;
}
if (postData.editId && postData.poll !== initialPoll.value) {
const { canceled } = await os.confirm({
type: 'warning',
title: i18n.ts._confirmPollEdit.title,
text: i18n.ts._confirmPollEdit.text,
});
if (canceled) return;
}
posting.value = true;
misskeyApi(postData.editId ? 'notes/edit' : (postData.scheduleNote ? 'notes/schedule/create' : 'notes/create'), postData, token).then(() => {
if (props.freezeAfterPosted) {
@ -1121,32 +1141,6 @@ function toggleScheduleNote() {
}
}
function showOtherMenu(ev: MouseEvent) {
const menuItems: MenuItem[] = [];
if ($i.policies.scheduleNoteMax > 0) {
menuItems.push({
type: 'button',
text: i18n.ts.schedulePost,
icon: 'ti ti-calendar-time',
action: toggleScheduleNote,
}, {
type: 'button',
text: i18n.ts.schedulePostList,
icon: 'ti ti-calendar-event',
action: () => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSchedulePostListDialog.vue')), {}, {
closed: () => {
dispose();
},
});
},
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
onMounted(() => {
if (props.autofocus) {
focus();
@ -1208,6 +1202,7 @@ onMounted(() => {
expiresAt: init.poll.expiresAt ? (new Date(init.poll.expiresAt)).getTime() : null,
expiredAfter: null,
};
if (props.editId) initialPoll.value = poll.value;
}
if (init.visibleUserIds) {
misskeyApi('users/show', { userIds: init.visibleUserIds }).then(users => {

View file

@ -203,6 +203,7 @@ const QUEUE_TYPES = [
'objectStorage',
'userWebhookDeliver',
'systemWebhookDeliver',
'scheduleNotePost',
] as const;
const tab: Ref<typeof QUEUE_TYPES[number] | '-'> = ref('-');

View file

@ -9,6 +9,7 @@ import * as NotificationType from './notification'
import FilterContext from './filter_context'
import Converter from './converter'
import MastodonEntity from './mastodon/entity';
import MisskeyEntity from './misskey/entity';
export {
Response,
@ -23,4 +24,5 @@ export {
Entity,
Converter,
MastodonEntity,
MisskeyEntity,
}

View file

@ -3,5 +3,8 @@ namespace MastodonEntity {
name: string
website?: string | null
vapid_key?: string | null
scopes: string[]
redirect_uris: string[]
redirect_uri?: string
}
}

View file

@ -39,9 +39,9 @@ export default class Misskey implements MegalodonInterface {
public async registerApp(
client_name: string,
options: Partial<{ scopes: Array<string>; redirect_uris: string; website: string }> = {
options: Partial<{ scopes: Array<string>; redirect_uri: string; website?: string }> = {
scopes: MisskeyAPI.DEFAULT_SCOPE,
redirect_uris: this.baseUrl
redirect_uri: this.baseUrl
}
): Promise<OAuth.AppData> {
return this.createApp(client_name, options).then(async appData => {
@ -62,13 +62,14 @@ export default class Misskey implements MegalodonInterface {
*/
public async createApp(
client_name: string,
options: Partial<{ scopes: Array<string>; redirect_uris: string; website: string }> = {
options: Partial<{ scopes: Array<string>; redirect_uri: string; website?: string }> = {
scopes: MisskeyAPI.DEFAULT_SCOPE,
redirect_uris: this.baseUrl
redirect_uri: this.baseUrl
}
): Promise<OAuth.AppData> {
const redirect_uris = options.redirect_uris || this.baseUrl
const redirect_uri = options.redirect_uri || this.baseUrl
const scopes = options.scopes || MisskeyAPI.DEFAULT_SCOPE
const website = options.website ?? '';
const params: {
name: string
@ -77,9 +78,9 @@ export default class Misskey implements MegalodonInterface {
callbackUrl: string
} = {
name: client_name,
description: '',
description: website,
permission: scopes,
callbackUrl: redirect_uris
callbackUrl: redirect_uri
}
/**
@ -101,7 +102,7 @@ export default class Misskey implements MegalodonInterface {
website: null,
redirect_uri: res.data.callbackUrl,
client_id: '',
client_secret: res.data.secret
client_secret: res.data.secret!
}
return OAuth.AppData.from(appData)
})
@ -121,11 +122,8 @@ export default class Misskey implements MegalodonInterface {
// ======================================
// apps
// ======================================
public async verifyAppCredentials(): Promise<Response<Entity.Application>> {
return new Promise((_, reject) => {
const err = new NoImplementedError('misskey does not support')
reject(err)
})
public async verifyAppCredentials(): Promise<Response<MisskeyAPI.Entity.App>> {
return await this.client.post<MisskeyAPI.Entity.App>('/api/app/current');
}
// ======================================
@ -1502,13 +1500,13 @@ export default class Misskey implements MegalodonInterface {
/**
* POST /api/drive/files/create
*/
public async uploadMedia(file: any, _options?: { description?: string; focus?: string }): Promise<Response<Entity.Attachment>> {
public async uploadMedia(file: { filepath: fs.PathLike, mimetype: string, filename: string }, _options?: { description?: string; focus?: string }): Promise<Response<Entity.Attachment>> {
const formData = new FormData()
formData.append('file', fs.createReadStream(file.path), {
formData.append('file', fs.createReadStream(file.filepath), {
contentType: file.mimetype,
});
if (file.originalname != null && file.originalname !== "file") formData.append("name", file.originalname);
if (file.filename && file.filename !== "file") formData.append("name", file.filename);
if (_options?.description != null) formData.append("comment", _options.description);
let headers: { [key: string]: string } = {}

View file

@ -1,6 +1,5 @@
import axios, { AxiosResponse, AxiosRequestConfig } from 'axios'
import dayjs from 'dayjs'
import FormData from 'form-data'
import { DEFAULT_UA } from '../default'
import Response from '../response'
@ -575,22 +574,26 @@ namespace MisskeyAPI {
this.accessToken = accessToken
this.baseUrl = baseUrl
this.userAgent = userAgent
this.abortController = new AbortController()
axios.defaults.signal = this.abortController.signal
this.abortController = new AbortController();
}
/**
* GET request to misskey API.
**/
public async get<T>(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
if (!headers['Authorization'] && this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
}
if (!headers['User-Agent']) {
headers['User-Agent'] = this.userAgent;
}
let options: AxiosRequestConfig = {
params: params,
headers: {
'User-Agent': this.userAgent,
...headers,
},
headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
maxBodyLength: Infinity,
signal: this.abortController.signal,
}
return axios.get<T>(this.baseUrl + path, options).then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
@ -610,22 +613,21 @@ namespace MisskeyAPI {
* @param headers Request header object
*/
public async post<T>(path: string, params: any = {}, headers: { [key: string]: string } = {}): Promise<Response<T>> {
if (!headers['Authorization'] && this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
}
if (!headers['User-Agent']) {
headers['User-Agent'] = this.userAgent;
}
let options: AxiosRequestConfig = {
headers: headers,
maxContentLength: Infinity,
maxBodyLength: Infinity
maxBodyLength: Infinity,
signal: this.abortController.signal,
}
let bodyParams = params
if (this.accessToken) {
if (params instanceof FormData) {
bodyParams.append('i', this.accessToken)
} else {
bodyParams = Object.assign(params, {
i: this.accessToken
})
}
}
return axios.post<T>(this.baseUrl + path, bodyParams, options).then((resp: AxiosResponse<T>) => {
return axios.post<T>(this.baseUrl + path, params, options).then((resp: AxiosResponse<T>) => {
const res: Response<T> = {
data: resp.data,
status: resp.status,

View file

@ -4,6 +4,6 @@ namespace MisskeyEntity {
name: string
callbackUrl: string
permission: Array<string>
secret: string
secret?: string
}
}

View file

@ -552,6 +552,9 @@ type AppCreateRequest = operations['app___create']['requestBody']['content']['ap
// @public (undocumented)
type AppCreateResponse = operations['app___create']['responses']['200']['content']['application/json'];
// @public (undocumented)
type AppCurrentResponse = operations['app___current']['responses']['200']['content']['application/json'];
// @public (undocumented)
type AppShowRequest = operations['app___show']['requestBody']['content']['application/json'];
@ -1643,6 +1646,7 @@ declare namespace entities {
ApShowResponse,
AppCreateRequest,
AppCreateResponse,
AppCurrentResponse,
AppShowRequest,
AppShowResponse,
AuthAcceptRequest,

View file

@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2025.4.1",
"version": "2025.4.2-rc",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

View file

@ -1313,6 +1313,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *No*
*/
request<E extends 'app/current', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View file

@ -159,6 +159,7 @@ import type {
ApShowResponse,
AppCreateRequest,
AppCreateResponse,
AppCurrentResponse,
AppShowRequest,
AppShowResponse,
AuthAcceptRequest,
@ -778,6 +779,7 @@ export type Endpoints = {
'ap/get': { req: ApGetRequest; res: ApGetResponse };
'ap/show': { req: ApShowRequest; res: ApShowResponse };
'app/create': { req: AppCreateRequest; res: AppCreateResponse };
'app/current': { req: EmptyRequest; res: AppCurrentResponse };
'app/show': { req: AppShowRequest; res: AppShowResponse };
'auth/accept': { req: AuthAcceptRequest; res: EmptyResponse };
'auth/session/generate': { req: AuthSessionGenerateRequest; res: AuthSessionGenerateResponse };

View file

@ -162,6 +162,7 @@ export type ApShowRequest = operations['ap___show']['requestBody']['content']['a
export type ApShowResponse = operations['ap___show']['responses']['200']['content']['application/json'];
export type AppCreateRequest = operations['app___create']['requestBody']['content']['application/json'];
export type AppCreateResponse = operations['app___create']['responses']['200']['content']['application/json'];
export type AppCurrentResponse = operations['app___current']['responses']['200']['content']['application/json'];
export type AppShowRequest = operations['app___show']['requestBody']['content']['application/json'];
export type AppShowResponse = operations['app___show']['responses']['200']['content']['application/json'];
export type AuthAcceptRequest = operations['auth___accept']['requestBody']['content']['application/json'];

View file

@ -1086,6 +1086,15 @@ export type paths = {
*/
post: operations['app___create'];
};
'/app/current': {
/**
* app/current
* @description No description provided.
*
* **Credential required**: *No*
*/
post: operations['app___current'];
};
'/app/show': {
/**
* app/show
@ -4659,6 +4668,9 @@ export type components = {
/** @enum {string} */
visibility: 'public' | 'home' | 'followers' | 'specified';
mentions?: string[];
mentionHandles?: {
[key: string]: string;
};
visibleUserIds?: string[];
fileIds?: string[];
files?: components['schemas']['DriveFile'][];
@ -9431,7 +9443,7 @@ export type operations = {
content: {
'application/json': {
/** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost';
/** @enum {string} */
state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed';
};
@ -9577,8 +9589,8 @@ export type operations = {
content: {
'application/json': {
/** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
state: ('active' | 'wait' | 'delayed' | 'completed' | 'failed')[];
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost';
state: ('active' | 'paused' | 'wait' | 'delayed' | 'completed' | 'failed')[];
search?: string;
};
};
@ -9631,7 +9643,7 @@ export type operations = {
content: {
'application/json': {
/** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost';
};
};
};
@ -9683,7 +9695,7 @@ export type operations = {
content: {
'application/json': {
/** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost';
};
};
};
@ -9779,7 +9791,7 @@ export type operations = {
content: {
'application/json': {
/** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost';
jobId: string;
};
};
@ -9832,7 +9844,7 @@ export type operations = {
content: {
'application/json': {
/** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost';
jobId: string;
};
};
@ -9885,7 +9897,7 @@ export type operations = {
content: {
'application/json': {
/** @enum {string} */
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver';
queue: 'system' | 'endedPollNotification' | 'deliver' | 'inbox' | 'db' | 'relationship' | 'objectStorage' | 'userWebhookDeliver' | 'systemWebhookDeliver' | 'scheduleNotePost';
jobId: string;
};
};
@ -13071,6 +13083,58 @@ export type operations = {
};
};
};
/**
* app/current
* @description No description provided.
*
* **Credential required**: *No*
*/
app___current: {
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': components['schemas']['App'];
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Too many requests */
429: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* app/show
* @description No description provided.
@ -17529,6 +17593,8 @@ export type operations = {
sort?: '+createdAt' | '-createdAt' | '+name' | '-name' | '+size' | '-size' | null;
/** @default */
searchQuery?: string;
/** @default false */
showAll?: boolean;
};
};
};

254
pnpm-lock.yaml generated
View file

@ -161,12 +161,6 @@ importers:
'@twemoji/parser':
specifier: 15.1.1
version: 15.1.1
'@types/psl':
specifier: ^1.1.3
version: 1.1.3
'@types/redis-info':
specifier: 3.0.3
version: 3.0.3
accepts:
specifier: 1.3.8
version: 1.3.8
@ -182,6 +176,9 @@ importers:
async-mutex:
specifier: 0.5.0
version: 0.5.0
axios:
specifier: 1.7.4
version: 1.7.4
bcryptjs:
specifier: 2.4.3
version: 2.4.3
@ -209,6 +206,9 @@ importers:
chalk-template:
specifier: 1.1.0
version: 1.1.0
cheerio:
specifier: 1.0.0
version: 1.0.0
chokidar:
specifier: 4.0.3
version: 4.0.3
@ -233,9 +233,6 @@ importers:
fastify:
specifier: 5.3.2
version: 5.3.2
fastify-multer:
specifier: ^2.0.3
version: 2.0.3
fastify-raw-body:
specifier: 5.0.0
version: 5.0.0
@ -284,9 +281,6 @@ importers:
js-yaml:
specifier: 4.1.0
version: 4.1.0
jsdom:
specifier: 26.1.0
version: 26.1.0(bufferutil@4.0.9)(canvas@3.1.0)(utf-8-validate@6.0.5)
json5:
specifier: 2.2.3
version: 2.2.3
@ -592,9 +586,6 @@ importers:
'@types/js-yaml':
specifier: 4.0.9
version: 4.0.9
'@types/jsdom':
specifier: 21.1.7
version: 21.1.7
'@types/jsonld':
specifier: 1.5.15
version: 1.5.15
@ -628,6 +619,9 @@ importers:
'@types/proxy-addr':
specifier: ^2.0.3
version: 2.0.3
'@types/psl':
specifier: ^1.1.3
version: 1.1.3
'@types/pug':
specifier: 2.0.10
version: 2.0.10
@ -640,6 +634,9 @@ importers:
'@types/ratelimiter':
specifier: 3.4.6
version: 3.4.6
'@types/redis-info':
specifier: 3.0.3
version: 3.0.3
'@types/rename':
specifier: 1.0.7
version: 1.0.7
@ -1016,7 +1013,7 @@ importers:
version: 8.31.0(eslint@9.25.1)(typescript@5.8.3)
'@vitest/coverage-v8':
specifier: 3.1.2
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
version: 3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
'@vue/compiler-core':
specifier: 3.5.13
version: 3.5.13
@ -1085,7 +1082,7 @@ importers:
version: 1.0.3
vitest:
specifier: 3.1.2
version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
version: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vitest-fetch-mock:
specifier: 0.4.5
version: 0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
@ -2274,10 +2271,6 @@ packages:
'@fastify/ajv-compiler@4.0.1':
resolution: {integrity: sha512-DxrBdgsjNLP0YM6W5Hd6/Fmj43S8zMKiFJYgi+Ri3htTGAowPVG/tG1wpnWLMjufEnehRivUCKZ1pLDIoZdTuw==}
'@fastify/busboy@1.2.1':
resolution: {integrity: sha512-7PQA7EH43S0CxcOa9OeAnaeA0oQ+e/DHNPZwSQM9CQHW76jle5+OvLdibRp/Aafs9KXbLhxyjOTkRjWUbQEd3Q==}
engines: {node: '>=14'}
'@fastify/busboy@2.1.0':
resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==}
engines: {node: '>=14'}
@ -4220,9 +4213,6 @@ packages:
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/jsdom@21.1.7':
resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -4400,9 +4390,6 @@ packages:
'@types/tmp@0.2.6':
resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==}
'@types/tough-cookie@4.0.2':
resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@ -5455,10 +5442,6 @@ packages:
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
engines: {'0': node >= 0.8}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
config-chain@1.1.13:
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
@ -6313,13 +6296,6 @@ packages:
resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==}
hasBin: true
fastify-multer@2.0.3:
resolution: {integrity: sha512-QnFqrRgxmUwWHTgX9uyQSu0C/hmVCfcxopqjApZ4uaZD5W9MJ+nHUlW4+9q7Yd3BRxDIuHvgiM5mjrh6XG8cAA==}
engines: {node: '>=10.17.0'}
fastify-plugin@2.3.4:
resolution: {integrity: sha512-I+Oaj6p9oiRozbam30sh39BiuiqBda7yK2nmSPVwDCfIBlKnT8YB3MY+pRQc2Fcd07bf6KPGklHJaQ2Qu81TYQ==}
fastify-plugin@4.5.1:
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
@ -6435,15 +6411,6 @@ packages:
resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==}
engines: {node: '>=18'}
follow-redirects@1.15.2:
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
@ -9960,9 +9927,6 @@ packages:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
text-decoding@1.0.0:
resolution: {integrity: sha512-/0TJD42KDnVwKmDK6jj3xP7E2MG7SHAOG4tyTgyUCRPdHwvkquYNLEQltmdMa3owq3TkddCVcTsoctJI8VQNKA==}
textarea-caret@3.1.0:
resolution: {integrity: sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==}
@ -10327,10 +10291,6 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici@5.28.2:
resolution: {integrity: sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==}
engines: {node: '>=14.0'}
undici@5.29.0:
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
engines: {node: '>=14.0'}
@ -10918,6 +10878,7 @@ snapshots:
'@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3)
'@csstools/css-tokenizer': 3.0.3
lru-cache: 10.4.3
optional: true
'@aws-crypto/crc32@5.2.0':
dependencies:
@ -11851,12 +11812,14 @@ snapshots:
'@cropper/utils@2.0.0': {}
'@csstools/color-helpers@5.0.2': {}
'@csstools/color-helpers@5.0.2':
optional: true
'@csstools/css-calc@2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)':
dependencies:
'@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3)
'@csstools/css-tokenizer': 3.0.3
optional: true
'@csstools/css-color-parser@3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)':
dependencies:
@ -11864,12 +11827,15 @@ snapshots:
'@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)
'@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3)
'@csstools/css-tokenizer': 3.0.3
optional: true
'@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)':
dependencies:
'@csstools/css-tokenizer': 3.0.3
optional: true
'@csstools/css-tokenizer@3.0.3': {}
'@csstools/css-tokenizer@3.0.3':
optional: true
'@cypress/request@3.0.8':
dependencies:
@ -11905,7 +11871,7 @@ snapshots:
dependencies:
ky: 0.33.3
ky-universal: 0.11.0(ky@0.33.3)(web-streams-polyfill@4.0.0)
undici: 5.28.2
undici: 5.29.0
transitivePeerDependencies:
- web-streams-polyfill
@ -12060,10 +12026,6 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.17.1)
fast-uri: 3.0.1
'@fastify/busboy@1.2.1':
dependencies:
text-decoding: 1.0.0
'@fastify/busboy@2.1.0': {}
'@fastify/busboy@3.0.0': {}
@ -14454,12 +14416,6 @@ snapshots:
'@types/js-yaml@4.0.9': {}
'@types/jsdom@21.1.7':
dependencies:
'@types/node': 22.15.2
'@types/tough-cookie': 4.0.2
parse5: 7.3.0
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
@ -14634,8 +14590,6 @@ snapshots:
'@types/tmp@0.2.6': {}
'@types/tough-cookie@4.0.2': {}
'@types/tough-cookie@4.0.5': {}
'@types/unist@3.0.2': {}
@ -14749,24 +14703,6 @@ snapshots:
vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vue: 3.5.13(typescript@5.8.3)
'@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
debug: 4.4.0(supports-color@8.1.1)
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.1.7
magic-string: 0.30.17
magicast: 0.3.5
std-env: 3.9.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@3.1.2(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))':
dependencies:
'@ampproject/remapping': 2.3.0
@ -15354,7 +15290,7 @@ snapshots:
axios@0.24.0:
dependencies:
follow-redirects: 1.15.2
follow-redirects: 1.15.9(debug@4.4.0)
transitivePeerDependencies:
- debug
@ -15990,13 +15926,6 @@ snapshots:
readable-stream: 2.3.7
typedarray: 0.0.6
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.0
typedarray: 0.0.6
config-chain@1.1.13:
dependencies:
ini: 1.3.8
@ -16181,6 +16110,7 @@ snapshots:
dependencies:
'@asamuzakjp/css-color': 3.1.1
rrweb-cssom: 0.8.0
optional: true
csstype@3.1.3: {}
@ -16241,6 +16171,7 @@ snapshots:
dependencies:
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
optional: true
data-view-buffer@1.0.1:
dependencies:
@ -16311,7 +16242,8 @@ snapshots:
decamelize@1.2.0: {}
decimal.js@10.5.0: {}
decimal.js@10.5.0:
optional: true
decode-bmp@0.2.1:
dependencies:
@ -17122,21 +17054,6 @@ snapshots:
dependencies:
strnum: 1.0.5
fastify-multer@2.0.3:
dependencies:
'@fastify/busboy': 1.2.1
append-field: 1.0.0
concat-stream: 2.0.0
fastify-plugin: 2.3.4
mkdirp: 1.0.4
on-finished: 2.4.1
type-is: 1.6.18
xtend: 4.0.2
fastify-plugin@2.3.4:
dependencies:
semver: 7.6.0
fastify-plugin@4.5.1: {}
fastify-plugin@5.0.1: {}
@ -17298,8 +17215,6 @@ snapshots:
async: 0.2.10
which: 1.3.1
follow-redirects@1.15.2: {}
follow-redirects@1.15.9(debug@4.4.0):
optionalDependencies:
debug: 4.4.0(supports-color@8.1.1)
@ -17680,6 +17595,7 @@ snapshots:
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1
optional: true
html-entities@2.5.2: {}
@ -17983,7 +17899,8 @@ snapshots:
is-plain-object@5.0.0: {}
is-potential-custom-element-name@1.0.1: {}
is-potential-custom-element-name@1.0.1:
optional: true
is-promise@2.2.2: {}
@ -18496,7 +18413,7 @@ snapshots:
jsdoc-type-pratt-parser@4.1.0: {}
jsdom@26.1.0:
jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5):
dependencies:
cssstyle: 4.3.0
data-urls: 5.0.0
@ -18524,35 +18441,6 @@ snapshots:
- utf-8-validate
optional: true
jsdom@26.1.0(bufferutil@4.0.9)(canvas@3.1.0)(utf-8-validate@6.0.5):
dependencies:
cssstyle: 4.3.0
data-urls: 5.0.0
decimal.js: 10.5.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.19
parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 5.1.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)
xml-name-validator: 5.0.0
optionalDependencies:
canvas: 3.1.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
jsesc@2.5.2: {}
json-buffer@3.0.1: {}
@ -19561,7 +19449,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
nwsapi@2.2.19: {}
nwsapi@2.2.19:
optional: true
oauth2orize-pkce@0.1.2: {}
@ -20650,7 +20539,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
rrweb-cssom@0.8.0: {}
rrweb-cssom@0.8.0:
optional: true
rss-parser@3.13.0:
dependencies:
@ -20712,6 +20602,7 @@ snapshots:
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
optional: true
scheduler@0.26.0: {}
@ -21324,7 +21215,7 @@ snapshots:
formidable: 3.5.4
methods: 1.1.2
mime: 2.6.0
qs: 6.13.0
qs: 6.14.0
transitivePeerDependencies:
- supports-color
@ -21366,7 +21257,8 @@ snapshots:
csso: 5.0.5
picocolors: 1.1.1
symbol-tree@3.2.4: {}
symbol-tree@3.2.4:
optional: true
systeminformation@5.25.11: {}
@ -21438,8 +21330,6 @@ snapshots:
glob: 10.4.5
minimatch: 9.0.5
text-decoding@1.0.0: {}
textarea-caret@3.1.0: {}
thenify-all@1.6.0:
@ -21484,11 +21374,13 @@ snapshots:
tinyspy@3.0.2: {}
tldts-core@6.1.63: {}
tldts-core@6.1.63:
optional: true
tldts@6.1.63:
dependencies:
tldts-core: 6.1.63
optional: true
tmp@0.2.3: {}
@ -21532,12 +21424,14 @@ snapshots:
tough-cookie@5.1.2:
dependencies:
tldts: 6.1.63
optional: true
tr46@0.0.3: {}
tr46@5.1.0:
dependencies:
punycode: 2.3.1
optional: true
tree-kill@1.2.2:
optional: true
@ -21743,10 +21637,6 @@ snapshots:
undici-types@6.21.0: {}
undici@5.28.2:
dependencies:
'@fastify/busboy': 2.1.0
undici@5.29.0:
dependencies:
'@fastify/busboy': 2.1.0
@ -21941,49 +21831,7 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)):
dependencies:
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3):
dependencies:
'@vitest/expect': 3.1.2
'@vitest/mocker': 3.1.2(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(vite@6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3))
'@vitest/pretty-format': 3.1.2
'@vitest/runner': 3.1.2
'@vitest/snapshot': 3.1.2
'@vitest/spy': 3.1.2
'@vitest/utils': 3.1.2
chai: 5.2.0
debug: 4.4.0(supports-color@8.1.1)
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
std-env: 3.9.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.13
tinypool: 1.0.2
tinyrainbow: 2.0.0
vite: 6.3.3(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vite-node: 3.1.2(@types/node@22.15.2)(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 22.15.2
happy-dom: 17.4.4
jsdom: 26.1.0(bufferutil@4.0.9)(canvas@3.1.0)(utf-8-validate@6.0.5)
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vitest: 3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3)
vitest@3.1.2(@types/debug@4.1.12)(@types/node@22.15.2)(happy-dom@17.4.4)(jsdom@26.1.0)(msw@2.7.5(@types/node@22.15.2)(typescript@5.8.3))(sass@1.87.0)(terser@5.39.0)(tsx@4.19.3):
dependencies:
@ -22012,7 +21860,7 @@ snapshots:
'@types/debug': 4.1.12
'@types/node': 22.15.2
happy-dom: 17.4.4
jsdom: 26.1.0
jsdom: 26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)
transitivePeerDependencies:
- jiti
- less
@ -22132,6 +21980,7 @@ snapshots:
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
optional: true
wait-on@8.0.3(debug@4.4.0):
dependencies:
@ -22192,6 +22041,7 @@ snapshots:
dependencies:
tr46: 5.1.0
webidl-conversions: 7.0.0
optional: true
whatwg-url@5.0.0:
dependencies:
@ -22295,7 +22145,8 @@ snapshots:
xml-name-validator@4.0.0: {}
xml-name-validator@5.0.0: {}
xml-name-validator@5.0.0:
optional: true
xml2js@0.5.0:
dependencies:
@ -22304,7 +22155,8 @@ snapshots:
xmlbuilder@11.0.1: {}
xmlchars@2.2.0: {}
xmlchars@2.2.0:
optional: true
xtend@4.0.2: {}

View file

@ -536,3 +536,7 @@ deleted: "Deleted"
enableProxyAccount: "Enable the proxy account."
enableProxyAccountDescription: "If disabled, then the proxy account will not be used. User lists will only include notes from local or followed users."
_confirmPollEdit:
title: Are you sure you want to edit this poll
text: Editing this poll will cause it to lose all previous votes