diff --git a/assets/reader-links.css b/assets/reader-links.css new file mode 100644 index 0000000..4b1b6aa --- /dev/null +++ b/assets/reader-links.css @@ -0,0 +1,151 @@ +/** + * OpenGraph link preview cards and AP link interception + * Styles for link preview cards in the ActivityPub reader + */ + +/* Link preview container */ +.ap-link-previews { + margin-top: var(--space-m); + display: flex; + flex-direction: column; + gap: var(--space-s); +} + +/* Individual link preview card */ +.ap-link-preview { + display: flex; + overflow: hidden; + border-radius: var(--border-radius-small); + border: 1px solid var(--color-neutral-lighter); + background-color: var(--color-offset); + text-decoration: none; + color: inherit; + transition: border-color 0.2s ease; +} + +.ap-link-preview:hover { + border-color: var(--color-primary); +} + +/* Text content area (left side) */ +.ap-link-preview__text { + flex: 1; + padding: var(--space-s); + min-width: 0; /* Enable text truncation */ +} + +.ap-link-preview__title { + font-weight: 600; + font-size: 0.875rem; + color: var(--color-on-background); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ap-link-preview__desc { + font-size: 0.75rem; + color: var(--color-on-offset); + margin: var(--space-xs) 0 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.ap-link-preview__domain { + font-size: 0.75rem; + color: var(--color-neutral); + margin: var(--space-s) 0 0; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.ap-link-preview__favicon { + width: 1rem; + height: 1rem; + display: inline-block; +} + +/* Image area (right side) */ +.ap-link-preview__image { + flex-shrink: 0; + width: 6rem; + height: 6rem; +} + +.ap-link-preview__image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Responsive - larger images on desktop */ +@media (min-width: 640px) { + .ap-link-preview__image { + width: 8rem; + height: 8rem; + } + + .ap-link-preview__title { + font-size: 1rem; + } + + .ap-link-preview__desc { + font-size: 0.875rem; + } +} + +/* Post detail thread view */ +.ap-post-detail__back { + margin-bottom: var(--space-m); +} + +.ap-post-detail__back-link { + font-size: 0.875rem; + color: var(--color-primary); + text-decoration: none; +} + +.ap-post-detail__back-link:hover { + text-decoration: underline; +} + +.ap-post-detail__section-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-neutral); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: var(--space-l) 0 var(--space-s); + padding-bottom: var(--space-xs); + border-bottom: 1px solid var(--color-neutral-lighter); +} + +.ap-post-detail__main { + margin: var(--space-m) 0; +} + +.ap-post-detail__parents, +.ap-post-detail__replies { + margin: var(--space-m) 0; +} + +.ap-post-detail__parent-item, +.ap-post-detail__reply-item { + margin-bottom: var(--space-s); +} + +/* Thread connector line between parent posts */ +.ap-post-detail__parents .ap-post-detail__parent-item { + position: relative; + padding-left: var(--space-m); + border-left: 2px solid var(--color-neutral-lighter); +} + +/* Main post highlight */ +.ap-post-detail__main .ap-card { + border-left-width: 3px; +} diff --git a/assets/reader-links.js b/assets/reader-links.js new file mode 100644 index 0000000..d5a7f8b --- /dev/null +++ b/assets/reader-links.js @@ -0,0 +1,88 @@ +/** + * Client-side AP link interception for internal navigation + * Redirects ActivityPub links to internal reader views + */ + +(function () { + "use strict"; + + // Fediverse URL patterns that should open internally + const AP_URL_PATTERN = + /\/@[\w.-]+\/\d+|\/@[\w.-]+\/statuses\/[\w]+|\/users\/[\w.-]+\/statuses\/\d+|\/objects\/[\w-]+|\/notice\/[\w]+|\/notes\/[\w]+|\/post\/\d+|\/comment\/\d+|\/p\/[\w.-]+\/\d+/; + + // Get mount path from DOM + function getMountPath() { + // Look for data-mount-path on reader container or header + const container = document.querySelector( + "[data-mount-path]", + ); + return container ? container.dataset.mountPath : "/activitypub"; + } + + // Check if a link should be intercepted + function shouldInterceptLink(link) { + const href = link.getAttribute("href"); + if (!href) return null; + + const classes = link.className || ""; + + // Mention links → profile view + if (classes.includes("mention")) { + return { type: "profile", url: href }; + } + + // AP object URL patterns → post detail view + if (AP_URL_PATTERN.test(href)) { + return { type: "post", url: href }; + } + + return null; + } + + // Handle link click + function handleLinkClick(event) { + const link = event.target.closest("a"); + if (!link) return; + + // Only intercept links inside post content + const contentDiv = link.closest(".ap-card__content"); + if (!contentDiv) return; + + const interception = shouldInterceptLink(link); + if (!interception) return; + + // Prevent default navigation + event.preventDefault(); + + const mountPath = getMountPath(); + const encodedUrl = encodeURIComponent(interception.url); + + if (interception.type === "profile") { + window.location.href = `${mountPath}/admin/reader/profile?url=${encodedUrl}`; + } else if (interception.type === "post") { + window.location.href = `${mountPath}/admin/reader/post?url=${encodedUrl}`; + } + } + + // Initialize on DOM ready + function init() { + // Use event delegation on timeline container + const timeline = document.querySelector(".ap-timeline"); + if (timeline) { + timeline.addEventListener("click", handleLinkClick); + } + + // Also set up on post detail view + const postDetail = document.querySelector(".ap-post-detail"); + if (postDetail) { + postDetail.addEventListener("click", handleLinkClick); + } + } + + // Run on DOMContentLoaded or immediately if already loaded + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/index.js b/index.js index 77bd09f..f71d0ce 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,7 @@ import { remoteProfileController, followController, unfollowController, + postDetailController, } from "./lib/controllers/reader.js"; import { likeController, @@ -195,6 +196,7 @@ export default class ActivityPubEndpoint { router.post("/admin/reader/boost", boostController(mp, this)); router.post("/admin/reader/unboost", unboostController(mp, this)); router.get("/admin/reader/profile", remoteProfileController(mp, this)); + router.get("/admin/reader/post", postDetailController(mp, this)); router.post("/admin/reader/follow", followController(mp, this)); router.post("/admin/reader/unfollow", unfollowController(mp, this)); router.get("/admin/reader/moderation", moderationController(mp)); diff --git a/lib/controllers/post-detail.js b/lib/controllers/post-detail.js new file mode 100644 index 0000000..8a1381d --- /dev/null +++ b/lib/controllers/post-detail.js @@ -0,0 +1,300 @@ +// Post detail controller — view individual AP posts/notes/articles +import { Article, Note, Person, Service, Application } from "@fedify/fedify"; +import { getToken } from "../csrf.js"; +import { extractObjectData } from "../timeline-store.js"; +import { getCached, setCache } from "../lookup-cache.js"; + +// Load parent posts (inReplyTo chain) up to maxDepth levels +async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) { + const parents = []; + let currentUrl = parentUrl; + let depth = 0; + + while (currentUrl && depth < maxDepth) { + depth++; + + // Check timeline first + let parent = timelineCol + ? await timelineCol.findOne({ + $or: [{ uid: currentUrl }, { url: currentUrl }], + }) + : null; + + if (!parent) { + // Fetch via lookupObject + const cached = getCached(currentUrl); + let object = cached; + + if (!object) { + try { + object = await ctx.lookupObject(new URL(currentUrl), { + documentLoader, + }); + if (object) { + setCache(currentUrl, object); + } + } catch { + break; // Stop on error + } + } + + if (!object || !(object instanceof Note || object instanceof Article)) { + break; + } + + try { + parent = await extractObjectData(object); + } catch { + break; + } + } + + if (parent) { + parents.unshift(parent); // Add to beginning (chronological order) + currentUrl = parent.inReplyTo; // Continue up the chain + } else { + break; + } + } + + return parents; +} + +// Load replies collection (best-effort) +async function loadReplies(object, ctx, documentLoader, timelineCol, maxReplies = 10) { + const replies = []; + + try { + const repliesCollection = await object.getReplies({ documentLoader }); + if (!repliesCollection) return replies; + + let items = []; + try { + items = await repliesCollection.getItems({ documentLoader }); + } catch { + return replies; + } + + for (const replyItem of items.slice(0, maxReplies)) { + try { + const replyUrl = replyItem.id?.href || replyItem.url?.href; + if (!replyUrl) continue; + + // Check timeline first + let reply = timelineCol + ? await timelineCol.findOne({ + $or: [{ uid: replyUrl }, { url: replyUrl }], + }) + : null; + + if (!reply) { + // Extract from the item we already have + if (replyItem instanceof Note || replyItem instanceof Article) { + reply = await extractObjectData(replyItem); + } + } + + if (reply) { + replies.push(reply); + } + } catch { + continue; // Skip failed replies + } + } + } catch { + // getReplies() failed or not available + } + + return replies; +} + +// GET /admin/reader/post — Show post detail view +export function postDetailController(mountPath, plugin) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const objectUrl = request.query.url; + + if (!objectUrl || typeof objectUrl !== "string") { + return response.status(400).render("error", { + title: "Error", + content: "Missing post URL", + }); + } + + // Validate URL format + try { + new URL(objectUrl); + } catch { + return response.status(400).render("error", { + title: "Error", + content: "Invalid post URL", + }); + } + + if (!plugin._federation) { + return response.status(503).render("error", { + title: "Error", + content: "Federation not initialized", + }); + } + + const timelineCol = application?.collections?.get("ap_timeline"); + const interactionsCol = + application?.collections?.get("ap_interactions"); + + // Check local timeline first (optimization) + let timelineItem = null; + if (timelineCol) { + timelineItem = await timelineCol.findOne({ + $or: [{ uid: objectUrl }, { url: objectUrl }], + }); + } + + let object = null; + + if (!timelineItem) { + // Not in local timeline — fetch via lookupObject + const handle = plugin.options.actor.handle; + const ctx = plugin._federation.createContext( + new URL(plugin._publicationUrl), + { handle, publicationUrl: plugin._publicationUrl }, + ); + + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + + // Check cache first + const cached = getCached(objectUrl); + if (cached) { + object = cached; + } else { + try { + object = await ctx.lookupObject(new URL(objectUrl), { + documentLoader, + }); + if (object) { + setCache(objectUrl, object); + } + } catch (error) { + console.warn( + `[post-detail] lookupObject failed for ${objectUrl}:`, + error.message, + ); + } + } + + if (!object) { + return response.status(404).render("activitypub-post-detail", { + title: response.locals.__("activitypub.reader.post.title"), + notFound: true, objectUrl, mountPath, + item: null, interactionMap: {}, csrfToken: null, + parentPosts: [], replyPosts: [], + }); + } + + // If it's an actor (Person, Service, Application), redirect to profile + if ( + object instanceof Person || + object instanceof Service || + object instanceof Application + ) { + return response.redirect( + `${mountPath}/admin/reader/profile?url=${encodeURIComponent(objectUrl)}`, + ); + } + + // Extract timeline item data from the Fedify object + if (object instanceof Note || object instanceof Article) { + try { + timelineItem = await extractObjectData(object); + } catch (error) { + console.error(`[post-detail] extractObjectData failed for ${objectUrl}:`, error.message); + return response.status(500).render("error", { + title: "Error", + content: "Failed to extract post data", + }); + } + } else { + return response.status(400).render("error", { + title: "Error", + content: "Object is not a viewable post (must be Note or Article)", + }); + } + } + + // Build interaction state for this post + const interactionMap = {}; + if (interactionsCol && timelineItem) { + const uid = timelineItem.uid; + const displayUrl = timelineItem.url || timelineItem.originalUrl; + + const interactions = await interactionsCol + .find({ + $or: [{ objectUrl: uid }, { objectUrl: displayUrl }], + }) + .toArray(); + + for (const interaction of interactions) { + const key = uid; + if (!interactionMap[key]) { + interactionMap[key] = {}; + } + interactionMap[key][interaction.type] = true; + } + } + + // Load thread (parent chain + replies) with timeout + let parentPosts = []; + let replyPosts = []; + + try { + const handle = plugin.options.actor.handle; + const ctx = plugin._federation.createContext( + new URL(plugin._publicationUrl), + { handle, publicationUrl: plugin._publicationUrl }, + ); + + const documentLoader = await ctx.getDocumentLoader({ + identifier: handle, + }); + + const threadPromise = Promise.all([ + // Load parent chain + timelineItem.inReplyTo + ? loadParentChain(ctx, documentLoader, timelineCol, timelineItem.inReplyTo) + : Promise.resolve([]), + // Load replies (if object is available) + object + ? loadReplies(object, ctx, documentLoader, timelineCol) + : Promise.resolve([]), + ]); + + // 15-second timeout for thread loading + const timeout = new Promise((resolve) => + setTimeout(() => resolve([[], []]), 15000), + ); + + [parentPosts, replyPosts] = await Promise.race([threadPromise, timeout]); + } catch (error) { + console.error("[post-detail] Thread loading failed:", error.message); + // Continue with empty thread + } + + const csrfToken = getToken(request.session); + + response.render("activitypub-post-detail", { + title: response.locals.__("activitypub.reader.post.title"), + item: timelineItem, + interactionMap, + csrfToken, + mountPath, + parentPosts, + replyPosts, + }); + } catch (error) { + next(error); + } + }; +} diff --git a/lib/controllers/reader.js b/lib/controllers/reader.js index 0100728..0cd624b 100644 --- a/lib/controllers/reader.js +++ b/lib/controllers/reader.js @@ -25,6 +25,7 @@ export { followController, unfollowController, } from "./profile.remote.js"; +export { postDetailController } from "./post-detail.js"; export function readerController(mountPath) { return async (request, response, next) => { @@ -47,6 +48,7 @@ export function readerController(mountPath) { // Tab filtering if (tab === "notes") { options.type = "note"; + options.excludeReplies = true; } else if (tab === "articles") { options.type = "article"; } else if (tab === "boosts") { diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index cdbe288..b2ef8b8 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -27,6 +27,7 @@ import { logActivity as logActivityShared } from "./activity-log.js"; import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js"; import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js"; import { addNotification } from "./storage/notifications.js"; +import { fetchAndStorePreviews } from "./og-unfurl.js"; /** * Register all inbox listeners on a federation's inbox chain. @@ -450,6 +451,14 @@ export function registerInboxListeners(inboxChain, options) { actorFallback: actorObj, }); await addTimelineItem(collections, timelineItem); + + // Fire-and-forget OG unfurling for notes and articles (not boosts) + if (timelineItem.type === "note" || timelineItem.type === "article") { + fetchAndStorePreviews(collections, timelineItem.uid, timelineItem.content.html) + .catch((error) => { + console.error(`[inbox] OG unfurl failed for ${timelineItem.uid}:`, error); + }); + } } catch (error) { // Log extraction errors but don't fail the entire handler console.error("Failed to store timeline item:", error); diff --git a/lib/lookup-cache.js b/lib/lookup-cache.js new file mode 100644 index 0000000..576a017 --- /dev/null +++ b/lib/lookup-cache.js @@ -0,0 +1,38 @@ +/** + * Simple in-memory LRU cache for lookupObject results + * Max 100 entries, 5-minute TTL + * @module lookup-cache + */ + +const lookupCache = new Map(); +const CACHE_MAX_SIZE = 100; +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Get a cached lookup result + * @param {string} url - URL key + * @returns {*} Cached data or null + */ +export function getCached(url) { + const entry = lookupCache.get(url); + if (!entry) return null; + if (Date.now() - entry.timestamp > CACHE_TTL_MS) { + lookupCache.delete(url); + return null; + } + return entry.data; +} + +/** + * Store a lookup result in cache + * @param {string} url - URL key + * @param {*} data - Data to cache + */ +export function setCache(url, data) { + // Evict oldest entry if at max size + if (lookupCache.size >= CACHE_MAX_SIZE) { + const firstKey = lookupCache.keys().next().value; + lookupCache.delete(firstKey); + } + lookupCache.set(url, { data, timestamp: Date.now() }); +} diff --git a/lib/og-unfurl.js b/lib/og-unfurl.js new file mode 100644 index 0000000..17edeac --- /dev/null +++ b/lib/og-unfurl.js @@ -0,0 +1,250 @@ +/** + * OpenGraph metadata fetching with concurrency limiting + * @module og-unfurl + */ + +import { unfurl } from "unfurl.js"; + +const USER_AGENT = + "Mozilla/5.0 (compatible; Indiekit/1.0; +https://getindiekit.com)"; +const TIMEOUT_MS = 10000; // 10 seconds per URL +const MAX_CONCURRENT = 3; // Lower than theme's 5 (inbox context) +const MAX_PREVIEWS = 3; // Max previews per post + +// Concurrency limiter — prevents overwhelming outbound network +let activeRequests = 0; +const queue = []; + +function runNext() { + if (queue.length === 0 || activeRequests >= MAX_CONCURRENT) return; + activeRequests++; + const { resolve: res, fn } = queue.shift(); + fn() + .then(res) + .finally(() => { + activeRequests--; + runNext(); + }); +} + +function throttled(fn) { + return new Promise((res) => { + queue.push({ resolve: res, fn }); + runNext(); + }); +} + +function extractDomain(url) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** + * Check if a URL points to a private/reserved IP or localhost (SSRF protection) + * @param {string} url - URL to check + * @returns {boolean} True if URL targets a private network + */ +function isPrivateUrl(url) { + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname.toLowerCase(); + + // Block non-http(s) schemes + if (urlObj.protocol !== "http:" && urlObj.protocol !== "https:") { + return true; + } + + // Block localhost variants + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") { + return true; + } + + // Block private IPv4 ranges + const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (ipv4Match) { + const [, a, b] = ipv4Match.map(Number); + if (a === 10) return true; // 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata) + if (a === 127) return true; // 127.0.0.0/8 + if (a === 0) return true; // 0.0.0.0/8 + } + + // Block IPv6 private ranges (basic check) + if (hostname.startsWith("[fc") || hostname.startsWith("[fd") || hostname.startsWith("[fe80")) { + return true; + } + + return false; + } catch { + return true; // Invalid URL, treat as private + } +} + +/** + * Extract links from HTML content + * @param {string} html - Sanitized HTML content + * @returns {Array<{url: string, classes: string}>} Links with their class attributes + */ +function extractLinks(html) { + if (!html) return []; + + const links = []; + // Match complete tags and extract href + class from anywhere in attributes + const anchorRegex = /]+)>/gi; + + let match; + while ((match = anchorRegex.exec(html)) !== null) { + const attrs = match[1]; + const hrefMatch = attrs.match(/href="([^"]+)"/); + const classMatch = attrs.match(/class="([^"]+)"/); + if (hrefMatch) { + links.push({ url: hrefMatch[1], classes: classMatch ? classMatch[1] : "" }); + } + } + + return links; +} + +/** + * Check if URL is likely an ActivityPub object or media file + * @param {string} url - URL to check + * @returns {boolean} True if URL should be skipped + */ +function shouldSkipUrl(url) { + try { + const urlObj = new URL(url); + + // SSRF protection — skip private/internal URLs + if (isPrivateUrl(url)) { + return true; + } + + // Skip media extensions + const mediaExtensions = /\.(jpg|jpeg|png|gif|webp|mp4|webm|mov|mp3|wav|ogg)$/i; + if (mediaExtensions.test(urlObj.pathname)) { + return true; + } + + // Skip common AP object patterns (heuristic - not exhaustive) + const apPatterns = [ + /\/@[\w.-]+\/\d+/, // Mastodon /@user/12345 + /\/@[\w.-]+\/statuses\/[\w]+/, // GoToSocial /@user/statuses/id + /\/users\/[\w.-]+\/statuses\/\d+/, // Mastodon/Pleroma /users/user/statuses/12345 + /\/objects\/[\w-]+/, // Pleroma/Akkoma /objects/uuid + /\/notice\/[\w]+/, // Pleroma /notice/id + /\/notes\/[\w]+/, // Misskey /notes/id + ]; + + return apPatterns.some((pattern) => pattern.test(urlObj.pathname)); + } catch { + return true; // Invalid URL, skip + } +} + +/** + * Fetch OpenGraph metadata for external links in HTML content + * @param {string} html - Sanitized HTML content + * @returns {Promise>} Link preview objects + */ +export async function fetchLinkPreviews(html) { + if (!html) return []; + + const links = extractLinks(html); + + // Filter links + const urlsToFetch = links + .filter((link) => { + // Skip mention links (class="mention") + if (link.classes.includes("mention")) return false; + + // Skip hashtag links (class="hashtag") + if (link.classes.includes("hashtag")) return false; + + // Skip AP object URLs and media files + if (shouldSkipUrl(link.url)) return false; + + return true; + }) + .map((link) => link.url) + .filter((url, index, self) => self.indexOf(url) === index) // Dedupe + .slice(0, MAX_PREVIEWS); // Cap at max + + if (urlsToFetch.length === 0) return []; + + // Fetch metadata for each URL (throttled) + const previews = await Promise.all( + urlsToFetch.map(async (url) => { + const metadata = await throttled(async () => { + try { + return await unfurl(url, { + timeout: TIMEOUT_MS, + headers: { "User-Agent": USER_AGENT }, + }); + } catch (error) { + console.warn(`[og-unfurl] Failed to fetch ${url}: ${error.message}`); + return null; + } + }); + + if (!metadata) return null; + + const og = metadata.open_graph || {}; + const tc = metadata.twitter_card || {}; + + const title = og.title || tc.title || metadata.title || extractDomain(url); + const description = og.description || tc.description || metadata.description || ""; + const image = og.images?.[0]?.url || tc.images?.[0]?.url || null; + const favicon = metadata.favicon || null; + const domain = extractDomain(url); + + // Truncate description + const maxDesc = 160; + const desc = + description.length > maxDesc + ? description.slice(0, maxDesc).trim() + "\u2026" + : description; + + return { + url, + title, + description: desc, + image, + favicon, + domain, + fetchedAt: new Date().toISOString(), + }; + }), + ); + + // Filter out failed fetches (null results) + return previews.filter((preview) => preview !== null); +} + +/** + * Fetch link previews and store them on a timeline item + * Fire-and-forget — caller does NOT await. Errors are caught and logged. + * @param {object} collections - MongoDB collections + * @param {string} uid - Timeline item UID + * @param {string} html - Post content HTML + * @returns {Promise} + */ +export async function fetchAndStorePreviews(collections, uid, html) { + try { + const linkPreviews = await fetchLinkPreviews(html); + + await collections.ap_timeline.updateOne( + { uid }, + { $set: { linkPreviews } }, + ); + } catch (error) { + // Fire-and-forget — log errors but don't throw + console.error( + `[og-unfurl] Failed to store previews for ${uid}: ${error.message}`, + ); + } +} diff --git a/lib/storage/timeline.js b/lib/storage/timeline.js index 28589d3..8d4e7a9 100644 --- a/lib/storage/timeline.js +++ b/lib/storage/timeline.js @@ -24,6 +24,7 @@ * @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts * @param {Date} [item.boostedAt] - Boost timestamp * @param {string} [item.originalUrl] - Original post URL for boosts + * @param {Array<{url: string, title: string, description: string, image: string, favicon: string, domain: string, fetchedAt: string}>} [item.linkPreviews] - OpenGraph link previews for external links in content * @param {string} item.createdAt - ISO string creation timestamp * @returns {Promise} Created or existing item */ @@ -75,6 +76,15 @@ export async function getTimelineItems(collections, options = {}) { query.type = options.type; } + // Exclude replies (notes with inReplyTo set) + if (options.excludeReplies) { + query.$or = [ + { inReplyTo: null }, + { inReplyTo: "" }, + { inReplyTo: { $exists: false } }, + ]; + } + // Author filter (for profile view) — validate string type to prevent operator injection if (options.authorUrl) { if (typeof options.authorUrl !== "string") { diff --git a/locales/en.json b/locales/en.json index b146e7d..a62dd6c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -176,6 +176,19 @@ "boosted": "Boosted", "likeError": "Could not like this post", "boostError": "Could not boost this post" + }, + "post": { + "title": "Post Detail", + "notFound": "Post not found or no longer available.", + "openExternal": "Open on original instance", + "parentPosts": "Thread", + "replies": "Replies", + "back": "Back to timeline", + "loadingThread": "Loading thread...", + "threadError": "Could not load full thread" + }, + "linkPreview": { + "label": "Link preview" } } } diff --git a/package-lock.json b/package-lock.json index 020f802..c364a8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.0.29", + "version": "1.1.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.0.29", + "version": "1.1.13", "license": "MIT", "dependencies": { "@fedify/express": "^1.10.3", @@ -15,7 +15,8 @@ "@js-temporal/polyfill": "^0.5.0", "express": "^5.0.0", "ioredis": "^5.9.3", - "sanitize-html": "^2.13.1" + "sanitize-html": "^2.13.1", + "unfurl.js": "^6.4.0" }, "engines": { "node": ">=22" @@ -793,6 +794,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -1070,6 +1080,26 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1486,6 +1516,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1524,6 +1560,43 @@ "node": ">=18.17" } }, + "node_modules/unfurl.js": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/unfurl.js/-/unfurl.js-6.4.0.tgz", + "integrity": "sha512-DogJFWPkOWMcu2xPdpmbcsL+diOOJInD3/jXOv6saX1upnWmMK8ndAtDWUfJkuInqNI9yzADud4ID9T+9UeWCw==", + "license": "ISC", + "dependencies": { + "debug": "^3.2.7", + "he": "^1.2.0", + "htmlparser2": "^8.0.1", + "iconv-lite": "^0.4.24", + "node-fetch": "^2.6.7" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/unfurl.js/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/unfurl.js/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1569,6 +1642,22 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index c7a9744..17eb1e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "1.1.12", + "version": "1.1.14", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", @@ -43,7 +43,8 @@ "@js-temporal/polyfill": "^0.5.0", "express": "^5.0.0", "ioredis": "^5.9.3", - "sanitize-html": "^2.13.1" + "sanitize-html": "^2.13.1", + "unfurl.js": "^6.4.0" }, "peerDependencies": { "@indiekit/error": "^1.0.0-beta.25", diff --git a/views/activitypub-post-detail.njk b/views/activitypub-post-detail.njk new file mode 100644 index 0000000..3aedf7e --- /dev/null +++ b/views/activitypub-post-detail.njk @@ -0,0 +1,61 @@ +{% extends "layouts/ap-reader.njk" %} + +{% from "heading/macro.njk" import heading with context %} + +{% block readercontent %} + {{ heading({ + text: title, + level: 1, + parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" } + }) }} + +
+ {# Back button #} +
+ + ← {{ __("activitypub.reader.post.back") }} + +
+ + {% if notFound %} + {# Post not found — show message with external link #} +
+

{{ __("activitypub.reader.post.notFound") }}

+ {% if objectUrl %} +

{{ __("activitypub.reader.post.openExternal") }} →

+ {% endif %} +
+ {% else %} + {# Parent posts (thread context above main post) #} + {% if parentPosts and parentPosts.length > 0 %} +
+

{{ __("activitypub.reader.post.parentPosts") }}

+ {% for parentItem in parentPosts %} + {% set item = parentItem %} +
+ {% include "partials/ap-item-card.njk" %} +
+ {% endfor %} +
+ {% endif %} + + {# Main post #} +
+ {% include "partials/ap-item-card.njk" %} +
+ + {# Replies (below main post) #} + {% if replyPosts and replyPosts.length > 0 %} +
+

{{ __("activitypub.reader.post.replies") }}

+ {% for replyItem in replyPosts %} + {% set item = replyItem %} +
+ {% include "partials/ap-item-card.njk" %} +
+ {% endfor %} +
+ {% endif %} + {% endif %} +
+{% endblock %} diff --git a/views/activitypub-reader.njk b/views/activitypub-reader.njk index 47be2a1..08494cd 100644 --- a/views/activitypub-reader.njk +++ b/views/activitypub-reader.njk @@ -34,7 +34,7 @@ {# Timeline items #} {% if items.length > 0 %} -
+
{% for item in items %} {% include "partials/ap-item-card.njk" %} {% endfor %} diff --git a/views/activitypub-remote-profile.njk b/views/activitypub-remote-profile.njk index 7bf7a15..9b26896 100644 --- a/views/activitypub-remote-profile.njk +++ b/views/activitypub-remote-profile.njk @@ -46,7 +46,8 @@
{% if icon %} - {{ name }} + {{ name }} {% else %}
{{ name[0] }}
{% endif %} diff --git a/views/layouts/ap-reader.njk b/views/layouts/ap-reader.njk index 2ef8bd8..d91cd82 100644 --- a/views/layouts/ap-reader.njk +++ b/views/layouts/ap-reader.njk @@ -6,6 +6,10 @@ {# Reader stylesheet — loaded in body is fine for modern browsers #} + + + {# AP link interception for internal navigation #} + {% block readercontent %} {% endblock %} diff --git a/views/partials/ap-item-card.njk b/views/partials/ap-item-card.njk index 99c234a..3792d5b 100644 --- a/views/partials/ap-item-card.njk +++ b/views/partials/ap-item-card.njk @@ -17,14 +17,15 @@ {# Reply context if this is a reply #} {% if item.inReplyTo %}
- ↩ {{ __("activitypub.reader.replyingTo") }} {{ item.inReplyTo }} + ↩ {{ __("activitypub.reader.replyingTo") }} {{ item.inReplyTo }}
{% endif %} {# Author header #}
{% if item.author.photo %} - {{ item.author.name }} + {{ item.author.name }} {% else %} {% endif %} @@ -50,7 +51,7 @@ {# Post title (articles only) #} {% if item.name %}

- {{ item.name }} + {{ item.name }}

{% endif %} @@ -71,6 +72,9 @@
{% endif %} + {# Link previews #} + {% include "partials/ap-link-preview.njk" %} + {# Media hidden behind CW #} {% include "partials/ap-item-media.njk" %}
@@ -83,6 +87,9 @@
{% endif %} + {# Link previews #} + {% include "partials/ap-link-preview.njk" %} + {# Media visible directly #} {% include "partials/ap-item-media.njk" %} {% endif %} diff --git a/views/partials/ap-link-preview.njk b/views/partials/ap-link-preview.njk new file mode 100644 index 0000000..fdecbcb --- /dev/null +++ b/views/partials/ap-link-preview.njk @@ -0,0 +1,34 @@ +{# Link preview cards for external links (OpenGraph) #} +{% if item.linkPreviews and item.linkPreviews.length > 0 %} + +{% endif %}