initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
/*
* 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 {ICacheService} from '~/infrastructure/ICacheService';
import {Logger} from '~/Logger';
import * as FetchUtils from '~/utils/FetchUtils';
import type {ActivityPubPost, MastodonInstance, MastodonPost} from './ActivityPubTypes';
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), 3600);
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/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 tryFetchMastodonApi(baseUrl: string, postId: string): Promise<MastodonPost | null> {
try {
const apiUrl = `${baseUrl}/api/v1/statuses/${postId}`;
Logger.debug({apiUrl}, 'Attempting to fetch from Mastodon API');
const response = await FetchUtils.sendRequest({
url: apiUrl,
method: 'GET',
timeout: 5000,
headers: {Accept: 'application/json'},
});
if (response.status !== 200) {
Logger.debug({apiUrl, status: response.status}, 'Mastodon API request failed');
return null;
}
const data = await FetchUtils.streamToString(response.stream);
return JSON.parse(data) as MastodonPost;
} catch (error) {
Logger.error({error, baseUrl, postId}, 'Failed to fetch or parse Mastodon API data');
return null;
}
}
}

View File

@@ -0,0 +1,326 @@
/*
* 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 {MessageEmbedResponse} from '~/channel/ChannelModel';
import type {IMediaService} from '~/infrastructure/IMediaService';
import {Logger} from '~/Logger';
import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers';
import * as DOMUtils from '~/utils/DOMUtils';
import {parseString} from '~/utils/StringUtils';
import type {
ActivityPubAttachment,
ActivityPubAuthor,
ActivityPubContext,
ActivityPubPost,
MastodonMediaAttachment,
MastodonPost,
ProcessedMedia,
} from './ActivityPubTypes';
import {escapeMarkdownChars} from './ActivityPubUtils';
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 buildMastodonEmbed(post: MastodonPost, url: URL, context: ActivityPubContext): Promise<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);
let image: ProcessedMedia | undefined;
let video: ProcessedMedia | undefined;
let thumbnail: ProcessedMedia | undefined;
if (post.media_attachments?.length > 0) {
const firstMedia = post.media_attachments[0];
if (firstMedia.type === 'image' || firstMedia.type === 'gifv') {
image = await this.processMedia(firstMedia);
Logger.debug(
{
mediaType: firstMedia.type,
url: firstMedia.url,
hasAltText: !!firstMedia.description,
hasProcessedDescription: !!image?.description,
},
'Processed image media attachment',
);
} else if (firstMedia.type === 'video') {
video = await this.processMedia(firstMedia);
if (firstMedia.preview_url) {
const previewAttachment = {...firstMedia, url: firstMedia.preview_url};
thumbnail = await this.processMedia(previewAttachment);
}
Logger.debug(
{
mediaType: firstMedia.type,
url: firstMedia.url,
hasAltText: !!firstMedia.description,
hasVideoDescription: !!video?.description,
hasThumbnailDescription: !!thumbnail?.description,
},
'Processed video media attachment',
);
}
}
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, url: authorUrl, icon_url: post.account.avatar},
footer: {text: context.serverTitle, icon_url: 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;
}
async buildActivityPubEmbed(
post: ActivityPubPost,
url: URL,
context: ActivityPubContext,
fetchAuthorData: (url: string) => Promise<ActivityPubPost | null>,
): 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 authorUrl = '';
let authorIcon = '';
if (typeof post.attributedTo === 'string') {
const authorData = await fetchAuthorData(post.attributedTo);
if (authorData) {
if (isActivityPubAuthor(authorData)) {
authorName = authorData.name || authorData.preferredUsername || '';
authorUrl = authorData.url || post.attributedTo;
authorIcon = authorData.icon?.url || '';
} else {
const authorUrlObj = new URL(post.attributedTo);
authorName = authorUrlObj.pathname.split('/').pop() || '';
authorUrl = post.attributedTo;
}
} else {
const authorUrlObj = new URL(post.attributedTo);
authorName = authorUrlObj.pathname.split('/').pop() || '';
authorUrl = post.attributedTo;
}
} else if (post.attributedTo && typeof post.attributedTo === 'object') {
const author = post.attributedTo as ActivityPubAuthor;
authorName = author.name || author.preferredUsername || '';
authorUrl = author.url || '';
authorIcon = author.icon?.url || '';
}
let authorFullName = authorName;
const authorUsername = authorUrl.split('/').pop() || '';
authorFullName = `${authorName} (@${authorUsername}@${context.serverDomain})`;
const content = this.formatActivityPubContent(post, context);
let image: ProcessedMedia | undefined;
let video: ProcessedMedia | undefined;
let thumbnail: ProcessedMedia | undefined;
if (post.attachment && post.attachment.length > 0) {
const firstMedia = post.attachment[0];
if (firstMedia.mediaType.startsWith('image/')) {
image = await this.processMedia(firstMedia);
Logger.debug(
{
mediaType: firstMedia.mediaType,
url: firstMedia.url,
hasAltText: !!firstMedia.name,
hasProcessedDescription: !!image?.description,
},
'Processed ActivityPub image attachment',
);
} else if (firstMedia.mediaType.startsWith('video/')) {
video = await this.processMedia(firstMedia);
const thumbnailAttachment = post.attachment?.find(
(a) => a.type === 'Image' && a.mediaType.startsWith('image/'),
);
if (thumbnailAttachment) thumbnail = await this.processMedia(thumbnailAttachment);
Logger.debug(
{
mediaType: firstMedia.mediaType,
url: firstMedia.url,
hasAltText: !!firstMedia.name,
hasVideoDescription: !!video?.description,
hasThumbnailDescription: !!thumbnail?.description,
},
'Processed ActivityPub video attachment',
);
}
}
const fields = [];
const likesCount = typeof post.likes === 'number' ? post.likes : 0;
const sharesCount = typeof post.shares === 'number' ? post.shares : 0;
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,
timestamp: new Date(post.published).toISOString(),
author: {name: authorFullName, url: authorUrl, icon_url: authorIcon},
footer: {text: context.serverTitle, icon_url: 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;
}
private getAttachmentDimension(
attachment: MastodonMediaAttachment | ActivityPubAttachment,
dimension: 'width' | 'height',
): number | undefined {
if (dimension === 'width' && 'width' in attachment && typeof attachment.width === 'number') {
return attachment.width;
}
if (dimension === 'height' && 'height' in attachment && typeof attachment.height === 'number') {
return attachment.height;
}
if (this.isMastodonMediaAttachment(attachment)) {
return attachment.meta.original?.[dimension] ?? attachment.meta.small?.[dimension];
}
return undefined;
}
private isMastodonMediaAttachment(
attachment: MastodonMediaAttachment | ActivityPubAttachment,
): attachment is MastodonMediaAttachment {
return 'meta' in attachment;
}
}

View File

@@ -0,0 +1,153 @@
/*
* 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 {MessageEmbedResponse} from '~/channel/ChannelModel';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import {Logger} from '~/Logger';
import * as DOMUtils from '~/utils/DOMUtils';
import * as FetchUtils from '~/utils/FetchUtils';
import {ActivityPubFetcher} from './ActivityPubFetcher';
import {ActivityPubFormatter} from './ActivityPubFormatter';
import type {ActivityPubContext, MastodonPost} from './ActivityPubTypes';
import {extractAppleTouchIcon, extractPostId} from './ActivityPubUtils';
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 tryFetchParentPost(inReplyToUrl: string): Promise<ActivityPubContext['inReplyTo'] | undefined> {
try {
const parentPost = await this.fetcher.tryFetchActivityPubData(inReplyToUrl);
if (!parentPost) return;
let authorName = '';
if (typeof parentPost.attributedTo === 'string') {
const authorUrl = new URL(parentPost.attributedTo);
authorName = authorUrl.pathname.split('/').pop() || '';
} else if (parentPost.attributedTo) {
authorName = parentPost.attributedTo.preferredUsername || parentPost.attributedTo.name || '';
}
const content = parentPost.content ? DOMUtils.htmlToMarkdown(parentPost.content) : '';
const urlObj = new URL(parentPost.url);
const idMatch = extractPostId(urlObj);
return {author: authorName, content, url: parentPost.url, id: idMatch || undefined};
} catch (error) {
Logger.error({error, inReplyToUrl}, 'Failed to fetch parent post');
return;
}
}
async resolveActivityPub(
url: URL,
activityPubUrl: string | null,
html: string,
): Promise<Array<MessageEmbedResponse> | null> {
try {
Logger.debug({url: url.toString()}, 'Resolving ActivityPub URL');
const postId = extractPostId(url);
if (!postId) {
Logger.debug({url: url.toString()}, 'No post ID found in URL');
return null;
}
const instanceInfo = await this.fetcher.fetchInstanceInfo(url.origin);
const appleTouchIcon = extractAppleTouchIcon(html, url);
const cleanedHostname = url.hostname.replace(/^(?:www\.|social\.|mstdn\.)/, '');
const context: ActivityPubContext = {
serverDomain: instanceInfo?.domain || cleanedHostname,
serverName: instanceInfo?.domain || cleanedHostname,
serverTitle: instanceInfo?.title || `${cleanedHostname} Mastodon`,
serverIcon: appleTouchIcon,
};
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: 5000,
headers: {Accept: 'application/json'},
});
if (response.status === 200) {
const data = await FetchUtils.streamToString(response.stream);
const parentPost = JSON.parse(data) as MastodonPost;
const parentAuthor = parentPost.account.display_name || parentPost.account.username;
context.inReplyTo = {
author: parentAuthor,
content: DOMUtils.htmlToMarkdown(parentPost.content),
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');
}
}
const embed = await this.formatter.buildMastodonEmbed(mastodonPost, url, context);
return [embed];
}
if (activityPubUrl) {
Logger.debug({url: url.toString(), activityPubUrl}, 'Found ActivityPub link');
const activityPubPost = await this.fetcher.tryFetchActivityPubData(activityPubUrl);
if (activityPubPost) {
Logger.debug({url: url.toString(), postId}, '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 embed = await this.formatter.buildActivityPubEmbed(
activityPubPost,
url,
context,
this.fetcher.tryFetchActivityPubData.bind(this.fetcher),
);
return [embed];
}
}
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');
const embed = await this.formatter.buildMastodonEmbed(pleromaPost, url, context);
return [embed];
}
}
}
Logger.debug({url: url.toString()}, 'Could not resolve as ActivityPub');
return null;
} catch (error) {
Logger.error({error, url: url.toString()}, 'Failed to resolve ActivityPub URL');
return null;
}
}
}

View File

@@ -0,0 +1,198 @@
/*
* 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;
shares?: number;
replies?: {totalItems?: number};
}
export interface MastodonPost {
id: string;
created_at: string;
in_reply_to_id: string | null;
in_reply_to_account_id: string | null;
sensitive: boolean;
spoiler_text: string;
visibility: string;
language: string | null;
uri: string;
url: string;
replies_count: number;
reblogs_count: number;
favourites_count: number;
edited_at: string | null;
content: string;
reblog: MastodonPost | null;
application: {name: string} | null;
account: MastodonAccount;
media_attachments: Array<MastodonMediaAttachment>;
mentions: Array<MastodonMention>;
tags: Array<MastodonTag>;
emojis: Array<MastodonEmoji>;
card: MastodonCard | null;
poll: MastodonPoll | null;
}
export interface MastodonInstance {
domain: string;
title: string;
version: string;
source_url: string;
description: string;
usage: {users: {active_month: number}};
thumbnail: {url: string};
languages: Array<string>;
configuration: {urls: {streaming: string; status: string | null}};
registrations: {enabled: boolean; approval_required: boolean; message: string; url: string | null};
contact: {email: string; account: MastodonAccount};
rules: Array<{id: string; text: string; hint: string}>;
}
interface MastodonAccount {
id: string;
username: string;
acct: string;
url: string;
display_name: string;
note: string;
avatar: string;
avatar_static: string;
header: string;
header_static: string;
locked: boolean;
fields: Array<{name: string; value: string}>;
emojis: Array<MastodonEmoji>;
bot: boolean;
group: boolean;
discoverable: boolean | null;
}
export interface MastodonMediaAttachment {
id: string;
type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio';
url: string;
preview_url: string;
remote_url: string | null;
meta: {
original?: {width: number; height: number; size: string; aspect: number};
small?: {width: number; height: number; size: string; aspect: number};
};
description: string | null;
blurhash: string | null;
}
interface MastodonMention {
id: string;
username: string;
url: string;
acct: string;
}
interface MastodonTag {
name: string;
url: string;
}
interface MastodonEmoji {
shortcode: string;
url: string;
static_url: string;
visible_in_picker: boolean;
}
interface MastodonCard {
url: string;
title: string;
description: string;
type: string;
author_name: string;
author_url: string;
provider_name: string;
provider_url: string;
html: string;
width: number;
height: number;
image: string | null;
embed_url: string;
}
interface MastodonPoll {
id: string;
expires_at: string | null;
expired: boolean;
multiple: boolean;
votes_count: number;
voters_count: number | null;
voted: boolean | null;
own_votes: Array<number> | null;
options: Array<{title: string; votes_count: number | null}>;
emojis: Array<MastodonEmoji>;
}
export interface ProcessedMedia {
url: string;
proxy_url?: string;
width: number;
height: number;
placeholder?: string;
flags: number;
description?: string;
content_type?: string;
content_hash?: string;
duration?: number;
}
export interface ActivityPubContext {
inReplyTo?: {author: string; content: string; url: string; id?: string};
serverName: string;
serverTitle: string;
serverIcon?: string;
serverDomain: string;
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {selectOne} from 'css-select';
import type {Element} from 'domhandler';
import {parseDocument} from 'htmlparser2';
import {Logger} from '~/Logger';
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, '\\-');
}