Add mastodon compatibility APIs

This commit is contained in:
Kaity A 2022-12-19 09:58:37 +00:00
parent 770ca55121
commit 0a34d92130
4 changed files with 290 additions and 3 deletions

View file

@ -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;

View file

@ -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;
});

View file

@ -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<string, Emoji>
)
),
]);
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<string, Emoji>
): Promise<ContactType> => {
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)) ?? '';

View file

@ -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 {