feat: add internal AP link resolution and OpenGraph card unfurling (v1.1.14)

Reader now resolves ActivityPub links internally instead of navigating
to external instances. Actor links open the profile view, post links
open a new post detail view with thread context (parent chain + replies).

External links in post content get rich preview cards (title, description,
image, favicon) fetched via unfurl.js at ingest time with fire-and-forget
async processing and concurrency limiting.

New files: post-detail controller, og-unfurl module, lookup-cache,
link preview template/CSS, client-side link interception JS.
Includes SSRF protection for OG fetching and GoToSocial URL support.
This commit is contained in:
Ricardo
2026-02-21 18:32:12 +01:00
parent 313d5d414c
commit 5ff3197493
18 changed files with 1070 additions and 10 deletions

View File

@@ -17,14 +17,15 @@
{# Reply context if this is a reply #}
{% if item.inReplyTo %}
<div class="ap-card__reply-to">
↩ {{ __("activitypub.reader.replyingTo") }} <a href="{{ item.inReplyTo }}">{{ item.inReplyTo }}</a>
↩ {{ __("activitypub.reader.replyingTo") }} <a href="{{ mountPath }}/admin/reader/post?url={{ item.inReplyTo | urlencode }}">{{ item.inReplyTo }}</a>
</div>
{% endif %}
{# Author header #}
<header class="ap-card__author">
{% if item.author.photo %}
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy">
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar"
onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'ap-card__avatar ap-card__avatar--default',textContent:'{{ item.author.name[0] | upper if item.author.name else "?" }}'}))">
{% else %}
<span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
{% endif %}
@@ -50,7 +51,7 @@
{# Post title (articles only) #}
{% if item.name %}
<h2 class="ap-card__title">
<a href="{{ item.url }}">{{ item.name }}</a>
<a href="{{ mountPath }}/admin/reader/post?url={{ item.uid | urlencode }}">{{ item.name }}</a>
</h2>
{% endif %}
@@ -71,6 +72,9 @@
</div>
{% endif %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media hidden behind CW #}
{% include "partials/ap-item-media.njk" %}
</div>
@@ -83,6 +87,9 @@
</div>
{% endif %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media visible directly #}
{% include "partials/ap-item-media.njk" %}
{% endif %}

View File

@@ -0,0 +1,34 @@
{# Link preview cards for external links (OpenGraph) #}
{% if item.linkPreviews and item.linkPreviews.length > 0 %}
<div class="ap-link-previews">
{% for preview in item.linkPreviews %}
<a href="{{ preview.url }}"
rel="noopener"
target="_blank"
class="ap-link-preview"
aria-label="{{ __('activitypub.reader.linkPreview.label') }}: {{ preview.title }}">
<div class="ap-link-preview__text">
<p class="ap-link-preview__title">{{ preview.title }}</p>
{% if preview.description %}
<p class="ap-link-preview__desc">{{ preview.description }}</p>
{% endif %}
<p class="ap-link-preview__domain">
{% if preview.favicon %}
<img src="{{ preview.favicon }}" alt="" class="ap-link-preview__favicon" loading="lazy" />
{% endif %}
{{ preview.domain }}
</p>
</div>
{% if preview.image %}
<div class="ap-link-preview__image">
<img src="{{ preview.image }}" alt="" loading="lazy" decoding="async" />
</div>
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}