From 0a34d9213031caca99504a556f73732bdc6381bc Mon Sep 17 00:00:00 2001 From: Kaity A Date: Mon, 19 Dec 2022 09:58:37 +0000 Subject: [PATCH] Add mastodon compatibility APIs --- .../backend/src/server/api/compatibility.ts | 20 ++ .../endpoints/compatibility/custom-emojis.ts | 38 +++ .../endpoints/compatibility/instance-info.ts | 226 ++++++++++++++++++ packages/backend/src/server/api/index.ts | 9 +- 4 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/server/api/compatibility.ts create mode 100644 packages/backend/src/server/api/endpoints/compatibility/custom-emojis.ts create mode 100644 packages/backend/src/server/api/endpoints/compatibility/instance-info.ts diff --git a/packages/backend/src/server/api/compatibility.ts b/packages/backend/src/server/api/compatibility.ts new file mode 100644 index 0000000000..2f22cf718f --- /dev/null +++ b/packages/backend/src/server/api/compatibility.ts @@ -0,0 +1,20 @@ +import { IEndpoint } from './endpoints'; + +import * as cp___instanceInfo from './endpoints/compatibility/instance-info.js'; +import * as cp___customEmojis from './endpoints/compatibility/custom-emojis.js'; + +const cps = [ + ['v1/instance', cp___instanceInfo], + ['v1/custom_emojis', cp___customEmojis], +]; + +const compatibility: IEndpoint[] = cps.map(([name, cp]) => { + return { + name: name, + exec: cp.default, + meta: cp.meta || {}, + params: cp.paramDef, + } as IEndpoint; +}); + +export default compatibility; diff --git a/packages/backend/src/server/api/endpoints/compatibility/custom-emojis.ts b/packages/backend/src/server/api/endpoints/compatibility/custom-emojis.ts new file mode 100644 index 0000000000..42969cff90 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/compatibility/custom-emojis.ts @@ -0,0 +1,38 @@ +import { Emojis } from '@/models/index.js'; +import { Emoji } from '@/models/entities/emoji.js'; +import { IsNull, In } from 'typeorm'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import define from '../../define.js'; + +export const meta = { + requireCredential: false, + requireCredentialPrivateMode: true, + allowGet: true, + + tags: ['meta'], +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const now = Date.now(); + const emojis: Emoji[] = await Emojis.find({ + where: { host: IsNull(), type: In(FILE_TYPE_BROWSERSAFE) }, + select: ['name', 'originalUrl', 'publicUrl', 'category'], + }); + + const emojiList = emojis.map(emoji => ({ + shortcode: emoji.name, + url: emoji.originalUrl, + static_url: emoji.publicUrl, + visible_in_picker: true, + category: emoji.category, + })); + + return emojiList; +}); diff --git a/packages/backend/src/server/api/endpoints/compatibility/instance-info.ts b/packages/backend/src/server/api/endpoints/compatibility/instance-info.ts new file mode 100644 index 0000000000..825119120e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/compatibility/instance-info.ts @@ -0,0 +1,226 @@ +import * as mfm from 'mfm-js'; +import { toHtml } from '@/mfm/to-html.js'; +import config from '@/config/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; +import { Users, Notes, Instances, UserProfiles, Emojis, DriveFiles } from '@/models/index.js'; +import { Emoji } from '@/models/entities/emoji.js'; +import { User } from '@/models/entities/user.js'; +import { IsNull, In } from 'typeorm'; +import { MAX_NOTE_TEXT_LENGTH, FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import define from '../../define.js'; + +export const meta = { + requireCredential: false, + requireCredentialPrivateMode: true, + allowGet: true, + + tags: ['meta'], +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async () => { + const now = Date.now(); + const [ + meta, + total, + localPosts, + instanceCount, + firstAdmin, + emojis, + ] = await Promise.all([ + fetchMeta(true), + Users.count({ where: { host: IsNull() } }), + Notes.count({ where: { userHost: IsNull(), replyId: IsNull() } }), + Instances.count(), + Users.findOne({ + where: { host: IsNull(), isAdmin: true, isDeleted: false, isBot: false }, + order: { id: 'ASC' }, + }), + Emojis.find({ + where: { host: IsNull(), type: In(FILE_TYPE_BROWSERSAFE) }, + select: ['id', 'name', 'originalUrl', 'publicUrl'], + }).then(l => + l.reduce((a, e) => + { + a[e.name] = e + return a + }, + {} as Record + ) + ), + ]); + + let descSplit = splitN(meta.description, '\n', 2); + let shortDesc = markup(descSplit.length > 0 ? descSplit[0]: ''); + let longDesc = markup(meta.description ?? ''); + + return { + "uri": config.hostname, + "title": meta.name, + "short_description": shortDesc, + "description": longDesc, + "email": meta.maintainerEmail, + "version": config.version, + "urls": { + "streaming_api": `wss://${config.host}` + }, + "stats": { + "user_count": total, + "status_count": localPosts, + "domain_count": instanceCount + }, + "thumbnail": meta.logoImageUrl, + "languages": meta.langs, + "registrations": !meta.disableRegistration, + "approval_required": false, + "invites_enabled": false, + "configuration": { + "accounts": { + "max_featured_tags": 16 + }, + "statuses": { + "max_characters": MAX_NOTE_TEXT_LENGTH, + "max_media_attachments": 16, + "characters_reserved_per_url": 0 + }, + "media_attachments": { + "supported_mime_types": FILE_TYPE_BROWSERSAFE, + "image_size_limit": 10485760, + "image_matrix_limit": 16777216, + "video_size_limit": 41943040, + "video_frame_rate_limit": 60, + "video_matrix_limit": 2304000 + }, + "polls": { + "max_options": 10, + "max_characters_per_option": 50, + "min_expiration": 15, + "max_expiration": -1 + } + }, + "contact_account": await getContact(firstAdmin, emojis), + "rules": [] + }; +}); + +const splitN = ( + s: string | null, + split: string, + n: number +): string[] => { + const ret: string[] = []; + if (s == null) return ret; + if (s === '') { + ret.push(s); + return ret; + } + + let start = 0; + let pos = s.indexOf(split); + if (pos === -1) { + ret.push(s); + return ret; + } + + for (let i = 0; i < n - 1; i++) { + ret.push(s.substring(start, pos)); + start = pos + split.length; + pos = s.indexOf(split, start); + if (pos === -1) break; + } + ret.push(s.substring(start)); + + return ret; +}; + +type ContactType = { + id: string; + username: string; + acct: string; + display_name: string; + note?: string; + noindex?: boolean; + fields?: { + name: string; + value: string; + verified_at:string | null; + }[]; + locked: boolean; + bot: boolean; + created_at: string; + url: string; + followers_count: number; + following_count: number; + statuses_count: number; + last_status_at?: string; + emojis: any; +} | null; + +const getContact = async ( + user: User | null, + emojis: Record +): Promise => { + if (!user) return null; + + let contact: ContactType = { + id: user.id, + username: user.username, + acct: user.username, + display_name: user.name ?? user.username, + locked: user.isLocked, + bot: user.isBot, + created_at: user.createdAt.toISOString(), + url: `${config.url}/@${user.username}`, + followers_count: user.followersCount, + following_count: user.followingCount, + statuses_count: user.notesCount, + last_status_at: user.lastActiveDate?.toISOString(), + emojis: emojis ? user.emojis.map(e => ({ + shortcode: e, + static_url: `${config.url}/files/${emojis[e].publicUrl}`, + url: `${config.url}/files/${emojis[e].publicUrl}`, + visible_in_picker: true, + })) : [], + }; + + const [profile] = await Promise.all([ + UserProfiles.findOne({ where: { userId: user.id }}), + loadDriveFiles(contact, 'avatar', user.avatarId), + loadDriveFiles(contact, 'header', user.bannerId), + ]); + + if (!profile) { + return contact; + } + + contact = { + ...contact, + note: markup(profile.description ?? ''), + noindex: profile.noCrawle, + fields: profile.fields.map(f => ({ + name: f.name, + value: f.value, + verified_at: null, + })) + }; + + return contact; +} + +const loadDriveFiles = async (contact: any, key: string, fileId: string | null) => { + if (fileId) { + const file = await DriveFiles.findOneBy({ id: fileId }); + if (file) { + contact[key] = file.webpublicUrl ?? file.url; + contact[`${key}_static`] = contact[key]; + } + } +} + +const markup = (text: string): string => toHtml(mfm.parse(text)) ?? ''; diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts index 83ece51f51..37bcfffec8 100644 --- a/packages/backend/src/server/api/index.ts +++ b/packages/backend/src/server/api/index.ts @@ -7,10 +7,10 @@ import Router from '@koa/router'; import multer from '@koa/multer'; import bodyParser from 'koa-bodyparser'; import cors from '@koa/cors'; - import { Instances, AccessTokens, Users } from '@/models/index.js'; import config from '@/config/index.js'; import endpoints from './endpoints.js'; +import compatibility from './compatibility.js'; import handler from './api-handler.js'; import signup from './private/signup.js'; import signin from './private/signin.js'; @@ -34,7 +34,10 @@ app.use(async (ctx, next) => { app.use(bodyParser({ // リクエストが multipart/form-data でない限りはJSONだと見なす - detectJSON: ctx => !ctx.is('multipart/form-data'), + detectJSON: ctx => !( + ctx.is('multipart/form-data') || + ctx.is('application/x-www-form-urlencoded') + ) })); // Init multer instance @@ -52,7 +55,7 @@ const router = new Router(); /** * Register endpoint handlers */ -for (const endpoint of endpoints) { +for (const endpoint of [...endpoints, ...compatibility]) { if (endpoint.meta.requireFile) { router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)); } else {