diff --git a/Dockerfile b/Dockerfile index 3528847454..acda90fe88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ ARG UID="991" ARG GID="991" ENV COREPACK_DEFAULT_TO_LATEST=0 -RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng \ +RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng librsvg font-noto font-noto-cjk font-noto-thai \ && corepack enable \ && addgroup -g "${GID}" sharkey \ && adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \ diff --git a/locales/index.d.ts b/locales/index.d.ts index 382cd5a114..4314b1c468 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12986,6 +12986,10 @@ export interface Locale extends ILocale { * Unable to process quote. This post may be missing context. */ "quoteUnavailable": string; + /** + * One or more media attachments are unavailable and cannot be shown. + */ + "attachmentFailed": string; }; /** * Authorized Fetch @@ -13289,6 +13293,10 @@ export interface Locale extends ILocale { * ActivityPub user data in its raw form. These fields are public and accessible to other instances. */ "rawApDescription": string; + /** + * Signup Reason + */ + "signupReason": string; } declare const locales: { [lang: string]: Locale; diff --git a/packages/backend/package.json b/packages/backend/package.json index 331cddae78..88244db346 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -90,7 +90,7 @@ "@simplewebauthn/server": "12.0.0", "@sinonjs/fake-timers": "11.3.1", "@smithy/node-http-handler": "2.5.0", - "mfm-js": "npm:@transfem-org/sfm-js@0.24.6", + "mfm-js": "npm:@transfem-org/sfm-js@0.24.8", "@twemoji/parser": "15.1.1", "accepts": "1.3.8", "ajv": "8.17.1", diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts index 9bca795479..307f22586e 100644 --- a/packages/backend/src/core/AbuseReportNotificationService.ts +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -82,6 +82,28 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { } } + /** + * Collects all email addresses that a abuse report should be sent to. + */ + @bindThis + public async getRecipientEMailAddresses(): Promise { + const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it + .filter(it => it.isActive && it.userProfile?.emailVerified) + .map(it => it.userProfile?.email) + .filter(x => x != null), + ); + + if (this.meta.email) { + recipientEMailAddresses.push(this.meta.email); + } + + if (this.meta.maintainerEmail) { + recipientEMailAddresses.push(this.meta.maintainerEmail); + } + + return recipientEMailAddresses; + } + /** * Mailを用いて{@link abuseReports}の内容を管理者各位に通知する. * メールアドレスの送信先は以下の通り. @@ -96,15 +118,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { return; } - const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it - .filter(it => it.isActive && it.userProfile?.emailVerified) - .map(it => it.userProfile?.email) - .filter(x => x != null), - ); - - recipientEMailAddresses.push( - ...(this.meta.email ? [this.meta.email] : []), - ); + const recipientEMailAddresses = await this.getRecipientEMailAddresses(); if (recipientEMailAddresses.length <= 0) { return; diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 1f15b16617..b9be4e3039 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -164,7 +164,7 @@ export class DriveService { try { await this.videoProcessingService.webOptimizeVideo(path, type); } catch (err) { - this.registerLogger.warn(`Video optimization failed: ${err instanceof Error ? err.message : String(err)}`, { error: err }); + this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`); } } @@ -367,7 +367,7 @@ export class DriveService { this.registerLogger.debug('web image not created (not an required image)'); } } catch (err) { - this.registerLogger.warn('web image not created (an error occurred)', err as Error); + this.registerLogger.warn(`web image not created: ${renderInlineError(err)}`); } } else { if (satisfyWebpublic) this.registerLogger.debug('web image not created (original satisfies webpublic)'); @@ -386,7 +386,7 @@ export class DriveService { thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422); } } catch (err) { - this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error); + this.registerLogger.warn(`Error creating thumbnail: ${renderInlineError(err)}`); } // #endregion thumbnail @@ -420,27 +420,21 @@ export class DriveService { ); if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read'; - if (this.bunnyService.usingBunnyCDN(this.meta)) { - await this.bunnyService.upload(this.meta, key, stream).catch( - err => { - this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); - }, - ); - } else { - await this.s3Service.upload(this.meta, params) - .then( - result => { - if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput - this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); - } else { // AbortMultipartUploadCommandOutput - this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); - } - }) - .catch( - err => { - this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); - }, - ); + try { + if (this.bunnyService.usingBunnyCDN(this.meta)) { + await this.bunnyService.upload(this.meta, key, stream); + } else { + const result = await this.s3Service.upload(this.meta, params); + if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput + this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); + } else { // AbortMultipartUploadCommandOutput + this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); + throw new Error('S3 upload aborted'); + } + } + } catch (err) { + this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}: ${renderInlineError(err)}`); + throw err; } } @@ -857,7 +851,7 @@ export class DriveService { } } catch (err: any) { if (err.name === 'NoSuchKey') { - this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error); + this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`); return; } else { throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, { diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 551b25394a..4f9f553e7e 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -75,9 +75,8 @@ export class MfmService { switch (node.tagName) { case 'br': { text += '\n'; - break; + return; } - case 'a': { const txt = getText(node); const rel = node.attribs.rel; @@ -123,9 +122,16 @@ export class MfmService { text += generateLink(); } - break; + return; } + } + // Don't produce invalid empty MFM + if (node.childNodes.length < 1) { + return; + } + + switch (node.tagName) { case 'h1': { text += '**【'; appendChildren(node.childNodes); @@ -329,6 +335,38 @@ export class MfmService { break; } + // Replace iframe with link so we can generate previews. + // We shouldn't normally see this, but federated blogging platforms (WordPress, MicroBlog.Pub) can send it. + case 'iframe': { + const txt: string | undefined = node.attribs.title || node.attribs.alt; + const href: string | undefined = node.attribs.src; + if (href) { + if (href.match(/[\s>]/)) { + if (txt) { + // href is invalid + has a label => render a pseudo-link + text += `${text} (${href})`; + } else { + // href is invalid + no label => render plain text + text += href; + } + } else { + if (txt) { + // href is valid + has a label => render a link + const label = txt + .replaceAll('[', '(') + .replaceAll(']', ')') + .replaceAll(/\r?\n/, ' ') + .replaceAll('`', '\''); + text += `[${label}](<${href}>)`; + } else { + // href is valid + no label => render a plain URL + text += `<${href}>`; + } + } + } + break; + } + default: // includes inline elements { appendChildren(node.childNodes); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 0dd0a9b822..f4159facc3 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -731,7 +731,7 @@ export class NoteCreateService implements OnApplicationShutdown { //#region AP deliver if (!data.localOnly && this.userEntityService.isLocalUser(user)) { trackTask(async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user); + const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote }); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); // メンションされたリモートユーザーに配送 @@ -874,17 +874,6 @@ export class NoteCreateService implements OnApplicationShutdown { this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1); } - @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) { - if (data.localOnly) return null; - - const content = this.isRenote(data) && !this.isQuote(data) - ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) - : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note); - - return this.apRendererService.addContext(content); - } - @bindThis private index(note: MiNote) { if (note.text == null && note.cw == null) return; diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 533ee7942d..4be097465d 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -675,7 +675,7 @@ export class NoteEditService implements OnApplicationShutdown { //#region AP deliver if (!data.localOnly && this.userEntityService.isLocalUser(user)) { trackTask(async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user); + const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote }); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); // メンションされたリモートユーザーに配送 @@ -770,17 +770,6 @@ export class NoteEditService implements OnApplicationShutdown { (note.files != null && note.files.length > 0); } - @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) { - if (data.localOnly) return null; - - const content = this.isRenote(data) && !this.isQuote(data) - ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) - : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user); - - return this.apRendererService.addContext(content); - } - @bindThis private index(note: MiNote) { if (note.text == null && note.cw == null) return; diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index f375dff862..ddadab7022 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Not, IsNull } from 'typeorm'; import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; +import { MiUser } from '@/models/User.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -17,9 +17,15 @@ import { RelationshipJobData } from '@/queue/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; import { CacheService } from '@/core/CacheService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; +import { trackPromise } from '@/misc/promise-tracker.js'; @Injectable() export class UserSuspendService { + private readonly logger: Logger; + constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -36,7 +42,10 @@ export class UserSuspendService { private apRendererService: ApRendererService, private moderationLogService: ModerationLogService, private readonly cacheService: CacheService, + + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('user-suspend'); } @bindThis @@ -47,16 +56,16 @@ export class UserSuspendService { isSuspended: true, }); - this.moderationLogService.log(moderator, 'suspend', { + await this.moderationLogService.log(moderator, 'suspend', { userId: user.id, userUsername: user.username, userHost: user.host, }); - (async () => { - await this.postSuspend(user).catch(e => {}); - await this.unFollowAll(user).catch(e => {}); - })(); + trackPromise((async () => { + await this.postSuspend(user); + await this.freezeAll(user); + })().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`))); } @bindThis @@ -65,33 +74,36 @@ export class UserSuspendService { isSuspended: false, }); - this.moderationLogService.log(moderator, 'unsuspend', { + await this.moderationLogService.log(moderator, 'unsuspend', { userId: user.id, userUsername: user.username, userHost: user.host, }); - (async () => { - await this.postUnsuspend(user).catch(e => {}); - })(); + trackPromise((async () => { + await this.postUnsuspend(user); + await this.unFreezeAll(user); + })().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`))); } @bindThis private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); + /* this.followRequestsRepository.delete({ followeeId: user.id, }); this.followRequestsRepository.delete({ followerId: user.id, }); + */ if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにDelete配信 const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - const queue: string[] = []; + const queue = new Map(); const followings = await this.followingsRepository.find({ where: [ @@ -104,12 +116,12 @@ export class UserSuspendService { const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + if (inbox != null) { + queue.set(inbox, true); + } } - for (const inbox of queue) { - this.queueService.deliver(user, content, inbox, true); - } + await this.queueService.deliverMany(user, content, queue); } } @@ -121,7 +133,7 @@ export class UserSuspendService { // 知り得る全SharedInboxにUndo Delete配信 const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); - const queue: string[] = []; + const queue = new Map(); const followings = await this.followingsRepository.find({ where: [ @@ -134,12 +146,12 @@ export class UserSuspendService { const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + if (inbox != null) { + queue.set(inbox, true); + } } - for (const inbox of queue) { - this.queueService.deliver(user as any, content, inbox, true); - } + await this.queueService.deliverMany(user, content, queue); } } @@ -160,4 +172,36 @@ export class UserSuspendService { } this.queueService.createUnfollowJob(jobs); } + + @bindThis + private async freezeAll(user: MiUser): Promise { + // Freeze follow relations with all remote users + await this.followingsRepository + .createQueryBuilder('following') + .orWhere({ + followeeId: user.id, + followerHost: Not(IsNull()), + }) + .update({ + isFollowerHibernated: true, + }) + .execute(); + } + + @bindThis + private async unFreezeAll(user: MiUser): Promise { + // Restore follow relations with all remote users + await this.followingsRepository + .createQueryBuilder('following') + .innerJoin(MiUser, 'follower', 'user.id = following.followerId') + .andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen + .andWhere({ + followeeId: user.id, + followerHost: Not(IsNull()), + }) + .update({ + isFollowerHibernated: false, + }) + .execute(); + } } diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index aee16a74bb..6ae1f689ba 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -13,6 +13,7 @@ import { type WebhookEventTypes } from '@/models/Webhook.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js'; import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; const oneDayMillis = 24 * 60 * 60 * 1000; @@ -166,6 +167,7 @@ export class WebhookTestService { private userWebhookService: UserWebhookService, private systemWebhookService: SystemWebhookService, private queueService: QueueService, + private readonly idService: IdService, ) { } @@ -451,6 +453,8 @@ export class WebhookTestService { offsetX: it.offsetX, offsetY: it.offsetY, })), + createdAt: this.idService.parse(user.id).date.toISOString(), + description: '', isBot: user.isBot, isCat: user.isCat, emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host), diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 08a8f30049..623e7002cd 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -32,6 +32,8 @@ import { IdService } from '@/core/IdService.js'; import { appendContentWarning } from '@/misc/append-content-warning.js'; import { QueryService } from '@/core/QueryService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; @@ -75,6 +77,7 @@ export class ApRendererService { private idService: IdService, private readonly queryService: QueryService, private utilityService: UtilityService, + private readonly cacheService: CacheService, ) { } @@ -232,7 +235,7 @@ export class ApRendererService { */ @bindThis public async renderFollowUser(id: MiUser['id']): Promise { - const user = await this.usersRepository.findOneByOrFail({ id: id }) as MiPartialLocalUser | MiPartialRemoteUser; + const user = await this.cacheService.findUserById(id) as MiPartialLocalUser | MiPartialRemoteUser; return this.userEntityService.getUserUri(user); } @@ -402,7 +405,7 @@ export class ApRendererService { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); if (inReplyToNote != null) { - const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); + const inReplyToUser = await this.cacheService.findUserById(inReplyToNote.userId); if (inReplyToUser) { if (inReplyToNote.uri) { @@ -422,7 +425,7 @@ export class ApRendererService { let quote: string | undefined = undefined; - if (note.renoteId) { + if (isRenote(note) && isQuote(note)) { const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); if (renote) { @@ -542,6 +545,7 @@ export class ApRendererService { attributedTo, summary: summary ?? undefined, content: content ?? undefined, + updated: note.updatedAt?.toISOString() ?? undefined, _misskey_content: text, source: { content: text, @@ -756,176 +760,6 @@ export class ApRendererService { }; } - @bindThis - public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise { - const getPromisedFiles = async (ids: string[]): Promise => { - if (ids.length === 0) return []; - const items = await this.driveFilesRepository.findBy({ id: In(ids) }); - return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null); - }; - - let inReplyTo; - let inReplyToNote: MiNote | null; - - if (note.replyId) { - inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); - - if (inReplyToNote != null) { - const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - - if (inReplyToUser) { - if (inReplyToNote.uri) { - inReplyTo = inReplyToNote.uri; - } else { - if (dive) { - inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false); - } else { - inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; - } - } - } - } - } else { - inReplyTo = null; - } - - let quote: string | undefined = undefined; - - if (note.renoteId) { - const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); - - if (renote) { - quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; - } - } - - const attributedTo = this.userEntityService.genLocalUserUri(note.userId); - - const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : []; - - let to: string[] = []; - let cc: string[] = []; - - if (note.visibility === 'public') { - to = ['https://www.w3.org/ns/activitystreams#Public']; - cc = [`${attributedTo}/followers`].concat(mentions); - } else if (note.visibility === 'home') { - to = [`${attributedTo}/followers`]; - cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); - } else if (note.visibility === 'followers') { - to = [`${attributedTo}/followers`]; - cc = mentions; - } else { - to = mentions; - } - - const mentionedUsers = note.mentions && note.mentions.length > 0 ? await this.usersRepository.findBy({ - id: In(note.mentions), - }) : []; - - const hashtagTags = note.tags.map(tag => this.renderHashtag(tag)); - const mentionTags = mentionedUsers.map(u => this.renderMention(u as MiLocalUser | MiRemoteUser)); - - const files = await getPromisedFiles(note.fileIds); - - const text = note.text ?? ''; - let poll: MiPoll | null = null; - - if (note.hasPoll) { - poll = await this.pollsRepository.findOneBy({ noteId: note.id }); - } - - const apAppend: Appender[] = []; - - if (quote) { - // Append quote link as `

RE: ...` - // the claas name `quote-inline` is used in non-misskey clients for styling quote notes. - // For compatibility, the span part should be kept as possible. - apAppend.push((doc, body) => { - body.childNodes.push(new Element('br', {})); - body.childNodes.push(new Element('br', {})); - const span = new Element('span', { - class: 'quote-inline', - }); - span.childNodes.push(new Text('RE: ')); - const link = new Element('a', { - href: quote, - }); - link.childNodes.push(new Text(quote)); - span.childNodes.push(link); - body.childNodes.push(span); - }); - } - - let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - - // Apply mandatory CW, if applicable - if (author.mandatoryCW) { - summary = appendContentWarning(summary, author.mandatoryCW); - } - - const { content } = this.apMfmService.getNoteHtml(note, apAppend); - - const emojis = await this.getEmojis(note.emojis); - const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); - - const tag: IObject[] = [ - ...hashtagTags, - ...mentionTags, - ...apemojis, - ]; - - // https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md - if (quote) { - tag.push({ - type: 'Link', - mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', - rel: 'https://misskey-hub.net/ns#_misskey_quote', - href: quote, - } satisfies ILink); - } - - const asPoll = poll ? { - type: 'Question', - [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, - [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ - type: 'Note', - name: text, - replies: { - type: 'Collection', - totalItems: poll!.votes[i], - }, - })), - } as const : {}; - - return { - id: `${this.config.url}/notes/${note.id}`, - type: 'Note', - attributedTo, - summary: summary ?? undefined, - content: content ?? undefined, - updated: note.updatedAt?.toISOString(), - _misskey_content: text, - source: { - content: text, - mediaType: 'text/x.misskeymarkdown', - }, - _misskey_quote: quote, - quoteUrl: quote, - quoteUri: quote, - // https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md - quote: quote, - published: this.idService.parse(note.id).date.toISOString(), - to, - cc, - inReplyTo, - attachment: files.map(x => this.renderDocument(x)), - sensitive: note.cw != null || files.some(file => file.isSensitive), - tag, - ...asPoll, - }; - } - @bindThis public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate { return { @@ -1079,6 +913,27 @@ export class ApRendererService { }; } + @bindThis + public async renderNoteOrRenoteActivity(note: MiNote, user: MiUser, hint?: { renote?: MiNote | null }) { + if (note.localOnly) return null; + + if (isPureRenote(note)) { + const renote = hint?.renote ?? note.renote ?? await this.notesRepository.findOneByOrFail({ id: note.renoteId }); + const apAnnounce = this.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note); + return this.addContext(apAnnounce); + } + + const apNote = await this.renderNote(note, user, false); + + if (note.updatedAt != null) { + const apUpdate = this.renderUpdate(apNote, user); + return this.addContext(apUpdate); + } else { + const apCreate = this.renderCreate(apNote, note); + return this.addContext(apCreate); + } + } + @bindThis private async getEmojis(names: string[]): Promise { if (names.length === 0) return []; diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 201920612c..d53e265d36 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -21,6 +21,8 @@ import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { toArray } from '@/misc/prelude/array.js'; +import { isPureRenote } from '@/misc/is-renote.js'; +import { CacheService } from '@/core/CacheService.js'; import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; @@ -49,6 +51,7 @@ export class Resolver { private loggerService: LoggerService, private readonly apLogService: ApLogService, private readonly apUtilityService: ApUtilityService, + private readonly cacheService: CacheService, private recursionLimit = 256, ) { this.history = new Set(); @@ -355,18 +358,20 @@ export class Resolver { switch (parsed.type) { case 'notes': - return this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() }) + return this.notesRepository.findOneOrFail({ where: { id: parsed.id, userHost: IsNull() }, relations: { user: true, renote: true } }) .then(async note => { - const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); + const author = note.user ?? await this.cacheService.findUserById(note.userId); if (parsed.rest === 'activity') { - // this refers to the create activity and not the note itself - return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note)); + return await this.apRendererService.renderNoteOrRenoteActivity(note, author); + } else if (!isPureRenote(note)) { + const apNote = await this.apRendererService.renderNote(note, author); + return this.apRendererService.addContext(apNote); } else { - return this.apRendererService.renderNote(note, author); + throw new IdentifiableError('732c2633-3395-4d51-a9b7-c7084774e3e7', `Failed to resolve local ${url}: cannot resolve a boost as note`); } }) as Promise; case 'users': - return this.usersRepository.findOneByOrFail({ id: parsed.id, host: IsNull() }) + return this.cacheService.findLocalUserById(parsed.id) .then(user => this.apRendererService.renderPerson(user as MiLocalUser)); case 'questions': // Polls are indexed by the note they are attached to. @@ -387,14 +392,8 @@ export class Resolver { .then(async followRequest => { if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `failed to resolve local ${url}: invalid follow request ID`); const [follower, followee] = await Promise.all([ - this.usersRepository.findOneBy({ - id: followRequest.followerId, - host: IsNull(), - }), - this.usersRepository.findOneBy({ - id: followRequest.followeeId, - host: Not(IsNull()), - }), + this.cacheService.findLocalUserById(followRequest.followerId), + this.cacheService.findLocalUserById(followRequest.followeeId), ]); if (follower == null || followee == null) { throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `failed to resolve local ${url}: follower or followee does not exist`); @@ -440,6 +439,7 @@ export class ApResolverService { private loggerService: LoggerService, private readonly apLogService: ApLogService, private readonly apUtilityService: ApUtilityService, + private readonly cacheService: CacheService, ) { } @@ -465,6 +465,7 @@ export class ApResolverService { this.loggerService, this.apLogService, this.apUtilityService, + this.cacheService, opts?.recursionLimit, ); } diff --git a/packages/backend/src/core/activitypub/misc/extract-media-from-html.ts b/packages/backend/src/core/activitypub/misc/extract-media-from-html.ts new file mode 100644 index 0000000000..3816479fd3 --- /dev/null +++ b/packages/backend/src/core/activitypub/misc/extract-media-from-html.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { load as cheerio } from 'cheerio/slim'; +import type { IApDocument } from '@/core/activitypub/type.js'; +import type { CheerioAPI } from 'cheerio/slim'; + +/** + * Finds HTML elements representing inline media and returns them as simulated AP documents. + * Returns an empty array if the input cannot be parsed, or no media was found. + * @param html Input HTML to analyze. + */ +export function extractMediaFromHtml(html: string): IApDocument[] { + const $ = parseHtml(html); + if (!$) return []; + + const attachments = new Map(); + + // tags, including and fallback elements + // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img + $('img[src]') + .toArray() + .forEach(img => attachments.set(img.attribs.src, { + type: 'Image', + url: img.attribs.src, + name: img.attribs.alt || img.attribs.title || null, + })); + + // tags + // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/object + $('object[data]') + .toArray() + .forEach(object => attachments.set(object.attribs.data, { + type: 'Document', + url: object.attribs.data, + name: object.attribs.alt || object.attribs.title || null, + })); + + // tags + // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/embed + $('embed[src]') + .toArray() + .forEach(embed => attachments.set(embed.attribs.src, { + type: 'Document', + url: embed.attribs.src, + name: embed.attribs.alt || embed.attribs.title || null, + })); + + //