mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
Shorten long URLs in post content (30 char display limit with tooltip). Collapse hashtag-heavy paragraphs into expandable <details> toggle. Show BOT badge for Service/Application actors. Show pencil icon for edited posts with hover tooltip showing edit timestamp. Confab-Link: http://localhost:8080/sessions/e9d666ac-3c90-4298-9e92-9ac9d142bc06
193 lines
6.1 KiB
JavaScript
193 lines
6.1 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,
|
||
alt: att.description || "",
|
||
width: att.meta?.original?.width || null,
|
||
height: att.meta?.original?.height || null,
|
||
blurhash: att.blurhash || "",
|
||
focus: att.meta?.focus || null,
|
||
});
|
||
} 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(),
|
||
updated: status.edited_at || "",
|
||
author: {
|
||
name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
|
||
url: account.url || "",
|
||
photo: account.avatar || account.avatar_static || "",
|
||
handle,
|
||
emojis: authorEmojis,
|
||
bot: account.bot || false,
|
||
},
|
||
category,
|
||
mentions,
|
||
emojis,
|
||
photo,
|
||
video,
|
||
audio,
|
||
inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
|
||
counts: {
|
||
replies: status.replies_count ?? null,
|
||
boosts: status.reblogs_count ?? null,
|
||
likes: status.favourites_count ?? null,
|
||
},
|
||
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({
|
||
url: attUrl,
|
||
alt: att.description || "",
|
||
width: att.meta?.original?.width || null,
|
||
height: att.meta?.original?.height || null,
|
||
});
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|