Files
indiekit-endpoint-activitypub/views/activitypub-moderation.njk
Ricardo 1567b7c4e5 feat: operational resilience hardening — server blocking, caching, key refresh, async inbox (v2.14.0)
- Server-level blocking: O(1) Redis SISMEMBER check in all inbox listeners,
  admin UI for blocking/unblocking servers by hostname, MongoDB fallback
- Redis caching for collection dispatchers: 300s TTL on followers/following/liked
  counters and paginated pages, one-shot followers recipients cache
- Proactive key refresh: daily cron re-fetches actor documents for followers
  with 7+ day stale keys using lookupWithSecurity()
- Async inbox processing: MongoDB-backed queue with 3s polling, retry (3 attempts),
  24h TTL auto-prune. Follow keeps synchronous Accept, Block keeps synchronous
  follower removal. All other activity types fully deferred to background processor.

Inspired by wafrn's battle-tested multi-user AP implementation.

Confab-Link: http://localhost:8080/sessions/af5f8b45-6b8d-442d-8f25-78c326190709
2026-03-17 09:16:05 +01:00

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" 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 %}