mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
27 issues fixed from multi-dimensional code review (4 Critical, 6 High, 11 Medium, 6 Low): Security (Critical): - Escape HTML in OAuth authorization page to prevent XSS (C1) - Add CSRF protection to OAuth authorize flow (C2) - Replace bypassable regex sanitizer with sanitize-html library (C3) - Enforce OAuth scopes on all Mastodon API routes (C4) Security (Medium/Low): - Fix SSRF via DNS resolution before private IP check (M1) - Add rate limiting to API, auth, and app registration endpoints (M2) - Validate redirect_uri on POST /oauth/authorize (M4) - Fix custom emoji URL injection with scheme validation + escaping (M5) - Remove data: scheme from allowed image sources (L6) - Add access token expiry (1hr) and refresh token rotation (90d) (M3) - Hash client secrets before storage (L3) Architecture: - Extract batch-broadcast.js — shared delivery logic (H1a) - Extract init-indexes.js — MongoDB index creation (H1b) - Extract syndicator.js — syndication logic (H1c) - Create federation-actions.js facade for controllers (M6) - index.js reduced from 1810 to ~1169 lines (35%) Performance: - Cache moderation data with 30s TTL + write invalidation (H6) - Increase inbox queue throughput to 10 items/sec (H5) - Make account enrichment non-blocking with fire-and-forget (H4) - Remove ephemeral getReplies/getLikes/getShares from ingest (M11) - Fix LRU caches to use true LRU eviction (L1) - Fix N+1 backfill queries with batch $in lookup (L2) UI/UX: - Split 3441-line reader.css into 15 feature-scoped files (H2) - Extract inline Alpine.js interaction component (H3) - Reduce sidebar navigation from 7 to 3 items (M7) - Add ARIA live regions for dynamic content updates (M8) - Extract shared CW/non-CW content partial (M9) - Document form handling pattern convention (M10) - Add accessible labels to functional emoji icons (L4) - Convert profile editor to Alpine.js (L5) Audit: documentation-central/audits/2026-03-24-activitypub-code-review.md Plan: documentation-central/plans/2026-03-24-activitypub-audit-fixes.md
185 lines
11 KiB
Plaintext
185 lines
11 KiB
Plaintext
{# Timeline item card partial - reusable across timeline and profile views #}
|
|
|
|
{# Skip empty cards (e.g. Lemmy/PieFed activity IDs with no actual content) #}
|
|
{% set hasCardContent = item.content and (item.content.html or item.content.text) %}
|
|
{% set hasCardTitle = item.name %}
|
|
{% set hasCardMedia = (item.photo and item.photo.length > 0) or (item.video and item.video.length > 0) or (item.audio and item.audio.length > 0) %}
|
|
{% if hasCardContent or hasCardTitle or hasCardMedia %}
|
|
|
|
<article class="ap-card{% if item.type %} ap-card--{{ item.type }}{% endif %}{% if item.inReplyTo %} ap-card--reply{% endif %}{% if item._moderated %} ap-card--moderated{% endif %}{% if item.read %} ap-card--read{% endif %}" data-uid="{{ item.uid }}">
|
|
{# Moderation content warning wrapper #}
|
|
{% if item._moderated %}
|
|
{% if item._moderationReason == "muted_account" %}
|
|
{% set modLabel = __("activitypub.moderation.cwMutedAccount") %}
|
|
{% elif item._moderationReason == "muted_keyword" and item._moderationKeyword %}
|
|
{% set modLabel = __("activitypub.moderation.cwMutedKeyword") + ' "' + item._moderationKeyword + '"' %}
|
|
{% else %}
|
|
{% set modLabel = __("activitypub.moderation.cwFiltered") %}
|
|
{% endif %}
|
|
<div class="ap-card__moderation-cw" x-data="{ shown: false }">
|
|
<button @click="shown = !shown" class="ap-card__moderation-toggle">
|
|
<span x-show="!shown">🛡️ {{ modLabel }} — {{ __("activitypub.reader.showContent") }}</span>
|
|
<span x-show="shown" x-cloak>🛡️ {{ modLabel }} — {{ __("activitypub.reader.hideContent") }}</span>
|
|
</button>
|
|
<div x-show="shown" x-cloak>
|
|
{% endif %}
|
|
{# Boost header if this is a boosted post #}
|
|
{% if item.type == "boost" and item.boostedBy %}
|
|
<div class="ap-card__boost">
|
|
<span aria-hidden="true">🔁</span><span class="visually-hidden">{{ __("activitypub.reader.boosted") }}</span> {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}</a>{% else %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% endif %} {{ __("activitypub.reader.boosted") }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Reply context if this is a reply #}
|
|
{% if item.inReplyTo %}
|
|
<div class="ap-card__reply-to">
|
|
<span aria-hidden="true">↩</span> {{ __("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">
|
|
<div class="ap-card__avatar-wrap" data-avatar-fallback>
|
|
{% if item.author.photo %}
|
|
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy" crossorigin="anonymous">
|
|
{% endif %}
|
|
<span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
|
|
</div>
|
|
<div class="ap-card__author-info">
|
|
<div class="ap-card__author-name">
|
|
{% if item.author.url %}
|
|
<a href="{{ mountPath }}/admin/reader/profile?url={{ item.author.url | urlencode }}">{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</a>
|
|
{% else %}
|
|
<span>{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</span>
|
|
{% endif %}
|
|
{% if item.author.bot %}<span class="ap-card__bot-badge" title="Bot account">BOT</span>{% endif %}
|
|
</div>
|
|
{% if item.author.handle %}
|
|
<div class="ap-card__author-handle">{{ item.author.handle }}</div>
|
|
{% endif %}
|
|
</div>
|
|
{% if item.published %}
|
|
<a href="{{ mountPath }}/admin/reader/post?url={{ (item.uid or item.url) | urlencode }}" class="ap-card__timestamp-link" title="{{ __('activitypub.reader.post.title') }}">
|
|
<time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time>
|
|
{{ item.published | date("PPp") }}
|
|
</time>
|
|
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}" aria-label="Edited"><span aria-hidden="true">✏️</span></span>{% endif %}
|
|
</a>
|
|
{% if item.visibility and item.visibility != "public" %}
|
|
<span class="ap-card__visibility ap-card__visibility--{{ item.visibility }}" title="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}" aria-label="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}"><span aria-hidden="true">{% if item.visibility == "unlisted" %}🔓{% elif item.visibility == "private" %}🔒{% elif item.visibility == "direct" %}✉️{% endif %}</span></span>
|
|
{% endif %}
|
|
{% endif %}
|
|
</header>
|
|
|
|
{# Post title (articles only) #}
|
|
{% if item.name %}
|
|
<h2 class="ap-card__title">
|
|
<a href="{{ mountPath }}/admin/reader/post?url={{ item.uid | urlencode }}">{{ item.name }}</a>
|
|
</h2>
|
|
{% endif %}
|
|
|
|
{# Determine if content should be hidden behind CW #}
|
|
{% set hasCW = item.summary or item.sensitive %}
|
|
{% set cwLabel = item.summary if item.summary else __("activitypub.reader.sensitiveContent") %}
|
|
|
|
{% if hasCW %}
|
|
<div class="ap-card__cw" x-data="{ shown: false }">
|
|
<button @click="shown = !shown" class="ap-card__cw-toggle">
|
|
<span x-show="!shown">⚠️ {{ cwLabel }} — {{ __("activitypub.reader.showContent") }}</span>
|
|
<span x-show="shown" x-cloak>{{ __("activitypub.reader.hideContent") }}</span>
|
|
</button>
|
|
<div x-show="shown" x-cloak>
|
|
{% include "partials/ap-item-content.njk" %}
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
{% include "partials/ap-item-content.njk" %}
|
|
{% endif %}
|
|
|
|
{# Mentions and hashtags #}
|
|
{% set hasMentions = item.mentions and item.mentions.length > 0 %}
|
|
{% set hasHashtags = item.category and item.category.length > 0 %}
|
|
{% if hasMentions or hasHashtags %}
|
|
<div class="ap-card__tags">
|
|
{# Mentions — render with @ prefix, link to profile view when URL available #}
|
|
{% if hasMentions %}
|
|
{% for mention in item.mentions %}
|
|
{% if mention.url %}
|
|
<a href="{{ mountPath }}/admin/reader/profile?url={{ mention.url | urlencode }}" class="ap-card__mention">@{{ mention.name }}</a>
|
|
{% else %}
|
|
<span class="ap-card__mention ap-card__mention--legacy">@{{ mention.name }}</span>
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% endif %}
|
|
{# Hashtags — render with # prefix, link to tag timeline #}
|
|
{% if hasHashtags %}
|
|
{% for tag in item.category %}
|
|
<a href="{{ mountPath }}/admin/reader/tag?tag={{ tag | urlencode }}" class="ap-card__tag">#{{ tag }}</a>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Interaction buttons — Alpine.js for optimistic updates #}
|
|
{# Use canonical AP uid for interactions (Fedify lookupObject), display url for links #}
|
|
{% set itemUrl = item.url or item.originalUrl %}
|
|
{% set itemUid = item.uid or item.url or item.originalUrl %}
|
|
{% set isLiked = interactionMap[itemUid].like if interactionMap[itemUid] else false %}
|
|
{% set isBoosted = interactionMap[itemUid].boost if interactionMap[itemUid] else false %}
|
|
{% set replyCount = item.counts.replies if item.counts and item.counts.replies != null else null %}
|
|
{% set boostCount = item.counts.boosts if item.counts and item.counts.boosts != null else null %}
|
|
{% set likeCount = item.counts.likes if item.counts and item.counts.likes != null else null %}
|
|
<footer class="ap-card__actions"
|
|
data-item-uid="{{ itemUid }}"
|
|
data-item-url="{{ itemUrl }}"
|
|
data-csrf-token="{{ csrfToken }}"
|
|
data-mount-path="{{ mountPath }}"
|
|
data-liked="{{ 'true' if isLiked else 'false' }}"
|
|
data-boosted="{{ 'true' if isBoosted else 'false' }}"
|
|
data-like-count="{{ likeCount if likeCount != null else '' }}"
|
|
data-boost-count="{{ boostCount if boostCount != null else '' }}"
|
|
x-data="apCardInteraction()">
|
|
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ (itemUrl or itemUid) | urlencode }}"
|
|
class="ap-card__action ap-card__action--reply"
|
|
title="{{ __('activitypub.reader.actions.reply') }}">
|
|
↩ {{ __("activitypub.reader.actions.reply") }}{% if replyCount != null %}<span class="ap-card__count">{{ replyCount }}</span>{% endif %}
|
|
</a>
|
|
<button class="ap-card__action ap-card__action--boost"
|
|
:class="{ 'ap-card__action--active': boosted }"
|
|
:title="boosted ? '{{ __('activitypub.reader.actions.unboost') }}' : '{{ __('activitypub.reader.actions.boost') }}'"
|
|
:disabled="loading"
|
|
@click="interact(boosted ? 'unboost' : 'boost')">
|
|
<span aria-hidden="true">🔁</span> <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span><template x-if="boostCount !== null"><span class="ap-card__count" x-text="boostCount"></span></template>
|
|
</button>
|
|
<button class="ap-card__action ap-card__action--like"
|
|
:class="{ 'ap-card__action--active': liked }"
|
|
:title="liked ? '{{ __('activitypub.reader.actions.unlike') }}' : '{{ __('activitypub.reader.actions.like') }}'"
|
|
:disabled="loading"
|
|
@click="interact(liked ? 'unlike' : 'like')">
|
|
<span x-text="liked ? '❤️' : '♥'"></span>
|
|
<span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span><template x-if="likeCount !== null"><span class="ap-card__count" x-text="likeCount"></span></template>
|
|
</button>
|
|
<a href="{{ itemUrl }}" class="ap-card__action ap-card__action--link" target="_blank" rel="noopener">
|
|
<span aria-hidden="true">🔗</span> {{ __("activitypub.reader.actions.viewOriginal") }}
|
|
</a>
|
|
{% if application.readlaterEndpoint %}
|
|
<button class="ap-card__action ap-card__action--save"
|
|
:class="{ 'ap-card__action--active': saved }"
|
|
:disabled="saved"
|
|
@click="saveLater()"
|
|
:title="saved ? 'Saved' : 'Save for later'">
|
|
<span x-text="saved ? '🔖' : '📑'"></span>
|
|
<span x-text="saved ? 'Saved' : 'Save'"></span>
|
|
</button>
|
|
{% endif %}
|
|
<div x-show="error" x-text="error" class="ap-card__action-error" role="alert" x-transition></div>
|
|
</footer>
|
|
{# Close moderation content warning wrapper #}
|
|
{% if item._moderated %}
|
|
</div>{# /x-show="shown" #}
|
|
</div>{# /ap-card__moderation-cw #}
|
|
{% endif %}
|
|
</article>
|
|
|
|
{% endif %}{# end hasCardContent/hasCardTitle/hasCardMedia guard #}
|