Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ Add-on skills (activated when corresponding add-on is installed):
- `matrix-dendrite.md` — Matrix homeserver on Dendrite: create/join/leave rooms, send messages, sync, invite users, federation health; appservice registration prep for F.12 bridges; :8448-vs-well-known either/or federation story
- `funkwhale.md` — Funkwhale federated music pod: library listing, search, upload, follow remote channels/libraries, playlists, listening history, moderation (block_user/mute inline; block_domain/defederate queued), media prune; on-disk or S3 audio storage via storage-translators.funkwhale()
- `pixelfed.md` — Pixelfed federated photo-sharing: post photos (upload+status), feed, search, follow, moderation (block_user/mute inline; block_domain/defederate/import_blocklist queued), admin reports, remote reporting, media prune; Mastodon-compatible REST API; on-disk or S3 media via storage-translators.pixelfed()
- `lemmy.md` — Lemmy federated link aggregator: status, list/follow/unfollow communities, post (link + body), comment, feed (Subscribed/Local/All), search, moderation (block_user/block_community inline; block_instance/defederate queued), admin reports, pict-rs media prune; Lemmy v3 REST API; community-scoped federation
- `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS
- `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download
- `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read
Expand Down
152 changes: 152 additions & 0 deletions bundles/lemmy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Lemmy — federated link aggregator + discussion platform.
#
# Four-container bundle: lemmy (backend, Rust) + lemmy-ui (frontend) +
# postgres + pict-rs (image hosting). lemmy + lemmy-ui on crow-federation
# (Caddy reverse-proxies to lemmy-ui:1234); DB + pict-rs on default.
#
# Lemmy-UI serves the SPA AND proxies /api/v3/* to lemmy:8536 internally,
# so a single Caddy federation site is enough.
#
# Data:
# ~/.crow/lemmy/postgres/ Postgres data dir
# ~/.crow/lemmy/pictrs/ pict-rs sled db + media files
# ~/.crow/lemmy/lemmy.hjson Generated once at install time; not volume-mounted
# because lemmy reads env-var overrides too.
#
# Images: dessalines/lemmy:0.19 and dessalines/lemmy-ui:0.19 (floats
# within 0.19.x — verify latest + CVE feed at implementation time per the
# plan's image-tag policy).

networks:
crow-federation:
external: true
default:

services:
postgres:
image: postgres:16-alpine
container_name: crow-lemmy-postgres
networks:
- default
environment:
POSTGRES_USER: lemmy
POSTGRES_PASSWORD: ${LEMMY_DB_PASSWORD}
POSTGRES_DB: lemmy
volumes:
- ${LEMMY_DATA_DIR:-~/.crow/lemmy}/postgres:/var/lib/postgresql/data
init: true
mem_limit: 512m
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lemmy"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s

pictrs:
image: asonix/pictrs:0.5
container_name: crow-lemmy-pictrs
user: "991:991"
networks:
- default
environment:
PICTRS__SERVER__API_KEY: ${LEMMY_PICTRS_API_KEY}
PICTRS__MEDIA__VIDEO_CODEC: vp9
PICTRS__MEDIA__GIF__MAX_WIDTH: "256"
PICTRS__MEDIA__GIF__MAX_HEIGHT: "256"
PICTRS__MEDIA__GIF__MAX_AREA: "65536"
PICTRS__MEDIA__GIF__MAX_FRAME_COUNT: "400"
RUST_LOG: warn,tracing=warn
RUST_BACKTRACE: full
volumes:
- ${LEMMY_DATA_DIR:-~/.crow/lemmy}/pictrs:/mnt
init: true
mem_limit: 512m
restart: unless-stopped

lemmy:
image: dessalines/lemmy:0.19
container_name: crow-lemmy
hostname: lemmy
networks:
- default
- crow-federation
depends_on:
postgres:
condition: service_healthy
environment:
RUST_LOG: "warn"
RUST_BACKTRACE: "full"
LEMMY_DATABASE_URL: "postgres://lemmy:${LEMMY_DB_PASSWORD}@postgres:5432/lemmy"
LEMMY_CONFIG_LOCATION: /config/config.hjson
volumes:
- ./config:/config:ro
entrypoint:
- sh
- -c
- |
mkdir -p /config
cat > /config/config.hjson <<HJSON
{
hostname: "${LEMMY_HOSTNAME}"
bind: "0.0.0.0"
port: 8536
tls_enabled: false
database: {
host: postgres
port: 5432
database: lemmy
user: lemmy
password: "${LEMMY_DB_PASSWORD}"
}
pictrs: {
url: "http://pictrs:8080/"
api_key: "${LEMMY_PICTRS_API_KEY}"
}
federation: {
enabled: ${LEMMY_FEDERATION_ENABLED:-true}
debug: false
}
setup: {
admin_username: "admin_pending"
admin_password: "change_me_via_web_setup"
site_name: "Crow Lemmy"
}
captcha: { enabled: true, difficulty: medium }
opentelemetry_url: null
}
HJSON
exec /app/lemmy
init: true
mem_limit: 768m
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8536/api/v3/site >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 10
start_period: 60s

lemmy-ui:
image: dessalines/lemmy-ui:0.19
container_name: crow-lemmy-ui
networks:
- default
- crow-federation
depends_on:
lemmy:
condition: service_healthy
environment:
LEMMY_UI_LEMMY_INTERNAL_HOST: "lemmy:8536"
LEMMY_UI_LEMMY_EXTERNAL_HOST: ${LEMMY_HOSTNAME}
LEMMY_UI_HTTPS: "true"
init: true
mem_limit: 256m
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:1234/ >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 10
start_period: 60s
45 changes: 45 additions & 0 deletions bundles/lemmy/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"id": "lemmy",
"name": "Lemmy",
"version": "1.0.0",
"description": "Federated link aggregator and discussion platform over ActivityPub — Reddit-alternative on the fediverse. Posts, comments, communities, votes; remote Lemmy/Mastodon/Kbin users can follow your communities and comment cross-server.",
"type": "bundle",
"author": "Crow",
"category": "federated-social",
"tags": ["lemmy", "activitypub", "fediverse", "link-aggregator", "discussion", "reddit-alt"],
"icon": "message-circle",
"docker": { "composefile": "docker-compose.yml" },
"server": {
"command": "node",
"args": ["server/index.js"],
"envKeys": ["LEMMY_URL", "LEMMY_JWT", "LEMMY_HOSTNAME"]
},
"panel": "panel/lemmy.js",
"panelRoutes": "panel/routes.js",
"skills": ["skills/lemmy.md"],
"consent_required": true,
"install_consent_messages": {
"en": "Lemmy joins the public fediverse over ActivityPub — your instance becomes publicly addressable at the domain you configure, any community you host or post you publish can be replicated to federated servers (other Lemmy instances, Kbin, Mastodon, Friendica) and cannot be fully recalled. Lemmy's pict-rs image cache grows with federated content: 5-20 GB within weeks is typical on an active instance. Lemmy is hardware-gated to refuse install on hosts with <1 GB effective RAM after committed bundles; warns below 4 GB total. Because Lemmy's federation is community-scoped rather than user-scoped, a single large community can pull heavy content from many remote instances — monitor disk. Hosting illegal content (CSAM, credible threats) is your legal responsibility — major hubs (lemmy.world, lemmy.ml) may defederate your instance if reports go unaddressed.",
"es": "Lemmy se une al fediverso público vía ActivityPub — tu instancia será direccionable en el dominio que configures, cualquier comunidad que hospedes o publicación que hagas puede replicarse a servidores federados (otras instancias de Lemmy, Kbin, Mastodon, Friendica) y no puede recuperarse completamente. El caché de imágenes pict-rs de Lemmy crece con el contenido federado: 5-20 GB en semanas es típico en una instancia activa. Lemmy está limitado por hardware: rechazado en hosts con <1 GB de RAM efectiva tras paquetes comprometidos; advierte bajo 4 GB totales. Como la federación de Lemmy es por comunidad (no por usuario), una sola comunidad grande puede traer contenido pesado de muchas instancias remotas — monitoriza el disco. Hospedar contenido ilegal (CSAM, amenazas creíbles) es tu responsabilidad legal — los hubs principales (lemmy.world, lemmy.ml) pueden dejar de federarse con tu instancia si los reportes no son atendidos."
},
"requires": {
"env": ["LEMMY_HOSTNAME", "LEMMY_DB_PASSWORD", "LEMMY_PICTRS_API_KEY"],
"bundles": ["caddy"],
"min_ram_mb": 1000,
"recommended_ram_mb": 2000,
"min_disk_mb": 5000,
"recommended_disk_mb": 50000
},
"env_vars": [
{ "name": "LEMMY_HOSTNAME", "description": "Public domain (subdomain; path-mounts break ActivityPub).", "required": true },
{ "name": "LEMMY_DB_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true },
{ "name": "LEMMY_PICTRS_API_KEY", "description": "Shared secret Lemmy uses to talk to pict-rs (any random 32+ char string).", "required": true, "secret": true },
{ "name": "LEMMY_JWT", "description": "Admin login JWT (obtain via POST /api/v3/user/login after registering the admin via the web UI).", "required": false, "secret": true },
{ "name": "LEMMY_URL", "description": "Internal URL the MCP server uses to reach Lemmy's API (default http://lemmy:8536 over crow-federation).", "default": "http://lemmy:8536", "required": false },
{ "name": "LEMMY_OPEN_REGISTRATION", "description": "Allow new user signups (true/false). Default false — opens registration only after moderation tooling is configured.", "default": "false", "required": false },
{ "name": "LEMMY_FEDERATION_ENABLED", "description": "Whether to federate with other ActivityPub servers (true/false).", "default": "true", "required": false }
],
"ports": [],
"webUI": null,
"notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. The UI proxies /api/v3/ to lemmy:8536 internally. Initial admin registers via the /signup page during the first-boot setup wizard (no CLI bootstrap needed)."
}
11 changes: 11 additions & 0 deletions bundles/lemmy/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "crow-lemmy",
"version": "1.0.0",
"description": "Lemmy (federated link aggregator) MCP server — posts, comments, communities, votes, moderation",
"type": "module",
"main": "server/index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.24.0"
}
}
154 changes: 154 additions & 0 deletions bundles/lemmy/panel/lemmy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Crow's Nest Panel — Lemmy: instance status + subscribed communities + hot posts.
* XSS-safe (textContent / createElement only).
*/

export default {
id: "lemmy",
name: "Lemmy",
icon: "message-circle",
route: "/dashboard/lemmy",
navOrder: 76,
category: "federated-social",

async handler(req, res, { layout }) {
const content = `
<style>${styles()}</style>
<div class="lm-panel">
<h1>Lemmy <span class="lm-subtitle">federated link aggregator</span></h1>

<div class="lm-section">
<h3>Status</h3>
<div id="lm-status" class="lm-status"><div class="np-loading">Loading…</div></div>
</div>

<div class="lm-section">
<h3>Local Communities</h3>
<div id="lm-communities" class="lm-communities"><div class="np-loading">Loading…</div></div>
</div>

<div class="lm-section">
<h3>Hot Posts (local)</h3>
<div id="lm-posts" class="lm-posts"><div class="np-loading">Loading…</div></div>
</div>

<div class="lm-section lm-notes">
<h3>Notes</h3>
<ul>
<li>Lemmy federation is community-scoped. A single large federated community can pull heavy content; monitor disk.</li>
<li>Moderation reports from all federated instances land in your admin queue. Review regularly.</li>
<li>pict-rs cache grows with federated image content. Tune retention via <code>lemmy_media_prune</code>.</li>
</ul>
</div>
</div>
<script>${script()}</script>
`;
res.send(layout({ title: "Lemmy", content }));
},
};

function script() {
return `
function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
function row(label, value) {
const r = document.createElement('div'); r.className = 'lm-row';
const b = document.createElement('b'); b.textContent = label;
const s = document.createElement('span'); s.textContent = value == null ? '—' : String(value);
r.appendChild(b); r.appendChild(s); return r;
}
function err(msg) { const d = document.createElement('div'); d.className = 'np-error'; d.textContent = msg; return d; }

async function loadStatus() {
const el = document.getElementById('lm-status'); clear(el);
try {
const res = await fetch('/api/lemmy/status'); const d = await res.json();
if (d.error) { el.appendChild(err(d.error)); return; }
const card = document.createElement('div'); card.className = 'lm-card';
card.appendChild(row('Site name', d.site_name || '(unset)'));
card.appendChild(row('Hostname', d.hostname || '—'));
card.appendChild(row('Version', d.version || '—'));
card.appendChild(row('Users', d.users ?? '—'));
card.appendChild(row('Posts', d.posts ?? '—'));
card.appendChild(row('Communities', d.communities ?? '—'));
card.appendChild(row('Federation', d.federation_enabled ? 'enabled' : 'disabled'));
card.appendChild(row('Registration', d.registration_mode || '—'));
card.appendChild(row('Authenticated', d.my_user || '(no JWT)'));
el.appendChild(card);
} catch (e) { el.appendChild(err('Cannot reach Lemmy.')); }
}

async function loadCommunities() {
const el = document.getElementById('lm-communities'); clear(el);
try {
const res = await fetch('/api/lemmy/communities'); const d = await res.json();
if (d.error) { el.appendChild(err(d.error)); return; }
if (!d.communities || d.communities.length === 0) {
const i = document.createElement('div'); i.className = 'np-idle';
i.textContent = 'No local communities yet. Create one in the web UI.';
el.appendChild(i); return;
}
for (const c of d.communities) {
const li = document.createElement('div'); li.className = 'lm-community';
const t = document.createElement('b'); t.textContent = c.title || c.name;
li.appendChild(t);
const meta = document.createElement('div'); meta.className = 'lm-community-meta';
meta.textContent = '!' + c.name + ' · ' + (c.subscribers || 0) + ' subs · ' + (c.posts || 0) + ' posts';
li.appendChild(meta);
el.appendChild(li);
}
} catch (e) { el.appendChild(err('Cannot load communities: ' + e.message)); }
}

async function loadPosts() {
const el = document.getElementById('lm-posts'); clear(el);
try {
const res = await fetch('/api/lemmy/posts'); const d = await res.json();
if (d.error) { el.appendChild(err(d.error)); return; }
if (!d.posts || d.posts.length === 0) {
const i = document.createElement('div'); i.className = 'np-idle';
i.textContent = 'No posts yet.';
el.appendChild(i); return;
}
for (const p of d.posts) {
const c = document.createElement('div'); c.className = 'lm-post';
const t = document.createElement('b'); t.textContent = p.name;
c.appendChild(t);
const meta = document.createElement('div'); meta.className = 'lm-post-meta';
meta.textContent = '!' + (p.community || '?') + ' · ' + (p.score || 0) + ' pts · ' + (p.comments || 0) + ' comments · ' + (p.creator || '?');
c.appendChild(meta);
el.appendChild(c);
}
} catch (e) { el.appendChild(err('Cannot load posts: ' + e.message)); }
}

loadStatus();
loadCommunities();
loadPosts();
`;
}

function styles() {
return `
.lm-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; }
.lm-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; }
.lm-section { margin-bottom: 1.8rem; }
.lm-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase;
letter-spacing: 0.05em; margin: 0 0 0.7rem; }
.lm-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
border-radius: 10px; padding: 1rem; }
.lm-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); }
.lm-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 160px; }
.lm-community, .lm-post { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
border-radius: 8px; padding: .6rem .9rem; margin-bottom: .4rem; }
.lm-community b, .lm-post b { color: var(--crow-text-primary); font-size: .9rem; }
.lm-community-meta, .lm-post-meta { font-size: .75rem; color: var(--crow-text-muted); margin-top: .2rem; font-family: ui-monospace, monospace; }
.lm-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; }
.lm-notes li { margin-bottom: .3rem; }
.lm-notes code { font-family: ui-monospace, monospace; background: var(--crow-bg);
padding: 1px 4px; border-radius: 3px; font-size: .8em; }
.np-idle, .np-loading { color: var(--crow-text-muted); font-size: 0.9rem; padding: 1rem;
background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; }
.np-error { color: #ef4444; font-size: 0.9rem; padding: 1rem;
background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; }
`;
}
Loading
Loading