summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api/mastodon/MastodonConverters.ts
diff options
context:
space:
mode:
authorJulia <julia@insertdomain.name>2025-05-29 00:07:38 +0000
committerJulia <julia@insertdomain.name>2025-05-29 00:07:38 +0000
commit6b554c178b81f13f83a69b19d44b72b282a0c119 (patch)
treef5537f1a56323a4dd57ba150b3cb84a2d8b5dc63 /packages/backend/src/server/api/mastodon/MastodonConverters.ts
parentmerge: Security fixes (!970) (diff)
parentbump version for release (diff)
downloadsharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.gz
sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.bz2
sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.zip
merge: release 2025.4.2 (!1051)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1051 Approved-by: Hazelnoot <acomputerdog@gmail.com> Approved-by: Marie <github@yuugi.dev> Approved-by: Julia <julia@insertdomain.name>
Diffstat (limited to 'packages/backend/src/server/api/mastodon/MastodonConverters.ts')
-rw-r--r--packages/backend/src/server/api/mastodon/MastodonConverters.ts470
1 files changed, 470 insertions, 0 deletions
diff --git a/packages/backend/src/server/api/mastodon/MastodonConverters.ts b/packages/backend/src/server/api/mastodon/MastodonConverters.ts
new file mode 100644
index 0000000000..375ea1ef08
--- /dev/null
+++ b/packages/backend/src/server/api/mastodon/MastodonConverters.ts
@@ -0,0 +1,470 @@
+/*
+ * SPDX-FileCopyrightText: marie and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Entity, MastodonEntity, MisskeyEntity } from 'megalodon';
+import mfm from '@transfem-org/sfm-js';
+import { MastodonNotificationType } from 'megalodon/lib/src/mastodon/notification.js';
+import { NotificationType } from 'megalodon/lib/src/notification.js';
+import { DI } from '@/di-symbols.js';
+import { MfmService } from '@/core/MfmService.js';
+import type { Config } from '@/config.js';
+import { IMentionedRemoteUsers, MiNote } from '@/models/Note.js';
+import type { MiLocalUser, MiUser } from '@/models/User.js';
+import type { NoteEditRepository, UserProfilesRepository } from '@/models/_.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import { CustomEmojiService } from '@/core/CustomEmojiService.js';
+import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
+import { IdService } from '@/core/IdService.js';
+import type { Packed } from '@/misc/json-schema.js';
+import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { appendContentWarning } from '@/misc/append-content-warning.js';
+import { isRenote } from '@/misc/is-renote.js';
+
+// Missing from Megalodon apparently
+// https://docs.joinmastodon.org/entities/StatusEdit/
+export interface StatusEdit {
+ content: string;
+ spoiler_text: string;
+ sensitive: boolean;
+ created_at: string;
+ account: MastodonEntity.Account;
+ poll?: {
+ options: {
+ title: string;
+ }[]
+ },
+ media_attachments: MastodonEntity.Attachment[],
+ emojis: MastodonEntity.Emoji[],
+}
+
+export const escapeMFM = (text: string): string => text
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;')
+ .replace(/`/g, '&#x60;')
+ .replace(/\r?\n/g, '<br>');
+
+@Injectable()
+export class MastodonConverters {
+ constructor(
+ @Inject(DI.config)
+ private readonly config: Config,
+
+ @Inject(DI.userProfilesRepository)
+ private readonly userProfilesRepository: UserProfilesRepository,
+
+ @Inject(DI.noteEditRepository)
+ private readonly noteEditRepository: NoteEditRepository,
+
+ private readonly mfmService: MfmService,
+ private readonly getterService: GetterService,
+ private readonly customEmojiService: CustomEmojiService,
+ private readonly idService: IdService,
+ private readonly driveFileEntityService: DriveFileEntityService,
+ private readonly mastodonDataService: MastodonDataService,
+ ) {}
+
+ private encode(u: MiUser, m: IMentionedRemoteUsers): MastodonEntity.Mention {
+ let acct = u.username;
+ let acctUrl = `https://${u.host || this.config.host}/@${u.username}`;
+ let url: string | null = null;
+ if (u.host) {
+ const info = m.find(r => r.username === u.username && r.host === u.host);
+ acct = `${u.username}@${u.host}`;
+ acctUrl = `https://${u.host}/@${u.username}`;
+ if (info) url = info.url ?? info.uri;
+ }
+ return {
+ id: u.id,
+ username: u.username,
+ acct: acct,
+ url: url ?? acctUrl,
+ };
+ }
+
+ public fileType(s: string): 'unknown' | 'image' | 'gifv' | 'video' | 'audio' {
+ if (s === 'image/gif') {
+ return 'gifv';
+ }
+ if (s.includes('image')) {
+ return 'image';
+ }
+ if (s.includes('video')) {
+ return 'video';
+ }
+ if (s.includes('audio')) {
+ return 'audio';
+ }
+ return 'unknown';
+ }
+
+ public encodeFile(f: Packed<'DriveFile'>): MastodonEntity.Attachment {
+ const { width, height } = f.properties;
+ const size = (width && height) ? `${width}x${height}` : undefined;
+ const aspect = (width && height) ? (width / height) : undefined;
+
+ return {
+ id: f.id,
+ type: this.fileType(f.type),
+ url: f.url,
+ remote_url: f.url,
+ preview_url: f.thumbnailUrl,
+ text_url: f.url,
+ meta: {
+ original: {
+ width,
+ height,
+ size,
+ aspect,
+ },
+ width,
+ height,
+ size,
+ aspect,
+ },
+ description: f.comment ?? null,
+ blurhash: f.blurhash ?? null,
+ };
+ }
+
+ public async getUser(id: string): Promise<MiUser> {
+ return this.getterService.getUser(id).then(p => {
+ return p;
+ });
+ }
+
+ private encodeField(f: Entity.Field): MastodonEntity.Field {
+ return {
+ name: f.name,
+ value: this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value),
+ verified_at: null,
+ };
+ }
+
+ public async convertAccount(account: Entity.Account | MiUser): Promise<MastodonEntity.Account> {
+ const user = await this.getUser(account.id);
+ const profile = await this.userProfilesRepository.findOneBy({ userId: user.id });
+ const emojis = await this.customEmojiService.populateEmojis(user.emojis, user.host ? user.host : this.config.host);
+ const emoji: Entity.Emoji[] = [];
+ Object.entries(emojis).forEach(entry => {
+ const [key, value] = entry;
+ emoji.push({
+ shortcode: key,
+ static_url: value,
+ url: value,
+ visible_in_picker: true,
+ category: undefined,
+ });
+ });
+ const fqn = `${user.username}@${user.host ?? this.config.hostname}`;
+ let acct = user.username;
+ let acctUrl = `https://${user.host || this.config.host}/@${user.username}`;
+ const acctUri = `https://${this.config.host}/users/${user.id}`;
+ if (user.host) {
+ acct = `${user.username}@${user.host}`;
+ acctUrl = `https://${user.host}/@${user.username}`;
+ }
+
+ const bioText = profile?.description && this.mfmService.toMastoApiHtml(mfm.parse(profile.description));
+
+ return awaitAll({
+ id: account.id,
+ username: user.username,
+ acct: acct,
+ fqn: fqn,
+ display_name: user.name ?? user.username,
+ locked: user.isLocked,
+ created_at: this.idService.parse(user.id).date.toISOString(),
+ followers_count: profile?.followersVisibility === 'public' ? user.followersCount : 0,
+ following_count: profile?.followingVisibility === 'public' ? user.followingCount : 0,
+ statuses_count: user.notesCount,
+ note: bioText ?? '',
+ url: user.uri ?? acctUrl,
+ uri: user.uri ?? acctUri,
+ avatar: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png',
+ avatar_static: user.avatarUrl ?? 'https://dev.joinsharkey.org/static-assets/avatar.png',
+ header: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png',
+ header_static: user.bannerUrl ?? 'https://dev.joinsharkey.org/static-assets/transparent.png',
+ emojis: emoji,
+ moved: null, //FIXME
+ fields: profile?.fields.map(p => this.encodeField(p)) ?? [],
+ bot: user.isBot,
+ discoverable: user.isExplorable,
+ noindex: user.noindex,
+ group: null,
+ suspended: user.isSuspended,
+ limited: user.isSilenced,
+ });
+ }
+
+ public async getEdits(id: string, me: MiLocalUser | null): Promise<StatusEdit[]> {
+ const note = await this.mastodonDataService.getNote(id, me);
+ if (!note) {
+ return [];
+ }
+
+ const noteUser = await this.getUser(note.userId);
+ const account = await this.convertAccount(noteUser);
+ const edits = await this.noteEditRepository.find({ where: { noteId: note.id }, order: { id: 'ASC' } });
+ const history: StatusEdit[] = [];
+
+ const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers);
+ const renote = isRenote(note) ? await this.mastodonDataService.requireNote(note.renoteId, me) : null;
+
+ // TODO this looks wrong, according to mastodon docs
+ let lastDate = this.idService.parse(note.id).date;
+
+ for (const edit of edits) {
+ // TODO avoid re-packing files for each edit
+ const files = await this.driveFileEntityService.packManyByIds(edit.fileIds);
+
+ const cw = appendContentWarning(edit.cw, noteUser.mandatoryCW) ?? '';
+
+ const isQuote = renote && (edit.cw || edit.newText || edit.fileIds.length > 0 || note.replyId);
+ const quoteUri = isQuote
+ ? renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}`
+ : null;
+
+ const item = {
+ account: account,
+ content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), mentionedRemoteUsers, false, quoteUri) ?? '',
+ created_at: lastDate.toISOString(),
+ emojis: [], //FIXME
+ sensitive: !!cw,
+ spoiler_text: cw,
+ media_attachments: files.length > 0 ? files.map((f) => this.encodeFile(f)) : [],
+ };
+ lastDate = edit.updatedAt;
+ history.push(item);
+ }
+
+ return history;
+ }
+
+ private async convertReblog(status: Entity.Status | null, me: MiLocalUser | null): Promise<MastodonEntity.Status | null> {
+ if (!status) return null;
+ return await this.convertStatus(status, me);
+ }
+
+ public async convertStatus(status: Entity.Status, me: MiLocalUser | null): Promise<MastodonEntity.Status> {
+ const convertedAccount = this.convertAccount(status.account);
+ const note = await this.mastodonDataService.requireNote(status.id, me);
+ const noteUser = await this.getUser(status.account.id);
+ const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers);
+
+ const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host);
+ const emoji: Entity.Emoji[] = [];
+ Object.entries(emojis).forEach(entry => {
+ const [key, value] = entry;
+ emoji.push({
+ shortcode: key,
+ static_url: value,
+ url: value,
+ visible_in_picker: true,
+ category: undefined,
+ });
+ });
+
+ const mentions = Promise.all(note.mentions.map(p =>
+ this.getUser(p)
+ .then(u => this.encode(u, mentionedRemoteUsers))
+ .catch(() => null)))
+ .then((p: Entity.Mention[]) => p.filter(m => m));
+
+ const tags = note.tags.map(tag => {
+ return {
+ name: tag,
+ url: `${this.config.url}/tags/${tag}`,
+ } as Entity.Tag;
+ });
+
+ // This must mirror the usual isQuote / isPureRenote logic used elsewhere.
+ const isQuote = note.renoteId && (note.text || note.cw || note.fileIds.length > 0 || note.hasPoll || note.replyId);
+
+ const renote: Promise<MiNote> | null = note.renoteId ? this.mastodonDataService.requireNote(note.renoteId, me) : null;
+
+ const quoteUri = Promise.resolve(renote).then(renote => {
+ if (!renote || !isQuote) return null;
+ return renote.url ?? renote.uri ?? `${this.config.url}/notes/${renote.id}`;
+ });
+
+ const text = note.text;
+ const content = text !== null
+ ? quoteUri.then(quote => this.mfmService.toMastoApiHtml(mfm.parse(text), mentionedRemoteUsers, false, quote) ?? escapeMFM(text))
+ : '';
+
+ const cw = appendContentWarning(note.cw, noteUser.mandatoryCW) ?? '';
+
+ const reblogged = await this.mastodonDataService.hasReblog(note.id, me);
+
+ // noinspection ES6MissingAwait
+ return await awaitAll({
+ id: note.id,
+ uri: note.uri ?? `https://${this.config.host}/notes/${note.id}`,
+ url: note.url ?? note.uri ?? `https://${this.config.host}/notes/${note.id}`,
+ account: convertedAccount,
+ in_reply_to_id: note.replyId,
+ in_reply_to_account_id: note.replyUserId,
+ reblog: !isQuote ? this.convertReblog(status.reblog, me) : null,
+ content: content,
+ content_type: 'text/x.misskeymarkdown',
+ text: note.text,
+ created_at: status.created_at,
+ edited_at: note.updatedAt?.toISOString() ?? null,
+ emojis: emoji,
+ replies_count: note.repliesCount,
+ reblogs_count: note.renoteCount,
+ favourites_count: status.favourites_count,
+ reblogged,
+ favourited: status.favourited,
+ muted: status.muted,
+ sensitive: status.sensitive || !!cw,
+ spoiler_text: cw,
+ visibility: status.visibility,
+ media_attachments: status.media_attachments.map((a: Entity.Account) => convertAttachment(a)),
+ mentions: mentions,
+ tags: tags,
+ card: null, //FIXME
+ poll: status.poll ?? null,
+ application: null, //FIXME
+ language: null, //FIXME
+ pinned: false, //FIXME
+ bookmarked: false, //FIXME
+ quote_id: isQuote ? status.reblog?.id : undefined,
+ quote: isQuote ? this.convertReblog(status.reblog, me) : null,
+ reactions: status.emoji_reactions,
+ });
+ }
+
+ public async convertConversation(conversation: Entity.Conversation, me: MiLocalUser | null): Promise<MastodonEntity.Conversation> {
+ return {
+ id: conversation.id,
+ accounts: await Promise.all(conversation.accounts.map((a: Entity.Account) => this.convertAccount(a))),
+ last_status: conversation.last_status ? await this.convertStatus(conversation.last_status, me) : null,
+ unread: conversation.unread,
+ };
+ }
+
+ public async convertNotification(notification: Entity.Notification, me: MiLocalUser | null): Promise<MastodonEntity.Notification | null> {
+ const status = notification.status
+ ? await this.convertStatus(notification.status, me).catch(() => null)
+ : null;
+
+ // We sometimes get notifications for inaccessible notes, these should be ignored.
+ if (!status) {
+ return null;
+ }
+
+ return {
+ account: await this.convertAccount(notification.account),
+ created_at: notification.created_at,
+ id: notification.id,
+ status,
+ type: convertNotificationType(notification.type as NotificationType),
+ };
+ }
+
+ public convertApplication(app: MisskeyEntity.App): MastodonEntity.Application {
+ return {
+ name: app.name,
+ scopes: app.permission,
+ redirect_uri: app.callbackUrl,
+ redirect_uris: [app.callbackUrl],
+ };
+ }
+}
+
+function simpleConvert<T>(data: T): T {
+ // copy the object to bypass weird pass by reference bugs
+ return Object.assign({}, data);
+}
+
+function convertNotificationType(type: NotificationType): MastodonNotificationType {
+ switch (type) {
+ case 'emoji_reaction': return 'reaction';
+ case 'poll_vote':
+ case 'poll_expired':
+ return 'poll';
+ // Not supported by mastodon
+ case 'move':
+ return type as MastodonNotificationType;
+ default: return type;
+ }
+}
+
+export function convertAnnouncement(announcement: Entity.Announcement): MastodonEntity.Announcement {
+ return {
+ ...announcement,
+ updated_at: announcement.updated_at ?? announcement.published_at,
+ };
+}
+
+export function convertAttachment(attachment: Entity.Attachment): MastodonEntity.Attachment {
+ const { width, height } = attachment.meta?.original ?? attachment.meta ?? {};
+ const size = (width && height) ? `${width}x${height}` : undefined;
+ const aspect = (width && height) ? (width / height) : undefined;
+ return {
+ ...attachment,
+ meta: attachment.meta ? {
+ ...attachment.meta,
+ original: {
+ ...attachment.meta.original,
+ width,
+ height,
+ size,
+ aspect,
+ frame_rate: String(attachment.meta.fps),
+ duration: attachment.meta.duration,
+ bitrate: attachment.meta.audio_bitrate ? parseInt(attachment.meta.audio_bitrate) : undefined,
+ },
+ width,
+ height,
+ size,
+ aspect,
+ } : null,
+ };
+}
+export function convertFilter(filter: Entity.Filter): MastodonEntity.Filter {
+ return simpleConvert(filter);
+}
+export function convertList(list: Entity.List): MastodonEntity.List {
+ return {
+ id: list.id,
+ title: list.title,
+ replies_policy: list.replies_policy ?? 'followed',
+ };
+}
+export function convertFeaturedTag(tag: Entity.FeaturedTag): MastodonEntity.FeaturedTag {
+ return simpleConvert(tag);
+}
+
+export function convertPoll(poll: Entity.Poll): MastodonEntity.Poll {
+ return simpleConvert(poll);
+}
+
+// Megalodon sometimes returns broken / stubbed relationship data
+export function convertRelationship(relationship: Partial<Entity.Relationship> & { id: string }): MastodonEntity.Relationship {
+ return {
+ id: relationship.id,
+ following: relationship.following ?? false,
+ showing_reblogs: relationship.showing_reblogs ?? true,
+ notifying: relationship.notifying ?? true,
+ languages: [],
+ followed_by: relationship.followed_by ?? false,
+ blocking: relationship.blocking ?? false,
+ blocked_by: relationship.blocked_by ?? false,
+ muting: relationship.muting ?? false,
+ muting_notifications: relationship.muting_notifications ?? false,
+ requested: relationship.requested ?? false,
+ requested_by: relationship.requested_by ?? false,
+ domain_blocking: relationship.domain_blocking ?? false,
+ endorsed: relationship.endorsed ?? false,
+ note: relationship.note ?? '',
+ };
+}