Add mastodon compatibility APIs
This commit is contained in:
parent
770ca55121
commit
0a34d92130
|
@ -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 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 {
|
||||
|
|
Loading…
Reference in New Issue