/**
* Timeline item extraction helpers
* @module timeline-store
*/
import { Article, Application, Emoji, Hashtag, Mention, Question, Service } from "@fedify/fedify/vocab";
import sanitizeHtml from "sanitize-html";
/**
* Sanitize HTML content for safe display
* @param {string} html - Raw HTML content
* @returns {string} Sanitized HTML
*/
export function sanitizeContent(html) {
if (!html) return "";
return sanitizeHtml(html, {
allowedTags: [
"p", "br", "a", "strong", "em", "ul", "ol", "li",
"blockquote", "code", "pre", "h1", "h2", "h3", "h4", "h5", "h6",
"span", "div", "img"
],
allowedAttributes: {
a: ["href", "rel", "class"],
img: ["src", "alt", "class"],
span: ["class"],
div: ["class"]
},
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: {
img: ["http", "https"]
}
});
}
/**
* Replace custom emoji :shortcode: placeholders with inline
tags.
* Applied AFTER sanitization — the
tags are controlled output from
* trusted emoji data, not user-supplied HTML.
*
* @param {string} html - Content HTML (already sanitized)
* @param {Array<{shortcode: string, url: string}>} emojis - Custom emoji data
* @returns {string} HTML with shortcodes replaced by
tags
*/
export function replaceCustomEmoji(html, emojis) {
if (!emojis?.length || !html) return html;
let result = html;
for (const { shortcode, url } of emojis) {
// Validate URL is HTTP(S) only — reject data:, javascript:, etc.
if (!url || (!url.startsWith("https://") && !url.startsWith("http://"))) continue;
// Escape HTML special characters in URL and shortcode to prevent attribute injection
const safeUrl = url.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
const safeShortcode = shortcode.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`:${escaped}:`, "g");
result = result.replace(
pattern,
`
`,
);
}
return result;
}
/**
* Extract actor information from Fedify Person/Application/Service object
* @param {object} actor - Fedify actor object
* @param {object} [options] - Options
* @param {object} [options.documentLoader] - Authenticated DocumentLoader for Secure Mode servers
* @returns {object} { name, url, photo, handle }
*/
export async function extractActorInfo(actor, options = {}) {
if (!actor) {
return {
name: "Unknown",
url: "",
photo: "",
handle: ""
};
}
const rawName = actor.name?.toString() || actor.preferredUsername?.toString() || "Unknown";
// Strip all HTML from actor names to prevent stored XSS
const name = sanitizeHtml(rawName, { allowedTags: [], allowedAttributes: {} });
const url = actor.id?.href || "";
// Extract photo URL from icon (Fedify uses async getters)
const loaderOpts = options.documentLoader ? { documentLoader: options.documentLoader } : {};
let photo = "";
try {
if (typeof actor.getIcon === "function") {
const iconObj = await actor.getIcon(loaderOpts);
photo = iconObj?.url?.href || "";
} else {
const iconObj = await actor.icon;
photo = iconObj?.url?.href || "";
}
} catch {
// No icon available
}
// Extract handle from actor URL
let handle = "";
try {
const actorUrl = new URL(url);
const username = actor.preferredUsername?.toString() || "";
if (username) {
handle = `@${username}@${actorUrl.hostname}`;
}
} catch {
// Invalid URL, keep handle empty
}
// Extract custom emoji from actor tags
const emojis = [];
try {
if (typeof actor.getTags === "function") {
const tags = await actor.getTags(loaderOpts);
for await (const tag of tags) {
if (tag instanceof Emoji) {
const shortcode = (tag.name?.toString() || "").replace(/^:|:$/g, "");
const iconUrl = tag.iconId?.href || "";
if (shortcode && iconUrl) {
emojis.push({ shortcode, url: iconUrl });
}
}
}
}
} catch {
// Emoji extraction failed — non-critical
}
// Bot detection — Service and Application actors are automated accounts
const bot = actor instanceof Service || actor instanceof Application;
// Replace custom emoji shortcodes in display name with
tags
const nameHtml = replaceCustomEmoji(name, emojis);
return { name, nameHtml, url, photo, handle, emojis, bot };
}
/**
* Extract timeline item data from Fedify Note/Article object
* @param {object} object - Fedify Note or Article object
* @param {object} options - Extraction options
* @param {object} [options.boostedBy] - Actor info for boosts
* @param {Date} [options.boostedAt] - Boost timestamp
* @param {object} [options.actorFallback] - Fedify actor to use when object.getAttributedTo() fails
* @param {object} [options.documentLoader] - Authenticated DocumentLoader for Secure Mode servers
* @returns {Promise