diff options
| author | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
|---|---|---|
| committer | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
| commit | 6b554c178b81f13f83a69b19d44b72b282a0c119 (patch) | |
| tree | f5537f1a56323a4dd57ba150b3cb84a2d8b5dc63 /packages/backend/src/server/api/mastodon/MastodonConverters.ts | |
| parent | merge: Security fixes (!970) (diff) | |
| parent | bump version for release (diff) | |
| download | sharkey-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.ts | 470 |
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, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/`/g, '`') + .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 ?? '', + }; +} |