diff --git a/locales/index.d.ts b/locales/index.d.ts index a22a8e893e..da0f7a963f 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12954,6 +12954,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 diff --git a/packages/backend/package.json b/packages/backend/package.json index 5ec6ededba..f853068e8f 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/MfmService.ts b/packages/backend/src/core/MfmService.ts index 839cdf534c..4f9f553e7e 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -335,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/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, + })); + + //