mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
fix: comprehensive security, performance, and architecture audit fixes
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
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p x-show="actionResult" x-text="actionResult" class="ap-federation__result" x-cloak></p>
|
||||
<p x-show="actionResult" x-text="actionResult" class="ap-federation__result" role="status" x-cloak></p>
|
||||
</section>
|
||||
|
||||
{# --- Object Lookup --- #}
|
||||
@@ -49,7 +49,7 @@
|
||||
<span x-show="lookupLoading" x-cloak>{{ __("activitypub.federationMgmt.lookupLoading") }}</span>
|
||||
</button>
|
||||
</form>
|
||||
<p x-show="lookupError" x-text="lookupError" class="ap-federation__error" x-cloak></p>
|
||||
<p x-show="lookupError" x-text="lookupError" class="ap-federation__error" role="alert" x-cloak></p>
|
||||
<pre x-show="lookupResult" x-text="lookupResult" class="ap-federation__json-view" x-cloak></pre>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
{{ __("activitypub.moderation.addKeyword") }}
|
||||
</button>
|
||||
</form>
|
||||
<p x-show="error" x-text="error" class="ap-moderation__error" x-cloak></p>
|
||||
<p x-show="error" x-text="error" class="ap-moderation__error" role="alert" x-cloak></p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -75,29 +75,25 @@
|
||||
values: [profile.actorType or "Person"]
|
||||
}) }}
|
||||
|
||||
<fieldset class="fieldset" style="margin-block-end: var(--space-l);">
|
||||
<fieldset class="fieldset" style="margin-block-end: var(--space-l);" x-data="{ links: [{% if profile.attachments and profile.attachments.length > 0 %}{% for att in profile.attachments %}{ name: {{ att.name | dump | safe }}, value: {{ att.value | dump | safe }} }{% if not loop.last %},{% endif %}{% endfor %}{% endif %}] }">
|
||||
<legend class="label">{{ __("activitypub.profile.linksLabel") }}</legend>
|
||||
<p class="hint">{{ __("activitypub.profile.linksHint") }}</p>
|
||||
|
||||
<div id="profile-links">
|
||||
{% if profile.attachments and profile.attachments.length > 0 %}
|
||||
{% for att in profile.attachments %}
|
||||
<div class="profile-link-row" style="display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);">
|
||||
<div>
|
||||
<label class="label" for="link_name_{{ loop.index }}">{{ __("activitypub.profile.linkNameLabel") }}</label>
|
||||
<input class="input" type="text" id="link_name_{{ loop.index }}" name="link_name[]" value="{{ att.name }}" placeholder="Website">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="link_value_{{ loop.index }}">{{ __("activitypub.profile.linkValueLabel") }}</label>
|
||||
<input class="input" type="url" id="link_value_{{ loop.index }}" name="link_value[]" value="{{ att.value }}" placeholder="https://example.com">
|
||||
</div>
|
||||
<button type="button" class="button button--small profile-link-remove" style="margin-block-end: 4px;">{{ __("activitypub.profile.removeLink") }}</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<template x-for="(link, index) in links" :key="index">
|
||||
<div class="profile-link-row" style="display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);">
|
||||
<div>
|
||||
<label class="label">{{ __("activitypub.profile.linkNameLabel") }}</label>
|
||||
<input class="input" type="text" :name="'link_name[]'" x-model="link.name" placeholder="Website">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">{{ __("activitypub.profile.linkValueLabel") }}</label>
|
||||
<input class="input" type="url" :name="'link_value[]'" x-model="link.value" placeholder="https://example.com">
|
||||
</div>
|
||||
<button type="button" class="button button--small profile-link-remove" style="margin-block-end: 4px;" @click="links.splice(index, 1)">{{ __("activitypub.profile.removeLink") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button type="button" class="button button--small" id="add-link-btn">{{ __("activitypub.profile.addLink") }}</button>
|
||||
<button type="button" class="button button--small" @click="links.push({ name: '', value: '' })">{{ __("activitypub.profile.addLink") }}</button>
|
||||
</fieldset>
|
||||
|
||||
{{ checkboxes({
|
||||
@@ -127,60 +123,4 @@
|
||||
{{ button({ text: __("activitypub.profile.save") }) }}
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
document.getElementById('profile-links').addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.profile-link-remove');
|
||||
if (btn) btn.closest('.profile-link-row').remove();
|
||||
});
|
||||
|
||||
var linkCount = {{ (profile.attachments.length if profile.attachments) or 0 }};
|
||||
document.getElementById('add-link-btn').addEventListener('click', function() {
|
||||
linkCount++;
|
||||
var container = document.getElementById('profile-links');
|
||||
var row = document.createElement('div');
|
||||
row.className = 'profile-link-row';
|
||||
row.style.cssText = 'display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);';
|
||||
|
||||
var nameDiv = document.createElement('div');
|
||||
var nameLabel = document.createElement('label');
|
||||
nameLabel.className = 'label';
|
||||
nameLabel.setAttribute('for', 'link_name_' + linkCount);
|
||||
nameLabel.textContent = 'Label';
|
||||
var nameInput = document.createElement('input');
|
||||
nameInput.className = 'input';
|
||||
nameInput.type = 'text';
|
||||
nameInput.id = 'link_name_' + linkCount;
|
||||
nameInput.name = 'link_name[]';
|
||||
nameInput.placeholder = 'Website';
|
||||
nameDiv.appendChild(nameLabel);
|
||||
nameDiv.appendChild(nameInput);
|
||||
|
||||
var valueDiv = document.createElement('div');
|
||||
var valueLabel = document.createElement('label');
|
||||
valueLabel.className = 'label';
|
||||
valueLabel.setAttribute('for', 'link_value_' + linkCount);
|
||||
valueLabel.textContent = 'URL';
|
||||
var valueInput = document.createElement('input');
|
||||
valueInput.className = 'input';
|
||||
valueInput.type = 'url';
|
||||
valueInput.id = 'link_value_' + linkCount;
|
||||
valueInput.name = 'link_value[]';
|
||||
valueInput.placeholder = 'https://example.com';
|
||||
valueDiv.appendChild(valueLabel);
|
||||
valueDiv.appendChild(valueInput);
|
||||
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'button button--small profile-link-remove';
|
||||
removeBtn.style.cssText = 'margin-block-end: 4px;';
|
||||
removeBtn.textContent = 'Remove';
|
||||
|
||||
row.appendChild(nameDiv);
|
||||
row.appendChild(valueDiv);
|
||||
row.appendChild(removeBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
data-tab="{{ tab }}"
|
||||
data-mount-path="{{ mountPath }}"
|
||||
x-show="count > 0"
|
||||
role="status"
|
||||
x-cloak>
|
||||
<button class="ap-new-posts-banner__btn" @click="loadNew()">
|
||||
<span x-text="count + ' new post' + (count !== 1 ? 's' : '')"></span> — Load
|
||||
@@ -152,7 +153,7 @@
|
||||
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done && !loading">
|
||||
{{ __("activitypub.reader.pagination.loadMore") }}
|
||||
</button>
|
||||
<div class="ap-skeleton-group" x-show="loading" x-cloak>
|
||||
<div class="ap-skeleton-group" x-show="loading" aria-live="polite" x-cloak>
|
||||
{% include "partials/ap-skeleton-card.njk" %}
|
||||
{% include "partials/ap-skeleton-card.njk" %}
|
||||
{% include "partials/ap-skeleton-card.njk" %}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
{% block content %}
|
||||
{# Infinite scroll component — must load before Alpine to register via alpine:init #}
|
||||
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
|
||||
{# Card interaction component — apCardInteraction Alpine component #}
|
||||
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-interactions.js"></script>
|
||||
{# Autocomplete components for explore + popular accounts #}
|
||||
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
|
||||
{# Tab components — apExploreTabs #}
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
{# Boost header if this is a boosted post #}
|
||||
{% if item.type == "boost" and item.boostedBy %}
|
||||
<div class="ap-card__boost">
|
||||
🔁 {% 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") }}
|
||||
<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">
|
||||
↩ {{ __("activitypub.reader.replyingTo") }} <a href="{{ mountPath }}/admin/reader/post?url={{ item.inReplyTo | urlencode }}">{{ item.inReplyTo }}</a>
|
||||
<span aria-hidden="true">↩</span> {{ __("activitypub.reader.replyingTo") }} <a href="{{ mountPath }}/admin/reader/post?url={{ item.inReplyTo | urlencode }}">{{ item.inReplyTo }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -63,10 +63,10 @@
|
||||
<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') }}">✏️</span>{% endif %}
|
||||
{% 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 %}">{% if item.visibility == "unlisted" %}🔓{% elif item.visibility == "private" %}🔒{% elif item.visibility == "direct" %}✉️{% endif %}</span>
|
||||
<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>
|
||||
@@ -89,48 +89,11 @@
|
||||
<span x-show="shown" x-cloak>{{ __("activitypub.reader.hideContent") }}</span>
|
||||
</button>
|
||||
<div x-show="shown" x-cloak>
|
||||
{% if item.content and item.content.html %}
|
||||
<div class="ap-card__content">
|
||||
{{ item.content.html | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quoted post embed #}
|
||||
{% include "partials/ap-quote-embed.njk" %}
|
||||
|
||||
{# Link previews #}
|
||||
{% include "partials/ap-link-preview.njk" %}
|
||||
|
||||
{# Media hidden behind CW #}
|
||||
{% include "partials/ap-item-media.njk" %}
|
||||
|
||||
{# Poll options #}
|
||||
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
|
||||
{% include "partials/ap-poll-options.njk" %}
|
||||
{% endif %}
|
||||
{% include "partials/ap-item-content.njk" %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Regular content (no CW) #}
|
||||
{% if item.content and item.content.html %}
|
||||
<div class="ap-card__content">
|
||||
{{ item.content.html | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quoted post embed #}
|
||||
{% include "partials/ap-quote-embed.njk" %}
|
||||
|
||||
{# Link previews #}
|
||||
{% include "partials/ap-link-preview.njk" %}
|
||||
|
||||
{# Media visible directly #}
|
||||
{% include "partials/ap-item-media.njk" %}
|
||||
|
||||
{# Poll options #}
|
||||
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
|
||||
{% include "partials/ap-poll-options.njk" %}
|
||||
{% endif %}
|
||||
{% include "partials/ap-item-content.njk" %}
|
||||
{% endif %}
|
||||
|
||||
{# Mentions and hashtags #}
|
||||
@@ -171,77 +134,11 @@
|
||||
data-item-url="{{ itemUrl }}"
|
||||
data-csrf-token="{{ csrfToken }}"
|
||||
data-mount-path="{{ mountPath }}"
|
||||
x-data="{
|
||||
liked: {{ 'true' if isLiked else 'false' }},
|
||||
boosted: {{ 'true' if isBoosted else 'false' }},
|
||||
saved: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
boostCount: {{ boostCount if boostCount != null else 'null' }},
|
||||
likeCount: {{ likeCount if likeCount != null else 'null' }},
|
||||
async saveLater() {
|
||||
if (this.saved) return;
|
||||
const el = this.$root;
|
||||
const itemUrl = el.dataset.itemUrl;
|
||||
try {
|
||||
const res = await fetch('/readlater/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: itemUrl,
|
||||
title: el.closest('article')?.querySelector('p')?.textContent?.substring(0, 80) || itemUrl,
|
||||
source: 'activitypub'
|
||||
}),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (res.ok) this.saved = true;
|
||||
else this.error = 'Failed to save';
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
}
|
||||
if (this.error) setTimeout(() => this.error = '', 3000);
|
||||
},
|
||||
async interact(action) {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
const el = this.$root;
|
||||
const itemUid = el.dataset.itemUid;
|
||||
const csrfToken = el.dataset.csrfToken;
|
||||
const basePath = el.dataset.mountPath;
|
||||
const prev = { liked: this.liked, boosted: this.boosted, boostCount: this.boostCount, likeCount: this.likeCount };
|
||||
if (action === 'like') { this.liked = true; if (this.likeCount !== null) this.likeCount++; }
|
||||
else if (action === 'unlike') { this.liked = false; if (this.likeCount !== null && this.likeCount > 0) this.likeCount--; }
|
||||
else if (action === 'boost') { this.boosted = true; if (this.boostCount !== null) this.boostCount++; }
|
||||
else if (action === 'unboost') { this.boosted = false; if (this.boostCount !== null && this.boostCount > 0) this.boostCount--; }
|
||||
try {
|
||||
const res = await fetch(basePath + '/admin/reader/' + action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ url: itemUid })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
this.liked = prev.liked;
|
||||
this.boosted = prev.boosted;
|
||||
this.boostCount = prev.boostCount;
|
||||
this.likeCount = prev.likeCount;
|
||||
this.error = data.error || 'Failed';
|
||||
}
|
||||
} catch (e) {
|
||||
this.liked = prev.liked;
|
||||
this.boosted = prev.boosted;
|
||||
this.boostCount = prev.boostCount;
|
||||
this.likeCount = prev.likeCount;
|
||||
this.error = e.message;
|
||||
}
|
||||
this.loading = false;
|
||||
if (this.error) setTimeout(() => this.error = '', 3000);
|
||||
}
|
||||
}">
|
||||
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') }}">
|
||||
@@ -252,7 +149,7 @@
|
||||
:title="boosted ? '{{ __('activitypub.reader.actions.unboost') }}' : '{{ __('activitypub.reader.actions.boost') }}'"
|
||||
:disabled="loading"
|
||||
@click="interact(boosted ? 'unboost' : 'boost')">
|
||||
🔁 <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>
|
||||
<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 }"
|
||||
@@ -263,7 +160,7 @@
|
||||
<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">
|
||||
🔗 {{ __("activitypub.reader.actions.viewOriginal") }}
|
||||
<span aria-hidden="true">🔗</span> {{ __("activitypub.reader.actions.viewOriginal") }}
|
||||
</a>
|
||||
{% if application.readlaterEndpoint %}
|
||||
<button class="ap-card__action ap-card__action--save"
|
||||
@@ -275,7 +172,7 @@
|
||||
<span x-text="saved ? 'Saved' : 'Save'"></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
|
||||
<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 %}
|
||||
|
||||
20
views/partials/ap-item-content.njk
Normal file
20
views/partials/ap-item-content.njk
Normal file
@@ -0,0 +1,20 @@
|
||||
{# Shared content rendering — included in both CW and non-CW paths #}
|
||||
{% if item.content and item.content.html %}
|
||||
<div class="ap-card__content">
|
||||
{{ item.content.html | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Quoted post embed #}
|
||||
{% include "partials/ap-quote-embed.njk" %}
|
||||
|
||||
{# Link previews #}
|
||||
{% include "partials/ap-link-preview.njk" %}
|
||||
|
||||
{# Media attachments #}
|
||||
{% include "partials/ap-item-media.njk" %}
|
||||
|
||||
{# Poll options #}
|
||||
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
|
||||
{% include "partials/ap-poll-options.njk" %}
|
||||
{% endif %}
|
||||
@@ -14,7 +14,7 @@
|
||||
<img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous">
|
||||
{% endif %}
|
||||
<span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
|
||||
<span class="ap-notification__type-badge">
|
||||
<span class="ap-notification__type-badge" aria-hidden="true">
|
||||
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" or item.type == "follow_request" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user