mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
- 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
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" 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 %}
|