mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
Extract custom emoji from ActivityPub objects (Fedify Emoji tags) and Mastodon API (status.emojis, account.emojis). Replace :shortcode: patterns with <img> tags in the unified processing pipeline. Emoji rendering applies to post content, author display names, boost attribution, and quote embed authors. Uses the shared postProcessItems() pipeline so both reader and explore views get emoji automatically. Bump version to 2.5.1. Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
174 lines
5.5 KiB
JavaScript
174 lines
5.5 KiB
JavaScript
/**
|
||
* Shared utilities for explore controllers.
|
||
*
|
||
* Extracted to break the circular dependency between explore.js and tabs.js:
|
||
* - explore.js needs validateHashtag (was in tabs.js)
|
||
* - tabs.js needs validateInstance (was in explore.js)
|
||
* - hashtag-explore.js needs mapMastodonStatusToItem (was duplicated)
|
||
*/
|
||
|
||
import sanitizeHtml from "sanitize-html";
|
||
import { sanitizeContent } from "../timeline-store.js";
|
||
|
||
/**
|
||
* Validate the instance parameter to prevent SSRF.
|
||
* Only allows hostnames — no IPs, no localhost, no port numbers.
|
||
* @param {string} instance - Raw instance parameter from query string
|
||
* @returns {string|null} Validated hostname or null
|
||
*/
|
||
export function validateInstance(instance) {
|
||
if (!instance || typeof instance !== "string") return null;
|
||
|
||
try {
|
||
const url = new URL(`https://${instance.trim()}`);
|
||
const hostname = url.hostname;
|
||
if (
|
||
hostname === "localhost" ||
|
||
hostname === "127.0.0.1" ||
|
||
hostname === "0.0.0.0" ||
|
||
hostname === "::1" ||
|
||
hostname.startsWith("192.168.") ||
|
||
hostname.startsWith("10.") ||
|
||
hostname.startsWith("169.254.") ||
|
||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
||
/^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(hostname) ||
|
||
hostname.includes("[")
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return hostname;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validates a hashtag value.
|
||
* Returns the cleaned hashtag (leading # stripped) or null if invalid.
|
||
*
|
||
* Rules match Mastodon's hashtag character rules:
|
||
* - Alphanumeric + underscore only (\w+)
|
||
* - 1–100 characters after stripping leading #
|
||
*/
|
||
export function validateHashtag(raw) {
|
||
if (typeof raw !== "string") return null;
|
||
const cleaned = raw.replace(/^#+/, "");
|
||
if (!cleaned || cleaned.length > 100) return null;
|
||
if (!/^[\w]+$/.test(cleaned)) return null;
|
||
return cleaned;
|
||
}
|
||
|
||
/**
|
||
* Map a Mastodon API status object to our timeline item format.
|
||
* @param {object} status - Mastodon API status
|
||
* @param {string} instance - Instance hostname (for handle construction)
|
||
* @returns {object} Timeline item compatible with ap-item-card.njk
|
||
*/
|
||
export function mapMastodonStatusToItem(status, instance) {
|
||
const account = status.account || {};
|
||
const acct = account.acct || "";
|
||
const handle = acct.includes("@") ? `@${acct}` : `@${acct}@${instance}`;
|
||
|
||
const mentions = (status.mentions || []).map((m) => ({
|
||
name: m.acct.includes("@") ? m.acct : `${m.acct}@${instance}`,
|
||
url: m.url || "",
|
||
}));
|
||
|
||
const category = (status.tags || []).map((t) => t.name || "");
|
||
|
||
const photo = [];
|
||
const video = [];
|
||
const audio = [];
|
||
for (const att of status.media_attachments || []) {
|
||
const url = att.url || att.remote_url || "";
|
||
if (!url) continue;
|
||
if (att.type === "image" || att.type === "gifv") {
|
||
photo.push(url);
|
||
} else if (att.type === "video") {
|
||
video.push(url);
|
||
} else if (att.type === "audio") {
|
||
audio.push(url);
|
||
}
|
||
}
|
||
|
||
// Extract custom emoji — Mastodon API provides emojis on both status and account
|
||
const emojis = (status.emojis || [])
|
||
.filter((e) => e.shortcode && e.url)
|
||
.map((e) => ({ shortcode: e.shortcode, url: e.url }));
|
||
const authorEmojis = (account.emojis || [])
|
||
.filter((e) => e.shortcode && e.url)
|
||
.map((e) => ({ shortcode: e.shortcode, url: e.url }));
|
||
|
||
const item = {
|
||
uid: status.url || status.uri || "",
|
||
url: status.url || status.uri || "",
|
||
type: "note",
|
||
name: "",
|
||
content: {
|
||
text: (status.content || "").replace(/<[^>]*>/g, ""),
|
||
html: sanitizeContent(status.content || ""),
|
||
},
|
||
summary: status.spoiler_text || "",
|
||
sensitive: status.sensitive || false,
|
||
published: status.created_at || new Date().toISOString(),
|
||
author: {
|
||
name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
|
||
url: account.url || "",
|
||
photo: account.avatar || account.avatar_static || "",
|
||
handle,
|
||
emojis: authorEmojis,
|
||
},
|
||
category,
|
||
mentions,
|
||
emojis,
|
||
photo,
|
||
video,
|
||
audio,
|
||
inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
|
||
createdAt: new Date().toISOString(),
|
||
_explore: true,
|
||
};
|
||
|
||
// Map quoted post data if present (Mastodon 4.3+ quote support)
|
||
// Mastodon API wraps the quoted status: { state: "accepted", quoted_status: { ...fullStatus } }
|
||
const quotedStatus = status.quote?.quoted_status || null;
|
||
if (quotedStatus) {
|
||
item.quoteUrl = quotedStatus.url || quotedStatus.uri || "";
|
||
|
||
const q = quotedStatus;
|
||
const qAccount = q.account || {};
|
||
const qAcct = qAccount.acct || "";
|
||
const qHandle = qAcct.includes("@") ? `@${qAcct}` : `@${qAcct}@${instance}`;
|
||
const qPhoto = [];
|
||
for (const att of q.media_attachments || []) {
|
||
const attUrl = att.url || att.remote_url || "";
|
||
if (attUrl && (att.type === "image" || att.type === "gifv")) {
|
||
qPhoto.push(attUrl);
|
||
}
|
||
}
|
||
|
||
item.quote = {
|
||
url: q.url || q.uri || "",
|
||
uid: q.uri || q.url || "",
|
||
author: {
|
||
name: sanitizeHtml(qAccount.display_name || qAccount.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
|
||
url: qAccount.url || "",
|
||
photo: qAccount.avatar || qAccount.avatar_static || "",
|
||
handle: qHandle,
|
||
},
|
||
content: {
|
||
text: (q.content || "").replace(/<[^>]*>/g, ""),
|
||
html: sanitizeContent(q.content || ""),
|
||
},
|
||
published: q.created_at || "",
|
||
name: "",
|
||
photo: qPhoto.slice(0, 1),
|
||
};
|
||
} else {
|
||
item.quoteUrl = "";
|
||
}
|
||
|
||
return item;
|
||
}
|