add: Megalodon, initial mastodon api
This commit is contained in:
parent
240d76a987
commit
2375d043d1
103 changed files with 9492 additions and 82 deletions
|
@ -39,6 +39,7 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
|
|||
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
|
||||
import { UserListChannelService } from './api/stream/channels/user-list.js';
|
||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js';
|
||||
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
||||
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
|
@ -84,6 +85,7 @@ import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
|||
ServerStatsChannelService,
|
||||
UserListChannelService,
|
||||
OpenApiServerService,
|
||||
MastodonApiServerService,
|
||||
OAuth2ProviderService,
|
||||
],
|
||||
exports: [
|
||||
|
|
|
@ -30,6 +30,7 @@ import { WellKnownServerService } from './WellKnownServerService.js';
|
|||
import { FileServerService } from './FileServerService.js';
|
||||
import { ClientServerService } from './web/ClientServerService.js';
|
||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
|
||||
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
@ -56,6 +57,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||
private userEntityService: UserEntityService,
|
||||
private apiServerService: ApiServerService,
|
||||
private openApiServerService: OpenApiServerService,
|
||||
private mastodonApiServerService: MastodonApiServerService,
|
||||
private streamingApiServerService: StreamingApiServerService,
|
||||
private activityPubServerService: ActivityPubServerService,
|
||||
private wellKnownServerService: WellKnownServerService,
|
||||
|
@ -95,6 +97,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||
|
||||
fastify.register(this.apiServerService.createServer, { prefix: '/api' });
|
||||
fastify.register(this.openApiServerService.createServer);
|
||||
fastify.register(this.mastodonApiServerService.createServer, { prefix: '/api' });
|
||||
fastify.register(this.fileServerService.createServer);
|
||||
fastify.register(this.activityPubServerService.createServer);
|
||||
fastify.register(this.nodeinfoServerService.createServer);
|
||||
|
|
|
@ -0,0 +1,192 @@
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import megalodon, { MegalodonInterface } from "megalodon";
|
||||
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { convertId, IdConvertType as IdType, convertAccount, convertAnnouncement, convertFilter, convertAttachment } from './converters.js';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { Config } from '@/config.js';
|
||||
import { getInstance } from './endpoints/meta.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import multer from 'fastify-multer';
|
||||
|
||||
const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url));
|
||||
|
||||
export function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface {
|
||||
const accessTokenArr = authorization?.split(" ") ?? [null];
|
||||
const accessToken = accessTokenArr[accessTokenArr.length - 1];
|
||||
const generator = (megalodon as any).default;
|
||||
const client = generator(BASE_URL, accessToken) as MegalodonInterface;
|
||||
return client;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MastodonApiServerService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
private metaService: MetaService,
|
||||
) { }
|
||||
|
||||
@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.register(multer.contentParser);
|
||||
|
||||
fastify.get("/v1/custom_emojis", async (_request, reply) => {
|
||||
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
|
||||
const accessTokens = _request.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const data = await client.getInstanceCustomEmojis();
|
||||
reply.send(data.data);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
reply.code(401).send(e.response.data);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/v1/instance", async (_request, reply) => {
|
||||
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
|
||||
const accessTokens = _request.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
|
||||
// displayed without being logged in
|
||||
try {
|
||||
const data = await client.getInstance();
|
||||
const admin = await this.usersRepository.findOne({
|
||||
where: {
|
||||
host: IsNull(),
|
||||
isRoot: true,
|
||||
isDeleted: false,
|
||||
isSuspended: false,
|
||||
},
|
||||
order: { id: "ASC" },
|
||||
});
|
||||
const contact = admin == null ? null : convertAccount((await client.getAccount(admin.id)).data);
|
||||
reply.send(await getInstance(data.data, contact, this.config, await this.metaService.fetch()));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
reply.code(401).send(e.response.data);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/v1/announcements", async (_request, reply) => {
|
||||
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
|
||||
const accessTokens = _request.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const data = await client.getInstanceAnnouncements();
|
||||
reply.send(data.data.map((announcement) => convertAnnouncement(announcement)));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
reply.code(401).send(e.response.data);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post<{ Body: { id: string } }>("/v1/announcements/:id/dismiss", async (_request, reply) => {
|
||||
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
|
||||
const accessTokens = _request.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const data = await client.dismissInstanceAnnouncement(
|
||||
convertId(_request.body['id'], IdType.SharkeyId)
|
||||
);
|
||||
reply.send(data.data);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
reply.code(401).send(e.response.data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post("/v1/media", { preHandler: upload.single('file') }, async (_request, reply) => {
|
||||
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
|
||||
const accessTokens = _request.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const multipartData = await _request.file;
|
||||
if (!multipartData) {
|
||||
reply.code(401).send({ error: "No image" });
|
||||
return;
|
||||
}
|
||||
const data = await client.uploadMedia(multipartData);
|
||||
reply.send(convertAttachment(data.data as Entity.Attachment));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
reply.code(401).send(e.response.data);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post("/v2/media", { preHandler: upload.single('file') }, async (_request, reply) => {
|
||||
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
|
||||
const accessTokens = _request.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const multipartData = await _request.file;
|
||||
if (!multipartData) {
|
||||
reply.code(401).send({ error: "No image" });
|
||||
return;
|
||||
}
|
||||
const data = await client.uploadMedia(multipartData, _request.body!);
|
||||
reply.send(convertAttachment(data.data as Entity.Attachment));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
reply.code(401).send(e.response.data);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/v1/filters", async (_request, reply) => {
|
||||
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
|
||||
const accessTokens = _request.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
|
||||
// displayed without being logged in
|
||||
try {
|
||||
const data = await client.getFilters();
|
||||
reply.send(data.data.map((filter) => convertFilter(filter)));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
reply.code(401).send(e.response.data);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/v1/trends", async (_request, reply) => {
|
||||
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
|
||||
const accessTokens = _request.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
|
||||
// displayed without being logged in
|
||||
try {
|
||||
const data = await client.getInstanceTrends();
|
||||
reply.send(data.data);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
reply.code(401).send(e.response.data);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get("/v1/preferences", async (_request, reply) => {
|
||||
const BASE_URL = `${_request.protocol}://${_request.hostname}`;
|
||||
const accessTokens = _request.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
|
||||
// displayed without being logged in
|
||||
try {
|
||||
const data = await client.getPreferences();
|
||||
reply.send(data.data);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
reply.code(401).send(e.response.data);
|
||||
}
|
||||
});
|
||||
done();
|
||||
}
|
||||
}
|
136
packages/backend/src/server/api/mastodon/converters.ts
Normal file
136
packages/backend/src/server/api/mastodon/converters.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { Entity } from "megalodon";
|
||||
|
||||
const CHAR_COLLECTION: string = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
export enum IdConvertType {
|
||||
MastodonId,
|
||||
SharkeyId,
|
||||
}
|
||||
|
||||
export function convertId(in_id: string, id_convert_type: IdConvertType): string {
|
||||
switch (id_convert_type) {
|
||||
case IdConvertType.MastodonId:
|
||||
let out: bigint = BigInt(0);
|
||||
const lowerCaseId = in_id.toLowerCase();
|
||||
for (let i = 0; i < lowerCaseId.length; i++) {
|
||||
const charValue = numFromChar(lowerCaseId.charAt(i));
|
||||
out += BigInt(charValue) * BigInt(36) ** BigInt(i);
|
||||
}
|
||||
return out.toString();
|
||||
|
||||
case IdConvertType.SharkeyId:
|
||||
let input: bigint = BigInt(in_id);
|
||||
let outStr = '';
|
||||
while (input > BigInt(0)) {
|
||||
const remainder = Number(input % BigInt(36));
|
||||
outStr = charFromNum(remainder) + outStr;
|
||||
input /= BigInt(36);
|
||||
}
|
||||
return outStr;
|
||||
|
||||
default:
|
||||
throw new Error('Invalid ID conversion type');
|
||||
}
|
||||
}
|
||||
|
||||
function numFromChar(character: string): number {
|
||||
for (let i = 0; i < CHAR_COLLECTION.length; i++) {
|
||||
if (CHAR_COLLECTION.charAt(i) === character) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid character in parsed base36 id');
|
||||
}
|
||||
|
||||
function charFromNum(number: number): string {
|
||||
if (number >= 0 && number < CHAR_COLLECTION.length) {
|
||||
return CHAR_COLLECTION.charAt(number);
|
||||
} else {
|
||||
throw new Error('Invalid number for base-36 encoding');
|
||||
}
|
||||
}
|
||||
|
||||
function simpleConvert(data: any) {
|
||||
// copy the object to bypass weird pass by reference bugs
|
||||
const result = Object.assign({}, data);
|
||||
result.id = convertId(data.id, IdConvertType.MastodonId);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertAccount(account: Entity.Account) {
|
||||
return simpleConvert(account);
|
||||
}
|
||||
export function convertAnnouncement(announcement: Entity.Announcement) {
|
||||
return simpleConvert(announcement);
|
||||
}
|
||||
export function convertAttachment(attachment: Entity.Attachment) {
|
||||
return simpleConvert(attachment);
|
||||
}
|
||||
export function convertFilter(filter: Entity.Filter) {
|
||||
return simpleConvert(filter);
|
||||
}
|
||||
export function convertList(list: Entity.List) {
|
||||
return simpleConvert(list);
|
||||
}
|
||||
export function convertFeaturedTag(tag: Entity.FeaturedTag) {
|
||||
return simpleConvert(tag);
|
||||
}
|
||||
|
||||
export function convertNotification(notification: Entity.Notification) {
|
||||
notification.account = convertAccount(notification.account);
|
||||
notification.id = convertId(notification.id, IdConvertType.MastodonId);
|
||||
if (notification.status)
|
||||
notification.status = convertStatus(notification.status);
|
||||
if (notification.reaction)
|
||||
notification.reaction = convertReaction(notification.reaction);
|
||||
return notification;
|
||||
}
|
||||
|
||||
export function convertPoll(poll: Entity.Poll) {
|
||||
return simpleConvert(poll);
|
||||
}
|
||||
export function convertReaction(reaction: Entity.Reaction) {
|
||||
if (reaction.accounts) {
|
||||
reaction.accounts = reaction.accounts.map(convertAccount);
|
||||
}
|
||||
return reaction;
|
||||
}
|
||||
export function convertRelationship(relationship: Entity.Relationship) {
|
||||
return simpleConvert(relationship);
|
||||
}
|
||||
|
||||
export function convertStatus(status: Entity.Status) {
|
||||
status.account = convertAccount(status.account);
|
||||
status.id = convertId(status.id, IdConvertType.MastodonId);
|
||||
if (status.in_reply_to_account_id)
|
||||
status.in_reply_to_account_id = convertId(
|
||||
status.in_reply_to_account_id,
|
||||
IdConvertType.MastodonId,
|
||||
);
|
||||
if (status.in_reply_to_id)
|
||||
status.in_reply_to_id = convertId(status.in_reply_to_id, IdConvertType.MastodonId);
|
||||
status.media_attachments = status.media_attachments.map((attachment) =>
|
||||
convertAttachment(attachment),
|
||||
);
|
||||
status.mentions = status.mentions.map((mention) => ({
|
||||
...mention,
|
||||
id: convertId(mention.id, IdConvertType.MastodonId),
|
||||
}));
|
||||
if (status.poll) status.poll = convertPoll(status.poll);
|
||||
if (status.reblog) status.reblog = convertStatus(status.reblog);
|
||||
if (status.quote) status.quote = convertStatus(status.quote);
|
||||
status.reactions = status.reactions.map(convertReaction);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export function convertConversation(conversation: Entity.Conversation) {
|
||||
conversation.id = convertId(conversation.id, IdConvertType.MastodonId);
|
||||
conversation.accounts = conversation.accounts.map(convertAccount);
|
||||
if (conversation.last_status) {
|
||||
conversation.last_status = convertStatus(conversation.last_status);
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
63
packages/backend/src/server/api/mastodon/endpoints/meta.ts
Normal file
63
packages/backend/src/server/api/mastodon/endpoints/meta.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { Entity } from "megalodon";
|
||||
import { MAX_NOTE_TEXT_LENGTH, FILE_TYPE_BROWSERSAFE } from "@/const.js";
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiMeta } from "@/models/Meta.js";
|
||||
|
||||
export async function getInstance(
|
||||
response: Entity.Instance,
|
||||
contact: Entity.Account,
|
||||
config: Config,
|
||||
meta: MiMeta,
|
||||
) {
|
||||
return {
|
||||
uri: config.url,
|
||||
title: meta.name || "Sharkey",
|
||||
short_description:
|
||||
meta.description?.substring(0, 50) || "See real server website",
|
||||
description:
|
||||
meta.description ||
|
||||
"This is a vanilla Sharkey Instance. It doesn't seem to have a description.",
|
||||
email: response.email || "",
|
||||
version: `3.0.0 (compatible; Sharkey ${config.version})`,
|
||||
urls: response.urls,
|
||||
stats: {
|
||||
user_count: response.stats.user_count,
|
||||
status_count: response.stats.status_count,
|
||||
domain_count: response.stats.domain_count,
|
||||
},
|
||||
thumbnail: meta.backgroundImageUrl || "/static-assets/transparent.png",
|
||||
languages: meta.langs,
|
||||
registrations: !meta.disableRegistration || response.registrations,
|
||||
approval_required: !response.registrations,
|
||||
invites_enabled: response.registrations,
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_featured_tags: 20,
|
||||
},
|
||||
statuses: {
|
||||
max_characters: MAX_NOTE_TEXT_LENGTH,
|
||||
max_media_attachments: 16,
|
||||
characters_reserved_per_url: response.uri.length,
|
||||
},
|
||||
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: 50,
|
||||
max_expiration: 2629746,
|
||||
},
|
||||
reactions: {
|
||||
max_reactions: 1,
|
||||
},
|
||||
},
|
||||
contact_account: contact,
|
||||
rules: [],
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue