refactor progress
This commit is contained in:
43
packages/api/src/unfurler/resolvers/AudioResolver.tsx
Normal file
43
packages/api/src/unfurler/resolvers/AudioResolver.tsx
Normal 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),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
74
packages/api/src/unfurler/resolvers/BaseResolver.tsx
Normal file
74
packages/api/src/unfurler/resolvers/BaseResolver.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
321
packages/api/src/unfurler/resolvers/BlueskyResolver.tsx
Normal file
321
packages/api/src/unfurler/resolvers/BlueskyResolver.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
449
packages/api/src/unfurler/resolvers/DefaultResolver.tsx
Normal file
449
packages/api/src/unfurler/resolvers/DefaultResolver.tsx
Normal 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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
151
packages/api/src/unfurler/resolvers/HackerNewsResolver.tsx
Normal file
151
packages/api/src/unfurler/resolvers/HackerNewsResolver.tsx
Normal 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}`;
|
||||
}
|
||||
}
|
||||
42
packages/api/src/unfurler/resolvers/ImageResolver.tsx
Normal file
42
packages/api/src/unfurler/resolvers/ImageResolver.tsx
Normal 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];
|
||||
}
|
||||
}
|
||||
170
packages/api/src/unfurler/resolvers/KlipyResolver.tsx
Normal file
170
packages/api/src/unfurler/resolvers/KlipyResolver.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
75
packages/api/src/unfurler/resolvers/TenorResolver.tsx
Normal file
75
packages/api/src/unfurler/resolvers/TenorResolver.tsx
Normal 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};
|
||||
}
|
||||
}
|
||||
42
packages/api/src/unfurler/resolvers/VideoResolver.tsx
Normal file
42
packages/api/src/unfurler/resolvers/VideoResolver.tsx
Normal 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];
|
||||
}
|
||||
}
|
||||
185
packages/api/src/unfurler/resolvers/WikipediaResolver.tsx
Normal file
185
packages/api/src/unfurler/resolvers/WikipediaResolver.tsx
Normal 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}`;
|
||||
}
|
||||
}
|
||||
84
packages/api/src/unfurler/resolvers/XkcdResolver.tsx
Normal file
84
packages/api/src/unfurler/resolvers/XkcdResolver.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
194
packages/api/src/unfurler/resolvers/YouTubeResolver.tsx
Normal file
194
packages/api/src/unfurler/resolvers/YouTubeResolver.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
159
packages/api/src/unfurler/resolvers/bluesky/BlueskyApiClient.tsx
Normal file
159
packages/api/src/unfurler/resolvers/bluesky/BlueskyApiClient.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
230
packages/api/src/unfurler/resolvers/bluesky/BlueskyTypes.tsx
Normal file
230
packages/api/src/unfurler/resolvers/bluesky/BlueskyTypes.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '\\-');
|
||||
}
|
||||
56
packages/api/src/unfurler/tests/ActivityPubFetcher.test.tsx
Normal file
56
packages/api/src/unfurler/tests/ActivityPubFetcher.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
176
packages/api/src/unfurler/tests/ActivityPubFormatter.test.tsx
Normal file
176
packages/api/src/unfurler/tests/ActivityPubFormatter.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
215
packages/api/src/unfurler/tests/ActivityPubResolver.test.tsx
Normal file
215
packages/api/src/unfurler/tests/ActivityPubResolver.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
174
packages/api/src/unfurler/tests/AudioResolver.test.tsx
Normal file
174
packages/api/src/unfurler/tests/AudioResolver.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
265
packages/api/src/unfurler/tests/BaseResolver.test.tsx
Normal file
265
packages/api/src/unfurler/tests/BaseResolver.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
262
packages/api/src/unfurler/tests/BlueskyEmbedProcessor.test.tsx
Normal file
262
packages/api/src/unfurler/tests/BlueskyEmbedProcessor.test.tsx
Normal 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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
387
packages/api/src/unfurler/tests/BlueskyResolver.test.tsx
Normal file
387
packages/api/src/unfurler/tests/BlueskyResolver.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
566
packages/api/src/unfurler/tests/DefaultResolver.test.tsx
Normal file
566
packages/api/src/unfurler/tests/DefaultResolver.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
packages/api/src/unfurler/tests/HackerNewsResolver.test.tsx
Normal file
127
packages/api/src/unfurler/tests/HackerNewsResolver.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
208
packages/api/src/unfurler/tests/ImageResolver.test.tsx
Normal file
208
packages/api/src/unfurler/tests/ImageResolver.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
379
packages/api/src/unfurler/tests/KlipyResolver.test.tsx
Normal file
379
packages/api/src/unfurler/tests/KlipyResolver.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
305
packages/api/src/unfurler/tests/ResolverTestUtils.tsx
Normal file
305
packages/api/src/unfurler/tests/ResolverTestUtils.tsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
230
packages/api/src/unfurler/tests/TenorResolver.test.tsx
Normal file
230
packages/api/src/unfurler/tests/TenorResolver.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
packages/api/src/unfurler/tests/VideoResolver.test.tsx
Normal file
171
packages/api/src/unfurler/tests/VideoResolver.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
211
packages/api/src/unfurler/tests/WikipediaResolver.test.tsx
Normal file
211
packages/api/src/unfurler/tests/WikipediaResolver.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
236
packages/api/src/unfurler/tests/XkcdResolver.test.tsx
Normal file
236
packages/api/src/unfurler/tests/XkcdResolver.test.tsx
Normal 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/');
|
||||
});
|
||||
});
|
||||
});
|
||||
193
packages/api/src/unfurler/tests/YouTubeResolver.test.tsx
Normal file
193
packages/api/src/unfurler/tests/YouTubeResolver.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user