diff options
Diffstat (limited to 'packages/backend/src/server/api')
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 |