refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '@fluxer/api/src/unfurler/resolvers/media/MediaMetadataHelpers';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
export class AudioResolver extends BaseResolver {
match(_url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('audio/');
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: Buffer.from(content).toString('base64'),
isNSFWAllowed,
});
return [
{
type: 'audio',
url: url.href,
audio: buildEmbedMediaPayload(url.href, metadata),
},
];
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import {Logger} from '@fluxer/api/src/Logger';
import {buildEmbedMediaPayload} from '@fluxer/api/src/unfurler/resolvers/media/MediaMetadataHelpers';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {URLType} from '@fluxer/schema/src/primitives/UrlValidators';
export abstract class BaseResolver {
constructor(protected mediaService: IMediaService) {}
abstract match(url: URL, mimeType: string, content: Uint8Array): boolean;
abstract resolve(url: URL, content: Uint8Array, isNSFWAllowed?: boolean): Promise<Array<MessageEmbedResponse>>;
transformUrl(_url: URL): URL | null {
return null;
}
protected resolveRelativeURL(baseUrl: string, relativeUrl?: string): string | null {
if (!relativeUrl) {
return null;
}
try {
return new URL(relativeUrl, baseUrl).href;
} catch (error) {
Logger.error({error}, 'Failed to resolve relative URL');
return relativeUrl;
}
}
protected async resolveMediaURL(
url: URL,
mediaUrl?: string | null,
isNSFWAllowed: boolean = false,
): Promise<MessageEmbedResponse['image']> {
if (!mediaUrl) {
return null;
}
const resolvedUrl = this.resolveRelativeURL(url.href, mediaUrl);
if (resolvedUrl && URLType.safeParse(resolvedUrl).success) {
try {
const metadata = await this.mediaService.getMetadata({
type: 'external',
url: resolvedUrl,
isNSFWAllowed,
});
return buildEmbedMediaPayload(resolvedUrl, metadata);
} catch (error) {
Logger.error({error}, 'Failed to resolve media URL metadata');
return null;
}
}
return null;
}
}

View File

@@ -0,0 +1,321 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import {Logger} from '@fluxer/api/src/Logger';
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {BlueskyApiClient} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyApiClient';
import {BlueskyEmbedProcessor} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyEmbedProcessor';
import {BlueskyTextFormatter} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyTextFormatter';
import type {
BlueskyAuthor,
BlueskyProcessedEmbeddedPost,
BlueskyProcessedExternalEmbed,
BlueskyProcessedPostEmbed,
} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyTypes';
import {sanitizeOptionalAbsoluteUrl} from '@fluxer/api/src/utils/UrlSanitizer';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
import type {EmbedFieldResponse, MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
interface BlueskyEmbedBuildInput {
postUrl: string;
postDescription: string;
createdAt: string;
author: BlueskyAuthor;
processedEmbed?: BlueskyProcessedPostEmbed;
fields?: Array<EmbedFieldResponse>;
children?: Array<MessageEmbedResponse>;
isNested?: boolean;
}
export class BlueskyResolver extends BaseResolver {
private static readonly BLUESKY_COLOR = 0x1185fe;
private static readonly BLUESKY_ICON = 'https://bsky.app/static/apple-touch-icon.png';
private static readonly PATH_SEPARATOR = '/';
private apiClient: BlueskyApiClient;
private textFormatter: BlueskyTextFormatter;
private embedProcessor: BlueskyEmbedProcessor;
constructor(cacheService: ICacheService, mediaService: IMediaService) {
super(mediaService);
this.apiClient = new BlueskyApiClient(cacheService);
this.textFormatter = new BlueskyTextFormatter();
this.embedProcessor = new BlueskyEmbedProcessor(mediaService, this.apiClient);
}
static formatCount(count: number): string {
if (count < 1000) {
return count.toString();
}
if (count < 10000) {
const thousands = count / 1000;
return `${thousands.toFixed(1)}K`;
}
const thousands = Math.floor(count / 1000);
return `${thousands}K`;
}
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
const isMatch = url.hostname === 'bsky.app' && mimeType.startsWith('text/html');
Logger.debug({url: url.toString(), mimeType, isMatch}, 'BlueskyResolver match check');
return isMatch;
}
async resolve(url: URL, _content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
try {
Logger.debug({url: url.toString()}, 'Starting URL resolution');
if (this.isPostUrl(url)) {
return await this.resolvePost(url, isNSFWAllowed);
}
if (this.isProfileUrl(url)) {
Logger.debug({url: url.toString()}, 'Resolving profile URL');
const handle = this.parsePathParts(url)[1];
const profile = await this.apiClient.fetchProfile(handle);
if (!profile) return [];
const embed: MessageEmbedResponse = {
type: 'rich',
url: url.href,
title: profile.displayName ? `${profile.displayName} (@${profile.handle})` : `@${profile.handle}`,
description: profile.description,
color: BlueskyResolver.BLUESKY_COLOR,
footer: {text: 'Bluesky', icon_url: BlueskyResolver.BLUESKY_ICON},
};
return [embed];
}
Logger.debug({url: url.toString()}, 'URL does not match any supported patterns');
return [];
} catch (error) {
Logger.error({error, url: url.toString()}, 'Failed to resolve URL');
return [];
}
}
private async resolvePost(url: URL, isNSFWAllowed: boolean): Promise<Array<MessageEmbedResponse>> {
Logger.debug({url: url.toString()}, 'Resolving post URL');
const atUri = await this.getAtUri(url);
if (!atUri) return [];
const thread = await this.apiClient.fetchPost(atUri);
if (!thread) return [];
const {post} = thread.thread;
const [processedEmbed, processedEmbeddedPost] = await Promise.all([
this.embedProcessor.processPostEmbed(post, isNSFWAllowed),
this.embedProcessor.processEmbeddedPost(post, isNSFWAllowed),
]);
const rootDescription = this.textFormatter.formatPostContent(post, thread);
const childEmbed = this.buildEmbeddedPostEmbed(processedEmbeddedPost);
const rootEmbed = this.buildBlueskyEmbed({
postUrl: url.href,
postDescription: rootDescription,
createdAt: post.record.createdAt,
author: post.author,
processedEmbed,
fields: this.buildEngagementFields(post),
children: childEmbed ? [childEmbed] : undefined,
});
Logger.debug(
{
url: url.toString(),
embedType: post.embed?.$type,
hasImage: !!processedEmbed.image,
hasThumbnail: !!processedEmbed.thumbnail,
hasVideo: !!processedEmbed.video,
hasImageAltText: !!processedEmbed.image?.description,
hasNestedEmbed: !!childEmbed,
isReply: !!post.record.reply,
replyCount: post.replyCount,
repostCount: post.repostCount,
likeCount: post.likeCount,
quoteCount: post.quoteCount,
bookmarkCount: post.bookmarkCount ?? 0,
},
'Processed post embeds',
);
const galleryEmbeds =
processedEmbed.galleryImages?.map((galleryImage) => ({
type: 'rich',
url: url.href,
image: galleryImage,
})) ?? [];
return [rootEmbed, ...galleryEmbeds];
}
private buildEmbeddedPostEmbed(
processedEmbeddedPost?: BlueskyProcessedEmbeddedPost,
): MessageEmbedResponse | undefined {
if (!processedEmbeddedPost) {
return;
}
const postUrl = this.atUriToPostUrl(processedEmbeddedPost.uri, processedEmbeddedPost.author.handle);
const formattedText = this.textFormatter.embedLinksInText(processedEmbeddedPost.text, processedEmbeddedPost.facets);
return this.buildBlueskyEmbed({
postUrl,
postDescription: formattedText,
createdAt: processedEmbeddedPost.createdAt,
author: processedEmbeddedPost.author,
processedEmbed: processedEmbeddedPost.embed,
isNested: true,
});
}
private buildBlueskyEmbed(input: BlueskyEmbedBuildInput): MessageEmbedResponse {
const externalSummary = this.buildExternalSummary(input.processedEmbed?.external);
const description = externalSummary
? this.appendSection(input.postDescription, externalSummary)
: input.postDescription;
const embed: MessageEmbedResponse = {
type: MessageEmbedTypes.BLUESKY,
url: input.postUrl,
description,
color: BlueskyResolver.BLUESKY_COLOR,
author: {
name: `${input.author.displayName || input.author.handle} (@${input.author.handle})`,
url: input.postUrl,
...this.buildOptionalIconUrl(input.author.avatar),
},
...(input.processedEmbed?.image ? {image: input.processedEmbed.image} : {}),
...(input.processedEmbed?.video
? {
thumbnail: input.processedEmbed.thumbnail,
video: input.processedEmbed.video,
}
: {}),
...(!input.processedEmbed?.video && !input.processedEmbed?.image && input.processedEmbed?.thumbnail
? {thumbnail: input.processedEmbed.thumbnail}
: {}),
children: input.children,
};
if (input.isNested) {
return embed;
}
embed.title = input.processedEmbed?.external?.title;
embed.timestamp = new Date(input.createdAt).toISOString();
embed.fields = input.fields ?? [];
embed.footer = {text: 'Bluesky', icon_url: BlueskyResolver.BLUESKY_ICON};
return embed;
}
private buildOptionalIconUrl(iconUrl: string | undefined): {icon_url?: string} {
const sanitizedIconUrl = sanitizeOptionalAbsoluteUrl(iconUrl);
if (!sanitizedIconUrl) {
return {};
}
return {icon_url: sanitizedIconUrl};
}
private buildEngagementFields(post: {
repostCount?: number;
quoteCount?: number;
likeCount?: number;
bookmarkCount?: number;
}): Array<EmbedFieldResponse> {
const engagementFields: Array<{name: string; count?: number}> = [
{name: 'repostCount', count: post.repostCount},
{name: 'quoteCount', count: post.quoteCount},
{name: 'likeCount', count: post.likeCount},
{name: 'bookmarkCount', count: post.bookmarkCount},
];
return engagementFields
.filter((field): field is {name: string; count: number} => typeof field.count === 'number' && field.count > 0)
.map((field) => ({name: field.name, value: BlueskyResolver.formatCount(field.count), inline: true}));
}
private buildExternalSummary(external?: BlueskyProcessedExternalEmbed): string | undefined {
if (!external) {
return;
}
const title = external.title || external.uri;
const titleLine = `[${title}](${external.uri})`;
if (external.description) {
return `${titleLine}\n${external.description}`;
}
return titleLine;
}
private appendSection(base: string, section: string): string {
if (!base) {
return section;
}
return `${base}\n\n${section}`;
}
private atUriToPostUrl(atUri: string, fallbackHandle: string): string {
const parts = atUri.replace('at://', '').split('/');
const postId = parts[2];
if (!postId) {
return `https://bsky.app/profile/${fallbackHandle}`;
}
return `https://bsky.app/profile/${fallbackHandle}/post/${postId}`;
}
private parsePathParts(url: URL): Array<string> {
return url.pathname.replace(/^\/+|\/+$/g, '').split(BlueskyResolver.PATH_SEPARATOR);
}
private isProfileUrl(url: URL): boolean {
const parts = this.parsePathParts(url);
const isProfile = parts.length === 2 && parts[0] === 'profile';
Logger.debug({url: url.toString(), parts, isProfile}, 'Profile URL check');
return isProfile;
}
private isPostUrl(url: URL): boolean {
const parts = this.parsePathParts(url);
const isPost = parts.length === 4 && parts[0] === 'profile' && parts[2] === 'post' && parts[3].length > 0;
Logger.debug({url: url.toString(), parts, isPost}, 'Post URL check');
return isPost;
}
private async getAtUri(url: URL): Promise<string | null> {
const parts = this.parsePathParts(url);
if (parts.length !== 4) throw new Error('Invalid URL format for AT URI conversion');
const handle = parts[1];
const postId = parts[3];
const did = await this.apiClient.resolveDid(handle);
if (!did) return null;
const atUri = `at://${did}/app.bsky.feed.post/${postId}`;
Logger.debug({url: url.toString(), handle, did, postId, atUri}, 'Generated AT URI');
return atUri;
}
}

View File

@@ -0,0 +1,449 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import {Logger} from '@fluxer/api/src/Logger';
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {ActivityPubResolver} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubResolver';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import {parseString} from '@fluxer/api/src/utils/StringUtils';
import {sanitizeOptionalAbsoluteUrl} from '@fluxer/api/src/utils/UrlSanitizer';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {selectAll, selectOne} from 'css-select';
import type {Document, Element, Text} from 'domhandler';
import {parseDocument} from 'htmlparser2';
interface OEmbedResponse {
provider_name?: string;
provider_url?: string;
author_name?: string;
author_url?: string;
}
export interface DefaultResolverRequestContext {
requestUrl: URL;
finalUrl: URL;
wasRedirected: boolean;
}
const COLOR_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
export class DefaultResolver extends BaseResolver {
private activityPubResolver: ActivityPubResolver;
private static readonly MAX_GALLERY_IMAGES = 10;
private static readonly MAX_CANONICAL_ACTIVITY_PUB_REDIRECTS = 1;
constructor(
private cacheService: ICacheService,
mediaService: IMediaService,
) {
super(mediaService);
this.activityPubResolver = new ActivityPubResolver(this.cacheService, mediaService);
}
match(_url: URL, mimeType: string, _content: Uint8Array): boolean {
Logger.debug({mimeType}, 'Checking if content type matches HTML');
const matches = mimeType.startsWith('text/html');
Logger.debug({matches}, 'Content type match result');
return matches;
}
async resolve(
url: URL,
content: Uint8Array,
isNSFWAllowed: boolean = false,
requestContext?: DefaultResolverRequestContext,
): Promise<Array<MessageEmbedResponse>> {
Logger.debug({url: url.href}, 'Starting HTML resolution');
let document: Document;
let htmlString: string;
try {
htmlString = Buffer.from(content).toString('utf-8');
document = parseDocument(htmlString);
Logger.debug('Successfully parsed HTML document');
} catch (error) {
Logger.error({error}, 'Failed to parse HTML document');
throw error;
}
const activityPubLink = this.findActivityPubLink(document);
let activityPubUrl: string | null = null;
if (activityPubLink) {
activityPubUrl = activityPubLink.startsWith('http')
? activityPubLink
: new URL(activityPubLink, url.origin).toString();
Logger.debug({activityPubUrl}, 'Found ActivityPub link in HTML');
try {
const activityPubEmbeds = await this.activityPubResolver.resolveActivityPub(url, activityPubUrl, htmlString);
if (activityPubEmbeds && activityPubEmbeds.length > 0) {
Logger.debug({url: url.href}, 'Resolved as ActivityPub');
return activityPubEmbeds;
}
} catch (error) {
Logger.error({error, url: url.href}, 'Failed to resolve as ActivityPub');
}
}
if (requestContext?.wasRedirected) {
const canonicalUrl = this.findCanonicalUrl(document, url);
if (canonicalUrl && !this.areUrlsEquivalent(canonicalUrl, url.href)) {
try {
const canonicalUrlObject = new URL(canonicalUrl);
const canonicalActivityPubEmbeds = await this.activityPubResolver.resolveActivityPub(
canonicalUrlObject,
canonicalUrl,
htmlString,
{
maxActivityPubRedirects: DefaultResolver.MAX_CANONICAL_ACTIVITY_PUB_REDIRECTS,
preferActivityPubJson: true,
skipMastodonFallback: true,
},
);
if (canonicalActivityPubEmbeds && canonicalActivityPubEmbeds.length > 0) {
Logger.debug(
{
requestUrl: requestContext.requestUrl.href,
finalUrl: requestContext.finalUrl.href,
canonicalUrl,
},
'Resolved ActivityPub via redirect canonical URL',
);
return canonicalActivityPubEmbeds;
}
} catch (error) {
Logger.error(
{
error,
requestUrl: requestContext.requestUrl.href,
finalUrl: requestContext.finalUrl.href,
canonicalUrl,
},
'Failed to resolve ActivityPub via redirect canonical URL',
);
}
}
}
const title = this.extractTitle(document);
Logger.debug({title}, 'Extracted title');
const description = this.extractDescription(document);
Logger.debug({description}, 'Extracted description');
const rawColor = this.extractMetaField(document, 'theme-color');
const color = this.extractColor(rawColor);
Logger.debug({rawColor, color}, 'Extracted and parsed color');
const oEmbedData = await this.fetchOEmbedData(url, document);
Logger.debug({oEmbedData}, 'Fetched oEmbed data');
const oEmbedAuthorURL = sanitizeOptionalAbsoluteUrl(oEmbedData.authorURL);
const oEmbedProviderURL = sanitizeOptionalAbsoluteUrl(oEmbedData.providerURL) ?? url.origin;
const siteName = oEmbedData.providerName ?? this.extractSiteName(document);
Logger.debug({siteName}, 'Determined site name');
const imageUrls = this.extractImageURLs(document);
Logger.debug({imageUrls}, 'Extracted image URLs');
const resolvedImages: Array<MessageEmbedResponse['image']> = [];
for (const imageUrl of imageUrls) {
if (resolvedImages.length >= DefaultResolver.MAX_GALLERY_IMAGES) break;
const media = await this.resolveMediaURL(url, imageUrl, isNSFWAllowed);
if (media) {
resolvedImages.push(media);
}
}
const imageMedia = resolvedImages.shift();
if (imageMedia) {
const imageDescription =
this.extractMetaField(document, 'og:image:alt') ??
this.extractMetaField(document, 'twitter:image:alt') ??
this.extractMetaField(document, 'og:image:description');
if (imageDescription) {
imageMedia.description = parseString(imageDescription, 4096);
Logger.debug({imageDescription: imageMedia.description}, 'Applied description to image media');
}
}
Logger.debug({imageMedia}, 'Resolved image media');
const videoUrl = this.extractMediaURL(document, 'video');
const videoMedia = await this.resolveMediaURL(url, videoUrl, isNSFWAllowed);
Logger.debug({videoUrl, videoMedia}, 'Resolved video media');
const audioUrl = this.extractMediaURL(document, 'audio');
const audioMedia = await this.resolveMediaURL(url, audioUrl, isNSFWAllowed);
Logger.debug({audioUrl, audioMedia}, 'Resolved audio media');
const embed: MessageEmbedResponse = {
type: 'link',
url: url.href,
...(title && {title: parseString(title, 70)}),
...(description && {description: parseString(description, 350)}),
...(color !== undefined && {color}),
...(oEmbedData.authorName && {
author: {
name: parseString(oEmbedData.authorName, 256),
...(oEmbedAuthorURL ? {url: oEmbedAuthorURL} : {}),
},
}),
...(siteName && {provider: {name: parseString(siteName, 256), url: oEmbedProviderURL}}),
...(imageMedia && {thumbnail: imageMedia}),
...(videoMedia && {video: videoMedia}),
...(audioMedia && {audio: audioMedia}),
};
const extraImageEmbeds = resolvedImages.map((image) => ({
type: 'rich' as const,
url: url.href,
image,
}));
Logger.debug({embed, galleryImages: extraImageEmbeds.length}, 'Successfully created link embed');
return [embed, ...extraImageEmbeds];
}
private findActivityPubLink(document: Document): string | null {
const linkElement = selectOne(
'link[rel="alternate"][type="application/activity+json"]',
document,
) as Element | null;
return linkElement?.attribs['href'] || null;
}
private findCanonicalUrl(document: Document, url: URL): string | null {
const canonicalLinkElement = selectOne('link[rel="canonical"]', document) as Element | null;
const canonicalHref = canonicalLinkElement?.attribs['href'];
if (!canonicalHref) {
return null;
}
try {
return new URL(canonicalHref, url.href).toString();
} catch (error) {
Logger.debug({error, canonicalHref, url: url.href}, 'Failed to parse canonical URL');
return null;
}
}
private areUrlsEquivalent(leftUrl: string, rightUrl: string): boolean {
const normalizedLeftUrl = this.normalizeUrl(leftUrl);
const normalizedRightUrl = this.normalizeUrl(rightUrl);
return normalizedLeftUrl === normalizedRightUrl;
}
private extractMetaField(document: Document, property: string, attribute = 'content'): string | undefined {
Logger.debug({property, attribute}, 'Extracting meta field');
const values = this.extractMetaFieldValues(document, property, attribute);
return values.length > 0 ? values[values.length - 1] : undefined;
}
private extractMetaFieldValues(document: Document, property: string, attribute = 'content'): Array<string> {
const selectors = [
`meta[property="${property}"]`,
`meta[name="${property}"]`,
`meta[property="twitter:${property.replace('og:', '')}"]`,
`meta[name="twitter:${property.replace('og:', '')}"]`,
];
const values: Array<string> = [];
for (const selector of selectors) {
const nodes = selectAll(selector, document) as Array<Element | Document>;
const elements = nodes.flatMap((node) => (node && 'attribs' in (node as Element) ? [node as Element] : []));
for (const element of elements) {
if (element?.attribs[attribute]) {
Logger.debug({selector, value: element.attribs[attribute]}, 'Found meta value');
values.push(element.attribs[attribute]);
}
}
}
return values;
}
private extractTitle(document: Document): string | undefined {
const ogTitle = this.extractMetaField(document, 'og:title');
if (ogTitle) {
Logger.debug({ogTitle}, 'Found OpenGraph title');
return ogTitle;
}
const twitterTitle = this.extractMetaField(document, 'twitter:title');
if (twitterTitle) {
Logger.debug({twitterTitle}, 'Found Twitter title');
return twitterTitle;
}
const titleElement = selectOne('title', document) as Element | null;
if (titleElement?.children[0]) {
const titleText = (titleElement.children[0] as Text).data?.trim();
if (titleText) {
Logger.debug({titleText}, 'Found HTML title');
return titleText;
}
}
const metaTitle = this.extractMetaField(document, 'title');
if (metaTitle) {
Logger.debug({metaTitle}, 'Found meta title');
return metaTitle;
}
return;
}
private extractDescription(document: Document): string | undefined {
Logger.debug('Extracting description');
const description =
this.extractMetaField(document, 'og:description') ||
this.extractMetaField(document, 'description') ||
this.extractMetaField(document, 'twitter:description');
Logger.debug({description}, 'Found description');
return description;
}
private extractSiteName(document: Document): string | undefined {
Logger.debug('Extracting site name');
const siteName =
this.extractMetaField(document, 'og:site_name') ||
this.extractMetaField(document, 'twitter:site:name') ||
this.extractMetaField(document, 'application-name');
Logger.debug({siteName}, 'Found site name');
return siteName;
}
private extractImageURLs(document: Document): Array<string> {
Logger.debug('Extracting image URLs');
const properties = ['og:image', 'og:image:secure_url', 'twitter:image', 'twitter:image:src', 'image'];
const seen = new Set<string>();
const values: Array<string> = [];
for (const property of properties) {
const metaValues = this.extractMetaFieldValues(document, property);
for (const metaValue of metaValues) {
const normalized = this.normalizeUrl(metaValue);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
values.push(metaValue);
}
}
return values;
}
private normalizeUrl(value: string): string | null {
try {
return new URL(value).href.replace(/\/$/, '');
} catch (error) {
Logger.debug({error, value}, 'Failed to normalize URL');
return null;
}
}
private extractMediaURL(document: Document, type: 'video' | 'audio'): string | undefined {
Logger.debug({type}, 'Extracting media URL');
const mediaUrl =
this.extractMetaField(document, `og:${type}`) ||
this.extractMetaField(document, `og:${type}:url`) ||
this.extractMetaField(document, `og:${type}:secure_url`) ||
this.extractMetaField(document, `twitter:${type}`) ||
this.extractMetaField(document, `twitter:${type}:url`) ||
(type === 'video' ? this.extractMetaField(document, 'twitter:player') : undefined) ||
(type === 'video' ? this.extractMetaField(document, 'twitter:player:stream') : undefined);
Logger.debug({mediaUrl}, `Found ${type} URL`);
return mediaUrl;
}
private extractColor(color: string | undefined): number | undefined {
if (!color) return;
const normalizedColor = color.toLowerCase();
if (!COLOR_REGEX.test(normalizedColor)) {
Logger.debug({color}, 'Invalid color format');
return;
}
try {
const parsed = Number.parseInt(normalizedColor.slice(1), 16);
Logger.debug({color, parsed}, 'Successfully parsed color');
return parsed;
} catch (error) {
Logger.debug({error, color}, 'Failed to parse color');
return;
}
}
private async fetchOEmbedData(
url: URL,
document: Document,
): Promise<{
providerName?: string;
providerURL?: string;
authorName?: string;
authorURL?: string;
}> {
Logger.debug({url: url.href}, 'Attempting to fetch oEmbed data');
const oEmbedLink = selectOne('link[type="application/json+oembed"]', document) as Element | null;
if (!oEmbedLink?.attribs['href']) {
Logger.debug('No oEmbed link found');
return {};
}
const oEmbedUrl = this.resolveRelativeURL(url.href, oEmbedLink.attribs['href']);
if (!oEmbedUrl) {
Logger.debug('Could not resolve oEmbed URL');
return {};
}
try {
Logger.debug({url: oEmbedUrl}, 'Fetching oEmbed data');
const response = await FetchUtils.sendRequest({url: oEmbedUrl});
if (response.status !== 200) {
Logger.debug({status: response.status}, 'Failed to fetch oEmbed data');
return {};
}
const responseText = await FetchUtils.streamToString(response.stream);
const oEmbedJson = JSON.parse(responseText) as OEmbedResponse;
Logger.debug({oEmbedJson}, 'Successfully parsed oEmbed response');
return {
providerName: oEmbedJson.provider_name,
providerURL: oEmbedJson.provider_url,
authorName: oEmbedJson.author_name,
authorURL: oEmbedJson.author_url,
};
} catch (error) {
Logger.error({error, url: oEmbedUrl}, 'Failed to fetch oEmbed JSON');
return {};
}
}
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {htmlToMarkdown} from '@fluxer/api/src/utils/DOMUtils';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import {parseString} from '@fluxer/api/src/utils/StringUtils';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {ms} from 'itty-time';
interface HnItem {
id: number;
type: 'story' | 'comment' | 'job' | 'poll' | 'pollopt';
by?: string;
time: number;
text?: string;
dead?: boolean;
deleted?: boolean;
url?: string;
title?: string;
score?: number;
descendants?: number;
kids?: Array<number>;
parent?: number;
parts?: Array<number>;
poll?: number;
}
export class HackerNewsResolver extends BaseResolver {
private readonly API_BASE = 'https://hacker-news.firebaseio.com/v0';
private readonly SITE_BASE = 'https://news.ycombinator.com';
private readonly HN_COLOR = 0xff6600;
private readonly HN_ICON = 'https://fluxerstatic.com/embeds/icons/hn.webp';
private readonly MAX_DESCRIPTION_LENGTH = 400;
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
return (
url.hostname === 'news.ycombinator.com' && url.pathname.startsWith('/item') && mimeType.startsWith('text/html')
);
}
async resolve(url: URL, _content: Uint8Array, _isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
try {
const itemId = new URLSearchParams(url.search).get('id');
if (!itemId) return [];
const item = await this.fetchItem(itemId);
if (!item) return [];
if (item.deleted || item.dead) {
Logger.debug({itemId}, 'Skipping deleted or dead HN item');
return [];
}
const embed = this.buildEmbed(item);
return [embed];
} catch (error) {
Logger.error({error, url: url.toString()}, 'Failed to resolve Hacker News item');
return [];
}
}
private async fetchItem(itemId: string): Promise<HnItem | null> {
try {
const response = await FetchUtils.sendRequest({
url: `${this.API_BASE}/item/${itemId}.json`,
timeout: ms('5 seconds'),
});
if (response.status !== 200) {
Logger.debug({itemId, status: response.status}, 'Failed to fetch HN item');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
return JSON.parse(responseText) as HnItem;
} catch (error) {
Logger.error({error, itemId}, 'Failed to fetch or parse HN item');
return null;
}
}
private buildEmbed(item: HnItem): MessageEmbedResponse {
const embed: MessageEmbedResponse = {
type: 'rich',
url: this.getItemUrl(item.id),
color: this.HN_COLOR,
timestamp: this.formatTimestamp(item.time),
footer: {
text: 'Hacker News',
icon_url: this.HN_ICON,
},
};
if (this.hasTitle(item) && item.title) {
embed.title = parseString(item.title, 256);
}
if (item.by) {
embed.author = {
name: parseString(item.by, 256),
};
}
const description = this.buildDescription(item);
if (description) {
embed.description = description;
}
return embed;
}
private buildDescription(item: HnItem): string | undefined {
if (!item.text) return undefined;
const markdown = htmlToMarkdown(item.text);
const singleLine = markdown.replace(/\s+/g, ' ').trim();
if (!singleLine) return undefined;
return parseString(singleLine, this.MAX_DESCRIPTION_LENGTH);
}
private formatTimestamp(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toISOString();
}
private hasTitle(item: HnItem): boolean {
return item.type === 'story' || item.type === 'job' || item.type === 'poll';
}
private getItemUrl(id: number): string {
return `${this.SITE_BASE}/item?id=${id}`;
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '@fluxer/api/src/unfurler/resolvers/media/MediaMetadataHelpers';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
export class ImageResolver extends BaseResolver {
match(_url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('image/');
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: Buffer.from(content).toString('base64'),
isNSFWAllowed,
});
const embed: MessageEmbedResponse = {
type: 'image',
url: url.href,
thumbnail: buildEmbedMediaPayload(url.href, metadata),
};
return [embed];
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '@fluxer/api/src/unfurler/resolvers/media/MediaMetadataHelpers';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {URLType} from '@fluxer/schema/src/primitives/UrlValidators';
interface KlipyMediaFormat {
url?: string;
width?: number;
height?: number;
}
interface KlipyFileFormats {
gif?: KlipyMediaFormat;
webp?: KlipyMediaFormat;
mp4?: KlipyMediaFormat;
}
interface KlipyFile {
hd?: KlipyFileFormats;
md?: KlipyFileFormats;
sm?: KlipyFileFormats;
}
interface KlipyMedia {
uuid?: string;
slug?: string;
description?: string;
title?: string;
file?: KlipyFile;
type?: string;
}
export class KlipyResolver extends BaseResolver {
override transformUrl(url: URL): URL | null {
if (url.hostname !== 'klipy.com') {
return null;
}
const pathMatch = url.pathname.match(/^\/(gif|gifs|clip|clips)\/([^/]+)/);
if (!pathMatch) {
return null;
}
const [, type, slug] = pathMatch;
const normalizedType = type.startsWith('clip') ? 'clips' : 'gifs';
return new URL(`https://klipy.com/${normalizedType}/${slug}/player`);
}
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('text/html') && url.hostname === 'klipy.com';
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const playerContent = Buffer.from(content).toString('utf-8');
const media = this.extractMediaFromPlayerPage(playerContent);
if (!media) {
return [];
}
const {thumbnail: thumbnailFormat, video: videoFormat} = this.extractMediaFormats(media);
const thumbnail = thumbnailFormat ? await this.resolveKlipyMedia(url, thumbnailFormat, isNSFWAllowed) : undefined;
const video = videoFormat ? await this.resolveKlipyMedia(url, videoFormat, isNSFWAllowed) : undefined;
const embed: MessageEmbedResponse = {
type: 'gifv',
url: url.href,
provider: {name: 'KLIPY', url: 'https://klipy.com'},
thumbnail: thumbnail ?? undefined,
video: video ?? undefined,
};
return [embed];
}
private async resolveKlipyMedia(
baseUrl: URL,
format: KlipyMediaFormat,
isNSFWAllowed: boolean,
): Promise<MessageEmbedResponse['image']> {
if (!format.url) {
return null;
}
const resolvedUrl = this.resolveRelativeURL(baseUrl.href, format.url);
if (!resolvedUrl || !URLType.safeParse(resolvedUrl).success) {
return null;
}
try {
const metadata = await this.mediaService.getMetadata({
type: 'external',
url: resolvedUrl,
isNSFWAllowed,
});
return buildEmbedMediaPayload(resolvedUrl, metadata, {
width: format.width,
height: format.height,
});
} catch (error) {
Logger.error({error}, 'Failed to resolve Klipy media URL metadata');
return null;
}
}
private extractMediaFromPlayerPage(content: string): KlipyMedia | null {
const scriptMatches = content.matchAll(/self\.__next_f\.push\(\[1,"(.*?)"\]\)/gs);
for (const match of scriptMatches) {
if (!match[1]) {
continue;
}
const media = this.parseNextFlightData(match[1]);
if (media?.file) {
return media;
}
}
return null;
}
private parseNextFlightData(encodedData: string): KlipyMedia | null {
try {
const unescaped = JSON.parse(`"${encodedData}"`) as string;
const colonIndex = unescaped.indexOf(':');
if (colonIndex === -1) {
return null;
}
const jsonArrayStr = unescaped.slice(colonIndex + 1);
const flightArray = JSON.parse(jsonArrayStr) as Array<unknown>;
for (const item of flightArray) {
if (this.isMediaContainer(item)) {
return item.media;
}
}
return null;
} catch {
return null;
}
}
private isMediaContainer(item: unknown): item is {media: KlipyMedia} {
return (
typeof item === 'object' &&
item !== null &&
'media' in item &&
typeof (item as {media: unknown}).media === 'object' &&
(item as {media: {file?: unknown}}).media !== null &&
'file' in ((item as {media: {file?: unknown}}).media ?? {})
);
}
private extractMediaFormats(media: KlipyMedia): {thumbnail?: KlipyMediaFormat; video?: KlipyMediaFormat} {
const file = media.file;
return {
thumbnail: file?.hd?.webp,
video: file?.hd?.mp4,
};
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {selectOne} from 'css-select';
import type {Document, Element, Text} from 'domhandler';
import {parseDocument} from 'htmlparser2';
interface TenorJsonLd {
image?: {thumbnailUrl?: string};
video?: {contentUrl?: string};
}
export class TenorResolver extends BaseResolver {
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('text/html') && url.hostname === 'tenor.com';
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const document = parseDocument(Buffer.from(content).toString('utf-8'));
const jsonLdContent = this.extractJsonLdContent(document);
if (!jsonLdContent) {
return [];
}
const {thumbnailURL, videoURL} = this.extractURLsFromJsonLd(jsonLdContent);
const thumbnail = thumbnailURL ? await this.resolveMediaURL(url, thumbnailURL, isNSFWAllowed) : undefined;
const video = videoURL ? await this.resolveMediaURL(url, videoURL, isNSFWAllowed) : undefined;
const embed: MessageEmbedResponse = {
type: 'gifv',
url: url.href,
provider: {name: 'Tenor', url: 'https://tenor.com'},
thumbnail: thumbnail ?? undefined,
video: video ?? undefined,
};
return [embed];
}
private extractJsonLdContent(document: Document): TenorJsonLd | null {
const scriptElement = selectOne('script.dynamic[type="application/ld+json"]', document) as Element | null;
if (scriptElement && scriptElement.children.length > 0) {
const scriptContentNode = scriptElement.children[0] as Text;
const scriptContent = scriptContentNode.data;
try {
return JSON.parse(scriptContent) as TenorJsonLd;
} catch (error) {
Logger.error({error}, 'Failed to parse JSON-LD content');
}
}
return null;
}
private extractURLsFromJsonLd(jsonLdContent: TenorJsonLd): {thumbnailURL?: string; videoURL?: string} {
const thumbnailUrl = jsonLdContent.image?.thumbnailUrl;
const videoUrl = jsonLdContent.video?.contentUrl;
return {thumbnailURL: thumbnailUrl, videoURL: videoUrl};
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '@fluxer/api/src/unfurler/resolvers/media/MediaMetadataHelpers';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
export class VideoResolver extends BaseResolver {
match(_url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('video/');
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: Buffer.from(content).toString('base64'),
isNSFWAllowed,
});
const embed: MessageEmbedResponse = {
type: 'video',
url: url.href,
video: buildEmbedMediaPayload(url.href, metadata),
};
return [embed];
}
}

View File

@@ -0,0 +1,185 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '@fluxer/api/src/unfurler/resolvers/media/MediaMetadataHelpers';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import {parseString} from '@fluxer/api/src/utils/StringUtils';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
interface WikiSummaryResponse {
type: string;
title: string;
extract: string;
thumbnail?: {
source: string;
width: number;
height: number;
};
originalimage?: {
source: string;
width: number;
height: number;
};
description?: string;
pageid: number;
}
type ProcessedThumbnail = NonNullable<MessageEmbedResponse['image']>;
export class WikipediaResolver extends BaseResolver {
private readonly SUPPORTED_DOMAINS = [
'wikipedia.org',
'www.wikipedia.org',
...['en', 'de', 'fr', 'es', 'it', 'ja', 'ru', 'zh'].map((lang) => `${lang}.wikipedia.org`),
];
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
return (
this.SUPPORTED_DOMAINS.includes(url.hostname) &&
url.pathname.startsWith('/wiki/') &&
mimeType.startsWith('text/html')
);
}
private getLanguageFromURL(url: URL): string {
const subdomain = url.hostname.split('.')[0];
return this.SUPPORTED_DOMAINS.includes(`${subdomain}.wikipedia.org`) ? subdomain : 'en';
}
private async fetchArticleSummary(title: string, baseUrl: string): Promise<WikiSummaryResponse | null> {
const apiUrl = `${baseUrl}/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
try {
const response = await FetchUtils.sendRequest({
url: apiUrl,
});
if (response.status !== 200) {
Logger.debug({title, status: response.status}, 'Failed to fetch Wikipedia article summary');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
return JSON.parse(responseText) as WikiSummaryResponse;
} catch (error) {
Logger.error({error, title}, 'Failed to fetch or parse Wikipedia response');
return null;
}
}
private async processThumbnail(
thumbnailData: WikiSummaryResponse['thumbnail'],
isNSFWAllowed: boolean,
): Promise<ProcessedThumbnail | null> {
if (!thumbnailData) return null;
const thumbnailMetadata = await this.mediaService.getMetadata({
type: 'external',
url: thumbnailData.source,
isNSFWAllowed,
});
return buildEmbedMediaPayload(thumbnailData.source, thumbnailMetadata, {
width: thumbnailData.width,
height: thumbnailData.height,
}) as ProcessedThumbnail;
}
async resolve(url: URL, _content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
try {
const title = decodeURIComponent(url.pathname.split('/wiki/')[1]);
if (!title) return [];
const language = this.getLanguageFromURL(url);
const baseUrl = `https://${language}.wikipedia.org`;
const article = await this.fetchArticleSummary(title, baseUrl);
if (!article) return [];
const thumbnail = await this.processThumbnail(article.thumbnail, isNSFWAllowed);
const originalImage = await this.processThumbnail(article.originalimage, isNSFWAllowed);
const uniqueImages = this.deduplicateThumbnails([thumbnail, originalImage]);
const primaryThumbnail = uniqueImages[0];
const embed: MessageEmbedResponse = {
type: 'article',
url: url.href,
title: parseString(article.title, 256),
description: parseString(article.extract, 350),
thumbnail: primaryThumbnail ?? undefined,
};
const extraImageEmbeds = uniqueImages.slice(1).map((image) => ({
type: 'rich' as const,
url: url.href,
image,
}));
return [embed, ...extraImageEmbeds];
} catch (error) {
Logger.error({error, url: url.toString()}, 'Failed to resolve Wikipedia article');
return [];
}
}
private deduplicateThumbnails(images: Array<ProcessedThumbnail | null>): Array<ProcessedThumbnail> {
const seen = new Set<string>();
const unique: Array<ProcessedThumbnail> = [];
for (const image of images) {
if (!image) continue;
const normalized = this.normalizeUrl(image.url);
if (!normalized) {
unique.push(image);
continue;
}
if (seen.has(normalized)) continue;
seen.add(normalized);
unique.push(image);
}
return unique;
}
private normalizeUrl(url?: string): string | null {
if (!url) return null;
try {
const normalizedUrl = new URL(url);
this.normalizeWikipediaImagePath(normalizedUrl);
return normalizedUrl.href.replace(/\/$/, '');
} catch (error) {
Logger.debug({error, url}, 'Failed to normalize Wikipedia image URL');
return null;
}
}
private normalizeWikipediaImagePath(imageUrl: URL): void {
if (imageUrl.hostname !== 'upload.wikimedia.org' || !imageUrl.pathname.includes('/wikipedia/commons/thumb/')) {
return;
}
const segments = imageUrl.pathname.split('/');
const thumbIndex = segments.indexOf('thumb');
if (thumbIndex === -1 || segments.length <= thumbIndex + 2) {
return;
}
const normalizedSegments = [...segments.slice(0, thumbIndex), ...segments.slice(thumbIndex + 1, -1)];
const normalizedPath = normalizedSegments.join('/') || '/';
imageUrl.pathname = normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {parseString} from '@fluxer/api/src/utils/StringUtils';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {selectOne} from 'css-select';
import type {Document, Element, Text} from 'domhandler';
import {parseDocument} from 'htmlparser2';
export class XkcdResolver extends BaseResolver {
match(url: URL, mimeType: string, _content: Uint8Array): boolean {
return mimeType.startsWith('text/html') && url.hostname === 'xkcd.com';
}
async resolve(url: URL, content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
const document = parseDocument(Buffer.from(content).toString('utf-8'));
const title = this.extractTitle(document);
const imageUrl = this.extractImageURL(document);
const imageMedia = await this.resolveMediaURL(url, imageUrl, isNSFWAllowed);
const imageAlt = this.extractImageAlt(document);
const footerText = this.extractFooterText(document);
if (imageMedia) {
imageMedia.description = imageAlt;
}
const embed: MessageEmbedResponse = {
type: 'rich',
url: url.href,
title: title ? parseString(title, 70) : undefined,
color: 0x000000,
image: imageMedia ?? undefined,
footer: footerText ? {text: footerText} : undefined,
};
return [embed];
}
private extractTitle(document: Document): string | undefined {
const ogTitle = this.extractMetaField(document, 'og:title');
if (ogTitle) {
return ogTitle;
}
const titleElement = selectOne('title', document) as Element | null;
if (titleElement && titleElement.children.length > 0) {
const titleText = titleElement.children[0] as Text;
return titleText.data;
}
return;
}
private extractImageURL(document: Document): string | undefined {
return this.extractMetaField(document, 'og:image');
}
private extractImageAlt(document: Document): string | undefined {
const imageElement = selectOne('#comic img', document) as Element | null;
return imageElement ? imageElement.attribs['title'] : undefined;
}
private extractFooterText(document: Document): string | undefined {
const imageElement = selectOne('#comic img', document) as Element | null;
return imageElement ? imageElement.attribs['title'] : undefined;
}
private extractMetaField(document: Document, property: string, attribute = 'content'): string | undefined {
const element = selectOne(`meta[property="${property}"], meta[name="${property}"]`, document) as Element | null;
return element?.attribs[attribute] ?? undefined;
}
}

View File

@@ -0,0 +1,194 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {URL} from 'node:url';
import {Config} from '@fluxer/api/src/Config';
import {Logger} from '@fluxer/api/src/Logger';
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {buildEmbedMediaPayload} from '@fluxer/api/src/unfurler/resolvers/media/MediaMetadataHelpers';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import {parseString} from '@fluxer/api/src/utils/StringUtils';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
interface YouTubeApiResponse {
items?: Array<{
snippet: {
title: string;
description: string;
channelTitle: string;
channelId: string;
thumbnails: {
maxres?: {
url: string;
width: number;
height: number;
};
high: {
url: string;
width: number;
height: number;
};
};
};
player: {
embedHtml: string;
};
status: {
uploadStatus?: string;
privacyStatus?: string;
embeddable?: boolean;
publicStatsViewable?: boolean;
madeForKids?: boolean;
};
}>;
}
export class YouTubeResolver extends BaseResolver {
private readonly API_BASE = 'https://www.googleapis.com/youtube/v3';
private readonly YOUTUBE_COLOR = 0xff0000;
match(url: URL, _mimeType: string, _content: Uint8Array): boolean {
if (!['www.youtube.com', 'youtube.com', 'youtu.be'].includes(url.hostname)) {
return false;
}
return (
url.pathname.startsWith('/watch') ||
url.pathname.startsWith('/shorts') ||
url.pathname.startsWith('/v/') ||
url.hostname === 'youtu.be'
);
}
async resolve(url: URL, _content: Uint8Array, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
if (!Config.youtube.apiKey) {
Logger.debug('No Google API key configured');
return [];
}
const videoId = this.extractVideoId(url);
if (!videoId) {
Logger.error('No video ID found in URL');
return [];
}
try {
const timestamp = this.extractTimestamp(url);
const apiUrl = new URL(`${this.API_BASE}/videos`);
apiUrl.searchParams.set('key', Config.youtube.apiKey);
apiUrl.searchParams.set('id', videoId);
apiUrl.searchParams.set('part', 'snippet,player,status');
const response = await FetchUtils.sendRequest({
url: apiUrl.toString(),
});
if (response.status !== 200) {
Logger.error({videoId, status: response.status}, 'Failed to fetch YouTube API data');
return [];
}
const responseText = await FetchUtils.streamToString(response.stream);
const data = JSON.parse(responseText) as YouTubeApiResponse;
const video = data.items?.[0];
if (!video) {
Logger.error({videoId}, 'No video data found');
return [];
}
const thumbnailUrl = video.snippet.thumbnails.maxres?.url || video.snippet.thumbnails.high.url;
const thumbnailMetadata = await this.mediaService.getMetadata({
type: 'external',
url: thumbnailUrl,
isNSFWAllowed,
});
const embedHtmlMatch = video.player.embedHtml.match(/width="(\d+)"\s+height="(\d+)"/);
const embedWidth = embedHtmlMatch ? Number.parseInt(embedHtmlMatch[1], 10) : 1280;
const embedHeight = embedHtmlMatch ? Number.parseInt(embedHtmlMatch[2], 10) : 720;
const mainUrl = new URL('https://www.youtube.com/watch');
mainUrl.searchParams.set('v', videoId);
if (timestamp !== undefined) {
mainUrl.searchParams.set('start', timestamp.toString());
}
const embedUrl = new URL(`https://www.youtube.com/embed/${videoId}`);
if (timestamp !== undefined) {
embedUrl.searchParams.set('start', timestamp.toString());
}
const embed: MessageEmbedResponse = {
type: 'video',
url: mainUrl.toString(),
title: video.snippet.title,
description: parseString(video.snippet.description, 350),
color: this.YOUTUBE_COLOR,
author: video.snippet.channelTitle
? {
name: video.snippet.channelTitle,
url: `https://www.youtube.com/channel/${video.snippet.channelId}`,
}
: undefined,
provider: {
name: 'YouTube',
url: 'https://www.youtube.com',
},
thumbnail: buildEmbedMediaPayload(thumbnailUrl, thumbnailMetadata, {
width: video.snippet.thumbnails.maxres?.width || video.snippet.thumbnails.high.width,
height: video.snippet.thumbnails.maxres?.height || video.snippet.thumbnails.high.height,
}),
video: {
url: embedUrl.toString(),
width: embedWidth,
height: embedHeight,
flags: 0,
},
};
return [embed];
} catch (error) {
Logger.error({error, videoId: this.extractVideoId(url)}, 'Failed to resolve YouTube URL');
return [];
}
}
private extractVideoId(url: URL): string {
if (url.pathname.startsWith('/shorts/')) {
return url.pathname.split('/shorts/')[1];
}
if (url.pathname.startsWith('/v/')) {
return url.pathname.split('/v/')[1];
}
if (url.hostname === 'youtu.be') {
return url.pathname.slice(1);
}
return url.searchParams.get('v') || '';
}
private extractTimestamp(url: URL): number | undefined {
const tParam = url.searchParams.get('t');
if (tParam) {
if (tParam.endsWith('s')) {
return Number.parseInt(tParam.slice(0, -1), 10);
}
return Number.parseInt(tParam, 10);
}
return;
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import type {
BlueskyPostThread,
BlueskyProfile,
HandleResolution,
} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyTypes';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {seconds} from 'itty-time';
export class BlueskyApiClient {
private static readonly API_BASE = 'https://api.bsky.app/xrpc';
constructor(private cacheService: ICacheService) {}
async resolveDid(handle: string): Promise<string | null> {
Logger.debug({handle}, 'Resolving handle to DID');
if (handle.startsWith('did:')) {
Logger.debug({handle}, 'Handle is already a DID');
return handle;
}
try {
const response = await FetchUtils.sendRequest({
url: `${BlueskyApiClient.API_BASE}/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
method: 'GET',
});
if (response.status !== 200) {
Logger.debug({handle, status: response.status}, 'Failed to resolve handle to DID');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
const resolution = JSON.parse(responseText) as HandleResolution;
Logger.debug({handle, did: resolution.did}, 'Successfully resolved handle to DID');
return resolution.did;
} catch (error) {
Logger.error({error, handle}, 'Failed to resolve handle to DID');
return null;
}
}
async getServiceEndpoint(did: string): Promise<string> {
const cacheKey = `bluesky:service-endpoint:${did}`;
const cached = await this.cacheService.get<string>(cacheKey);
if (cached) return cached;
try {
let url: string;
if (did.startsWith('did:web:')) {
url = `https://${did.split(':')[2]}/.well-known/did.json`;
} else {
url = `https://plc.directory/${did}`;
}
const response = await FetchUtils.sendRequest({url, method: 'GET'});
if (response.status !== 200) {
Logger.debug({did, status: response.status}, 'Failed to fetch service endpoint');
return 'https://bsky.social';
}
const responseText = await FetchUtils.streamToString(response.stream);
const didDoc = JSON.parse(responseText);
let serviceEndpoint = 'https://bsky.social';
for (const service of didDoc.service || []) {
if (service.type === 'AtprotoPersonalDataServer') {
serviceEndpoint = service.serviceEndpoint;
break;
}
}
await this.cacheService.set(cacheKey, serviceEndpoint, seconds('1 hour'));
Logger.debug({did, serviceEndpoint}, 'Retrieved and cached service endpoint');
return serviceEndpoint;
} catch (error) {
Logger.error({error, did}, 'Failed to fetch service endpoint');
return 'https://bsky.social';
}
}
async fetchPost(atUri: string): Promise<BlueskyPostThread | null> {
Logger.debug({atUri}, 'Fetching post');
try {
const response = await FetchUtils.sendRequest({
url: `${BlueskyApiClient.API_BASE}/app.bsky.feed.getPostThread?uri=${encodeURIComponent(atUri)}&depth=0`,
method: 'GET',
});
if (response.status !== 200) {
Logger.debug({atUri, status: response.status}, 'Failed to fetch post');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
const thread = JSON.parse(responseText) as BlueskyPostThread;
Logger.debug(
{
atUri,
author: thread.thread.post.author.handle,
hasEmbed: !!thread.thread.post.embed,
isReply: !!thread.thread.post.record.reply,
hasParent: !!thread.thread.parent,
},
'Post fetched and parsed successfully',
);
return thread;
} catch (error) {
Logger.error({error, atUri}, 'Failed to fetch post');
return null;
}
}
async fetchProfile(handle: string): Promise<BlueskyProfile | null> {
Logger.debug({handle}, 'Fetching profile');
try {
const response = await FetchUtils.sendRequest({
url: `${BlueskyApiClient.API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`,
method: 'GET',
});
if (response.status !== 200) {
Logger.debug({handle, status: response.status}, 'Failed to fetch profile');
return null;
}
const responseText = await FetchUtils.streamToString(response.stream);
const profile = JSON.parse(responseText) as BlueskyProfile;
Logger.debug(
{handle, did: profile.did, hasAvatar: !!profile.avatar, hasBanner: !!profile.banner},
'Profile fetched and parsed successfully',
);
return profile;
} catch (error) {
Logger.error({error, handle}, 'Failed to fetch profile');
return null;
}
}
}

View File

@@ -0,0 +1,321 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import {Logger} from '@fluxer/api/src/Logger';
import type {BlueskyApiClient} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyApiClient';
import type {
BlueskyAspectRatio,
BlueskyExternalEmbed,
BlueskyMediaEmbedView,
BlueskyPost,
BlueskyPostEmbed,
BlueskyProcessedEmbeddedPost,
BlueskyProcessedExternalEmbed,
BlueskyProcessedPostEmbed,
BlueskyRecordViewRecord,
BlueskyVideoEmbedView,
ProcessedMedia,
ProcessedVideoResult,
} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyTypes';
import {buildEmbedMediaPayload} from '@fluxer/api/src/unfurler/resolvers/media/MediaMetadataHelpers';
import {parseString} from '@fluxer/api/src/utils/StringUtils';
export class BlueskyEmbedProcessor {
private static readonly MAX_ALT_TEXT_LENGTH = 4096;
private static readonly MAX_GALLERY_IMAGES = 10;
constructor(
private mediaService: IMediaService,
private apiClient: BlueskyApiClient,
) {}
async processImage(
imageUrl?: string,
aspectRatio?: BlueskyAspectRatio,
altText?: string,
isNSFWAllowed: boolean = false,
): Promise<ProcessedMedia | undefined> {
if (!imageUrl) {
Logger.debug('No image URL provided to process');
return;
}
try {
Logger.debug({imageUrl, aspectRatio, hasAltText: !!altText}, 'Processing image');
const metadata = await this.mediaService.getMetadata({type: 'external', url: imageUrl, isNSFWAllowed});
let description: string | undefined;
if (altText) {
description = parseString(altText, BlueskyEmbedProcessor.MAX_ALT_TEXT_LENGTH);
Logger.debug(
{imageUrl, altTextLength: altText.length, processedLength: description.length},
'Added alt text as description to image',
);
}
const result = buildEmbedMediaPayload(imageUrl, metadata, {
width: aspectRatio?.width,
height: aspectRatio?.height,
description,
}) as ProcessedMedia;
Logger.debug({imageUrl, metadata: result}, 'Image processed successfully');
return result;
} catch (error) {
Logger.error({error, imageUrl}, 'Failed to process image');
return;
}
}
async processPostEmbed(post: BlueskyPost, isNSFWAllowed: boolean): Promise<BlueskyProcessedPostEmbed> {
if (!post.embed) {
return {};
}
return this.processEmbed(post.embed, post.author.did, isNSFWAllowed);
}
async processEmbeddedPost(
post: BlueskyPost,
isNSFWAllowed: boolean,
): Promise<BlueskyProcessedEmbeddedPost | undefined> {
const embeddedRecord = this.extractEmbeddedRecord(post.embed);
if (!embeddedRecord) {
return;
}
const nestedEmbedView = this.extractEmbeddedRecordMedia(embeddedRecord);
const embed = nestedEmbedView
? await this.processEmbed(nestedEmbedView, embeddedRecord.author.did, isNSFWAllowed)
: undefined;
return {
uri: embeddedRecord.uri,
author: embeddedRecord.author,
text: embeddedRecord.value.text,
createdAt: embeddedRecord.value.createdAt,
facets: embeddedRecord.value.facets,
replyCount: embeddedRecord.replyCount,
repostCount: embeddedRecord.repostCount,
likeCount: embeddedRecord.likeCount,
quoteCount: embeddedRecord.quoteCount,
bookmarkCount: embeddedRecord.bookmarkCount,
embed,
};
}
private async processEmbed(
embed: BlueskyPostEmbed,
authorDid: string,
isNSFWAllowed: boolean,
): Promise<BlueskyProcessedPostEmbed> {
let image: ProcessedMedia | undefined;
let thumbnail: ProcessedMedia | undefined;
let video: ProcessedMedia | undefined;
let external: BlueskyProcessedExternalEmbed | undefined;
Logger.debug({embedType: embed.$type, hasEmbed: true, authorDid}, 'Processing Bluesky embed payload');
const processedImages = await this.processEmbedImages(embed, isNSFWAllowed);
if (processedImages.length > 0) {
image = processedImages[0];
}
if (embed.$type === 'app.bsky.embed.video#view') {
const processed = await this.processVideoEmbed(embed, authorDid, isNSFWAllowed);
thumbnail = processed.thumbnail;
video = processed.video;
}
if (embed.$type === 'app.bsky.embed.external#view') {
const processed = await this.processExternalEmbed(embed.external, isNSFWAllowed);
external = processed.external;
thumbnail = processed.thumbnail;
}
if (embed.$type === 'app.bsky.embed.recordWithMedia#view' && embed.media) {
if (embed.media.$type === 'app.bsky.embed.video#view') {
const processed = await this.processVideoEmbed(embed.media, authorDid, isNSFWAllowed);
thumbnail = processed.thumbnail;
video = processed.video;
}
if (embed.media.$type === 'app.bsky.embed.external#view') {
const processed = await this.processExternalEmbed(embed.media.external, isNSFWAllowed);
external = processed.external;
if (!thumbnail) {
thumbnail = processed.thumbnail;
}
}
}
const galleryImages = processedImages.length > 1 ? processedImages.slice(1) : undefined;
return {image, thumbnail, video, galleryImages, external};
}
private async processVideoEmbed(
embed: BlueskyVideoEmbedView,
did: string,
isNSFWAllowed: boolean,
): Promise<ProcessedVideoResult> {
Logger.debug(
{embedType: embed.$type, hasThumbnail: !!embed.thumbnail, cid: embed.cid, aspectRatio: embed.aspectRatio},
'Processing video embed',
);
try {
const thumbnail = await this.processImage(embed.thumbnail, embed.aspectRatio, undefined, isNSFWAllowed);
if (!thumbnail || !embed.cid) {
Logger.debug(
{embedType: embed.$type, hasThumbnail: !!thumbnail, hasCid: !!embed.cid},
'Missing required video data',
);
return {};
}
const serviceEndpoint = await this.apiClient.getServiceEndpoint(did);
const directUrl = `${serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${embed.cid}`;
const videoMetadata = await this.mediaService.getMetadata({type: 'external', url: directUrl, isNSFWAllowed});
const video = buildEmbedMediaPayload(directUrl, videoMetadata, {
width: videoMetadata?.width ?? thumbnail.width,
height: videoMetadata?.height ?? thumbnail.height,
}) as ProcessedMedia;
Logger.debug(
{thumbnailProcessed: !!thumbnail, videoMetadata, aspectRatio: embed.aspectRatio, serviceEndpoint},
'Successfully processed video embed',
);
return {thumbnail, video};
} catch (error) {
Logger.error({error, embedType: embed.$type}, 'Failed to process video embed');
return {};
}
}
private async processExternalEmbed(
external: BlueskyExternalEmbed,
isNSFWAllowed: boolean,
): Promise<{external: BlueskyProcessedExternalEmbed; thumbnail?: ProcessedMedia}> {
Logger.debug({uri: external.uri, title: external.title, hasThumb: !!external.thumb}, 'Processing external embed');
let thumbnail: ProcessedMedia | undefined;
if (external.thumb) {
thumbnail = await this.processImage(external.thumb, undefined, undefined, isNSFWAllowed);
}
const formattedExternal: BlueskyProcessedExternalEmbed = {
uri: external.uri,
title: external.title || external.uri,
description: external.description || undefined,
thumbnail,
};
return {external: formattedExternal, thumbnail};
}
private async processEmbedImages(embed: BlueskyPostEmbed, isNSFWAllowed: boolean): Promise<Array<ProcessedMedia>> {
const imageEntries = this.collectImageEntries(embed);
if (imageEntries.length === 0) return [];
const processedImages: Array<ProcessedMedia> = [];
const seenUrls = new Set<string>();
for (const entry of imageEntries) {
const normalizedUrl = this.normalizeUrl(entry.url);
if (!normalizedUrl) continue;
if (seenUrls.has(normalizedUrl)) continue;
seenUrls.add(normalizedUrl);
const processedImage = await this.processImage(entry.url, entry.aspectRatio, entry.alt, isNSFWAllowed);
if (processedImage) {
processedImages.push(processedImage);
if (processedImages.length >= BlueskyEmbedProcessor.MAX_GALLERY_IMAGES) break;
}
}
return processedImages;
}
private collectImageEntries(
embed: BlueskyPostEmbed,
): Array<{url: string; aspectRatio?: BlueskyAspectRatio; alt?: string}> {
const entries: Array<{url: string; aspectRatio?: BlueskyAspectRatio; alt?: string}> = [];
const addImages = (
images?: Array<{thumb: string; fullsize?: string; alt?: string; aspectRatio?: BlueskyAspectRatio}>,
) => {
if (!images) return;
for (const image of images) {
const resolvedUrl = image.fullsize ?? image.thumb;
if (!resolvedUrl) continue;
entries.push({url: resolvedUrl, aspectRatio: image.aspectRatio, alt: image.alt});
}
};
if (embed.$type === 'app.bsky.embed.images#view') {
addImages(embed.images);
}
if (embed.$type === 'app.bsky.embed.recordWithMedia#view' && embed.media?.$type === 'app.bsky.embed.images#view') {
addImages(embed.media.images);
}
return entries;
}
private extractEmbeddedRecord(embed?: BlueskyPostEmbed): BlueskyRecordViewRecord | undefined {
if (!embed) return;
if (embed.$type === 'app.bsky.embed.recordWithMedia#view') {
return embed.record?.record;
}
if (embed.$type === 'app.bsky.embed.record#view') {
return embed.record;
}
return;
}
private extractEmbeddedRecordMedia(record: BlueskyRecordViewRecord): BlueskyMediaEmbedView | undefined {
for (const candidate of record.embeds ?? []) {
if (!candidate || typeof candidate !== 'object') {
continue;
}
if (
candidate.$type === 'app.bsky.embed.images#view' ||
candidate.$type === 'app.bsky.embed.video#view' ||
candidate.$type === 'app.bsky.embed.external#view'
) {
return candidate;
}
}
return;
}
private normalizeUrl(url: string): string | null {
try {
return new URL(url).href.replace(/\/$/, '');
} catch (error) {
Logger.error({error, url}, 'Failed to normalize image URL');
return null;
}
}
}

View File

@@ -0,0 +1,125 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AppBskyRichtextFacet, RichText} from '@atproto/api';
import {Logger} from '@fluxer/api/src/Logger';
import type {
BlueskyAuthor,
BlueskyPost,
BlueskyPostThread,
Facet,
ReplyContext,
} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyTypes';
export class BlueskyTextFormatter {
embedLinksInText(text: string, facets?: Array<Facet>): string {
if (!facets) return text;
const richText = new RichText({text, facets});
let result = '';
let lastIndex = 0;
const utf8ToUtf16Map = new Map<number, number>();
for (let i = 0; i < text.length; i++) {
const utf8Index = richText.unicodeText.utf16IndexToUtf8Index(i);
utf8ToUtf16Map.set(utf8Index, i);
}
const sortedFacets = [...(richText.facets || [])].sort((a, b) => a.index.byteStart - b.index.byteStart);
for (const facet of sortedFacets) {
const startUtf16 = utf8ToUtf16Map.get(facet.index.byteStart) ?? lastIndex;
const endUtf16 = utf8ToUtf16Map.get(facet.index.byteEnd) ?? text.length;
result += text.slice(lastIndex, startUtf16);
const facetText = text.slice(startUtf16, endUtf16);
const feature = facet.features[0];
if (AppBskyRichtextFacet.isLink(feature)) {
result += `[${this.getLinkDisplayText(feature.uri)}](${feature.uri})`;
} else if (AppBskyRichtextFacet.isMention(feature)) {
result += `[${facetText}](https://bsky.app/profile/${feature.did})`;
} else if (AppBskyRichtextFacet.isTag(feature)) {
const tagText = facetText.startsWith('#') ? facetText.slice(1) : facetText;
result += `[${facetText}](https://bsky.app/search?q=%23${encodeURIComponent(tagText)})`;
} else {
result += facetText;
}
lastIndex = endUtf16;
}
result += text.slice(lastIndex);
return result;
}
getLinkDisplayText(uri: string): string {
const url = new URL(uri);
const hostname = url.hostname;
const path = url.pathname;
const trimmedPath = path.replace(/^\/+|\/+$/g, '');
if (!trimmedPath) {
return `${hostname}/`;
}
const truncatedPath = trimmedPath.length > 12 ? `${trimmedPath.slice(0, 12)}...` : trimmedPath;
return `${hostname}/${truncatedPath}`;
}
formatAuthor(author: BlueskyAuthor): string {
const displayName = author.displayName || author.handle;
const handle = author.handle;
const profileUrl = `https://bsky.app/profile/${handle}`;
return `**[${displayName} (@${handle})](${profileUrl})**`;
}
formatPostContent(post: BlueskyPost, thread: BlueskyPostThread): string {
let processedText = this.embedLinksInText(post.record.text, post.record.facets);
const replyContext = this.extractReplyContext(post, thread);
if (replyContext) {
processedText = `-# ↩ [${replyContext.authorName} (@${replyContext.authorHandle})](${replyContext.postUrl})\n${processedText}`;
Logger.debug(
{postUri: post.uri, replyingTo: replyContext.authorName, replyingToHandle: replyContext.authorHandle},
'Added reply indicator to post content',
);
}
return processedText;
}
extractReplyContext(post: BlueskyPost, thread: BlueskyPostThread): ReplyContext | null {
if (!post.record.reply) {
Logger.debug({postUri: post.uri}, 'Post is not a reply');
return null;
}
if (thread.thread.parent?.post) {
const parentPost = thread.thread.parent.post;
const authorName = parentPost.author.displayName || parentPost.author.handle;
const authorHandle = parentPost.author.handle;
const postUrl = `https://bsky.app/profile/${authorHandle}/post/${parentPost.uri.split('/').pop()}`;
Logger.debug({parentAuthor: authorName, parentHandle: authorHandle, postUrl}, 'Found parent post in thread data');
return {authorName, authorHandle, postUrl};
}
Logger.debug({postUri: post.uri}, 'Parent post not found in thread data');
return null;
}
}

View File

@@ -0,0 +1,230 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
type FacetFeatureType =
| 'app.bsky.richtext.facet#link'
| 'app.bsky.richtext.facet#mention'
| 'app.bsky.richtext.facet#tag';
interface FacetFeature {
$type: FacetFeatureType;
uri?: string;
did?: string;
tag?: string;
}
interface FacetBytePosition {
byteStart: number;
byteEnd: number;
}
export interface Facet {
index: FacetBytePosition;
features: [FacetFeature];
}
export interface BlueskyAuthor {
did: string;
handle: string;
displayName?: string;
avatar?: string;
}
interface BlueskyImageEmbed {
alt?: string;
image: {ref: {$link: string}};
}
interface BlueskyVideoEmbed {
$type: string;
ref: {$link: string};
mimeType: string;
size: number;
}
export interface BlueskyAspectRatio {
width: number;
height: number;
}
interface BlueskyRecordEmbed {
$type: string;
aspectRatio?: BlueskyAspectRatio;
video?: BlueskyVideoEmbed;
images?: Array<BlueskyImageEmbed>;
}
export interface BlueskyExternalEmbed {
uri: string;
title: string;
description: string;
thumb?: string;
}
export interface BlueskyImageEmbedView {
thumb: string;
fullsize?: string;
alt?: string;
aspectRatio?: BlueskyAspectRatio;
}
export interface BlueskyImagesEmbedView {
$type: 'app.bsky.embed.images#view';
images?: Array<BlueskyImageEmbedView>;
}
export interface BlueskyVideoEmbedView {
$type: 'app.bsky.embed.video#view';
cid?: string;
aspectRatio?: BlueskyAspectRatio;
thumbnail?: string;
playlist?: string;
}
export interface BlueskyExternalEmbedView {
$type: 'app.bsky.embed.external#view';
external: BlueskyExternalEmbed;
}
export type BlueskyMediaEmbedView = BlueskyImagesEmbedView | BlueskyVideoEmbedView | BlueskyExternalEmbedView;
export interface BlueskyRecordViewRecord {
$type: 'app.bsky.embed.record#viewRecord';
uri: string;
cid: string;
author: BlueskyAuthor;
value: {$type: string; text: string; createdAt: string; facets?: Array<Facet>; embed?: BlueskyRecordEmbed};
labels?: Array<Record<string, unknown>>;
indexedAt: string;
replyCount?: number;
repostCount?: number;
likeCount?: number;
quoteCount?: number;
bookmarkCount?: number;
embeds?: Array<BlueskyMediaEmbedView>;
}
export interface BlueskyRecordEmbedView {
$type: 'app.bsky.embed.record#view';
record?: BlueskyRecordViewRecord;
}
export interface BlueskyRecordWithMediaEmbedView {
$type: 'app.bsky.embed.recordWithMedia#view';
media?: BlueskyMediaEmbedView;
record?: BlueskyRecordEmbedView;
}
export type BlueskyPostEmbed =
| BlueskyImagesEmbedView
| BlueskyVideoEmbedView
| BlueskyExternalEmbedView
| BlueskyRecordEmbedView
| BlueskyRecordWithMediaEmbedView;
interface BlueskyRecord {
text: string;
createdAt: string;
facets?: Array<Facet>;
embed?: BlueskyRecordEmbed;
reply?: {parent: {cid: string; uri: string}; root: {cid: string; uri: string}};
}
export interface BlueskyPost {
uri: string;
author: BlueskyAuthor;
record: BlueskyRecord;
embed?: BlueskyPostEmbed;
indexedAt: string;
replyCount: number;
repostCount: number;
likeCount: number;
quoteCount: number;
bookmarkCount?: number;
}
export interface BlueskyPostThread {
thread: {post: BlueskyPost; parent?: {post: BlueskyPost}; replies?: Array<{post: BlueskyPost}>};
}
export interface BlueskyProfile {
did: string;
handle: string;
displayName?: string;
description?: string;
avatar?: string;
banner?: string;
indexedAt: string;
}
export interface HandleResolution {
did: string;
}
export interface ProcessedMedia {
url: string;
width: number;
height: number;
placeholder?: string;
flags: number;
description?: string;
content_type?: string;
content_hash?: string;
duration?: number;
}
export interface ProcessedVideoResult {
thumbnail?: ProcessedMedia;
video?: ProcessedMedia;
}
export interface BlueskyProcessedExternalEmbed {
uri: string;
title: string;
description?: string;
thumbnail?: ProcessedMedia;
}
export interface BlueskyProcessedPostEmbed {
image?: ProcessedMedia;
thumbnail?: ProcessedMedia;
video?: ProcessedMedia;
galleryImages?: Array<ProcessedMedia>;
external?: BlueskyProcessedExternalEmbed;
}
export interface BlueskyProcessedEmbeddedPost {
uri: string;
author: BlueskyAuthor;
text: string;
createdAt: string;
facets?: Array<Facet>;
replyCount?: number;
repostCount?: number;
likeCount?: number;
quoteCount?: number;
bookmarkCount?: number;
embed?: BlueskyProcessedPostEmbed;
}
export interface ReplyContext {
authorName: string;
authorHandle: string;
postUrl: string;
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MediaProxyMetadataResponse} from '@fluxer/api/src/infrastructure/IMediaService';
import {EmbedMediaFlags} from '@fluxer/constants/src/ChannelConstants';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
interface BuildMediaOptions {
width?: number;
height?: number;
description?: string;
}
export function buildEmbedMediaPayload(
url: string,
metadata: MediaProxyMetadataResponse | null,
options: BuildMediaOptions = {},
): MessageEmbedResponse['image'] {
const flags =
(metadata?.animated ? EmbedMediaFlags.IS_ANIMATED : 0) |
(metadata?.nsfw ? EmbedMediaFlags.CONTAINS_EXPLICIT_MEDIA : 0);
return {
url,
width: options.width ?? metadata?.width ?? undefined,
height: options.height ?? metadata?.height ?? undefined,
description: options.description ?? undefined,
placeholder: metadata?.placeholder ?? undefined,
flags,
content_hash: metadata?.content_hash ?? undefined,
content_type: metadata?.content_type ?? undefined,
duration: metadata?.duration ?? undefined,
};
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import type {
ActivityPubPost,
MastodonInstance,
MastodonPost,
} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubTypes';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import {seconds} from 'itty-time';
export class ActivityPubFetcher {
constructor(private cacheService: ICacheService) {}
async fetchInstanceInfo(baseUrl: string): Promise<MastodonInstance | null> {
const cacheKey = `activitypub:instance:${baseUrl}`;
const cached = await this.cacheService.get<MastodonInstance>(cacheKey);
if (cached) return cached;
try {
const apiUrl = `${baseUrl}/api/v2/instance`;
Logger.debug({apiUrl}, 'Fetching instance info');
const response = await FetchUtils.sendRequest({
url: apiUrl,
method: 'GET',
timeout: 5000,
headers: {Accept: 'application/json'},
});
if (response.status !== 200) {
Logger.debug({apiUrl, status: response.status}, 'Instance info request failed');
return null;
}
const data = await FetchUtils.streamToString(response.stream);
const instanceInfo = JSON.parse(data) as MastodonInstance;
await this.cacheService.set(cacheKey, JSON.stringify(instanceInfo), seconds('1 hour'));
return instanceInfo;
} catch (error) {
Logger.error({error, baseUrl}, 'Failed to fetch instance info');
return null;
}
}
async tryFetchActivityPubData(url: string): Promise<ActivityPubPost | null> {
try {
const response = await FetchUtils.sendRequest({
url,
method: 'GET',
timeout: 5000,
headers: {
Accept:
'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
});
if (response.status !== 200) {
Logger.debug({url, status: response.status}, 'Failed to fetch ActivityPub data');
return null;
}
const data = await FetchUtils.streamToString(response.stream);
const parsedData = JSON.parse(data);
if (!parsedData || typeof parsedData !== 'object' || !('id' in parsedData) || !('type' in parsedData)) {
Logger.debug({url}, 'Response is not a valid ActivityPub object');
return null;
}
return parsedData as ActivityPubPost;
} catch (error) {
Logger.error({error, url}, 'Failed to fetch or parse ActivityPub data');
return null;
}
}
async tryFetchActivityPubDataWithRedirectLimit(url: string, maxRedirects: number): Promise<ActivityPubPost | null> {
try {
const response = await FetchUtils.sendRequest(
{
url,
method: 'GET',
timeout: 5000,
headers: {
Accept:
'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
},
{maxRedirects},
);
if (response.status !== 200) {
Logger.debug({url, status: response.status}, 'Failed to fetch ActivityPub data');
return null;
}
const data = await FetchUtils.streamToString(response.stream);
const parsedData = JSON.parse(data);
if (!parsedData || typeof parsedData !== 'object' || !('id' in parsedData) || !('type' in parsedData)) {
Logger.debug({url}, 'Response is not a valid ActivityPub object');
return null;
}
return parsedData as ActivityPubPost;
} catch (error) {
Logger.error({error, url, maxRedirects}, 'Failed to fetch or parse ActivityPub data');
return null;
}
}
async tryFetchMastodonApi(baseUrl: string, postId: string): Promise<MastodonPost | null> {
try {
const apiUrl = `${baseUrl}/api/v1/statuses/${postId}`;
Logger.debug({apiUrl}, 'Attempting to fetch from Mastodon API');
const response = await FetchUtils.sendRequest({
url: apiUrl,
method: 'GET',
timeout: 5000,
headers: {Accept: 'application/json'},
});
if (response.status !== 200) {
Logger.debug({apiUrl, status: response.status}, 'Mastodon API request failed');
return null;
}
const data = await FetchUtils.streamToString(response.stream);
return JSON.parse(data) as MastodonPost;
} catch (error) {
Logger.error({error, baseUrl, postId}, 'Failed to fetch or parse Mastodon API data');
return null;
}
}
}

View File

@@ -0,0 +1,529 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import {Logger} from '@fluxer/api/src/Logger';
import {buildEmbedMediaPayload} from '@fluxer/api/src/unfurler/resolvers/media/MediaMetadataHelpers';
import type {
ActivityPubAttachment,
ActivityPubAuthor,
ActivityPubContext,
ActivityPubPost,
MastodonMediaAttachment,
MastodonPost,
ProcessedMedia,
} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubTypes';
import {escapeMarkdownChars} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubUtils';
import * as DOMUtils from '@fluxer/api/src/utils/DOMUtils';
import {parseString} from '@fluxer/api/src/utils/StringUtils';
import {sanitizeOptionalAbsoluteUrl} from '@fluxer/api/src/utils/UrlSanitizer';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
export class ActivityPubFormatter {
private static readonly DEFAULT_COLOR = 0x6364ff;
private static readonly FAVORITE_THRESHOLD = 100;
private static readonly MAX_ALT_TEXT_LENGTH = 4096;
constructor(private mediaService: IMediaService) {}
async processMedia(attachment: MastodonMediaAttachment | ActivityPubAttachment): Promise<ProcessedMedia | undefined> {
try {
if (!('url' in attachment) || attachment.url == null) return;
const url = attachment.url;
let altText: string | null = null;
if ('description' in attachment) {
altText = attachment.description;
Logger.debug({url, hasAltText: !!altText}, 'Found Mastodon alt text');
} else if ('name' in attachment) {
altText = attachment.name || null;
Logger.debug({url, hasAltText: !!altText}, 'Found ActivityPub alt text');
}
const metadata = await this.mediaService.getMetadata({type: 'external', url: url, isNSFWAllowed: false});
if (!metadata) {
Logger.debug({url}, 'Failed to get media metadata');
return;
}
let description: string | undefined;
if (altText) {
description = parseString(altText, ActivityPubFormatter.MAX_ALT_TEXT_LENGTH);
Logger.debug(
{url, originalAltLength: altText.length, processedAltLength: description.length},
'Added sanitized alt text as description',
);
}
const widthOverride = this.getAttachmentDimension(attachment, 'width') ?? metadata.width;
const heightOverride = this.getAttachmentDimension(attachment, 'height') ?? metadata.height;
return buildEmbedMediaPayload(url, metadata, {
width: widthOverride,
height: heightOverride,
description,
}) as ProcessedMedia;
} catch (error) {
Logger.error({error}, 'Failed to process media');
return;
}
}
formatMastodonContent(post: MastodonPost, context?: ActivityPubContext): string {
let text = DOMUtils.htmlToMarkdown(post.content);
if (post.spoiler_text) {
text = `**${post.spoiler_text}**\n\n${text}`;
}
if (post.reblog) {
const reblogAuthor = post.reblog.account.display_name || post.reblog.account.username;
const reblogText = DOMUtils.htmlToMarkdown(post.reblog.content);
text = `**Boosted from ${reblogAuthor}**\n\n${reblogText}`;
}
if (context?.inReplyTo) {
text = `-# ↩ [${context.inReplyTo.author}](${context.inReplyTo.url})\n${text}`;
}
return escapeMarkdownChars(text);
}
formatActivityPubContent(post: ActivityPubPost, context?: ActivityPubContext): string {
let text = post.content ? DOMUtils.htmlToMarkdown(post.content) : '';
if (post.summary) {
text = `**${post.summary}**\n\n${text}`;
}
if (context?.inReplyTo) {
text = `-# ↩ [${context.inReplyTo.author}](${context.inReplyTo.url})\n${text}`;
}
return escapeMarkdownChars(text);
}
async buildMastodonEmbeds(
post: MastodonPost,
url: URL,
context: ActivityPubContext,
): Promise<Array<MessageEmbedResponse>> {
const authorName = post.account.display_name || post.account.username;
const authorFullName = `${authorName} (@${post.account.username}@${context.serverDomain})`;
const authorUrl = post.account.url;
const content = this.formatMastodonContent(post, context);
const {image, video, thumbnail, galleryEmbeds} = await this.resolveMastodonMediaEmbeds(post, url);
const fields = [];
if (post.favourites_count >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Favorites', value: post.favourites_count.toString(), inline: true});
if (post.reblogs_count >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Boosts', value: post.reblogs_count.toString(), inline: true});
if (post.replies_count >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Replies', value: post.replies_count.toString(), inline: true});
if (post.poll) {
const pollOptions = post.poll.options
.map((option) => {
const votes = option.votes_count != null ? `: ${option.votes_count}` : '';
return `${option.title}${votes}`;
})
.join('\n');
fields.push({name: `Poll (${post.poll.votes_count} votes)`, value: pollOptions, inline: false});
}
const embed: MessageEmbedResponse = {
type: 'rich',
url: url.toString(),
description: content,
color: ActivityPubFormatter.DEFAULT_COLOR,
timestamp: new Date(post.created_at).toISOString(),
author: {
name: authorFullName,
...this.buildOptionalAuthorUrl(authorUrl),
...this.buildOptionalIconUrl(post.account.avatar),
},
footer: {text: context.serverTitle, ...this.buildOptionalIconUrl(context.serverIcon)},
fields: fields.length > 0 ? fields : undefined,
};
if (image) {
embed.image = image;
}
if (video) {
embed.video = video;
if (thumbnail) {
embed.thumbnail = thumbnail;
}
}
return [embed, ...galleryEmbeds];
}
async buildActivityPubEmbeds(
post: ActivityPubPost,
url: URL,
context: ActivityPubContext,
fetchAuthorData: (url: string) => Promise<ActivityPubPost | null>,
): Promise<Array<MessageEmbedResponse>> {
const {image, video, thumbnail, galleryEmbeds} = await this.resolveActivityPubMediaEmbeds(post, url);
const rootEmbed = await this.buildActivityPubEmbed(post, url, context, fetchAuthorData, {image, video, thumbnail});
return [rootEmbed, ...galleryEmbeds];
}
async buildActivityPubEmbed(
post: ActivityPubPost,
url: URL,
context: ActivityPubContext,
fetchAuthorData: (url: string) => Promise<ActivityPubPost | null>,
mediaOverrides?: {image?: ProcessedMedia; video?: ProcessedMedia; thumbnail?: ProcessedMedia},
isNested: boolean = false,
): Promise<MessageEmbedResponse> {
const isActivityPubAuthor = (data: unknown): data is ActivityPubAuthor =>
typeof data === 'object' &&
data !== null &&
('name' in data || 'preferredUsername' in data || 'url' in data || 'icon' in data);
let authorName = '';
let authorPreferredUsername = '';
let authorUrl = '';
let authorIcon = '';
if (typeof post.attributedTo === 'string') {
const authorData = (await fetchAuthorData(post.attributedTo)) as unknown;
if (authorData) {
if (isActivityPubAuthor(authorData)) {
authorName = authorData.name || authorData.preferredUsername || '';
authorPreferredUsername = authorData.preferredUsername || '';
authorUrl = authorData.url || post.attributedTo;
authorIcon = authorData.icon?.url || '';
} else {
authorName = this.getActivityPubUsernameFromUrl(post.attributedTo) || '';
authorUrl = post.attributedTo;
}
} else {
authorName = this.getActivityPubUsernameFromUrl(post.attributedTo) || '';
authorUrl = post.attributedTo;
}
} else if (post.attributedTo && typeof post.attributedTo === 'object') {
const author = post.attributedTo as ActivityPubAuthor;
authorName = author.name || author.preferredUsername || '';
authorPreferredUsername = author.preferredUsername || '';
authorUrl = author.url || '';
authorIcon = author.icon?.url || '';
}
const authorFullName = this.buildActivityPubAuthorLabel(
authorName,
authorPreferredUsername,
authorUrl,
context.serverDomain,
);
const content = this.formatActivityPubContent(post, context);
const fields = [];
const likesCount = this.getCollectionTotalItems(post.likes);
const sharesCount = this.getCollectionTotalItems(post.shares);
const repliesCount = post.replies?.totalItems || 0;
if (likesCount >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Likes', value: likesCount.toString(), inline: true});
if (sharesCount >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Shares', value: sharesCount.toString(), inline: true});
if (repliesCount >= ActivityPubFormatter.FAVORITE_THRESHOLD)
fields.push({name: 'Replies', value: repliesCount.toString(), inline: true});
const embed: MessageEmbedResponse = {
type: 'rich',
url: url.toString(),
description: content,
color: ActivityPubFormatter.DEFAULT_COLOR,
author: {
name: authorFullName,
...this.buildOptionalAuthorUrl(authorUrl),
...this.buildOptionalIconUrl(authorIcon),
},
};
if (!isNested) {
embed.timestamp = new Date(post.published).toISOString();
embed.footer = {text: context.serverTitle, ...this.buildOptionalIconUrl(context.serverIcon)};
embed.fields = fields.length > 0 ? fields : undefined;
}
if (mediaOverrides?.image) embed.image = mediaOverrides.image;
if (mediaOverrides?.video) {
embed.video = mediaOverrides.video;
if (mediaOverrides.thumbnail) embed.thumbnail = mediaOverrides.thumbnail;
}
return embed;
}
private async resolveMastodonMediaEmbeds(
post: MastodonPost,
url: URL,
): Promise<{
image?: ProcessedMedia;
video?: ProcessedMedia;
thumbnail?: ProcessedMedia;
galleryEmbeds: Array<MessageEmbedResponse>;
}> {
let image: ProcessedMedia | undefined;
let video: ProcessedMedia | undefined;
let thumbnail: ProcessedMedia | undefined;
let primaryMediaClaimed = false;
const galleryEmbeds: Array<MessageEmbedResponse> = [];
for (const attachment of post.media_attachments || []) {
if (attachment.type === 'image' || attachment.type === 'gifv') {
const processedImage = await this.processMedia(attachment);
if (!processedImage) continue;
Logger.debug(
{
mediaType: attachment.type,
url: attachment.url,
hasAltText: !!attachment.description,
hasProcessedDescription: !!processedImage.description,
},
'Processed image media attachment',
);
if (!primaryMediaClaimed) {
image = processedImage;
primaryMediaClaimed = true;
} else {
galleryEmbeds.push(this.createRichImageEmbed(url, processedImage));
}
continue;
}
if (attachment.type === 'video') {
const processedVideo = await this.processMedia(attachment);
if (!processedVideo) continue;
let processedThumbnail: ProcessedMedia | undefined;
if (attachment.preview_url) {
const previewAttachment = {...attachment, url: attachment.preview_url};
processedThumbnail = await this.processMedia(previewAttachment);
}
Logger.debug(
{
mediaType: attachment.type,
url: attachment.url,
hasAltText: !!attachment.description,
hasVideoDescription: !!processedVideo.description,
hasThumbnailDescription: !!processedThumbnail?.description,
},
'Processed video media attachment',
);
if (!primaryMediaClaimed) {
video = processedVideo;
thumbnail = processedThumbnail;
primaryMediaClaimed = true;
} else {
galleryEmbeds.push(this.createRichVideoEmbed(url, processedVideo, processedThumbnail));
}
}
}
return {image, video, thumbnail, galleryEmbeds};
}
private async resolveActivityPubMediaEmbeds(
post: ActivityPubPost,
url: URL,
): Promise<{
image?: ProcessedMedia;
video?: ProcessedMedia;
thumbnail?: ProcessedMedia;
galleryEmbeds: Array<MessageEmbedResponse>;
}> {
let image: ProcessedMedia | undefined;
let video: ProcessedMedia | undefined;
let thumbnail: ProcessedMedia | undefined;
let primaryMediaClaimed = false;
const galleryEmbeds: Array<MessageEmbedResponse> = [];
for (const attachment of post.attachment || []) {
if (attachment.mediaType.startsWith('image/')) {
const processedImage = await this.processMedia(attachment);
if (!processedImage) continue;
Logger.debug(
{
mediaType: attachment.mediaType,
url: attachment.url,
hasAltText: !!attachment.name,
hasProcessedDescription: !!processedImage.description,
},
'Processed ActivityPub image attachment',
);
if (!primaryMediaClaimed) {
image = processedImage;
primaryMediaClaimed = true;
} else {
galleryEmbeds.push(this.createRichImageEmbed(url, processedImage));
}
continue;
}
if (attachment.mediaType.startsWith('video/')) {
const processedVideo = await this.processMedia(attachment);
if (!processedVideo) continue;
const thumbnailAttachment = post.attachment?.find(
(candidate) => candidate.mediaType.startsWith('image/') && candidate.url !== attachment.url,
);
const processedThumbnail = thumbnailAttachment ? await this.processMedia(thumbnailAttachment) : undefined;
Logger.debug(
{
mediaType: attachment.mediaType,
url: attachment.url,
hasAltText: !!attachment.name,
hasVideoDescription: !!processedVideo.description,
hasThumbnailDescription: !!processedThumbnail?.description,
},
'Processed ActivityPub video attachment',
);
if (!primaryMediaClaimed) {
video = processedVideo;
thumbnail = processedThumbnail;
primaryMediaClaimed = true;
} else {
galleryEmbeds.push(this.createRichVideoEmbed(url, processedVideo, processedThumbnail));
}
}
}
return {image, video, thumbnail, galleryEmbeds};
}
private createRichImageEmbed(url: URL, image: ProcessedMedia): MessageEmbedResponse {
return {
type: 'rich',
url: url.toString(),
image,
};
}
private createRichVideoEmbed(url: URL, video: ProcessedMedia, thumbnail?: ProcessedMedia): MessageEmbedResponse {
return {
type: 'rich',
url: url.toString(),
video,
thumbnail,
};
}
private getCollectionTotalItems(collection: number | {totalItems?: number} | undefined): number {
if (typeof collection === 'number') {
return collection;
}
if (collection && typeof collection.totalItems === 'number') {
return collection.totalItems;
}
return 0;
}
private buildOptionalIconUrl(iconUrl: string | undefined): {icon_url?: string} {
const sanitizedIconUrl = sanitizeOptionalAbsoluteUrl(iconUrl);
if (!sanitizedIconUrl) {
return {};
}
return {icon_url: sanitizedIconUrl};
}
private buildOptionalAuthorUrl(authorUrl: string | undefined): {url?: string} {
const sanitizedAuthorUrl = sanitizeOptionalAbsoluteUrl(authorUrl);
if (!sanitizedAuthorUrl) {
return {};
}
return {url: sanitizedAuthorUrl};
}
private buildActivityPubAuthorLabel(
authorName: string,
preferredUsername: string,
authorUrl: string,
serverDomain: string,
): string {
const usernameFromUrl = this.getActivityPubUsernameFromUrl(authorUrl);
const username = (preferredUsername || usernameFromUrl || authorName).replace(/^@/, '');
const displayName = authorName || username;
if (!username) {
return displayName;
}
return `${displayName} (@${username}@${serverDomain})`;
}
private getActivityPubUsernameFromUrl(url: string): string | undefined {
if (!url) {
return;
}
try {
const parsedUrl = new URL(url);
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
if (pathSegments.length === 0) {
return;
}
if (pathSegments[0] === '@' && pathSegments[1]) {
return pathSegments[1];
}
if (pathSegments[0]?.startsWith('@')) {
return pathSegments[0].slice(1);
}
const usersIndex = pathSegments.indexOf('users');
if (usersIndex >= 0 && pathSegments[usersIndex + 1]) {
return pathSegments[usersIndex + 1];
}
return pathSegments[pathSegments.length - 1];
} catch (error) {
Logger.debug({error, url}, 'Failed to parse ActivityPub author URL');
return;
}
}
private getAttachmentDimension(
attachment: MastodonMediaAttachment | ActivityPubAttachment,
dimension: 'width' | 'height',
): number | undefined {
if (dimension === 'width' && 'width' in attachment && typeof attachment.width === 'number') {
return attachment.width;
}
if (dimension === 'height' && 'height' in attachment && typeof attachment.height === 'number') {
return attachment.height;
}
if (this.isMastodonMediaAttachment(attachment)) {
return attachment.meta.original?.[dimension] ?? attachment.meta.small?.[dimension];
}
return undefined;
}
private isMastodonMediaAttachment(
attachment: MastodonMediaAttachment | ActivityPubAttachment,
): attachment is MastodonMediaAttachment {
return 'meta' in attachment;
}
}

View File

@@ -0,0 +1,359 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import {Logger} from '@fluxer/api/src/Logger';
import {ActivityPubFetcher} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubFetcher';
import {ActivityPubFormatter} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubFormatter';
import type {
ActivityPubContext,
ActivityPubPost,
MastodonPost,
} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubTypes';
import {extractAppleTouchIcon, extractPostId} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubUtils';
import * as DOMUtils from '@fluxer/api/src/utils/DOMUtils';
import * as FetchUtils from '@fluxer/api/src/utils/FetchUtils';
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {ms} from 'itty-time';
interface ResolveActivityPubOptions {
maxActivityPubRedirects?: number;
preferActivityPubJson?: boolean;
skipMastodonFallback?: boolean;
}
export class ActivityPubResolver {
private fetcher: ActivityPubFetcher;
private formatter: ActivityPubFormatter;
constructor(cacheService: ICacheService, mediaService: IMediaService) {
this.fetcher = new ActivityPubFetcher(cacheService);
this.formatter = new ActivityPubFormatter(mediaService);
}
private async buildContext(url: URL, html?: string): Promise<ActivityPubContext> {
const instanceInfo = await this.fetcher.fetchInstanceInfo(url.origin);
const appleTouchIcon = html ? extractAppleTouchIcon(html, url) : undefined;
const instanceThumbnail = instanceInfo?.thumbnail?.url;
const cleanedHostname = url.hostname.replace(/^(?:www\.|social\.|mstdn\.)/, '');
return {
serverDomain: instanceInfo?.domain || cleanedHostname,
serverName: instanceInfo?.domain || cleanedHostname,
serverTitle: instanceInfo?.title || `${cleanedHostname} Mastodon`,
serverIcon: instanceThumbnail || appleTouchIcon,
};
}
private extractUsernameFromActorUrl(actorUrl: string): string | undefined {
try {
const url = new URL(actorUrl);
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length === 0) {
return;
}
if (parts[0] === 'users' && parts[1]) {
return parts[1];
}
if (parts[0]?.startsWith('@')) {
return parts[0].slice(1);
}
return parts[parts.length - 1];
} catch (error) {
Logger.debug({error, actorUrl}, 'Failed to extract username from actor URL');
return;
}
}
private buildActorProfileUrl(actorUrl: string, preferredUsername?: string): string {
try {
const url = new URL(actorUrl);
const username = (preferredUsername || this.extractUsernameFromActorUrl(actorUrl) || '').replace(/^@/, '');
if (!username) {
return actorUrl;
}
return `${url.origin}/@${username}`;
} catch (error) {
Logger.debug({error, actorUrl}, 'Failed to build actor profile URL');
return actorUrl;
}
}
private buildActivityPubHandle(username: string, host: string): string {
const normalizedUsername = username.replace(/^@/, '');
if (!normalizedUsername) {
return '';
}
return `@${normalizedUsername}@${host}`;
}
private extractHostname(url: string): string | undefined {
try {
return new URL(url).hostname;
} catch (error) {
Logger.debug({error, url}, 'Failed to extract hostname');
return;
}
}
private isActivityPubPost(post: ActivityPubPost | null): post is ActivityPubPost {
return (
post != null &&
typeof post.url === 'string' &&
typeof post.published === 'string' &&
typeof post.attributedTo !== 'undefined'
);
}
private resolvePostUrl(fallbackUrl: URL, postUrl?: string): URL {
if (!postUrl) {
return fallbackUrl;
}
try {
return new URL(postUrl);
} catch (error) {
Logger.debug({error, postUrl}, 'Failed to parse post URL, using fallback URL');
return fallbackUrl;
}
}
private getQuoteUrl(post: ActivityPubPost): string | undefined {
return post.quote || post.quoteUri || post._misskey_quote;
}
private async tryFetchParentPost(inReplyToUrl: string): Promise<ActivityPubContext['inReplyTo'] | undefined> {
try {
const parentPost = await this.fetcher.tryFetchActivityPubData(inReplyToUrl);
if (!this.isActivityPubPost(parentPost)) return;
let authorName = '';
let authorUrl = parentPost.url;
if (typeof parentPost.attributedTo === 'string') {
const actorHostname = this.extractHostname(parentPost.attributedTo);
const username = this.extractUsernameFromActorUrl(parentPost.attributedTo) || '';
authorName = actorHostname ? this.buildActivityPubHandle(username, actorHostname) : username;
authorUrl = this.buildActorProfileUrl(parentPost.attributedTo, username);
} else if (parentPost.attributedTo) {
const attributedTo = parentPost.attributedTo;
const actorHostname = attributedTo.url ? this.extractHostname(attributedTo.url) : undefined;
const username =
attributedTo.preferredUsername ||
(attributedTo.url ? this.extractUsernameFromActorUrl(attributedTo.url) : '') ||
attributedTo.name ||
'';
authorName = actorHostname ? this.buildActivityPubHandle(username, actorHostname) : username;
if (attributedTo.url) {
authorUrl = this.buildActorProfileUrl(attributedTo.url, attributedTo.preferredUsername);
}
}
const content = parentPost.content ? DOMUtils.htmlToMarkdown(parentPost.content) : '';
const urlObj = new URL(parentPost.url);
const idMatch = extractPostId(urlObj);
return {author: authorName, content, url: authorUrl, id: idMatch || undefined};
} catch (error) {
Logger.error({error, inReplyToUrl}, 'Failed to fetch parent post');
return;
}
}
private formatMastodonParentAuthor(parentPost: MastodonPost): string {
const accountHost = this.extractHostname(parentPost.account.url) || '';
const accountHandle =
parentPost.account.acct.includes('@') || !accountHost
? parentPost.account.acct
: `${parentPost.account.acct}@${accountHost}`;
return `@${accountHandle}`.replace(/^@@/, '@');
}
private async tryResolveFromActivityPubData(
url: URL,
activityPubUrl: string,
context: ActivityPubContext,
maxActivityPubRedirects?: number,
): Promise<Array<MessageEmbedResponse> | null> {
const activityPubPost = maxActivityPubRedirects
? await this.fetcher.tryFetchActivityPubDataWithRedirectLimit(activityPubUrl, maxActivityPubRedirects)
: await this.fetcher.tryFetchActivityPubData(activityPubUrl);
if (!this.isActivityPubPost(activityPubPost)) {
return null;
}
Logger.debug({url: url.toString(), activityPubUrl}, 'Successfully fetched ActivityPub data');
if (activityPubPost.inReplyTo) {
const parentUrl = typeof activityPubPost.inReplyTo === 'string' ? activityPubPost.inReplyTo : null;
if (parentUrl) {
context.inReplyTo = await this.tryFetchParentPost(parentUrl);
}
}
const embedUrl = this.resolvePostUrl(url, activityPubPost.url);
const embeds = await this.formatter.buildActivityPubEmbeds(
activityPubPost,
embedUrl,
context,
this.fetcher.tryFetchActivityPubData.bind(this.fetcher),
);
await this.attachQuoteChildEmbed(activityPubPost, embeds);
return embeds;
}
private async attachQuoteChildEmbed(post: ActivityPubPost, embeds: Array<MessageEmbedResponse>): Promise<void> {
const rootEmbed = embeds[0];
if (!rootEmbed) {
return;
}
const quoteUrl = this.getQuoteUrl(post);
if (!quoteUrl) {
return;
}
const quotePost = await this.fetcher.tryFetchActivityPubData(quoteUrl);
if (!this.isActivityPubPost(quotePost)) {
return;
}
let fallbackQuoteUrl: URL;
try {
fallbackQuoteUrl = new URL(quoteUrl);
} catch (error) {
Logger.debug({error, quoteUrl}, 'Skipping quote embed with invalid quote URL');
return;
}
const quoteEmbedUrl = this.resolvePostUrl(fallbackQuoteUrl, quotePost.url);
const quoteContext = await this.buildContext(quoteEmbedUrl);
if (quotePost.inReplyTo && typeof quotePost.inReplyTo === 'string') {
quoteContext.inReplyTo = await this.tryFetchParentPost(quotePost.inReplyTo);
}
const quoteChildEmbed = await this.formatter.buildActivityPubEmbed(
quotePost,
quoteEmbedUrl,
quoteContext,
this.fetcher.tryFetchActivityPubData.bind(this.fetcher),
undefined,
true,
);
rootEmbed.children = [quoteChildEmbed];
}
async resolveActivityPub(
url: URL,
activityPubUrl: string | null,
html: string,
options: ResolveActivityPubOptions = {},
): Promise<Array<MessageEmbedResponse> | null> {
try {
Logger.debug({url: url.toString()}, 'Resolving ActivityPub URL');
const context = await this.buildContext(url, html);
const preferActivityPubJson = options.preferActivityPubJson ?? true;
if (activityPubUrl && preferActivityPubJson) {
const activityPubEmbeds = await this.tryResolveFromActivityPubData(
url,
activityPubUrl,
context,
options.maxActivityPubRedirects,
);
if (activityPubEmbeds) {
return activityPubEmbeds;
}
if (options.skipMastodonFallback) {
Logger.debug({url: url.toString(), activityPubUrl}, 'Skipping Mastodon API fallback');
return null;
}
}
const postId = extractPostId(url);
if (!postId) {
Logger.debug({url: url.toString()}, 'No post ID found in URL');
return null;
}
const mastodonPost = await this.fetcher.tryFetchMastodonApi(url.origin, postId);
if (mastodonPost) {
Logger.debug({url: url.toString(), postId}, 'Successfully fetched Mastodon API data');
if (mastodonPost.in_reply_to_id && mastodonPost.in_reply_to_account_id) {
try {
const parentPostUrl = `${url.origin}/api/v1/statuses/${mastodonPost.in_reply_to_id}`;
const response = await FetchUtils.sendRequest({
url: parentPostUrl,
method: 'GET',
timeout: ms('5 seconds'),
headers: {Accept: 'application/json'},
});
if (response.status === 200) {
const data = await FetchUtils.streamToString(response.stream);
const parentPost = JSON.parse(data) as MastodonPost;
context.inReplyTo = {
author: this.formatMastodonParentAuthor(parentPost),
content: DOMUtils.htmlToMarkdown(parentPost.content),
url: parentPost.account.url || parentPost.url,
id: mastodonPost.in_reply_to_id,
};
}
} catch (error) {
Logger.error({error, inReplyToId: mastodonPost.in_reply_to_id}, 'Failed to fetch parent post for Mastodon');
}
}
return await this.formatter.buildMastodonEmbeds(mastodonPost, url, context);
}
if (activityPubUrl && !preferActivityPubJson) {
const activityPubEmbeds = await this.tryResolveFromActivityPubData(
url,
activityPubUrl,
context,
options.maxActivityPubRedirects,
);
if (activityPubEmbeds) {
return activityPubEmbeds;
}
}
if (url.pathname.includes('/notice/')) {
const noticeId = url.pathname.split('/notice/')[1]?.split('/')[0];
if (noticeId) {
const pleromaApiUrl = `${url.origin}/api/v1/statuses/${noticeId}`;
Logger.debug({pleromaApiUrl}, 'Trying Pleroma-compatible API endpoint');
const pleromaPost = await this.fetcher.tryFetchMastodonApi(url.origin, noticeId);
if (pleromaPost) {
Logger.debug({url: url.toString(), noticeId}, 'Successfully fetched Pleroma API data');
return await this.formatter.buildMastodonEmbeds(pleromaPost, url, context);
}
}
}
Logger.debug({url: url.toString()}, 'Could not resolve as ActivityPub');
return null;
} catch (error) {
Logger.error({error, url: url.toString()}, 'Failed to resolve ActivityPub URL');
return null;
}
}
}

View File

@@ -0,0 +1,201 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface ActivityPubAuthor {
id: string;
type: string;
name?: string;
preferredUsername?: string;
url?: string;
icon?: {type: string; mediaType: string; url: string};
}
export interface ActivityPubAttachment {
type: string;
mediaType: string;
url: string;
width?: number;
height?: number;
name?: string;
blurhash?: string;
}
export interface ActivityPubPost {
id: string;
type: string;
url: string;
published: string;
attributedTo: string | ActivityPubAuthor;
content: string;
summary?: string;
sensitive?: boolean;
attachment?: Array<ActivityPubAttachment>;
tag?: Array<{type: string; name: string; href?: string}>;
to?: Array<string>;
cc?: Array<string>;
inReplyTo?: string | null;
likes?: number | {totalItems?: number};
shares?: number | {totalItems?: number};
replies?: {totalItems?: number};
quote?: string;
quoteUri?: string;
_misskey_quote?: string;
}
export interface MastodonPost {
id: string;
created_at: string;
in_reply_to_id: string | null;
in_reply_to_account_id: string | null;
sensitive: boolean;
spoiler_text: string;
visibility: string;
language: string | null;
uri: string;
url: string;
replies_count: number;
reblogs_count: number;
favourites_count: number;
edited_at: string | null;
content: string;
reblog: MastodonPost | null;
application: {name: string} | null;
account: MastodonAccount;
media_attachments: Array<MastodonMediaAttachment>;
mentions: Array<MastodonMention>;
tags: Array<MastodonTag>;
emojis: Array<MastodonEmoji>;
card: MastodonCard | null;
poll: MastodonPoll | null;
}
export interface MastodonInstance {
domain: string;
title: string;
version: string;
source_url: string;
description: string;
usage: {users: {active_month: number}};
thumbnail: {url: string};
languages: Array<string>;
configuration: {urls: {streaming: string; status: string | null}};
registrations: {enabled: boolean; approval_required: boolean; message: string; url: string | null};
contact: {email: string; account: MastodonAccount};
rules: Array<{id: string; text: string; hint: string}>;
}
interface MastodonAccount {
id: string;
username: string;
acct: string;
url: string;
display_name: string;
note: string;
avatar: string;
avatar_static: string;
header: string;
header_static: string;
locked: boolean;
fields: Array<{name: string; value: string}>;
emojis: Array<MastodonEmoji>;
bot: boolean;
group: boolean;
discoverable: boolean | null;
}
export interface MastodonMediaAttachment {
id: string;
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio';
url: string;
preview_url: string;
remote_url: string | null;
meta: {
original?: {width: number; height: number; size: string; aspect: number};
small?: {width: number; height: number; size: string; aspect: number};
};
description: string | null;
blurhash: string | null;
}
interface MastodonMention {
id: string;
username: string;
url: string;
acct: string;
}
interface MastodonTag {
name: string;
url: string;
}
interface MastodonEmoji {
shortcode: string;
url: string;
static_url: string;
visible_in_picker: boolean;
}
interface MastodonCard {
url: string;
title: string;
description: string;
type: string;
author_name: string;
author_url: string;
provider_name: string;
provider_url: string;
html: string;
width: number;
height: number;
image: string | null;
embed_url: string;
}
interface MastodonPoll {
id: string;
expires_at: string | null;
expired: boolean;
multiple: boolean;
votes_count: number;
voters_count: number | null;
voted: boolean | null;
own_votes: Array<number> | null;
options: Array<{title: string; votes_count: number | null}>;
emojis: Array<MastodonEmoji>;
}
export interface ProcessedMedia {
url: string;
proxy_url?: string;
width: number;
height: number;
placeholder?: string;
flags: number;
description?: string;
content_type?: string;
content_hash?: string;
duration?: number;
}
export interface ActivityPubContext {
inReplyTo?: {author: string; content: string; url: string; id?: string};
serverName: string;
serverTitle: string;
serverIcon?: string;
serverDomain: string;
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@fluxer/api/src/Logger';
import {selectOne} from 'css-select';
import type {Element} from 'domhandler';
import {parseDocument} from 'htmlparser2';
export function extractPostId(url: URL): string | null {
const mastodonMatch = url.pathname.match(/\/@([^/]+)\/(\w+)/);
if (mastodonMatch) return mastodonMatch[2];
const altMastodonMatch = url.pathname.match(/\/users\/[^/]+\/status(?:es)?\/(\w+)/);
if (altMastodonMatch) return altMastodonMatch[1];
const genericMatch = url.pathname.match(/\/[^/]+\/status(?:es)?\/(\w+)/);
if (genericMatch) return genericMatch[1];
const pleromaMatch = url.pathname.match(/\/notice\/([a-zA-Z0-9]+)/);
if (pleromaMatch) return pleromaMatch[1];
const misskeyMatch = url.pathname.match(/\/notes\/([a-zA-Z0-9]+)/);
if (misskeyMatch) return misskeyMatch[1];
Logger.debug({url: url.toString()}, 'Could not extract post ID from URL');
return null;
}
export function extractAppleTouchIcon(html: string, url: URL): string | undefined {
Logger.debug({url: url.toString()}, 'Attempting to extract apple touch icon');
try {
const document = parseDocument(html);
const appleTouchIcon180 = selectOne('link[rel="apple-touch-icon"][sizes="180x180"]', document) as Element | null;
if (appleTouchIcon180?.attribs['href']) {
const iconPath = appleTouchIcon180.attribs['href'];
const fullPath = iconPath.startsWith('http') ? iconPath : new URL(iconPath, url.origin).toString();
Logger.debug({iconPath, fullPath}, 'Found 180x180 apple touch icon');
return fullPath;
}
const anyAppleTouchIcon = selectOne('link[rel="apple-touch-icon"]', document) as Element | null;
if (anyAppleTouchIcon?.attribs['href']) {
const iconPath = anyAppleTouchIcon.attribs['href'];
const fullPath = iconPath.startsWith('http') ? iconPath : new URL(iconPath, url.origin).toString();
Logger.debug({iconPath, fullPath}, 'Found fallback apple touch icon');
return fullPath;
}
Logger.debug('No apple touch icon found');
return;
} catch (error) {
Logger.error({error}, 'Error parsing HTML for apple touch icon');
return;
}
}
export function escapeMarkdownChars(text: string): string {
return text
.replace(/\\\[/g, '\\[')
.replace(/\\\]/g, '\\]')
.replace(/\\\(/g, '\\(')
.replace(/\\\)/g, '\\)')
.replace(/\\-/g, '\\-');
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ActivityPubFetcher} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubFetcher';
import {MockCacheService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {describe, expect, it} from 'vitest';
describe('ActivityPubFetcher', () => {
it('returns null for ActivityPub actor fetches to internal IPv4 literals', async () => {
const fetcher = new ActivityPubFetcher(new MockCacheService());
const result = await fetcher.tryFetchActivityPubData('http://127.0.0.1/users/alice');
expect(result).toBeNull();
});
it('returns null for ActivityPub actor fetches to localhost hostnames', async () => {
const fetcher = new ActivityPubFetcher(new MockCacheService());
const result = await fetcher.tryFetchActivityPubData('https://localhost/users/alice');
expect(result).toBeNull();
});
it('returns null for ActivityPub actor fetches to internal IPv6 literals', async () => {
const fetcher = new ActivityPubFetcher(new MockCacheService());
const result = await fetcher.tryFetchActivityPubData('http://[::1]/users/alice');
expect(result).toBeNull();
});
it('returns null for instance metadata fetches to internal addresses', async () => {
const fetcher = new ActivityPubFetcher(new MockCacheService());
const result = await fetcher.fetchInstanceInfo('http://127.0.0.1');
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,176 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ActivityPubFormatter} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubFormatter';
import type {
ActivityPubAuthor,
ActivityPubContext,
ActivityPubPost,
} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubTypes';
import {MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {MessageEmbedResponse as MessageEmbedResponseSchema} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {describe, expect, it} from 'vitest';
function createContext(overrides: Partial<ActivityPubContext> = {}): ActivityPubContext {
return {
serverDomain: 'example.com',
serverName: 'example',
serverTitle: 'Example Server',
...overrides,
};
}
function createPost(overrides: Partial<ActivityPubPost> = {}): ActivityPubPost {
return {
id: 'https://remote.example/users/alice/statuses/1',
type: 'Note',
url: 'https://remote.example/@alice/1',
published: '2026-02-16T09:00:00.000Z',
attributedTo: 'https://remote.example/users/alice',
content: '<p>Hello world</p>',
...overrides,
};
}
function createFetchedAuthorData(
options: {iconUrl?: string; authorName?: string; preferredUsername?: string; authorUrl?: string} = {},
): ActivityPubPost & ActivityPubAuthor {
const authorUrl = options.authorUrl ?? 'https://remote.example/@alice';
return {
id: 'https://remote.example/users/alice',
type: 'Person',
url: authorUrl,
published: '2026-02-16T09:00:00.000Z',
attributedTo: authorUrl,
content: '',
name: options.authorName ?? 'Alice',
preferredUsername: options.preferredUsername ?? 'alice',
...(options.iconUrl !== undefined
? {
icon: {
type: 'Image',
mediaType: 'image/png',
url: options.iconUrl,
},
}
: {}),
};
}
describe('ActivityPubFormatter', () => {
it('omits empty icon URLs when author and server icons are missing', async () => {
const formatter = new ActivityPubFormatter(new MockMediaService());
const post = createPost();
const context = createContext({serverIcon: ''});
const embed = await formatter.buildActivityPubEmbed(post, new URL(post.url), context, async () => null);
expect(embed.author).toBeDefined();
expect(embed.author?.url).toBe('https://remote.example/users/alice');
expect(embed.author).not.toHaveProperty('icon_url');
expect(embed.footer).toBeDefined();
expect(embed.footer).not.toHaveProperty('icon_url');
expect(() => MessageEmbedResponseSchema.parse(embed)).not.toThrow();
});
it('omits invalid icon URLs from author and footer', async () => {
const formatter = new ActivityPubFormatter(new MockMediaService());
const post = createPost();
const context = createContext({serverIcon: 'not-a-url'});
const fetchedAuthorData = createFetchedAuthorData({iconUrl: 'not-a-url', authorUrl: 'not-a-url'});
const embed = await formatter.buildActivityPubEmbed(
post,
new URL(post.url),
context,
async () => fetchedAuthorData,
);
expect(embed.author).toBeDefined();
expect(embed.author).not.toHaveProperty('url');
expect(embed.author).not.toHaveProperty('icon_url');
expect(embed.footer).toBeDefined();
expect(embed.footer).not.toHaveProperty('icon_url');
expect(() => MessageEmbedResponseSchema.parse(embed)).not.toThrow();
});
it('omits an empty author icon URL from object attributedTo payloads', async () => {
const formatter = new ActivityPubFormatter(new MockMediaService());
const post = createPost({
attributedTo: {
id: 'https://remote.example/users/alice',
type: 'Person',
name: 'Alice',
preferredUsername: 'alice',
url: 'https://remote.example/@alice',
icon: {
type: 'Image',
mediaType: 'image/png',
url: '',
},
},
});
const context = createContext();
const embed = await formatter.buildActivityPubEmbed(post, new URL(post.url), context, async () => null);
expect(embed.author).toBeDefined();
expect(embed.author).not.toHaveProperty('icon_url');
expect(() => MessageEmbedResponseSchema.parse(embed)).not.toThrow();
});
it('omits an invalid author url from object attributedTo payloads', async () => {
const formatter = new ActivityPubFormatter(new MockMediaService());
const post = createPost({
attributedTo: {
id: 'https://remote.example/users/alice',
type: 'Person',
name: 'Alice',
preferredUsername: 'alice',
url: 'not-a-url',
},
});
const context = createContext();
const embed = await formatter.buildActivityPubEmbed(post, new URL(post.url), context, async () => null);
expect(embed.author).toBeDefined();
expect(embed.author).not.toHaveProperty('url');
expect(() => MessageEmbedResponseSchema.parse(embed)).not.toThrow();
});
it('keeps valid icon URLs for author and footer', async () => {
const formatter = new ActivityPubFormatter(new MockMediaService());
const post = createPost();
const context = createContext({serverIcon: 'https://remote.example/server-icon.png'});
const fetchedAuthorData = createFetchedAuthorData({iconUrl: 'https://remote.example/avatar.png'});
const embed = await formatter.buildActivityPubEmbed(
post,
new URL(post.url),
context,
async () => fetchedAuthorData,
);
expect(embed.author?.url).toBe('https://remote.example/@alice');
expect(embed.author?.icon_url).toBe('https://remote.example/avatar.png');
expect(embed.footer?.icon_url).toBe('https://remote.example/server-icon.png');
expect(() => MessageEmbedResponseSchema.parse(embed)).not.toThrow();
});
});

View File

@@ -0,0 +1,215 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {server} from '@fluxer/api/src/test/msw/server';
import {ActivityPubResolver} from '@fluxer/api/src/unfurler/resolvers/subresolvers/ActivityPubResolver';
import {MockCacheService, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {
MessageEmbedChildResponse,
MessageEmbedResponse as MessageEmbedResponseSchema,
} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {HttpResponse, http} from 'msw';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('ActivityPubResolver', () => {
let mediaService: MockMediaService;
let cacheService: MockCacheService;
let resolver: ActivityPubResolver;
beforeEach(() => {
mediaService = new MockMediaService();
cacheService = new MockCacheService();
resolver = new ActivityPubResolver(cacheService, mediaService);
});
afterEach(() => {
mediaService.reset();
cacheService.reset();
});
it('resolves media galleries, reply labels, and quote child embeds from ActivityPub JSON', async () => {
const postUrl = new URL('https://infosec.exchange/@SwiftOnSecurity/115561371389489150');
const parentPostUrl = 'https://infosec.exchange/users/SwiftOnSecurity/statuses/115561339842883224';
const quotePostUrl = 'https://tldr.nettime.org/@tante/116024418338054881';
server.use(
http.get('https://infosec.exchange/api/v2/instance', () => {
return HttpResponse.json({
domain: 'infosec.exchange',
title: 'Infosec Exchange',
});
}),
http.get('https://tldr.nettime.org/api/v2/instance', () => {
return HttpResponse.json({
domain: 'tldr.nettime.org',
title: 'TLDR',
});
}),
http.get(postUrl.href, () => {
return HttpResponse.json({
id: 'https://infosec.exchange/users/SwiftOnSecurity/statuses/115561371389489150',
type: 'Note',
url: postUrl.href,
published: '2025-11-16T20:56:29Z',
attributedTo: {
id: 'https://infosec.exchange/users/SwiftOnSecurity',
type: 'Person',
name: 'SwiftOnSecurity',
preferredUsername: 'SwiftOnSecurity',
url: 'https://infosec.exchange/@SwiftOnSecurity',
icon: {
type: 'Image',
mediaType: 'image/jpeg',
url: 'https://media.infosec.exchange/accounts/swiftonsecurity.jpeg',
},
},
inReplyTo: parentPostUrl,
content: '<p>New computer who dis</p>',
quote: quotePostUrl,
attachment: [
{
type: 'Document',
mediaType: 'image/jpeg',
url: 'https://media.infosec.exchange/1.jpeg',
name: 'Fractal Pop Air computer case with Noctua cooler and 4090',
width: 2494,
height: 3325,
},
{
type: 'Document',
mediaType: 'image/jpeg',
url: 'https://media.infosec.exchange/2.jpeg',
name: null,
width: 2239,
height: 3704,
},
{
type: 'Document',
mediaType: 'image/jpeg',
url: 'https://media.infosec.exchange/3.jpeg',
name: null,
width: 2494,
height: 3325,
},
],
});
}),
http.get(parentPostUrl, () => {
return HttpResponse.json({
id: parentPostUrl,
type: 'Note',
url: parentPostUrl,
published: '2025-11-16T20:50:00Z',
attributedTo: {
id: 'https://infosec.exchange/users/SwiftOnSecurity',
type: 'Person',
name: 'SwiftOnSecurity',
preferredUsername: 'SwiftOnSecurity',
url: 'https://infosec.exchange/@SwiftOnSecurity',
},
content: '<p>Parent post</p>',
});
}),
http.get(quotePostUrl, () => {
return HttpResponse.json({
id: 'https://tldr.nettime.org/users/tante/statuses/116024418338054881',
type: 'Note',
url: quotePostUrl,
published: '2026-02-06T15:50:00Z',
attributedTo: {
id: 'https://tldr.nettime.org/users/tante',
type: 'Person',
name: 'tante',
preferredUsername: 'tante',
url: 'https://tldr.nettime.org/@tante',
},
content: '<p>Quoted post body</p>',
});
}),
);
const embeds = await resolver.resolveActivityPub(postUrl, postUrl.href, '<html></html>');
expect(embeds).toBeTruthy();
expect(embeds).toHaveLength(3);
expect(embeds?.[0]?.description).toContain(
'-# ↩ [@SwiftOnSecurity@infosec.exchange](https://infosec.exchange/@SwiftOnSecurity)',
);
expect(embeds?.[0]?.description).toContain('New computer who dis');
expect(embeds?.[0]?.image?.description).toBe('Fractal Pop Air computer case with Noctua cooler and 4090');
expect(embeds?.[1]?.image?.url).toBe('https://media.infosec.exchange/2.jpeg');
expect(embeds?.[2]?.image?.url).toBe('https://media.infosec.exchange/3.jpeg');
const childEmbed = embeds?.[0]?.children?.[0];
expect(childEmbed?.description).toContain('Quoted post body');
expect(childEmbed?.timestamp).toBeUndefined();
expect(childEmbed?.footer).toBeUndefined();
expect(childEmbed?.fields).toBeUndefined();
for (const embed of embeds ?? []) {
expect(() => MessageEmbedResponseSchema.parse(embed)).not.toThrow();
for (const child of embed.children ?? []) {
expect(() => MessageEmbedChildResponse.parse(child)).not.toThrow();
}
}
});
it('sanitises invalid author and server icon URLs from ActivityPub payloads', async () => {
const postUrl = new URL('https://infosec.exchange/@alice/1');
server.use(
http.get('https://infosec.exchange/api/v2/instance', () => {
return HttpResponse.json({
domain: 'infosec.exchange',
title: 'Remote Example',
thumbnail: {url: 'not-a-valid-url'},
});
}),
http.get(postUrl.href, () => {
return HttpResponse.json({
id: 'https://infosec.exchange/users/alice/statuses/1',
type: 'Note',
url: postUrl.href,
published: '2026-02-16T20:56:29Z',
attributedTo: {
id: 'https://infosec.exchange/users/alice',
type: 'Person',
name: 'Alice',
preferredUsername: 'alice',
url: 'not-a-valid-url',
icon: {
type: 'Image',
mediaType: 'image/jpeg',
url: 'not-a-valid-url',
},
},
content: '<p>Sanitisation test</p>',
});
}),
);
const embeds = await resolver.resolveActivityPub(postUrl, postUrl.href, '<html></html>');
expect(embeds).toHaveLength(1);
expect(embeds?.[0]?.author).not.toHaveProperty('url');
expect(embeds?.[0]?.author).not.toHaveProperty('icon_url');
expect(embeds?.[0]?.footer).toBeDefined();
expect(embeds?.[0]?.footer).not.toHaveProperty('icon_url');
expect(() => MessageEmbedResponseSchema.parse(embeds?.[0])).not.toThrow();
});
});

View File

@@ -0,0 +1,174 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AudioResolver} from '@fluxer/api/src/unfurler/resolvers/AudioResolver';
import {createTestAudioContent, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('AudioResolver', () => {
let mediaService: MockMediaService;
let resolver: AudioResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new AudioResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('match', () => {
it('matches audio/mpeg mime type', () => {
const url = new URL('https://example.com/song.mp3');
const result = resolver.match(url, 'audio/mpeg', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches audio/wav mime type', () => {
const url = new URL('https://example.com/sound.wav');
const result = resolver.match(url, 'audio/wav', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches audio/ogg mime type', () => {
const url = new URL('https://example.com/track.ogg');
const result = resolver.match(url, 'audio/ogg', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches audio/flac mime type', () => {
const url = new URL('https://example.com/music.flac');
const result = resolver.match(url, 'audio/flac', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches audio/aac mime type', () => {
const url = new URL('https://example.com/audio.aac');
const result = resolver.match(url, 'audio/aac', new Uint8Array(0));
expect(result).toBe(true);
});
it('does not match video mime types', () => {
const url = new URL('https://example.com/video.mp4');
const result = resolver.match(url, 'video/mp4', new Uint8Array(0));
expect(result).toBe(false);
});
it('does not match image mime types', () => {
const url = new URL('https://example.com/image.png');
const result = resolver.match(url, 'image/png', new Uint8Array(0));
expect(result).toBe(false);
});
it('does not match text/html mime types', () => {
const url = new URL('https://example.com/page.html');
const result = resolver.match(url, 'text/html', new Uint8Array(0));
expect(result).toBe(false);
});
it('does not match application/json mime types', () => {
const url = new URL('https://example.com/data.json');
const result = resolver.match(url, 'application/json', new Uint8Array(0));
expect(result).toBe(false);
});
});
describe('resolve', () => {
it('returns an audio embed with correct structure', async () => {
const url = new URL('https://example.com/song.mp3');
const content = createTestAudioContent();
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(1);
expect(embeds[0]!.type).toBe('audio');
expect(embeds[0]!.url).toBe('https://example.com/song.mp3');
expect(embeds[0]!.audio).toBeDefined();
});
it('includes audio metadata in embed', async () => {
const url = new URL('https://example.com/track.mp3');
const content = createTestAudioContent();
mediaService.setMetadata('base64', {
format: 'mp3',
content_type: 'audio/mpeg',
width: undefined,
height: undefined,
duration: 180,
});
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.audio).toBeDefined();
expect(embeds[0]!.audio!.url).toBe('https://example.com/track.mp3');
});
it('handles NSFW content when allowed', async () => {
const url = new URL('https://example.com/audio.mp3');
const content = createTestAudioContent();
mediaService.setMetadata('base64', {nsfw: true});
const embeds = await resolver.resolve(url, content, true);
expect(embeds).toHaveLength(1);
expect(embeds[0]!.audio).toBeDefined();
});
it('handles NSFW content when not allowed', async () => {
const url = new URL('https://example.com/audio.mp3');
const content = createTestAudioContent();
mediaService.setMetadata('base64', {nsfw: true});
const embeds = await resolver.resolve(url, content, false);
expect(embeds).toHaveLength(1);
});
it('handles URLs with query parameters', async () => {
const url = new URL('https://example.com/song.mp3?token=abc123');
const content = createTestAudioContent();
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.url).toBe('https://example.com/song.mp3?token=abc123');
});
it('handles URLs with fragments', async () => {
const url = new URL('https://example.com/playlist.mp3#track2');
const content = createTestAudioContent();
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.url).toBe('https://example.com/playlist.mp3#track2');
});
it('preserves URL encoding in result', async () => {
const url = new URL('https://example.com/my%20song.mp3');
const content = createTestAudioContent();
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.url).toContain('my%20song.mp3');
});
});
});

View File

@@ -0,0 +1,265 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {BaseResolver} from '@fluxer/api/src/unfurler/resolvers/BaseResolver';
import {MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
class TestableResolver extends BaseResolver {
match(_url: URL, _mimeType: string, _content: Uint8Array): boolean {
return true;
}
async resolve(_url: URL, _content: Uint8Array, _isNSFWAllowed?: boolean): Promise<Array<MessageEmbedResponse>> {
return [];
}
testResolveRelativeURL(baseUrl: string, relativeUrl?: string): string | null {
return this.resolveRelativeURL(baseUrl, relativeUrl);
}
async testResolveMediaURL(
url: URL,
mediaUrl?: string | null,
isNSFWAllowed: boolean = false,
): Promise<MessageEmbedResponse['image']> {
return this.resolveMediaURL(url, mediaUrl, isNSFWAllowed);
}
}
describe('BaseResolver', () => {
let mediaService: MockMediaService;
let resolver: TestableResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new TestableResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('resolveRelativeURL', () => {
it('resolves relative paths correctly', () => {
const result = resolver.testResolveRelativeURL('https://example.com/page/', '../image.jpg');
expect(result).toBe('https://example.com/image.jpg');
});
it('resolves absolute paths correctly', () => {
const result = resolver.testResolveRelativeURL('https://example.com/page/', '/images/photo.jpg');
expect(result).toBe('https://example.com/images/photo.jpg');
});
it('preserves full URLs', () => {
const result = resolver.testResolveRelativeURL('https://example.com/page/', 'https://cdn.example.com/image.jpg');
expect(result).toBe('https://cdn.example.com/image.jpg');
});
it('handles protocol-relative URLs', () => {
const result = resolver.testResolveRelativeURL('https://example.com/page/', '//cdn.example.com/image.jpg');
expect(result).toBe('https://cdn.example.com/image.jpg');
});
it('returns null for undefined input', () => {
const result = resolver.testResolveRelativeURL('https://example.com/', undefined);
expect(result).toBeNull();
});
it('returns null for empty string input', () => {
const result = resolver.testResolveRelativeURL('https://example.com/', '');
expect(result).toBeNull();
});
it('handles URLs with query parameters', () => {
const result = resolver.testResolveRelativeURL('https://example.com/page/', 'image.jpg?v=123');
expect(result).toBe('https://example.com/page/image.jpg?v=123');
});
it('handles URLs with fragments', () => {
const result = resolver.testResolveRelativeURL('https://example.com/page/', 'image.jpg#section');
expect(result).toBe('https://example.com/page/image.jpg#section');
});
it('handles complex relative paths', () => {
const result = resolver.testResolveRelativeURL('https://example.com/a/b/c/', '../../d/image.jpg');
expect(result).toBe('https://example.com/a/d/image.jpg');
});
it('handles base URL without trailing slash', () => {
const result = resolver.testResolveRelativeURL('https://example.com/page', 'image.jpg');
expect(result).toBe('https://example.com/image.jpg');
});
it('handles data URLs by returning as-is', () => {
const dataUrl = 'data:image/png;base64,iVBORw0KGgo=';
const result = resolver.testResolveRelativeURL('https://example.com/', dataUrl);
expect(result).toBe(dataUrl);
});
it('returns original URL for invalid base URLs', () => {
const result = resolver.testResolveRelativeURL('not-a-url', 'image.jpg');
expect(result).toBe('image.jpg');
});
});
describe('resolveMediaURL', () => {
it('returns null for null media URL', async () => {
const url = new URL('https://example.com/page');
const result = await resolver.testResolveMediaURL(url, null);
expect(result).toBeNull();
});
it('returns null for undefined media URL', async () => {
const url = new URL('https://example.com/page');
const result = await resolver.testResolveMediaURL(url, undefined);
expect(result).toBeNull();
});
it('returns null for empty string media URL', async () => {
const url = new URL('https://example.com/page');
const result = await resolver.testResolveMediaURL(url, '');
expect(result).toBeNull();
});
it('resolves valid external media URL', async () => {
const url = new URL('https://example.com/page');
const result = await resolver.testResolveMediaURL(url, 'https://example.com/image.jpg');
expect(result).not.toBeNull();
expect(result!.url).toBe('https://example.com/image.jpg');
});
it('resolves relative media URL', async () => {
const url = new URL('https://example.com/page/');
const result = await resolver.testResolveMediaURL(url, 'image.jpg');
expect(result).not.toBeNull();
expect(result!.url).toBe('https://example.com/page/image.jpg');
});
it('includes width and height in result', async () => {
const url = new URL('https://example.com/page');
mediaService.setMetadata('https://example.com/image.jpg', {
width: 800,
height: 600,
});
const result = await resolver.testResolveMediaURL(url, 'https://example.com/image.jpg');
expect(result).not.toBeNull();
expect(result!.width).toBe(800);
expect(result!.height).toBe(600);
});
it('includes content_type in result', async () => {
const url = new URL('https://example.com/page');
mediaService.setMetadata('https://example.com/image.jpg', {
content_type: 'image/jpeg',
});
const result = await resolver.testResolveMediaURL(url, 'https://example.com/image.jpg');
expect(result).not.toBeNull();
expect(result!.content_type).toBe('image/jpeg');
});
it('includes content_hash in result', async () => {
const url = new URL('https://example.com/page');
const result = await resolver.testResolveMediaURL(url, 'https://example.com/image.jpg');
expect(result).not.toBeNull();
expect(result!.content_hash).toBeDefined();
});
it('includes placeholder in result when available', async () => {
const url = new URL('https://example.com/page');
mediaService.setMetadata('https://example.com/image.jpg', {
placeholder: 'data:image/jpeg;base64,/9j/4AAQ...',
});
const result = await resolver.testResolveMediaURL(url, 'https://example.com/image.jpg');
expect(result).not.toBeNull();
expect(result!.placeholder).toBe('data:image/jpeg;base64,/9j/4AAQ...');
});
it('passes NSFW flag to media service', async () => {
const url = new URL('https://example.com/page');
const result = await resolver.testResolveMediaURL(url, 'https://example.com/image.jpg', true);
expect(result).not.toBeNull();
});
it('returns null when media service fails', async () => {
const url = new URL('https://example.com/page');
mediaService.markAsFailing('https://example.com/broken.jpg');
const result = await resolver.testResolveMediaURL(url, 'https://example.com/broken.jpg');
expect(result).toBeNull();
});
it('handles URL-like strings that get resolved as relative paths', async () => {
const url = new URL('https://example.com/page/');
const result = await resolver.testResolveMediaURL(url, 'not a valid url at all');
expect(result).not.toBeNull();
expect(result!.url).toBe('https://example.com/page/not%20a%20valid%20url%20at%20all');
});
it('handles URLs with special characters', async () => {
const url = new URL('https://example.com/page');
const result = await resolver.testResolveMediaURL(url, 'https://example.com/image%20name.jpg');
expect(result).not.toBeNull();
expect(result!.url).toBe('https://example.com/image%20name.jpg');
});
it('handles URLs with unicode characters', async () => {
const url = new URL('https://example.com/page');
const result = await resolver.testResolveMediaURL(url, 'https://example.com/%E7%94%BB%E5%83%8F.jpg');
expect(result).not.toBeNull();
});
it('handles duration in result for video/audio', async () => {
const url = new URL('https://example.com/page');
mediaService.setMetadata('https://example.com/video.mp4', {
duration: 120.5,
});
const result = await resolver.testResolveMediaURL(url, 'https://example.com/video.mp4');
expect(result).not.toBeNull();
expect(result!.duration).toBe(120.5);
});
it('sets animated flag in result', async () => {
const url = new URL('https://example.com/page');
mediaService.setMetadata('https://example.com/animation.gif', {
animated: true,
});
const result = await resolver.testResolveMediaURL(url, 'https://example.com/animation.gif');
expect(result).not.toBeNull();
expect((result!.flags ?? 0) & 32).toBe(32);
});
it('sets NSFW flag in result when content is NSFW', async () => {
const url = new URL('https://example.com/page');
mediaService.setMetadata('https://example.com/nsfw.jpg', {
nsfw: true,
});
const result = await resolver.testResolveMediaURL(url, 'https://example.com/nsfw.jpg', true);
expect(result).not.toBeNull();
expect((result!.flags ?? 0) & 16).toBe(16);
});
});
});

View File

@@ -0,0 +1,262 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {BlueskyApiClient} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyApiClient';
import {BlueskyEmbedProcessor} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyEmbedProcessor';
import {BlueskyTextFormatter} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyTextFormatter';
import type {BlueskyPost, Facet} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyTypes';
import {MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
function createMockApiClient(): BlueskyApiClient {
return {
resolveDid: async () => 'did:plc:test',
getServiceEndpoint: async () => 'https://bsky.social',
fetchPost: async () => null,
fetchProfile: async () => null,
} as unknown as BlueskyApiClient;
}
function createTestPost(overrides: Partial<BlueskyPost> = {}): BlueskyPost {
return {
uri: 'at://did:plc:test/app.bsky.feed.post/abc123',
author: {
did: 'did:plc:test',
handle: 'testuser.bsky.social',
displayName: 'Test User',
avatar: 'https://cdn.bsky.app/avatar/test.jpg',
},
record: {
text: 'Test post content',
createdAt: new Date().toISOString(),
},
indexedAt: new Date().toISOString(),
replyCount: 0,
repostCount: 0,
likeCount: 0,
quoteCount: 0,
...overrides,
};
}
describe('BlueskyEmbedProcessor', () => {
let mediaService: MockMediaService;
let apiClient: BlueskyApiClient;
let processor: BlueskyEmbedProcessor;
beforeEach(() => {
mediaService = new MockMediaService();
apiClient = createMockApiClient();
processor = new BlueskyEmbedProcessor(mediaService, apiClient);
});
afterEach(() => {
mediaService.reset();
});
describe('processPostEmbed', () => {
it('processes external embed as structured metadata', async () => {
const post = createTestPost({
embed: {
$type: 'app.bsky.embed.external#view',
external: {
uri: 'https://example.com/article',
title: 'Example Article Title',
description: 'This is the article description.',
thumb: 'https://cdn.bsky.app/img/thumb/test.jpg',
},
},
});
const result = await processor.processPostEmbed(post, false);
expect(result.external?.uri).toBe('https://example.com/article');
expect(result.external?.title).toBe('Example Article Title');
expect(result.external?.description).toBe('This is the article description.');
expect(result.thumbnail?.url).toBe('https://cdn.bsky.app/img/thumb/test.jpg');
});
it('processes multiple images as gallery', async () => {
const post = createTestPost({
embed: {
$type: 'app.bsky.embed.images#view',
images: [
{
thumb: 'https://cdn.bsky.app/img/thumb/img1.jpg',
fullsize: 'https://cdn.bsky.app/img/full/img1.jpg',
},
{
thumb: 'https://cdn.bsky.app/img/thumb/img2.jpg',
fullsize: 'https://cdn.bsky.app/img/full/img2.jpg',
},
{
thumb: 'https://cdn.bsky.app/img/thumb/img3.jpg',
fullsize: 'https://cdn.bsky.app/img/full/img3.jpg',
},
],
},
});
const result = await processor.processPostEmbed(post, false);
expect(result.image?.url).toBe('https://cdn.bsky.app/img/full/img1.jpg');
expect(result.galleryImages).toHaveLength(2);
expect(result.galleryImages?.[0]?.url).toBe('https://cdn.bsky.app/img/full/img2.jpg');
expect(result.galleryImages?.[1]?.url).toBe('https://cdn.bsky.app/img/full/img3.jpg');
});
it('returns empty result when post has no embed', async () => {
const post = createTestPost();
const result = await processor.processPostEmbed(post, false);
expect(result.image).toBeUndefined();
expect(result.thumbnail).toBeUndefined();
expect(result.video).toBeUndefined();
expect(result.external).toBeUndefined();
expect(result.galleryImages).toBeUndefined();
});
});
describe('processEmbeddedPost', () => {
it('extracts quoted post and embedded media metadata', async () => {
const post = createTestPost({
embed: {
$type: 'app.bsky.embed.recordWithMedia#view',
record: {
$type: 'app.bsky.embed.record#view',
record: {
$type: 'app.bsky.embed.record#viewRecord',
uri: 'at://did:plc:quoted/app.bsky.feed.post/quoted123',
cid: 'quoted-cid',
author: {
did: 'did:plc:quoted',
handle: 'quoteduser.bsky.social',
displayName: 'Quoted User',
},
value: {
$type: 'app.bsky.feed.post',
text: 'Original post content here.',
createdAt: '2026-02-05T10:00:00.000Z',
},
replyCount: 2,
repostCount: 3,
likeCount: 7,
quoteCount: 1,
bookmarkCount: 5,
embeds: [
{
$type: 'app.bsky.embed.external#view',
external: {
uri: 'https://example.com/quoted-link',
title: 'Quoted Link',
description: 'Extra context from the quoted post.',
thumb: 'https://cdn.bsky.app/img/thumb/quoted.jpg',
},
},
],
indexedAt: '2026-02-05T10:00:00.000Z',
},
},
},
});
const result = await processor.processEmbeddedPost(post, false);
expect(result?.uri).toBe('at://did:plc:quoted/app.bsky.feed.post/quoted123');
expect(result?.author.handle).toBe('quoteduser.bsky.social');
expect(result?.text).toBe('Original post content here.');
expect(result?.likeCount).toBe(7);
expect(result?.embed?.external?.uri).toBe('https://example.com/quoted-link');
expect(result?.embed?.thumbnail?.url).toBe('https://cdn.bsky.app/img/thumb/quoted.jpg');
});
it('returns undefined when there is no quoted post record', async () => {
const post = createTestPost({
embed: {
$type: 'app.bsky.embed.images#view',
images: [
{thumb: 'https://cdn.bsky.app/img/thumb/test.jpg', fullsize: 'https://cdn.bsky.app/img/full/test.jpg'},
],
},
});
const result = await processor.processEmbeddedPost(post, false);
expect(result).toBeUndefined();
});
});
describe('BlueskyResolver.formatCount', () => {
let BlueskyResolver: typeof import('@fluxer/api/src/unfurler/resolvers/BlueskyResolver').BlueskyResolver;
beforeEach(async () => {
const module = await import('@fluxer/api/src/unfurler/resolvers/BlueskyResolver');
BlueskyResolver = module.BlueskyResolver;
});
it('returns count as-is for values less than 1000', () => {
expect(BlueskyResolver.formatCount(8)).toBe('8');
expect(BlueskyResolver.formatCount(999)).toBe('999');
expect(BlueskyResolver.formatCount(0)).toBe('0');
});
it('formats counts between 1000-9999 with one decimal place', () => {
expect(BlueskyResolver.formatCount(1234)).toBe('1.2K');
expect(BlueskyResolver.formatCount(5678)).toBe('5.7K');
expect(BlueskyResolver.formatCount(1000)).toBe('1.0K');
expect(BlueskyResolver.formatCount(9999)).toBe('10.0K');
});
it('formats counts 10000+ as whole number K suffix', () => {
expect(BlueskyResolver.formatCount(10000)).toBe('10K');
expect(BlueskyResolver.formatCount(29000)).toBe('29K');
expect(BlueskyResolver.formatCount(123456)).toBe('123K');
});
});
describe('BlueskyTextFormatter', () => {
let textFormatter: BlueskyTextFormatter;
beforeEach(() => {
textFormatter = new BlueskyTextFormatter();
});
it('truncates deep paths in link display text', () => {
const result = textFormatter.getLinkDisplayText('https://github.com/microsoft/TypeScript/issues/63085');
expect(result).toBe('github.com/microsoft/Ty...');
});
it('replaces link facets with markdown links', () => {
const text = 'Check this out github.com/microsoft/Ty...';
const facets: Array<Facet> = [
{
features: [
{$type: 'app.bsky.richtext.facet#link', uri: 'https://github.com/microsoft/TypeScript/issues/63085'},
],
index: {byteStart: 15, byteEnd: 41},
},
];
const result = textFormatter.embedLinksInText(text, facets);
expect(result).toBe(
'Check this out [github.com/microsoft/Ty...](https://github.com/microsoft/TypeScript/issues/63085)',
);
});
});
});

View File

@@ -0,0 +1,387 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createBlueskyApiHandlers, createBlueskyPost} from '@fluxer/api/src/test/msw/handlers/BlueskyApiHandlers';
import {server} from '@fluxer/api/src/test/msw/server';
import {BlueskyResolver} from '@fluxer/api/src/unfurler/resolvers/BlueskyResolver';
import {createMockContent, MockCacheService, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {MessageEmbedResponse as MessageEmbedResponseSchema} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {HttpResponse, http} from 'msw';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const TEST_DID = 'did:plc:testuser123';
const TEST_HANDLE = 'testuser.bsky.social';
const TEST_POST_ID = 'abc123xyz';
const TEST_POST_URI = `at://${TEST_DID}/app.bsky.feed.post/${TEST_POST_ID}`;
const TEST_POST_URL = `https://bsky.app/profile/${TEST_HANDLE}/post/${TEST_POST_ID}`;
const TEST_CREATED_AT = '2025-01-15T12:00:00.000Z';
describe('BlueskyResolver', () => {
let mediaService: MockMediaService;
let cacheService: MockCacheService;
let resolver: BlueskyResolver;
beforeEach(() => {
mediaService = new MockMediaService();
cacheService = new MockCacheService();
resolver = new BlueskyResolver(cacheService, mediaService);
});
afterEach(() => {
mediaService.reset();
cacheService.reset();
});
describe('match', () => {
it('matches bsky.app HTML links', () => {
const url = new URL('https://bsky.app/profile/handle.bsky.social/post/abc123');
expect(resolver.match(url, 'text/html', createMockContent(''))).toBe(true);
});
it('does not match non-HTML content types', () => {
const url = new URL('https://bsky.app/profile/handle.bsky.social');
expect(resolver.match(url, 'application/json', createMockContent(''))).toBe(false);
});
it('does not match non-bsky domains', () => {
const url = new URL('https://twitter.com/user/status/123');
expect(resolver.match(url, 'text/html', createMockContent(''))).toBe(false);
});
});
describe('resolve posts', () => {
it('returns empty array for unsupported URLs', async () => {
server.use(...createBlueskyApiHandlers({profiles: new Map()}));
const embeds = await resolver.resolve(new URL('https://bsky.app/about'), createMockContent('<html></html>'));
expect(embeds).toHaveLength(0);
});
it('resolves external embeds without blockquote syntax', async () => {
const handles = new Map([[TEST_HANDLE, TEST_DID]]);
const posts = new Map([
[
TEST_POST_URI,
createBlueskyPost({
uri: TEST_POST_URI,
did: TEST_DID,
handle: TEST_HANDLE,
displayName: 'Test User',
text: 'Check out this article!',
createdAt: TEST_CREATED_AT,
embed: {
$type: 'app.bsky.embed.external#view',
external: {
uri: 'https://example.com/article',
title: 'Amazing Article',
description: 'This is a great article about testing.',
thumb: 'https://cdn.bsky.app/img/thumb/external.jpg',
},
},
}),
],
]);
server.use(...createBlueskyApiHandlers({handles, posts}));
const embeds = await resolver.resolve(new URL(TEST_POST_URL), createMockContent('<html></html>'));
expect(embeds).toHaveLength(1);
expect(embeds[0].description).toContain('Check out this article!');
expect(embeds[0].description).toContain('[Amazing Article](https://example.com/article)');
expect(embeds[0].description).not.toContain('> -#');
expect(embeds[0].thumbnail?.url).toContain('cdn.bsky.app');
});
it('drops invalid author avatar URLs from embeds', async () => {
const handles = new Map([[TEST_HANDLE, TEST_DID]]);
const posts = new Map([
[
TEST_POST_URI,
createBlueskyPost({
uri: TEST_POST_URI,
did: TEST_DID,
handle: TEST_HANDLE,
displayName: 'Test User',
avatar: 'not-a-valid-url',
text: 'Avatar sanitisation test',
createdAt: TEST_CREATED_AT,
}),
],
]);
server.use(...createBlueskyApiHandlers({handles, posts}));
const embeds = await resolver.resolve(new URL(TEST_POST_URL), createMockContent('<html></html>'));
expect(embeds).toHaveLength(1);
expect(embeds[0]?.author).not.toHaveProperty('icon_url');
expect(() => MessageEmbedResponseSchema.parse(embeds[0])).not.toThrow();
});
it('moves quoted posts into nested children', async () => {
const quotedDid = 'did:plc:quoteduser456';
const quotedHandle = 'quoteduser.bsky.social';
const handles = new Map([
[TEST_HANDLE, TEST_DID],
[quotedHandle, quotedDid],
]);
const posts = new Map([
[
TEST_POST_URI,
createBlueskyPost({
uri: TEST_POST_URI,
did: TEST_DID,
handle: TEST_HANDLE,
displayName: 'Quote User',
text: 'This is so true!',
createdAt: TEST_CREATED_AT,
repostCount: 8,
quoteCount: 1,
likeCount: 50,
bookmarkCount: 7,
embed: {
$type: 'app.bsky.embed.recordWithMedia#view',
record: {
$type: 'app.bsky.embed.record#view',
record: {
$type: 'app.bsky.embed.record#viewRecord',
uri: `at://${quotedDid}/app.bsky.feed.post/quoted123`,
cid: 'quoted-cid',
author: {
did: quotedDid,
handle: quotedHandle,
displayName: 'Original Author',
},
value: {
$type: 'app.bsky.feed.post',
text: 'My original thought that got quoted.',
createdAt: '2025-01-14T10:00:00.000Z',
},
repostCount: 4,
quoteCount: 2,
likeCount: 30,
bookmarkCount: 3,
embeds: [
{
$type: 'app.bsky.embed.external#view',
external: {
uri: 'https://example.com/quoted-link',
title: 'Quoted link',
description: 'Nested link preview',
thumb: 'https://cdn.bsky.app/img/thumb/quoted.jpg',
},
},
],
indexedAt: '2025-01-14T10:00:00.000Z',
},
},
},
}),
],
]);
server.use(...createBlueskyApiHandlers({handles, posts}));
const embeds = await resolver.resolve(new URL(TEST_POST_URL), createMockContent('<html></html>'));
expect(embeds).toHaveLength(1);
const root = embeds[0];
expect(root.description).toContain('This is so true!');
expect(root.description).not.toContain('My original thought that got quoted.');
expect(root.children).toBeDefined();
expect(root.children).toHaveLength(1);
const child = root.children?.[0];
expect(child?.type).toBe('bluesky');
expect(child?.author?.name).toBe('Original Author (@quoteduser.bsky.social)');
expect(child?.description).toContain('My original thought that got quoted.');
expect(child?.description).toContain('[Quoted link](https://example.com/quoted-link)');
expect(child?.thumbnail?.url).toContain('cdn.bsky.app');
expect(child?.title).toBeUndefined();
expect(child?.fields).toBeUndefined();
expect(child?.timestamp).toBeUndefined();
expect(child?.footer).toBeUndefined();
});
it('resolves image galleries as root + additional gallery embeds', async () => {
const handles = new Map([[TEST_HANDLE, TEST_DID]]);
const posts = new Map([
[
TEST_POST_URI,
createBlueskyPost({
uri: TEST_POST_URI,
did: TEST_DID,
handle: TEST_HANDLE,
text: 'Photo gallery!',
createdAt: TEST_CREATED_AT,
embed: {
$type: 'app.bsky.embed.images#view',
images: [
{
thumb: 'https://cdn.bsky.app/img/1.jpg',
fullsize: 'https://cdn.bsky.app/img/1.jpg',
alt: 'First image',
},
{
thumb: 'https://cdn.bsky.app/img/2.jpg',
fullsize: 'https://cdn.bsky.app/img/2.jpg',
alt: 'Second image',
},
{
thumb: 'https://cdn.bsky.app/img/3.jpg',
fullsize: 'https://cdn.bsky.app/img/3.jpg',
alt: 'Third image',
},
],
},
}),
],
]);
server.use(...createBlueskyApiHandlers({handles, posts}));
const embeds = await resolver.resolve(new URL(TEST_POST_URL), createMockContent('<html></html>'));
expect(embeds).toHaveLength(3);
expect(embeds[0].image?.description).toBe('First image');
expect(embeds[1].type).toBe('rich');
expect(embeds[2].type).toBe('rich');
});
it('adds reply context to root post descriptions', async () => {
const parentDid = 'did:plc:parentuser';
const parentHandle = 'parentuser.bsky.social';
const parentPostId = 'parent123';
const parentUri = `at://${parentDid}/app.bsky.feed.post/${parentPostId}`;
const handles = new Map([
[TEST_HANDLE, TEST_DID],
[parentHandle, parentDid],
]);
const posts = new Map([
[
TEST_POST_URI,
createBlueskyPost({
uri: TEST_POST_URI,
did: TEST_DID,
handle: TEST_HANDLE,
displayName: 'Reply User',
text: 'I agree with this take!',
createdAt: TEST_CREATED_AT,
parent: {
did: parentDid,
handle: parentHandle,
displayName: 'Parent Author',
uri: parentUri,
text: 'This is the parent post content.',
},
}),
],
]);
server.use(...createBlueskyApiHandlers({handles, posts}));
const embeds = await resolver.resolve(new URL(TEST_POST_URL), createMockContent('<html></html>'));
expect(embeds).toHaveLength(1);
expect(embeds[0].description).toContain('-#');
expect(embeds[0].description).toContain('Parent Author');
expect(embeds[0].description).toContain('@parentuser.bsky.social');
});
it('adds engagement fields for non-zero counts', async () => {
const handles = new Map([[TEST_HANDLE, TEST_DID]]);
const posts = new Map([
[
TEST_POST_URI,
createBlueskyPost({
uri: TEST_POST_URI,
did: TEST_DID,
handle: TEST_HANDLE,
text: 'Popular post!',
createdAt: TEST_CREATED_AT,
repostCount: 500,
likeCount: 1500,
quoteCount: 150,
bookmarkCount: 1199,
}),
],
]);
server.use(...createBlueskyApiHandlers({handles, posts}));
const embeds = await resolver.resolve(new URL(TEST_POST_URL), createMockContent('<html></html>'));
expect(embeds).toHaveLength(1);
expect(embeds[0].fields).toHaveLength(4);
expect(embeds[0].fields?.find((field) => field.name === 'likeCount')?.value).toBe('1.5K');
expect(embeds[0].fields?.find((field) => field.name === 'bookmarkCount')?.value).toBe('1.2K');
});
});
describe('resolve profile URLs', () => {
it('resolves profile links', async () => {
const profiles = new Map([
[
TEST_HANDLE,
{
did: TEST_DID,
handle: TEST_HANDLE,
displayName: 'Test User',
description: 'A test account',
indexedAt: TEST_CREATED_AT,
},
],
]);
server.use(...createBlueskyApiHandlers({profiles}));
const embeds = await resolver.resolve(
new URL(`https://bsky.app/profile/${TEST_HANDLE}`),
createMockContent('<html></html>'),
);
expect(embeds).toHaveLength(1);
expect(embeds[0].title).toBe('Test User (@testuser.bsky.social)');
});
});
describe('error handling', () => {
it('returns empty array when handle resolution fails', async () => {
server.use(
http.get('https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle', () => {
return HttpResponse.json({error: 'Handle not found'}, {status: 404});
}),
);
const embeds = await resolver.resolve(new URL(TEST_POST_URL), createMockContent('<html></html>'));
expect(embeds).toHaveLength(0);
});
it('returns empty array when post fetch fails', async () => {
const handles = new Map([[TEST_HANDLE, TEST_DID]]);
server.use(
...createBlueskyApiHandlers({handles, posts: new Map()}),
http.get('https://api.bsky.app/xrpc/app.bsky.feed.getPostThread', () => {
return HttpResponse.json({error: 'Post not found'}, {status: 404});
}),
);
const embeds = await resolver.resolve(new URL(TEST_POST_URL), createMockContent('<html></html>'));
expect(embeds).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,566 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {server} from '@fluxer/api/src/test/msw/server';
import {DefaultResolver} from '@fluxer/api/src/unfurler/resolvers/DefaultResolver';
import {
createMinimalHtml,
createMockContent,
MockCacheService,
MockMediaService,
} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {MessageEmbedResponse as MessageEmbedResponseSchema} from '@fluxer/schema/src/domains/message/EmbedSchemas';
import {HttpResponse, http} from 'msw';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('DefaultResolver', () => {
let mediaService: MockMediaService;
let cacheService: MockCacheService;
let resolver: DefaultResolver;
beforeEach(() => {
mediaService = new MockMediaService();
cacheService = new MockCacheService();
resolver = new DefaultResolver(cacheService, mediaService);
});
afterEach(() => {
mediaService.reset();
cacheService.reset();
});
describe('match', () => {
it('matches text/html content type', () => {
const url = new URL('https://example.com/page');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches text/html with charset', () => {
const url = new URL('https://example.com/page');
const result = resolver.match(url, 'text/html; charset=utf-8', createMockContent(''));
expect(result).toBe(true);
});
it('does not match image types', () => {
const url = new URL('https://example.com/image.png');
const result = resolver.match(url, 'image/png', createMockContent(''));
expect(result).toBe(false);
});
it('does not match video types', () => {
const url = new URL('https://example.com/video.mp4');
const result = resolver.match(url, 'video/mp4', createMockContent(''));
expect(result).toBe(false);
});
it('does not match audio types', () => {
const url = new URL('https://example.com/audio.mp3');
const result = resolver.match(url, 'audio/mpeg', createMockContent(''));
expect(result).toBe(false);
});
it('does not match application/json', () => {
const url = new URL('https://example.com/api/data');
const result = resolver.match(url, 'application/json', createMockContent(''));
expect(result).toBe(false);
});
it('does not match text/plain', () => {
const url = new URL('https://example.com/readme.txt');
const result = resolver.match(url, 'text/plain', createMockContent(''));
expect(result).toBe(false);
});
it('does not match text/xml', () => {
const url = new URL('https://example.com/feed.xml');
const result = resolver.match(url, 'text/xml', createMockContent(''));
expect(result).toBe(false);
});
});
describe('resolve', () => {
it('extracts og:title from HTML', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({title: 'Test Article Title'});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
expect(embeds[0]!.title).toBe('Test Article Title');
});
it('extracts og:description from HTML', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({
title: 'Test Article',
description: 'This is a test article description',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.description).toBe('This is a test article description');
});
it('extracts og:image from HTML', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({
title: 'Test Article',
image: 'https://example.com/image.jpg',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.thumbnail).toBeDefined();
expect(embeds[0]!.thumbnail!.url).toBe('https://example.com/image.jpg');
});
it('extracts og:site_name from HTML', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({
title: 'Test Article',
siteName: 'Example Site',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.provider?.name).toBe('Example Site');
});
it('extracts theme-color from HTML', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({
title: 'Test Article',
themeColor: '#FF5500',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.color).toBe(0xff5500);
});
it('handles 3-character hex color', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({
title: 'Test Article',
themeColor: '#F50',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.color).toBe(0xf50);
});
it('ignores invalid color formats', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({
title: 'Test Article',
themeColor: 'not-a-color',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.color).toBeUndefined();
});
it('extracts og:video from HTML', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({
title: 'Test Article',
video: 'https://example.com/video.mp4',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.video).toBeDefined();
});
it('extracts og:audio from HTML', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({
title: 'Test Article',
audio: 'https://example.com/audio.mp3',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.audio).toBeDefined();
});
it('returns link type embed', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({title: 'Test Article'});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.type).toBe('link');
});
it('includes URL in embed', async () => {
const url = new URL('https://example.com/article?ref=test');
const html = createMinimalHtml({title: 'Test Article'});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.url).toBe('https://example.com/article?ref=test');
});
it('handles empty HTML document', async () => {
const url = new URL('https://example.com/empty');
const html = '<!DOCTYPE html><html><head></head><body></body></html>';
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
expect(embeds[0]!.type).toBe('link');
});
it('handles malformed HTML gracefully', async () => {
const url = new URL('https://example.com/malformed');
const html = '<html><head><title>Test</title><body>Unclosed tags';
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
});
it('truncates long titles', async () => {
const url = new URL('https://example.com/article');
const longTitle = 'A'.repeat(200);
const html = createMinimalHtml({title: longTitle});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.title!.length).toBeLessThanOrEqual(70);
});
it('truncates long descriptions', async () => {
const url = new URL('https://example.com/article');
const longDesc = 'B'.repeat(500);
const html = createMinimalHtml({title: 'Test', description: longDesc});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.description!.length).toBeLessThanOrEqual(350);
});
it('drops invalid oEmbed author URLs and falls back provider URL to origin', async () => {
const url = new URL('https://example.com/article');
const html = `<!DOCTYPE html>
<html>
<head>
<meta property="og:title" content="oEmbed test" />
<link rel="alternate" type="application/json+oembed" href="/oembed.json" />
</head>
<body></body>
</html>`;
server.use(
http.get('https://example.com/oembed.json', () => {
return HttpResponse.json({
provider_name: 'Example provider',
provider_url: 'not-a-valid-url',
author_name: 'Example author',
author_url: 'not-a-valid-url',
});
}),
);
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
expect(embeds[0]?.author?.name).toBe('Example author');
expect(embeds[0]?.author).not.toHaveProperty('url');
expect(embeds[0]?.provider?.name).toBe('Example provider');
expect(embeds[0]?.provider?.url).toBe('https://example.com');
expect(() => MessageEmbedResponseSchema.parse(embeds[0])).not.toThrow();
});
it('filters out relative image URLs that cannot be normalized', async () => {
const url = new URL('https://example.com/article');
const html = `<!DOCTYPE html>
<html>
<head>
<meta property="og:image" content="/images/photo.jpg" />
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.thumbnail).toBeUndefined();
});
it('filters out protocol-relative image URLs that cannot be normalized', async () => {
const url = new URL('https://example.com/article');
const html = `<!DOCTYPE html>
<html>
<head>
<meta property="og:image" content="//cdn.example.com/image.jpg" />
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.thumbnail).toBeUndefined();
});
it('handles NSFW images when allowed', async () => {
const url = new URL('https://example.com/article');
const html = createMinimalHtml({
title: 'Test',
image: 'https://example.com/nsfw-image.jpg',
});
mediaService.markAsNsfw('https://example.com/nsfw-image.jpg');
const embeds = await resolver.resolve(url, createMockContent(html), true);
expect(embeds[0]!.thumbnail).toBeDefined();
});
it('extracts multiple images for gallery', async () => {
const url = new URL('https://example.com/gallery');
const html = `<!DOCTYPE html>
<html>
<head>
<meta property="og:image" content="https://example.com/image1.jpg" />
<meta property="og:image" content="https://example.com/image2.jpg" />
<meta property="og:image" content="https://example.com/image3.jpg" />
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds.length).toBeGreaterThanOrEqual(1);
});
it('limits gallery images to maximum', async () => {
const url = new URL('https://example.com/gallery');
const imagesMeta = Array.from(
{length: 15},
(_, i) => `<meta property="og:image" content="https://example.com/image${i}.jpg" />`,
).join('\n');
const html = `<!DOCTYPE html>
<html>
<head>
${imagesMeta}
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds.length).toBeLessThanOrEqual(11);
});
it('deduplicates identical image URLs', async () => {
const url = new URL('https://example.com/gallery');
const html = `<!DOCTYPE html>
<html>
<head>
<meta property="og:image" content="https://example.com/image.jpg" />
<meta property="og:image" content="https://example.com/image.jpg" />
<meta name="twitter:image" content="https://example.com/image.jpg" />
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
});
it('falls back to HTML title when og:title missing', async () => {
const url = new URL('https://example.com/article');
const html = `<!DOCTYPE html>
<html>
<head>
<title>Fallback Title</title>
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.title).toBe('Fallback Title');
});
it('prefers og:title over HTML title', async () => {
const url = new URL('https://example.com/article');
const html = `<!DOCTYPE html>
<html>
<head>
<title>HTML Title</title>
<meta property="og:title" content="OG Title" />
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.title).toBe('OG Title');
});
it('falls back to twitter:title when og:title missing', async () => {
const url = new URL('https://example.com/article');
const html = `<!DOCTYPE html>
<html>
<head>
<meta name="twitter:title" content="Twitter Title" />
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.title).toBe('Twitter Title');
});
it('extracts image alt text', async () => {
const url = new URL('https://example.com/article');
const html = `<!DOCTYPE html>
<html>
<head>
<meta property="og:image" content="https://example.com/image.jpg" />
<meta property="og:image:alt" content="Description of the image" />
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.thumbnail?.description).toBe('Description of the image');
});
it('resolves ActivityPub via canonical URL when the page was reached through redirects', async () => {
const redirectPageUrl = new URL('https://mastodon.social/redirect/statuses/12345');
const canonicalUrl = 'https://example.com/@alice/12345';
const html = `<!DOCTYPE html>
<html>
<head>
<link href="${canonicalUrl}" rel="canonical" />
</head>
<body></body>
</html>`;
const activityPubAcceptHeaders: Array<string | null> = [];
server.use(
http.get('https://example.com/api/v2/instance', () => {
return HttpResponse.json({
domain: 'example.com',
title: 'Example Social',
});
}),
http.get(canonicalUrl, ({request}) => {
activityPubAcceptHeaders.push(request.headers.get('accept'));
return HttpResponse.json({
id: 'https://example.social/users/alice/statuses/12345',
type: 'Note',
url: canonicalUrl,
published: '2026-02-06T15:46:18Z',
attributedTo: {
id: 'https://example.com/users/alice',
type: 'Person',
name: 'Alice',
preferredUsername: 'alice',
url: 'https://example.com/@alice',
icon: {
type: 'Image',
mediaType: 'image/png',
url: 'https://example.com/media/avatar.png',
},
},
content: '<p>Canonical ActivityPub fallback works</p>',
attachment: [
{
type: 'Document',
mediaType: 'image/jpeg',
url: 'https://example.com/media/1.jpg',
name: 'Primary alt text',
width: 640,
height: 480,
},
{
type: 'Document',
mediaType: 'image/jpeg',
url: 'https://example.com/media/2.jpg',
name: 'Secondary alt text',
width: 640,
height: 480,
},
],
});
}),
);
const embeds = await resolver.resolve(redirectPageUrl, createMockContent(html), false, {
requestUrl: new URL('https://mastodon.social/@alice@example.social/12345'),
finalUrl: redirectPageUrl,
wasRedirected: true,
});
expect(activityPubAcceptHeaders[0]).toContain('application/json');
expect(embeds).toHaveLength(2);
expect(embeds[0]!.url).toBe(canonicalUrl);
expect(embeds[0]!.image?.description).toBe('Primary alt text');
expect(embeds[1]!.image?.url).toBe('https://example.com/media/2.jpg');
});
it('stops canonical ActivityPub resolution when a second redirect is required', async () => {
const redirectPageUrl = new URL('https://mastodon.social/redirect/statuses/777');
const canonicalUrl = 'https://example.com/@alice/777';
const html = `<!DOCTYPE html>
<html>
<head>
<link href="${canonicalUrl}" rel="canonical" />
</head>
<body></body>
</html>`;
server.use(
http.get('https://example.com/api/v2/instance', () => {
return HttpResponse.json({
domain: 'example.com',
title: 'Example Social',
});
}),
http.get(canonicalUrl, () => {
return HttpResponse.redirect('https://example.com/step-1', 302);
}),
http.get('https://example.com/step-1', () => {
return HttpResponse.redirect('https://example.com/step-2', 302);
}),
);
const embeds = await resolver.resolve(redirectPageUrl, createMockContent(html), false, {
requestUrl: new URL('https://mastodon.social/@alice@example.social/777'),
finalUrl: redirectPageUrl,
wasRedirected: true,
});
expect(embeds).toHaveLength(1);
expect(embeds[0]!.type).toBe('link');
expect(embeds[0]!.url).toBe(redirectPageUrl.href);
});
});
});

View File

@@ -0,0 +1,127 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {HackerNewsResolver} from '@fluxer/api/src/unfurler/resolvers/HackerNewsResolver';
import {createMockContent, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('HackerNewsResolver', () => {
let mediaService: MockMediaService;
let resolver: HackerNewsResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new HackerNewsResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('match', () => {
it('matches news.ycombinator.com item URLs', () => {
const url = new URL('https://news.ycombinator.com/item?id=12345678');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches HN item URLs with additional parameters', () => {
const url = new URL('https://news.ycombinator.com/item?id=12345678&p=2');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('does not match HN homepage', () => {
const url = new URL('https://news.ycombinator.com/');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match HN news page', () => {
const url = new URL('https://news.ycombinator.com/news');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match HN newest page', () => {
const url = new URL('https://news.ycombinator.com/newest');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match HN submit page', () => {
const url = new URL('https://news.ycombinator.com/submit');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match HN user page', () => {
const url = new URL('https://news.ycombinator.com/user?id=dang');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match non-HN domains', () => {
const url = new URL('https://example.com/item?id=12345678');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match HN with non-HTML content type', () => {
const url = new URL('https://news.ycombinator.com/item?id=12345678');
const result = resolver.match(url, 'application/json', createMockContent(''));
expect(result).toBe(false);
});
it('does not match ycombinator.com without news subdomain', () => {
const url = new URL('https://ycombinator.com/item?id=12345678');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
});
describe('resolve', () => {
it('returns empty array when no item ID in URL', async () => {
const url = new URL('https://news.ycombinator.com/item');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(0);
});
it('returns empty array for invalid item ID format', async () => {
const url = new URL('https://news.ycombinator.com/item?id=');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(0);
});
it('preserves HN URL structure in embed', async () => {
const url = new URL('https://news.ycombinator.com/item?id=12345678');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds.length === 0 || embeds[0]?.url?.includes('12345678')).toBe(true);
});
});
});

View File

@@ -0,0 +1,208 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ImageResolver} from '@fluxer/api/src/unfurler/resolvers/ImageResolver';
import {createTestImageContent, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('ImageResolver', () => {
let mediaService: MockMediaService;
let resolver: ImageResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new ImageResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('match', () => {
it('matches image/png mime type', () => {
const url = new URL('https://example.com/image.png');
const result = resolver.match(url, 'image/png', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches image/jpeg mime type', () => {
const url = new URL('https://example.com/photo.jpg');
const result = resolver.match(url, 'image/jpeg', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches image/gif mime type', () => {
const url = new URL('https://example.com/animation.gif');
const result = resolver.match(url, 'image/gif', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches image/webp mime type', () => {
const url = new URL('https://example.com/modern.webp');
const result = resolver.match(url, 'image/webp', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches image/svg+xml mime type', () => {
const url = new URL('https://example.com/vector.svg');
const result = resolver.match(url, 'image/svg+xml', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches image/avif mime type', () => {
const url = new URL('https://example.com/modern.avif');
const result = resolver.match(url, 'image/avif', new Uint8Array(0));
expect(result).toBe(true);
});
it('does not match video mime types', () => {
const url = new URL('https://example.com/video.mp4');
const result = resolver.match(url, 'video/mp4', new Uint8Array(0));
expect(result).toBe(false);
});
it('does not match audio mime types', () => {
const url = new URL('https://example.com/audio.mp3');
const result = resolver.match(url, 'audio/mpeg', new Uint8Array(0));
expect(result).toBe(false);
});
it('does not match text/html mime types', () => {
const url = new URL('https://example.com/page.html');
const result = resolver.match(url, 'text/html', new Uint8Array(0));
expect(result).toBe(false);
});
it('does not match application/octet-stream', () => {
const url = new URL('https://example.com/file.bin');
const result = resolver.match(url, 'application/octet-stream', new Uint8Array(0));
expect(result).toBe(false);
});
});
describe('resolve', () => {
it('returns an image embed with correct structure', async () => {
const url = new URL('https://example.com/photo.png');
const content = createTestImageContent('png');
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(1);
expect(embeds[0]!.type).toBe('image');
expect(embeds[0]!.url).toBe('https://example.com/photo.png');
expect(embeds[0]!.thumbnail).toBeDefined();
});
it('includes image metadata in thumbnail', async () => {
const url = new URL('https://example.com/photo.jpg');
const content = createTestImageContent('jpeg');
mediaService.setMetadata('base64', {
format: 'jpeg',
content_type: 'image/jpeg',
width: 800,
height: 600,
});
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.thumbnail).toBeDefined();
expect(embeds[0]!.thumbnail!.url).toBe('https://example.com/photo.jpg');
});
it('handles animated GIF content', async () => {
const url = new URL('https://example.com/animation.gif');
const content = createTestImageContent('gif');
mediaService.setMetadata('base64', {
format: 'gif',
content_type: 'image/gif',
animated: true,
});
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(1);
expect(embeds[0]!.thumbnail).toBeDefined();
});
it('handles NSFW content when allowed', async () => {
const url = new URL('https://example.com/image.png');
const content = createTestImageContent('png');
mediaService.setMetadata('base64', {nsfw: true});
const embeds = await resolver.resolve(url, content, true);
expect(embeds).toHaveLength(1);
expect(embeds[0]!.thumbnail).toBeDefined();
});
it('handles NSFW content when not allowed', async () => {
const url = new URL('https://example.com/image.png');
const content = createTestImageContent('png');
mediaService.setMetadata('base64', {nsfw: true});
const embeds = await resolver.resolve(url, content, false);
expect(embeds).toHaveLength(1);
});
it('handles placeholder in metadata', async () => {
const url = new URL('https://example.com/photo.jpg');
const content = createTestImageContent('jpeg');
mediaService.setMetadata('base64', {
placeholder: 'data:image/jpeg;base64,/9j/4AAQ...',
});
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.thumbnail).toBeDefined();
});
it('handles URLs with CDN parameters', async () => {
const url = new URL('https://cdn.example.com/images/photo.png?width=800&quality=80');
const content = createTestImageContent('png');
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.url).toBe('https://cdn.example.com/images/photo.png?width=800&quality=80');
});
it('handles URLs with special characters in path', async () => {
const url = new URL('https://example.com/images/my%20photo%20(1).png');
const content = createTestImageContent('png');
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.url).toContain('my%20photo');
});
it('handles international domain names', async () => {
const url = new URL('https://example.com/image.png');
const content = createTestImageContent('png');
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.url).toBe('https://example.com/image.png');
});
});
});

View File

@@ -0,0 +1,379 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {KlipyResolver} from '@fluxer/api/src/unfurler/resolvers/KlipyResolver';
import {createMockContent, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
interface KlipyMediaData {
uuid?: string;
slug?: string;
description?: string;
title?: string;
file?: {
hd?: {
webp?: {url?: string; width?: number; height?: number};
mp4?: {url?: string; width?: number; height?: number};
gif?: {url?: string; width?: number; height?: number};
};
md?: {
webp?: {url?: string; width?: number; height?: number};
mp4?: {url?: string; width?: number; height?: number};
};
};
type?: string;
}
function createKlipyPlayerContent(media: KlipyMediaData): Uint8Array {
const mediaJson = JSON.stringify(media);
const flightData = `10:["$","$L26",null,{"media":${mediaJson}}]`;
const escapedFlightData = JSON.stringify(flightData).slice(1, -1);
const html = `<!DOCTYPE html>
<html>
<head>
<script>self.__next_f.push([1,"${escapedFlightData}"])</script>
</head>
<body></body>
</html>`;
return new TextEncoder().encode(html);
}
describe('KlipyResolver', () => {
let mediaService: MockMediaService;
let resolver: KlipyResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new KlipyResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('transformUrl', () => {
it('transforms klipy.com gifs URLs to player URLs', () => {
const url = new URL('https://klipy.com/gifs/love-ghost-1');
const result = resolver.transformUrl(url);
expect(result?.href).toBe('https://klipy.com/gifs/love-ghost-1/player');
});
it('transforms klipy.com clips URLs to player URLs', () => {
const url = new URL('https://klipy.com/clips/some-clip');
const result = resolver.transformUrl(url);
expect(result?.href).toBe('https://klipy.com/clips/some-clip/player');
});
it('transforms legacy klipy.com gif URLs to player URLs', () => {
const url = new URL('https://klipy.com/gif/9054268440156284');
const result = resolver.transformUrl(url);
expect(result?.href).toBe('https://klipy.com/gifs/9054268440156284/player');
});
it('transforms legacy klipy.com clip URLs to player URLs', () => {
const url = new URL('https://klipy.com/clip/some-clip');
const result = resolver.transformUrl(url);
expect(result?.href).toBe('https://klipy.com/clips/some-clip/player');
});
it('handles URLs with query parameters', () => {
const url = new URL('https://klipy.com/gifs/love-ghost-1?v=2');
const result = resolver.transformUrl(url);
expect(result?.href).toBe('https://klipy.com/gifs/love-ghost-1/player');
});
it('handles URLs with trailing slash', () => {
const url = new URL('https://klipy.com/gifs/love-ghost-1/');
const result = resolver.transformUrl(url);
expect(result?.href).toBe('https://klipy.com/gifs/love-ghost-1/player');
});
it('returns null for non-klipy domains', () => {
const url = new URL('https://giphy.com/gifs/cat-12345');
const result = resolver.transformUrl(url);
expect(result).toBeNull();
});
it('returns null for non-gifs/clips paths', () => {
const url = new URL('https://klipy.com/about');
const result = resolver.transformUrl(url);
expect(result).toBeNull();
});
it('returns null for klipy subdomains', () => {
const url = new URL('https://static.klipy.com/image.gif');
const result = resolver.transformUrl(url);
expect(result).toBeNull();
});
});
describe('match', () => {
it('matches klipy.com URLs with text/html', () => {
const url = new URL('https://klipy.com/gifs/love-ghost-1');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches klipy.com clips URLs', () => {
const url = new URL('https://klipy.com/clips/some-clip-slug');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches klipy.com with text/html charset', () => {
const url = new URL('https://klipy.com/gifs/test');
const result = resolver.match(url, 'text/html; charset=utf-8', createMockContent(''));
expect(result).toBe(true);
});
it('does not match non-klipy domains', () => {
const url = new URL('https://giphy.com/gifs/cat-12345');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match klipy.com with non-HTML content type', () => {
const url = new URL('https://klipy.com/gifs/love-ghost-1');
const result = resolver.match(url, 'application/json', createMockContent(''));
expect(result).toBe(false);
});
it('does not match klipy subdomains', () => {
const url = new URL('https://static.klipy.com/image.gif');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match image mime types even on klipy domain', () => {
const url = new URL('https://klipy.com/image.gif');
const result = resolver.match(url, 'image/gif', createMockContent(''));
expect(result).toBe(false);
});
});
describe('resolve', () => {
it('returns gifv embed with thumbnail and video from player content', async () => {
const media: KlipyMediaData = {
uuid: 'c0db9951-4c03-4008-97fc-2a8986782c1b',
slug: 'love-ghost-1',
title: 'Cute Ghosts Sending Extra Love',
description: 'Two cute cartoon ghosts send extra love with a pink heart.',
file: {
hd: {
webp: {url: 'https://static.klipy.com/ii/test/kIsgEOKn.webp', width: 498, height: 498},
mp4: {url: 'https://static.klipy.com/ii/test/nyOtbnCFc6GfLxMBzd.mp4', width: 640, height: 640},
},
},
type: 'gif',
};
const url = new URL('https://klipy.com/gifs/love-ghost-1');
const embeds = await resolver.resolve(url, createKlipyPlayerContent(media));
expect(embeds).toHaveLength(1);
expect(embeds[0].type).toBe('gifv');
expect(embeds[0].url).toBe('https://klipy.com/gifs/love-ghost-1');
expect(embeds[0].provider).toEqual({name: 'KLIPY', url: 'https://klipy.com'});
expect(embeds[0].thumbnail).toBeDefined();
expect(embeds[0].video).toBeDefined();
});
it('extracts webp URL for thumbnail', async () => {
const media: KlipyMediaData = {
slug: 'test-gif',
file: {
hd: {
webp: {url: 'https://static.klipy.com/ii/abc/thumbnail.webp', width: 400, height: 400},
mp4: {url: 'https://static.klipy.com/ii/abc/video.mp4', width: 400, height: 400},
},
},
};
const url = new URL('https://klipy.com/gifs/test-gif');
const embeds = await resolver.resolve(url, createKlipyPlayerContent(media));
expect(embeds).toHaveLength(1);
expect(embeds[0].thumbnail?.url).toContain('thumbnail.webp');
});
it('extracts mp4 URL for video', async () => {
const media: KlipyMediaData = {
slug: 'test-gif',
file: {
hd: {
webp: {url: 'https://static.klipy.com/ii/abc/thumbnail.webp', width: 400, height: 400},
mp4: {url: 'https://static.klipy.com/ii/abc/video.mp4', width: 640, height: 640},
},
},
};
const url = new URL('https://klipy.com/gifs/test-gif');
const embeds = await resolver.resolve(url, createKlipyPlayerContent(media));
expect(embeds).toHaveLength(1);
expect(embeds[0].video?.url).toContain('video.mp4');
});
it('handles clips URLs', async () => {
const media: KlipyMediaData = {
slug: 'funny-clip',
file: {
hd: {
webp: {url: 'https://static.klipy.com/ii/clip/thumb.webp', width: 400, height: 400},
mp4: {url: 'https://static.klipy.com/ii/clip/video.mp4', width: 640, height: 640},
},
},
type: 'clip',
};
const url = new URL('https://klipy.com/clips/funny-clip');
const embeds = await resolver.resolve(url, createKlipyPlayerContent(media));
expect(embeds).toHaveLength(1);
expect(embeds[0].type).toBe('gifv');
expect(embeds[0].url).toBe('https://klipy.com/clips/funny-clip');
});
it('handles media with only thumbnail (no video)', async () => {
const media: KlipyMediaData = {
slug: 'image-only',
file: {
hd: {
webp: {url: 'https://static.klipy.com/ii/test/image.webp', width: 400, height: 400},
},
},
};
const url = new URL('https://klipy.com/gifs/image-only');
const embeds = await resolver.resolve(url, createKlipyPlayerContent(media));
expect(embeds).toHaveLength(1);
expect(embeds[0].thumbnail).toBeDefined();
expect(embeds[0].video).toBeUndefined();
});
it('handles media with only video (no thumbnail)', async () => {
const media: KlipyMediaData = {
slug: 'video-only',
file: {
hd: {
mp4: {url: 'https://static.klipy.com/ii/test/video.mp4', width: 640, height: 640},
},
},
};
const url = new URL('https://klipy.com/gifs/video-only');
const embeds = await resolver.resolve(url, createKlipyPlayerContent(media));
expect(embeds).toHaveLength(1);
expect(embeds[0].thumbnail).toBeUndefined();
expect(embeds[0].video).toBeDefined();
});
it('returns empty array when content has no media data', async () => {
const content = createMockContent('<!DOCTYPE html><html><body>No media</body></html>');
const url = new URL('https://klipy.com/gifs/no-media');
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(0);
});
it('returns empty array when media has no file property', async () => {
const flightData = `10:["$","$L26",null,{"media":{"uuid":"test","slug":"no-file"}}]`;
const escaped = JSON.stringify(flightData).slice(1, -1);
const html = `<!DOCTYPE html><html><head><script>self.__next_f.push([1,"${escaped}"])</script></head></html>`;
const content = new TextEncoder().encode(html);
const url = new URL('https://klipy.com/gifs/no-file');
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(0);
});
it('handles NSFW flag when allowed', async () => {
const media: KlipyMediaData = {
slug: 'nsfw-gif',
file: {
hd: {
webp: {url: 'https://static.klipy.com/ii/nsfw/thumb.webp', width: 400, height: 400},
mp4: {url: 'https://static.klipy.com/ii/nsfw/video.mp4', width: 640, height: 640},
},
},
};
mediaService.markAsNsfw('https://static.klipy.com/ii/nsfw/thumb.webp');
mediaService.markAsNsfw('https://static.klipy.com/ii/nsfw/video.mp4');
const url = new URL('https://klipy.com/gifs/nsfw-gif');
const embeds = await resolver.resolve(url, createKlipyPlayerContent(media), true);
expect(embeds).toHaveLength(1);
});
it('preserves original URL in embed output', async () => {
const media: KlipyMediaData = {
slug: 'special%20chars',
file: {
hd: {
webp: {url: 'https://static.klipy.com/ii/test/thumb.webp', width: 400, height: 400},
mp4: {url: 'https://static.klipy.com/ii/test/video.mp4', width: 640, height: 640},
},
},
};
const url = new URL('https://klipy.com/gifs/special%20chars');
const embeds = await resolver.resolve(url, createKlipyPlayerContent(media));
expect(embeds[0].url).toBe('https://klipy.com/gifs/special%20chars');
});
it('handles multiple next_f.push calls and finds media in any', async () => {
const media: KlipyMediaData = {
slug: 'multi-push',
file: {
hd: {
webp: {url: 'https://static.klipy.com/ii/test/thumb.webp', width: 400, height: 400},
mp4: {url: 'https://static.klipy.com/ii/test/video.mp4', width: 640, height: 640},
},
},
};
const mediaJson = JSON.stringify(media);
const flightData = `10:["$","$L26",null,{"media":${mediaJson}}]`;
const escaped = JSON.stringify(flightData).slice(1, -1);
const html = `<!DOCTYPE html>
<html>
<head>
<script>self.__next_f.push([1,"0:something-else"])</script>
<script>self.__next_f.push([1,"${escaped}"])</script>
</head>
</html>`;
const content = new TextEncoder().encode(html);
const url = new URL('https://klipy.com/gifs/multi-push');
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(1);
expect(embeds[0].thumbnail).toBeDefined();
});
});
});

View File

@@ -0,0 +1,305 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {
IMediaService,
type MediaProxyFrameRequest,
type MediaProxyFrameResponse,
type MediaProxyMetadataRequest,
type MediaProxyMetadataResponse,
} from '@fluxer/api/src/infrastructure/IMediaService';
import {formatLockKey, generateLockToken} from '@fluxer/cache/src/CacheLockValidation';
import {ICacheService} from '@fluxer/cache/src/ICacheService';
export class MockMediaService extends IMediaService {
private nsfwUrls = new Set<string>();
private animatedUrls = new Set<string>();
private customMetadata = new Map<string, Partial<MediaProxyMetadataResponse>>();
private failingUrls = new Set<string>();
markAsNsfw(url: string): void {
this.nsfwUrls.add(url);
}
markAsAnimated(url: string): void {
this.animatedUrls.add(url);
}
setMetadata(url: string, metadata: Partial<MediaProxyMetadataResponse>): void {
this.customMetadata.set(url, metadata);
}
markAsFailing(url: string): void {
this.failingUrls.add(url);
}
reset(): void {
this.nsfwUrls.clear();
this.animatedUrls.clear();
this.customMetadata.clear();
this.failingUrls.clear();
}
async getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null> {
const url = request.type === 'external' ? request.url : request.type === 'base64' ? 'base64' : 'unknown';
if (this.failingUrls.has(url)) {
throw new Error('Media service error');
}
const custom = this.customMetadata.get(url);
return {
format: custom?.format ?? 'png',
content_type: custom?.content_type ?? 'image/png',
content_hash: custom?.content_hash ?? crypto.createHash('md5').update(url).digest('hex'),
size: custom?.size ?? 1024,
width: custom?.width ?? 128,
height: custom?.height ?? 128,
animated: custom?.animated ?? this.animatedUrls.has(url),
nsfw: custom?.nsfw ?? this.nsfwUrls.has(url),
placeholder: custom?.placeholder,
duration: custom?.duration,
};
}
getExternalMediaProxyURL(url: string): string {
return `https://media-proxy.test/${encodeURIComponent(url)}`;
}
async getThumbnail(): Promise<Buffer | null> {
return Buffer.alloc(1024);
}
async extractFrames(_request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse> {
return {
frames: [
{
timestamp: 0,
mime_type: 'image/png',
base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
},
],
};
}
}
export class MockCacheService extends ICacheService {
private cache = new Map<string, unknown>();
private sets = new Map<string, Set<string>>();
private locks = new Map<string, {token: string; expiresAt: number}>();
reset(): void {
this.cache.clear();
this.sets.clear();
this.locks.clear();
}
async get<T>(key: string): Promise<T | null> {
return (this.cache.get(key) as T) ?? null;
}
async set<T>(key: string, value: T, _ttlSeconds?: number): Promise<void> {
this.cache.set(key, value);
}
async delete(key: string): Promise<void> {
this.cache.delete(key);
}
async getAndDelete<T>(key: string): Promise<T | null> {
const value = await this.get<T>(key);
if (value !== null) {
this.cache.delete(key);
}
return value;
}
async exists(key: string): Promise<boolean> {
return this.cache.has(key);
}
async expire(_key: string, _ttlSeconds: number): Promise<void> {
return;
}
async ttl(_key: string): Promise<number> {
return -1;
}
async mget<T>(keys: Array<string>): Promise<Array<T | null>> {
const results: Array<T | null> = [];
for (const key of keys) {
results.push(await this.get<T>(key));
}
return results;
}
async mset<T>(entries: Array<{key: string; value: T; ttlSeconds?: number}>): Promise<void> {
for (const entry of entries) {
await this.set(entry.key, entry.value, entry.ttlSeconds);
}
}
async deletePattern(pattern: string): Promise<number> {
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
let deletedCount = 0;
for (const key of this.cache.keys()) {
if (regex.test(key)) {
this.cache.delete(key);
deletedCount++;
}
}
return deletedCount;
}
async acquireLock(key: string, ttlSeconds: number): Promise<string | null> {
const lockKey = formatLockKey(key);
const existingLock = this.locks.get(lockKey);
if (existingLock && existingLock.expiresAt > Date.now()) {
return null;
}
const token = generateLockToken();
this.locks.set(lockKey, {token, expiresAt: Date.now() + ttlSeconds * 1000});
return token;
}
async releaseLock(key: string, token: string): Promise<boolean> {
const lockKey = formatLockKey(key);
const lock = this.locks.get(lockKey);
if (!lock || lock.token !== token) {
return false;
}
this.locks.delete(lockKey);
return true;
}
async getAndRenewTtl<T>(key: string, _newTtlSeconds: number): Promise<T | null> {
return await this.get<T>(key);
}
async publish(_channel: string, _message: string): Promise<void> {
return;
}
async sadd(key: string, member: string, _ttlSeconds?: number): Promise<void> {
let set = this.sets.get(key);
if (!set) {
set = new Set<string>();
this.sets.set(key, set);
}
set.add(member);
}
async srem(key: string, member: string): Promise<void> {
const set = this.sets.get(key);
if (set) {
set.delete(member);
if (set.size === 0) {
this.sets.delete(key);
}
}
}
async smembers(key: string): Promise<Set<string>> {
return this.sets.get(key) ?? new Set<string>();
}
async sismember(key: string, member: string): Promise<boolean> {
const set = this.sets.get(key);
return set?.has(member) ?? false;
}
}
export function createMockContent(html: string): Uint8Array {
return new TextEncoder().encode(html);
}
export function createMinimalHtml(options: {
title?: string;
description?: string;
image?: string;
siteName?: string;
themeColor?: string;
video?: string;
audio?: string;
}): string {
const metaTags: Array<string> = [];
if (options.title) {
metaTags.push(`<meta property="og:title" content="${escapeHtml(options.title)}" />`);
}
if (options.description) {
metaTags.push(`<meta property="og:description" content="${escapeHtml(options.description)}" />`);
}
if (options.image) {
metaTags.push(`<meta property="og:image" content="${escapeHtml(options.image)}" />`);
}
if (options.siteName) {
metaTags.push(`<meta property="og:site_name" content="${escapeHtml(options.siteName)}" />`);
}
if (options.themeColor) {
metaTags.push(`<meta name="theme-color" content="${escapeHtml(options.themeColor)}" />`);
}
if (options.video) {
metaTags.push(`<meta property="og:video" content="${escapeHtml(options.video)}" />`);
}
if (options.audio) {
metaTags.push(`<meta property="og:audio" content="${escapeHtml(options.audio)}" />`);
}
return `<!DOCTYPE html>
<html>
<head>
${options.title ? `<title>${escapeHtml(options.title)}</title>` : ''}
${metaTags.join('\n')}
</head>
<body></body>
</html>`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export const PNG_MAGIC_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
export const GIF_MAGIC_BYTES = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
export const JPEG_MAGIC_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
export function createTestImageContent(type: 'png' | 'gif' | 'jpeg'): Uint8Array {
const header =
type === 'png' ? PNG_MAGIC_BYTES : type === 'gif' ? GIF_MAGIC_BYTES : type === 'jpeg' ? JPEG_MAGIC_BYTES : [];
const content = new Uint8Array(128);
content.set(header);
return content;
}
export function createTestAudioContent(): Uint8Array {
return new Uint8Array(128);
}
export function createTestVideoContent(): Uint8Array {
return new Uint8Array(128);
}

View File

@@ -0,0 +1,230 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {TenorResolver} from '@fluxer/api/src/unfurler/resolvers/TenorResolver';
import {createMockContent, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
function createTenorHtml(options: {thumbnailUrl?: string; videoUrl?: string}): string {
const jsonLd: Record<string, unknown> = {};
if (options.thumbnailUrl) {
jsonLd.image = {thumbnailUrl: options.thumbnailUrl};
}
if (options.videoUrl) {
jsonLd.video = {contentUrl: options.videoUrl};
}
return `<!DOCTYPE html>
<html>
<head>
<script class="dynamic" type="application/ld+json">
${JSON.stringify(jsonLd)}
</script>
</head>
<body></body>
</html>`;
}
describe('TenorResolver', () => {
let mediaService: MockMediaService;
let resolver: TenorResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new TenorResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('match', () => {
it('matches tenor.com URLs with text/html', () => {
const url = new URL('https://tenor.com/view/cat-gif-12345');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches tenor.com view URLs', () => {
const url = new URL('https://tenor.com/view/funny-reaction-gif-67890');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches tenor.com URLs with various paths', () => {
const url = new URL('https://tenor.com/ko/view/excited-gif-123');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('does not match non-tenor domains', () => {
const url = new URL('https://giphy.com/gifs/cat-12345');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match tenor.com with non-HTML content type', () => {
const url = new URL('https://tenor.com/view/cat-gif-12345');
const result = resolver.match(url, 'application/json', createMockContent(''));
expect(result).toBe(false);
});
it('does not match tenor subdomains', () => {
const url = new URL('https://media.tenor.com/something.gif');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match image mime types even on tenor domain', () => {
const url = new URL('https://tenor.com/image.gif');
const result = resolver.match(url, 'image/gif', createMockContent(''));
expect(result).toBe(false);
});
});
describe('resolve', () => {
it('returns gifv embed with thumbnail and video', async () => {
const url = new URL('https://tenor.com/view/cat-gif-12345');
const html = createTenorHtml({
thumbnailUrl: 'https://media.tenor.com/thumbnail.png',
videoUrl: 'https://media.tenor.com/video.mp4',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
expect(embeds[0]!.type).toBe('gifv');
expect(embeds[0]!.url).toBe('https://tenor.com/view/cat-gif-12345');
expect(embeds[0]!.provider).toEqual({name: 'Tenor', url: 'https://tenor.com'});
});
it('handles tenor page with only thumbnail', async () => {
const url = new URL('https://tenor.com/view/cat-gif-12345');
const html = createTenorHtml({
thumbnailUrl: 'https://media.tenor.com/thumbnail.png',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
expect(embeds[0]!.type).toBe('gifv');
expect(embeds[0]!.thumbnail).toBeDefined();
});
it('handles tenor page with only video', async () => {
const url = new URL('https://tenor.com/view/cat-gif-12345');
const html = createTenorHtml({
videoUrl: 'https://media.tenor.com/video.mp4',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
expect(embeds[0]!.type).toBe('gifv');
expect(embeds[0]!.video).toBeDefined();
});
it('returns empty array when no JSON-LD found', async () => {
const url = new URL('https://tenor.com/view/cat-gif-12345');
const html = '<!DOCTYPE html><html><head></head><body></body></html>';
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(0);
});
it('returns empty array when JSON-LD is empty', async () => {
const url = new URL('https://tenor.com/view/cat-gif-12345');
const html = `<!DOCTYPE html>
<html>
<head>
<script class="dynamic" type="application/ld+json">
{}
</script>
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
});
it('returns empty array for invalid JSON-LD', async () => {
const url = new URL('https://tenor.com/view/cat-gif-12345');
const html = `<!DOCTYPE html>
<html>
<head>
<script class="dynamic" type="application/ld+json">
{invalid json}
</script>
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(0);
});
it('handles NSFW content flag when allowed', async () => {
const url = new URL('https://tenor.com/view/adult-gif-12345');
const html = createTenorHtml({
thumbnailUrl: 'https://media.tenor.com/nsfw-thumbnail.png',
videoUrl: 'https://media.tenor.com/nsfw-video.mp4',
});
mediaService.markAsNsfw('https://media.tenor.com/nsfw-thumbnail.png');
mediaService.markAsNsfw('https://media.tenor.com/nsfw-video.mp4');
const embeds = await resolver.resolve(url, createMockContent(html), true);
expect(embeds).toHaveLength(1);
});
it('preserves URL in embed output', async () => {
const url = new URL('https://tenor.com/view/special-chars-gif%20test-12345');
const html = createTenorHtml({
thumbnailUrl: 'https://media.tenor.com/thumbnail.png',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.url).toBe('https://tenor.com/view/special-chars-gif%20test-12345');
});
it('handles missing dynamic class on script tag', async () => {
const url = new URL('https://tenor.com/view/cat-gif-12345');
const html = `<!DOCTYPE html>
<html>
<head>
<script type="application/ld+json">
{"image": {"thumbnailUrl": "https://media.tenor.com/thumbnail.png"}}
</script>
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,171 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {VideoResolver} from '@fluxer/api/src/unfurler/resolvers/VideoResolver';
import {createTestVideoContent, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('VideoResolver', () => {
let mediaService: MockMediaService;
let resolver: VideoResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new VideoResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('match', () => {
it('matches video/mp4 mime type', () => {
const url = new URL('https://example.com/video.mp4');
const result = resolver.match(url, 'video/mp4', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches video/webm mime type', () => {
const url = new URL('https://example.com/video.webm');
const result = resolver.match(url, 'video/webm', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches video/ogg mime type', () => {
const url = new URL('https://example.com/video.ogv');
const result = resolver.match(url, 'video/ogg', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches video/quicktime mime type', () => {
const url = new URL('https://example.com/video.mov');
const result = resolver.match(url, 'video/quicktime', new Uint8Array(0));
expect(result).toBe(true);
});
it('matches video/x-msvideo mime type', () => {
const url = new URL('https://example.com/video.avi');
const result = resolver.match(url, 'video/x-msvideo', new Uint8Array(0));
expect(result).toBe(true);
});
it('does not match audio mime types', () => {
const url = new URL('https://example.com/audio.mp3');
const result = resolver.match(url, 'audio/mpeg', new Uint8Array(0));
expect(result).toBe(false);
});
it('does not match image mime types', () => {
const url = new URL('https://example.com/image.png');
const result = resolver.match(url, 'image/png', new Uint8Array(0));
expect(result).toBe(false);
});
it('does not match text/html mime types', () => {
const url = new URL('https://example.com/page.html');
const result = resolver.match(url, 'text/html', new Uint8Array(0));
expect(result).toBe(false);
});
});
describe('resolve', () => {
it('returns a video embed with correct structure', async () => {
const url = new URL('https://example.com/video.mp4');
const content = createTestVideoContent();
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(1);
expect(embeds[0]!.type).toBe('video');
expect(embeds[0]!.url).toBe('https://example.com/video.mp4');
expect(embeds[0]!.video).toBeDefined();
});
it('includes video metadata in embed', async () => {
const url = new URL('https://example.com/clip.mp4');
const content = createTestVideoContent();
mediaService.setMetadata('base64', {
format: 'mp4',
content_type: 'video/mp4',
width: 1920,
height: 1080,
duration: 120,
});
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.video).toBeDefined();
expect(embeds[0]!.video!.url).toBe('https://example.com/clip.mp4');
});
it('handles NSFW content when allowed', async () => {
const url = new URL('https://example.com/video.mp4');
const content = createTestVideoContent();
mediaService.setMetadata('base64', {nsfw: true});
const embeds = await resolver.resolve(url, content, true);
expect(embeds).toHaveLength(1);
expect(embeds[0]!.video).toBeDefined();
});
it('handles NSFW content when not allowed', async () => {
const url = new URL('https://example.com/video.mp4');
const content = createTestVideoContent();
mediaService.setMetadata('base64', {nsfw: true});
const embeds = await resolver.resolve(url, content, false);
expect(embeds).toHaveLength(1);
});
it('handles animated video content', async () => {
const url = new URL('https://example.com/animation.webm');
const content = createTestVideoContent();
mediaService.setMetadata('base64', {animated: true});
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(1);
expect(embeds[0]!.video).toBeDefined();
});
it('handles URLs with query parameters', async () => {
const url = new URL('https://cdn.example.com/video.mp4?sig=abc123&exp=1234567890');
const content = createTestVideoContent();
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.url).toBe('https://cdn.example.com/video.mp4?sig=abc123&exp=1234567890');
});
it('handles URLs with special characters', async () => {
const url = new URL('https://example.com/my%20video%20(1).mp4');
const content = createTestVideoContent();
const embeds = await resolver.resolve(url, content);
expect(embeds[0]!.url).toContain('my%20video');
});
});
});

View File

@@ -0,0 +1,211 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createWikipediaApiHandlers} from '@fluxer/api/src/test/msw/handlers/WikipediaApiHandlers';
import {server} from '@fluxer/api/src/test/msw/server';
import {WikipediaResolver} from '@fluxer/api/src/unfurler/resolvers/WikipediaResolver';
import {createMockContent, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('WikipediaResolver', () => {
let mediaService: MockMediaService;
let resolver: WikipediaResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new WikipediaResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('match', () => {
it('matches en.wikipedia.org wiki URLs', () => {
const url = new URL('https://en.wikipedia.org/wiki/Test_Article');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches de.wikipedia.org wiki URLs', () => {
const url = new URL('https://de.wikipedia.org/wiki/Test_Artikel');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches fr.wikipedia.org wiki URLs', () => {
const url = new URL('https://fr.wikipedia.org/wiki/Article_Test');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches es.wikipedia.org wiki URLs', () => {
const url = new URL('https://es.wikipedia.org/wiki/Articulo_Test');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches it.wikipedia.org wiki URLs', () => {
const url = new URL('https://it.wikipedia.org/wiki/Articolo_Test');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches ja.wikipedia.org wiki URLs', () => {
const url = new URL('https://ja.wikipedia.org/wiki/Test_Article');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches ru.wikipedia.org wiki URLs', () => {
const url = new URL('https://ru.wikipedia.org/wiki/Test_Article');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches zh.wikipedia.org wiki URLs', () => {
const url = new URL('https://zh.wikipedia.org/wiki/Test_Article');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches www.wikipedia.org wiki URLs', () => {
const url = new URL('https://www.wikipedia.org/wiki/Test_Article');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches wikipedia.org wiki URLs without subdomain', () => {
const url = new URL('https://wikipedia.org/wiki/Test_Article');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('does not match non-wiki paths on wikipedia', () => {
const url = new URL('https://en.wikipedia.org/w/index.php?title=Test');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match non-wiki paths like main page', () => {
const url = new URL('https://en.wikipedia.org/');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match other domains', () => {
const url = new URL('https://example.com/wiki/Test_Article');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match non-HTML content types', () => {
const url = new URL('https://en.wikipedia.org/wiki/Test_Article');
const result = resolver.match(url, 'application/json', createMockContent(''));
expect(result).toBe(false);
});
it('does not match unsupported language subdomains', () => {
const url = new URL('https://pt.wikipedia.org/wiki/Test_Article');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match wikimedia commons', () => {
const url = new URL('https://commons.wikimedia.org/wiki/File:Test.jpg');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match wikidata', () => {
const url = new URL('https://www.wikidata.org/wiki/Q42');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
});
describe('resolve', () => {
beforeEach(() => {
server.use(...createWikipediaApiHandlers());
});
it('returns empty array for missing article title', async () => {
const url = new URL('https://en.wikipedia.org/wiki/');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(0);
});
it('handles URL-encoded article titles', async () => {
const url = new URL('https://en.wikipedia.org/wiki/Test%20Article%20(disambiguation)');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds).toBeDefined();
});
it('handles special characters in article titles', async () => {
const url = new URL('https://en.wikipedia.org/wiki/C%2B%2B');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds).toBeDefined();
});
it('handles Japanese characters in article titles', async () => {
const url = new URL('https://ja.wikipedia.org/wiki/%E6%27%A5%E6%9C%AC');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds).toBeDefined();
});
it('handles Cyrillic characters in article titles', async () => {
const url = new URL('https://ru.wikipedia.org/wiki/%D0%A0%D0%BE%D1%81%D1%81%D0%B8%D1%8F');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds).toBeDefined();
});
it('handles anchor links in article URLs', async () => {
const url = new URL('https://en.wikipedia.org/wiki/Test_Article#Section');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds).toBeDefined();
});
it('preserves original URL in embed', async () => {
const url = new URL('https://en.wikipedia.org/wiki/Test_Article');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds.length === 0 || embeds[0]!.url === 'https://en.wikipedia.org/wiki/Test_Article').toBe(true);
});
});
});

View File

@@ -0,0 +1,236 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {XkcdResolver} from '@fluxer/api/src/unfurler/resolvers/XkcdResolver';
import {createMockContent, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
function createXkcdHtml(options: {title?: string; imageUrl?: string; imageAlt?: string; footerText?: string}): string {
const metaTags: Array<string> = [];
if (options.title) {
metaTags.push(`<meta property="og:title" content="${options.title}" />`);
}
if (options.imageUrl) {
metaTags.push(`<meta property="og:image" content="${options.imageUrl}" />`);
}
const comicImg = options.imageUrl
? `<div id="comic"><img src="${options.imageUrl}" title="${options.imageAlt || ''}" alt="Comic" /></div>`
: '';
return `<!DOCTYPE html>
<html>
<head>
${options.title ? `<title>${options.title}</title>` : ''}
${metaTags.join('\n')}
</head>
<body>
${comicImg}
</body>
</html>`;
}
describe('XkcdResolver', () => {
let mediaService: MockMediaService;
let resolver: XkcdResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new XkcdResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('match', () => {
it('matches xkcd.com URLs with text/html', () => {
const url = new URL('https://xkcd.com/1234/');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches xkcd.com root URL', () => {
const url = new URL('https://xkcd.com/');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches xkcd.com comic URLs without trailing slash', () => {
const url = new URL('https://xkcd.com/1234');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('does not match non-xkcd domains', () => {
const url = new URL('https://example.com/1234/');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match xkcd.com with non-HTML content type', () => {
const url = new URL('https://xkcd.com/1234/');
const result = resolver.match(url, 'application/json', createMockContent(''));
expect(result).toBe(false);
});
it('does not match xkcd subdomains', () => {
const url = new URL('https://what-if.xkcd.com/');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match image mime types on xkcd domain', () => {
const url = new URL('https://xkcd.com/comics/something.png');
const result = resolver.match(url, 'image/png', createMockContent(''));
expect(result).toBe(false);
});
});
describe('resolve', () => {
it('returns rich embed with comic title', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = createXkcdHtml({
title: 'xkcd: Test Comic',
imageUrl: 'https://imgs.xkcd.com/comics/test_comic.png',
imageAlt: 'This is the alt text hover joke',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
expect(embeds[0]!.type).toBe('rich');
expect(embeds[0]!.title).toBe('xkcd: Test Comic');
expect(embeds[0]!.url).toBe('https://xkcd.com/1234/');
});
it('includes comic image in embed', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = createXkcdHtml({
title: 'xkcd: Test Comic',
imageUrl: 'https://imgs.xkcd.com/comics/test_comic.png',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.image).toBeDefined();
});
it('includes alt text as image description', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = createXkcdHtml({
title: 'xkcd: Test Comic',
imageUrl: 'https://imgs.xkcd.com/comics/test_comic.png',
imageAlt: 'This is the alt text hover joke that explains the punchline',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.image?.description).toBe('This is the alt text hover joke that explains the punchline');
});
it('includes footer text from comic', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = createXkcdHtml({
title: 'xkcd: Test Comic',
imageUrl: 'https://imgs.xkcd.com/comics/test_comic.png',
imageAlt: 'Footer joke text',
footerText: 'Footer joke text',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.footer?.text).toBe('Footer joke text');
});
it('sets xkcd black color for embed', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = createXkcdHtml({
title: 'xkcd: Test Comic',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.color).toBe(0x000000);
});
it('handles comic without og:title by using HTML title', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = `<!DOCTYPE html>
<html>
<head>
<title>xkcd: Fallback Title</title>
</head>
<body></body>
</html>`;
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.title).toBe('xkcd: Fallback Title');
});
it('handles missing image gracefully', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = createXkcdHtml({
title: 'xkcd: No Image Comic',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
expect(embeds[0]!.title).toBe('xkcd: No Image Comic');
});
it('handles missing title gracefully', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = createXkcdHtml({
imageUrl: 'https://imgs.xkcd.com/comics/test.png',
});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds).toHaveLength(1);
expect(embeds[0]!.title).toBeUndefined();
});
it('handles NSFW content flag', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = createXkcdHtml({
title: 'xkcd: Test',
imageUrl: 'https://imgs.xkcd.com/comics/test.png',
});
mediaService.markAsNsfw('https://imgs.xkcd.com/comics/test.png');
const embeds = await resolver.resolve(url, createMockContent(html), false);
expect(embeds).toHaveLength(1);
});
it('preserves original URL in embed', async () => {
const url = new URL('https://xkcd.com/1234/');
const html = createXkcdHtml({title: 'Test'});
const embeds = await resolver.resolve(url, createMockContent(html));
expect(embeds[0]!.url).toBe('https://xkcd.com/1234/');
});
});
});

View File

@@ -0,0 +1,193 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {YouTubeResolver} from '@fluxer/api/src/unfurler/resolvers/YouTubeResolver';
import {createMockContent, MockMediaService} from '@fluxer/api/src/unfurler/tests/ResolverTestUtils';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('YouTubeResolver', () => {
let mediaService: MockMediaService;
let resolver: YouTubeResolver;
beforeEach(() => {
mediaService = new MockMediaService();
resolver = new YouTubeResolver(mediaService);
});
afterEach(() => {
mediaService.reset();
});
describe('match', () => {
it('matches www.youtube.com/watch URLs', () => {
const url = new URL('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches youtube.com/watch URLs without www', () => {
const url = new URL('https://youtube.com/watch?v=dQw4w9WgXcQ');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches youtu.be short URLs', () => {
const url = new URL('https://youtu.be/dQw4w9WgXcQ');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches youtube.com/shorts URLs', () => {
const url = new URL('https://www.youtube.com/shorts/abc123def');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches youtube.com/v URLs', () => {
const url = new URL('https://www.youtube.com/v/dQw4w9WgXcQ');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches watch URLs with timestamp', () => {
const url = new URL('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('matches short URLs with timestamp', () => {
const url = new URL('https://youtu.be/dQw4w9WgXcQ?t=42');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(true);
});
it('does not match YouTube homepage', () => {
const url = new URL('https://www.youtube.com/');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match YouTube channel URLs', () => {
const url = new URL('https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match YouTube user URLs', () => {
const url = new URL('https://www.youtube.com/@SomeChannel');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match YouTube playlist URLs', () => {
const url = new URL('https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match YouTube search URLs', () => {
const url = new URL('https://www.youtube.com/results?search_query=test');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match YouTube feed URLs', () => {
const url = new URL('https://www.youtube.com/feed/subscriptions');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match other domains', () => {
const url = new URL('https://example.com/watch?v=dQw4w9WgXcQ');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
it('does not match YouTube Music domain', () => {
const url = new URL('https://music.youtube.com/watch?v=dQw4w9WgXcQ');
const result = resolver.match(url, 'text/html', createMockContent(''));
expect(result).toBe(false);
});
});
describe('resolve', () => {
it('returns empty array when video ID cannot be extracted', async () => {
const url = new URL('https://www.youtube.com/watch');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds).toHaveLength(0);
});
it('extracts video ID from watch URL', async () => {
const url = new URL('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds.length === 0 || embeds[0]?.url?.includes('dQw4w9WgXcQ')).toBe(true);
});
it('extracts video ID from short URL', async () => {
const url = new URL('https://youtu.be/dQw4w9WgXcQ');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds.length === 0 || embeds[0]?.url?.includes('dQw4w9WgXcQ')).toBe(true);
});
it('extracts video ID from shorts URL', async () => {
const url = new URL('https://www.youtube.com/shorts/abc123def');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds.length === 0 || embeds[0]?.url?.includes('abc123def')).toBe(true);
});
it('extracts video ID from /v/ URL', async () => {
const url = new URL('https://www.youtube.com/v/dQw4w9WgXcQ');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds.length === 0 || embeds[0]?.url?.includes('dQw4w9WgXcQ')).toBe(true);
});
it('handles timestamp in seconds', async () => {
const url = new URL('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds.length === 0 || embeds[0]?.url !== undefined).toBe(true);
});
it('handles timestamp with s suffix', async () => {
const url = new URL('https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s');
const content = createMockContent('<html></html>');
const embeds = await resolver.resolve(url, content);
expect(embeds.length === 0 || embeds[0]?.url !== undefined).toBe(true);
});
});
});