Add mastodon compatibility APIs
This commit is contained in:
parent
770ca55121
commit
0a34d92130
4 changed files with 290 additions and 3 deletions
20
packages/backend/src/server/api/compatibility.ts
Normal file
20
packages/backend/src/server/api/compatibility.ts
Normal 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;
|
|
@ -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;
|
||||||
|
});
|
|
@ -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)) ?? '';
|
|
@ -7,10 +7,10 @@ import Router from '@koa/router';
|
||||||
import multer from '@koa/multer';
|
import multer from '@koa/multer';
|
||||||
import bodyParser from 'koa-bodyparser';
|
import bodyParser from 'koa-bodyparser';
|
||||||
import cors from '@koa/cors';
|
import cors from '@koa/cors';
|
||||||
|
|
||||||
import { Instances, AccessTokens, Users } from '@/models/index.js';
|
import { Instances, AccessTokens, Users } from '@/models/index.js';
|
||||||
import config from '@/config/index.js';
|
import config from '@/config/index.js';
|
||||||
import endpoints from './endpoints.js';
|
import endpoints from './endpoints.js';
|
||||||
|
import compatibility from './compatibility.js';
|
||||||
import handler from './api-handler.js';
|
import handler from './api-handler.js';
|
||||||
import signup from './private/signup.js';
|
import signup from './private/signup.js';
|
||||||
import signin from './private/signin.js';
|
import signin from './private/signin.js';
|
||||||
|
@ -34,7 +34,10 @@ app.use(async (ctx, next) => {
|
||||||
|
|
||||||
app.use(bodyParser({
|
app.use(bodyParser({
|
||||||
// リクエストが multipart/form-data でない限りはJSONだと見なす
|
// リクエストが 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
|
// Init multer instance
|
||||||
|
@ -52,7 +55,7 @@ const router = new Router();
|
||||||
/**
|
/**
|
||||||
* Register endpoint handlers
|
* Register endpoint handlers
|
||||||
*/
|
*/
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of [...endpoints, ...compatibility]) {
|
||||||
if (endpoint.meta.requireFile) {
|
if (endpoint.meta.requireFile) {
|
||||||
router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint));
|
router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint));
|
||||||
} else {
|
} else {
|
||||||
|
|
Loading…
Reference in a new issue