summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend/src/server/api')
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonApiServerService.ts192
-rw-r--r--packages/backend/src/server/api/mastodon/converters.ts136
-rw-r--r--packages/backend/src/server/api/mastodon/endpoints/meta.ts63
3 files changed, 391 insertions, 0 deletions
diff --git a/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
new file mode 100644
index 0000000000..b79489d18d
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/MastodonApiServerService.ts
@@ -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();
+ }
+} \ No newline at end of file
diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts
new file mode 100644
index 0000000000..94b70230d8
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/converters.ts
@@ -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;
+}
diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts
new file mode 100644
index 0000000000..a37742a068
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts
@@ -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: [],
+ };
+} \ No newline at end of file