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
279 lines
10 KiB
Plaintext
279 lines
10 KiB
Plaintext
{% extends "layouts/ap-reader.njk" %}
|
|
|
|
{% from "heading/macro.njk" import heading with context %}
|
|
{% from "prose/macro.njk" import prose with context %}
|
|
|
|
{% block readercontent %}
|
|
<div x-data="moderationPage()" data-mount-path="{{ mountPath }}" data-csrf-token="{{ csrfToken }}">
|
|
|
|
{# Filter mode toggle #}
|
|
<section class="ap-moderation__section">
|
|
<h2>{{ __("activitypub.moderation.filterModeTitle") }}</h2>
|
|
<p class="ap-moderation__hint">{{ __("activitypub.moderation.filterModeHint") }}</p>
|
|
<div class="ap-moderation__filter-toggle">
|
|
<label class="ap-moderation__radio">
|
|
<input type="radio" name="filterMode" value="hide"
|
|
{% if filterMode == "hide" %}checked{% endif %}
|
|
@change="setFilterMode('hide')">
|
|
<span>{{ __("activitypub.moderation.filterModeHide") }}</span>
|
|
</label>
|
|
<label class="ap-moderation__radio">
|
|
<input type="radio" name="filterMode" value="warn"
|
|
{% if filterMode == "warn" %}checked{% endif %}
|
|
@change="setFilterMode('warn')">
|
|
<span>{{ __("activitypub.moderation.filterModeWarn") }}</span>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
{# Blocked servers #}
|
|
<section class="ap-moderation__section">
|
|
<h2>Blocked Servers</h2>
|
|
<p class="ap-moderation__hint">Block entire instances by hostname. Activities from blocked servers are rejected before any processing.</p>
|
|
{% if blockedServers and blockedServers.length > 0 %}
|
|
<ul class="ap-moderation__list" x-ref="serverList">
|
|
{% for entry in blockedServers %}
|
|
<li class="ap-moderation__entry" data-hostname="{{ entry.hostname }}">
|
|
<code>{{ entry.hostname }}</code>
|
|
{% if entry.reason %}<span class="ap-moderation__reason">({{ entry.reason }})</span>{% endif %}
|
|
<button class="ap-moderation__remove"
|
|
@click="removeEntry($el, 'unblock-server', { hostname: $el.closest('li').dataset.hostname })">
|
|
Unblock
|
|
</button>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
<p class="ap-moderation__empty" x-ref="serverEmpty">No servers blocked.</p>
|
|
{% endif %}
|
|
|
|
<form class="ap-moderation__add-form" @submit.prevent="addBlockedServer()">
|
|
<input type="text" x-model="newServerHostname"
|
|
placeholder="spam.instance.social"
|
|
class="ap-moderation__input"
|
|
x-ref="serverInput">
|
|
<button type="submit" :disabled="submitting" class="ap-moderation__add-btn">
|
|
Block Server
|
|
</button>
|
|
</form>
|
|
</section>
|
|
|
|
{# Blocked actors #}
|
|
<section class="ap-moderation__section">
|
|
<h2>{{ __("activitypub.moderation.blockedTitle") }}</h2>
|
|
{% if blocked.length > 0 %}
|
|
<ul class="ap-moderation__list">
|
|
{% for entry in blocked %}
|
|
<li class="ap-moderation__entry" data-url="{{ entry.url }}">
|
|
<a href="{{ entry.url }}">{{ entry.url }}</a>
|
|
<button class="ap-moderation__remove"
|
|
@click="removeEntry($el, 'unblock', { url: $el.closest('li').dataset.url })">
|
|
{{ __("activitypub.moderation.unblock") }}
|
|
</button>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
{{ prose({ text: __("activitypub.moderation.noBlocked") }) }}
|
|
{% endif %}
|
|
</section>
|
|
|
|
{# Muted actors #}
|
|
<section class="ap-moderation__section">
|
|
<h2>{{ __("activitypub.moderation.mutedActorsTitle") }}</h2>
|
|
{% set mutedActors = muted | selectattr("url") %}
|
|
{% if mutedActors | length > 0 %}
|
|
<ul class="ap-moderation__list">
|
|
{% for entry in mutedActors %}
|
|
<li class="ap-moderation__entry" data-url="{{ entry.url }}">
|
|
<a href="{{ entry.url }}">{{ entry.url }}</a>
|
|
<button class="ap-moderation__remove"
|
|
@click="removeEntry($el, 'unmute', { url: $el.closest('li').dataset.url })">
|
|
{{ __("activitypub.moderation.unmute") }}
|
|
</button>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
{{ prose({ text: __("activitypub.moderation.noMutedActors") }) }}
|
|
{% endif %}
|
|
</section>
|
|
|
|
{# Muted keywords #}
|
|
<section class="ap-moderation__section">
|
|
<h2>{{ __("activitypub.moderation.mutedKeywordsTitle") }}</h2>
|
|
<ul class="ap-moderation__list" x-ref="keywordList">
|
|
{% set mutedKeywords = muted | selectattr("keyword") %}
|
|
{% for entry in mutedKeywords %}
|
|
<li class="ap-moderation__entry" data-keyword="{{ entry.keyword }}">
|
|
<code x-text="$el.closest('li').dataset.keyword">{{ entry.keyword }}</code>
|
|
<button class="ap-moderation__remove"
|
|
@click="removeEntry($el, 'unmute', { keyword: $el.closest('li').dataset.keyword })">
|
|
{{ __("activitypub.moderation.unmute") }}
|
|
</button>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% if not (mutedKeywords | length) %}
|
|
<p class="ap-moderation__empty" x-ref="keywordEmpty">{{ __("activitypub.moderation.noMutedKeywords") }}</p>
|
|
{% endif %}
|
|
</section>
|
|
|
|
{# Add keyword mute form #}
|
|
<section class="ap-moderation__section">
|
|
<h2>{{ __("activitypub.moderation.addKeywordTitle") }}</h2>
|
|
<form class="ap-moderation__add-form" @submit.prevent="addKeyword()">
|
|
<input type="text" x-model="newKeyword"
|
|
placeholder="{{ __('activitypub.moderation.keywordPlaceholder') }}"
|
|
class="ap-moderation__input"
|
|
x-ref="keywordInput">
|
|
<button type="submit" :disabled="submitting" class="ap-moderation__add-btn">
|
|
{{ __("activitypub.moderation.addKeyword") }}
|
|
</button>
|
|
</form>
|
|
<p x-show="error" x-text="error" class="ap-moderation__error" role="alert" x-cloak></p>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('moderationPage', () => ({
|
|
newKeyword: '',
|
|
newServerHostname: '',
|
|
submitting: false,
|
|
error: '',
|
|
|
|
get mountPath() { return this.$root.dataset.mountPath; },
|
|
get csrfToken() { return this.$root.dataset.csrfToken; },
|
|
|
|
async addKeyword() {
|
|
const kw = this.newKeyword.trim();
|
|
if (!kw) return;
|
|
this.submitting = true;
|
|
this.error = '';
|
|
try {
|
|
const res = await fetch(this.mountPath + '/admin/reader/mute', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': this.csrfToken,
|
|
},
|
|
body: JSON.stringify({ keyword: kw }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
// Add to list inline — no reload needed
|
|
const list = this.$refs.keywordList;
|
|
const li = document.createElement('li');
|
|
li.className = 'ap-moderation__entry';
|
|
li.dataset.keyword = kw;
|
|
const code = document.createElement('code');
|
|
code.textContent = kw;
|
|
const btn = document.createElement('button');
|
|
btn.className = 'ap-moderation__remove';
|
|
btn.textContent = 'Unmute';
|
|
btn.addEventListener('click', () => {
|
|
this.removeEntry(btn, 'unmute', { keyword: kw });
|
|
});
|
|
li.append(code, btn);
|
|
list.appendChild(li);
|
|
if (this.$refs.keywordEmpty) this.$refs.keywordEmpty.remove();
|
|
this.newKeyword = '';
|
|
this.$refs.keywordInput.focus();
|
|
} else {
|
|
this.error = data.error || 'Failed to add keyword';
|
|
}
|
|
} catch (e) {
|
|
this.error = 'Request failed';
|
|
}
|
|
this.submitting = false;
|
|
},
|
|
|
|
async addBlockedServer() {
|
|
const hostname = this.newServerHostname.trim();
|
|
if (!hostname) return;
|
|
this.submitting = true;
|
|
this.error = '';
|
|
try {
|
|
const res = await fetch(this.mountPath + '/admin/reader/block-server', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': this.csrfToken,
|
|
},
|
|
body: JSON.stringify({ hostname }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
const list = this.$refs.serverList;
|
|
if (list) {
|
|
const li = document.createElement('li');
|
|
li.className = 'ap-moderation__entry';
|
|
li.dataset.hostname = hostname;
|
|
const code = document.createElement('code');
|
|
code.textContent = hostname;
|
|
const btn = document.createElement('button');
|
|
btn.className = 'ap-moderation__remove';
|
|
btn.textContent = 'Unblock';
|
|
btn.addEventListener('click', () => {
|
|
this.removeEntry(btn, 'unblock-server', { hostname });
|
|
});
|
|
li.append(code, btn);
|
|
list.appendChild(li);
|
|
}
|
|
if (this.$refs.serverEmpty) this.$refs.serverEmpty.remove();
|
|
this.newServerHostname = '';
|
|
this.$refs.serverInput.focus();
|
|
} else {
|
|
this.error = data.error || 'Failed to block server';
|
|
}
|
|
} catch (e) {
|
|
this.error = 'Request failed';
|
|
}
|
|
this.submitting = false;
|
|
},
|
|
|
|
async removeEntry(el, action, payload) {
|
|
const li = el.closest('li');
|
|
if (!li) return;
|
|
el.disabled = true;
|
|
try {
|
|
const res = await fetch(this.mountPath + '/admin/reader/' + action, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': this.csrfToken,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
li.remove();
|
|
} else {
|
|
el.disabled = false;
|
|
}
|
|
} catch {
|
|
el.disabled = false;
|
|
}
|
|
},
|
|
|
|
async setFilterMode(mode) {
|
|
try {
|
|
await fetch(this.mountPath + '/admin/reader/moderation/filter-mode', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': this.csrfToken,
|
|
},
|
|
body: JSON.stringify({ mode }),
|
|
});
|
|
} catch {
|
|
// Silently fail — radio will visually stay on selected
|
|
}
|
|
},
|
|
}));
|
|
});
|
|
</script>
|
|
{% endblock %}
|