diff --git a/CLAUDE.md b/CLAUDE.md
index eff75f1..eac7cff 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -466,6 +466,7 @@ Add-on skills (activated when corresponding add-on is installed):
- `writefreely.md` — WriteFreely federated blog: create/update/publish/unpublish posts, list collections, fetch public posts, export; minimalist publisher (no comments, no moderation queue — WF is publish-oriented only)
- `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()
- `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
diff --git a/bundles/pixelfed/docker-compose.yml b/bundles/pixelfed/docker-compose.yml
new file mode 100644
index 0000000..009b04e
--- /dev/null
+++ b/bundles/pixelfed/docker-compose.yml
@@ -0,0 +1,186 @@
+# Pixelfed — federated photo-sharing on ActivityPub.
+#
+# Four-container bundle: pixelfed (nginx+PHP-FPM via supervisord in the
+# zknt image) + horizon (laravel queue worker) + postgres + redis. app +
+# horizon on crow-federation; DB/redis isolated to default. Caddy
+# reverse-proxies :443 → pixelfed:80.
+#
+# Data:
+# ~/.crow/pixelfed/postgres/ Postgres data dir
+# ~/.crow/pixelfed/redis/ Redis persistence
+# ~/.crow/pixelfed/storage/ Laravel storage/ (uploads, cache, logs)
+# ~/.crow/pixelfed/uploads/ Public uploads dir (symlinked from storage)
+#
+# Media storage: on-disk by default. Set PIXELFED_S3_* to route to MinIO /
+# external S3 — storage-translators.pixelfed() maps to the AWS_* +
+# FILESYSTEM_CLOUD=s3 envelope Pixelfed expects. configure-storage.mjs in
+# scripts/ does the translation at install time.
+#
+# Image: zknt/pixelfed:0.12 (floats within 0.12.x — verify the current
+# tag + CVE feed at implementation time per the plan's image-tag policy).
+
+networks:
+ crow-federation:
+ external: true
+ default:
+
+services:
+ postgres:
+ image: postgres:15-alpine
+ container_name: crow-pixelfed-postgres
+ networks:
+ - default
+ environment:
+ POSTGRES_USER: pixelfed
+ POSTGRES_PASSWORD: ${PIXELFED_DB_PASSWORD}
+ POSTGRES_DB: pixelfed
+ volumes:
+ - ${PIXELFED_DATA_DIR:-~/.crow/pixelfed}/postgres:/var/lib/postgresql/data
+ init: true
+ mem_limit: 512m
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U pixelfed"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 20s
+
+ redis:
+ image: redis:7-alpine
+ container_name: crow-pixelfed-redis
+ networks:
+ - default
+ volumes:
+ - ${PIXELFED_DATA_DIR:-~/.crow/pixelfed}/redis:/data
+ init: true
+ mem_limit: 256m
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+
+ pixelfed:
+ image: zknt/pixelfed:0.12
+ container_name: crow-pixelfed
+ networks:
+ - default
+ - crow-federation
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ APP_NAME: Pixelfed
+ APP_ENV: production
+ APP_DEBUG: "false"
+ APP_KEY: ${PIXELFED_APP_KEY}
+ APP_URL: https://${PIXELFED_HOSTNAME}
+ APP_DOMAIN: ${PIXELFED_HOSTNAME}
+ ADMIN_DOMAIN: ${PIXELFED_HOSTNAME}
+ SESSION_DOMAIN: ${PIXELFED_HOSTNAME}
+ TRUST_PROXIES: "*"
+ # DB
+ DB_CONNECTION: pgsql
+ DB_HOST: postgres
+ DB_PORT: "5432"
+ DB_DATABASE: pixelfed
+ DB_USERNAME: pixelfed
+ DB_PASSWORD: ${PIXELFED_DB_PASSWORD}
+ # Cache / queue
+ BROADCAST_DRIVER: log
+ CACHE_DRIVER: redis
+ QUEUE_DRIVER: redis
+ SESSION_DRIVER: redis
+ REDIS_CLIENT: predis
+ REDIS_HOST: redis
+ REDIS_PASSWORD: "null"
+ REDIS_PORT: "6379"
+ # Federation
+ ACTIVITY_PUB: "true"
+ AP_REMOTE_FOLLOW: "true"
+ AP_SHAREDINBOX: "true"
+ AP_INBOX: "true"
+ AP_OUTBOX: "true"
+ # Registration / limits
+ OPEN_REGISTRATION: ${PIXELFED_OPEN_REGISTRATION:-false}
+ ENFORCE_EMAIL_VERIFICATION: "true"
+ PF_MAX_USERS: ${PIXELFED_MAX_USERS:-1000}
+ OAUTH_ENABLED: "true"
+ # Media
+ PF_OPTIMIZE_IMAGES: "true"
+ IMAGE_DRIVER: imagick
+ MAX_PHOTO_SIZE: ${PIXELFED_MAX_PHOTO_SIZE:-15000}
+ MAX_ALBUM_LENGTH: ${PIXELFED_MAX_ALBUM_LENGTH:-4}
+ MEDIA_EXIF_DATABASE: "false"
+ MEDIA_DELETE_LOCAL_AFTER_CLOUD: "true"
+ # Remote media cache retention (days)
+ PF_REMOTE_MEDIA_DAYS: ${PIXELFED_MEDIA_RETENTION_DAYS:-14}
+ # S3 (empty unless configure-storage.mjs populated them)
+ FILESYSTEM_CLOUD: ${FILESYSTEM_CLOUD:-local}
+ PF_ENABLE_CLOUD: ${PF_ENABLE_CLOUD:-false}
+ AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
+ AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
+ AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-us-east-1}
+ AWS_BUCKET: ${AWS_BUCKET:-}
+ AWS_URL: ${AWS_URL:-}
+ AWS_ENDPOINT: ${AWS_ENDPOINT:-}
+ AWS_USE_PATH_STYLE_ENDPOINT: ${AWS_USE_PATH_STYLE_ENDPOINT:-true}
+ volumes:
+ - ${PIXELFED_DATA_DIR:-~/.crow/pixelfed}/storage:/var/www/storage
+ - ${PIXELFED_DATA_DIR:-~/.crow/pixelfed}/uploads:/var/www/public/storage
+ init: true
+ mem_limit: 1500m
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/api/v1/instance >/dev/null || exit 1"]
+ interval: 30s
+ timeout: 10s
+ retries: 10
+ start_period: 120s
+
+ horizon:
+ image: zknt/pixelfed:0.12
+ container_name: crow-pixelfed-horizon
+ networks:
+ - default
+ depends_on:
+ pixelfed:
+ condition: service_healthy
+ environment:
+ APP_NAME: Pixelfed
+ APP_ENV: production
+ APP_KEY: ${PIXELFED_APP_KEY}
+ APP_URL: https://${PIXELFED_HOSTNAME}
+ APP_DOMAIN: ${PIXELFED_HOSTNAME}
+ DB_CONNECTION: pgsql
+ DB_HOST: postgres
+ DB_DATABASE: pixelfed
+ DB_USERNAME: pixelfed
+ DB_PASSWORD: ${PIXELFED_DB_PASSWORD}
+ BROADCAST_DRIVER: log
+ CACHE_DRIVER: redis
+ QUEUE_DRIVER: redis
+ REDIS_CLIENT: predis
+ REDIS_HOST: redis
+ REDIS_PASSWORD: "null"
+ REDIS_PORT: "6379"
+ FILESYSTEM_CLOUD: ${FILESYSTEM_CLOUD:-local}
+ PF_ENABLE_CLOUD: ${PF_ENABLE_CLOUD:-false}
+ AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
+ AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
+ AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-us-east-1}
+ AWS_BUCKET: ${AWS_BUCKET:-}
+ AWS_URL: ${AWS_URL:-}
+ AWS_ENDPOINT: ${AWS_ENDPOINT:-}
+ AWS_USE_PATH_STYLE_ENDPOINT: ${AWS_USE_PATH_STYLE_ENDPOINT:-true}
+ volumes:
+ - ${PIXELFED_DATA_DIR:-~/.crow/pixelfed}/storage:/var/www/storage
+ - ${PIXELFED_DATA_DIR:-~/.crow/pixelfed}/uploads:/var/www/public/storage
+ command: ["php", "/var/www/artisan", "horizon"]
+ init: true
+ mem_limit: 768m
+ restart: unless-stopped
diff --git a/bundles/pixelfed/manifest.json b/bundles/pixelfed/manifest.json
new file mode 100644
index 0000000..c9e6cdd
--- /dev/null
+++ b/bundles/pixelfed/manifest.json
@@ -0,0 +1,51 @@
+{
+ "id": "pixelfed",
+ "name": "Pixelfed",
+ "version": "1.0.0",
+ "description": "Federated photo-sharing server over ActivityPub — Instagram-alternative on the fediverse. Publish photos/stories/collections; remote Mastodon/GoToSocial/Funkwhale followers see your posts in their timelines.",
+ "type": "bundle",
+ "author": "Crow",
+ "category": "federated-media",
+ "tags": ["pixelfed", "activitypub", "fediverse", "photos", "federated", "instagram-alt"],
+ "icon": "image",
+ "docker": { "composefile": "docker-compose.yml" },
+ "server": {
+ "command": "node",
+ "args": ["server/index.js"],
+ "envKeys": ["PIXELFED_URL", "PIXELFED_ACCESS_TOKEN", "PIXELFED_HOSTNAME"]
+ },
+ "panel": "panel/pixelfed.js",
+ "panelRoutes": "panel/routes.js",
+ "skills": ["skills/pixelfed.md"],
+ "consent_required": true,
+ "install_consent_messages": {
+ "en": "Pixelfed joins the public fediverse over ActivityPub — your instance becomes publicly addressable at the domain you configure, any post you publish can be replicated to federated servers (Mastodon, GoToSocial, Funkwhale, other Pixelfed instances) and cached there; replicated content cannot be fully recalled — deletions may not reach every server that cached the media. Pixelfed's remote-media cache grows fast under an active follow graph: 10-50 GB within weeks is typical, and scheduled pruning (default 14 days) is load-bearing. Pixelfed is hardware-gated to refuse install on hosts with <1.5 GB effective RAM after committed bundles; warns below 8 GB total host RAM. Uploading copyrighted or illegal imagery is your legal responsibility — major hubs (mastodon.social) may defederate instances reported for abuse. CSAM hosting is a criminal offense; media moderation is not optional — configure IFTAS or Bad Space blocklists before opening registration.",
+ "es": "Pixelfed se une al fediverso público vía ActivityPub — tu instancia será direccionable en el dominio que configures, cualquier publicación puede replicarse a servidores federados (Mastodon, GoToSocial, Funkwhale, otras instancias de Pixelfed) y cachearse allí; el contenido replicado no puede recuperarse completamente — las eliminaciones pueden no llegar a todos los servidores que cachearon el medio. El caché de medios remotos de Pixelfed crece rápido con un grafo de seguimiento activo: 10-50 GB en semanas es típico, y el recorte programado (14 días por defecto) es crítico. Pixelfed está limitado por hardware: rechazado en hosts con <1.5 GB de RAM efectiva tras paquetes comprometidos; advierte bajo 8 GB de RAM total. Subir imágenes con copyright o ilegales es tu responsabilidad legal — los hubs principales (mastodon.social) pueden dejar de federarse con instancias reportadas por abuso. Hospedar CSAM es un delito; la moderación de medios no es opcional — configura listas de bloqueo IFTAS o Bad Space antes de abrir el registro."
+ },
+ "requires": {
+ "env": ["PIXELFED_HOSTNAME", "PIXELFED_DB_PASSWORD", "PIXELFED_APP_KEY"],
+ "bundles": ["caddy"],
+ "min_ram_mb": 1500,
+ "recommended_ram_mb": 3000,
+ "min_disk_mb": 10000,
+ "recommended_disk_mb": 100000
+ },
+ "env_vars": [
+ { "name": "PIXELFED_HOSTNAME", "description": "Public domain (subdomain; path-mounts break ActivityPub).", "required": true },
+ { "name": "PIXELFED_DB_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true },
+ { "name": "PIXELFED_APP_KEY", "description": "Laravel application key (32+ random bytes). Generate: `php artisan key:generate --show` in a test container, or `openssl rand -base64 32 | head -c 32`.", "required": true, "secret": true },
+ { "name": "PIXELFED_ACCESS_TOKEN", "description": "OAuth2 Personal Access Token (Settings → Development → New Application).", "required": false, "secret": true },
+ { "name": "PIXELFED_URL", "description": "Internal URL the MCP server uses to reach Pixelfed (default http://pixelfed:80 over crow-federation).", "default": "http://pixelfed:80", "required": false },
+ { "name": "PIXELFED_OPEN_REGISTRATION", "description": "Allow new user signups (true/false). Default false — opening registration without moderation tooling invites abuse.", "default": "false", "required": false },
+ { "name": "PIXELFED_MAX_USERS", "description": "Cap on registered users (0 = unlimited, honors OPEN_REGISTRATION).", "default": "1000", "required": false },
+ { "name": "PIXELFED_MEDIA_RETENTION_DAYS", "description": "Remote media cache retention (default 14; 7 on Pi-class hosts).", "default": "14", "required": false },
+ { "name": "PIXELFED_S3_ENDPOINT", "description": "Optional S3-compatible endpoint for media storage (defaults to on-disk). If set with bucket/access/secret, media goes to S3 via storage-translators.pixelfed().", "required": false },
+ { "name": "PIXELFED_S3_BUCKET", "description": "S3 bucket for media.", "required": false },
+ { "name": "PIXELFED_S3_ACCESS_KEY", "description": "S3 access key.", "required": false, "secret": true },
+ { "name": "PIXELFED_S3_SECRET_KEY", "description": "S3 secret key.", "required": false, "secret": true },
+ { "name": "PIXELFED_S3_REGION", "description": "S3 region.", "default": "us-east-1", "required": false }
+ ],
+ "ports": [],
+ "webUI": null,
+ "notes": "Four containers (app + horizon + postgres + redis). app ships nginx+PHP-FPM via supervisord. Expose via caddy_add_federation_site { domain: PIXELFED_HOSTNAME, upstream: 'pixelfed:80', profile: 'activitypub' }. Admin account via `docker exec -it crow-pixelfed php artisan user:create`. Media retention enforced by scheduled horizon job; tune via PIXELFED_MEDIA_RETENTION_DAYS."
+}
diff --git a/bundles/pixelfed/package.json b/bundles/pixelfed/package.json
new file mode 100644
index 0000000..0afe829
--- /dev/null
+++ b/bundles/pixelfed/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "crow-pixelfed",
+ "version": "1.0.0",
+ "description": "Pixelfed (federated photo-sharing) MCP server — posts, photos, feeds, follows, moderation",
+ "type": "module",
+ "main": "server/index.js",
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "^1.12.0",
+ "zod": "^3.24.0"
+ }
+}
diff --git a/bundles/pixelfed/panel/pixelfed.js b/bundles/pixelfed/panel/pixelfed.js
new file mode 100644
index 0000000..94a4e01
--- /dev/null
+++ b/bundles/pixelfed/panel/pixelfed.js
@@ -0,0 +1,140 @@
+/**
+ * Crow's Nest Panel — Pixelfed: instance status + recent posts + federation peers.
+ * XSS-safe (textContent / createElement only).
+ */
+
+export default {
+ id: "pixelfed",
+ name: "Pixelfed",
+ icon: "image",
+ route: "/dashboard/pixelfed",
+ navOrder: 75,
+ category: "federated-media",
+
+ async handler(req, res, { layout }) {
+ const content = `
+
+
+
Pixelfed federated photo server
+
+
+
+
+
Recent Posts (home timeline)
+
+
+
+
+
Notes
+
+ - Moderation is non-optional on a federated photo server. Configure an IFTAS or Bad Space blocklist before opening registration.
+ - Remote media cache prunes on a horizon schedule (
PIXELFED_MEDIA_RETENTION_DAYS). Force a prune with pf_media_prune.
+ - Uploading copyrighted or illegal imagery is your legal responsibility.
+
+
+
+
+ `;
+ res.send(layout({ title: "Pixelfed", 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 = 'pf-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('pf-status'); clear(el);
+ try {
+ const res = await fetch('/api/pixelfed/status'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ const card = document.createElement('div'); card.className = 'pf-card';
+ card.appendChild(row('Instance', d.instance?.uri || d.hostname || '(unset)'));
+ card.appendChild(row('Title', d.instance?.title || '—'));
+ card.appendChild(row('Version', d.instance?.version || '—'));
+ card.appendChild(row('Users', d.instance?.stats?.user_count ?? '—'));
+ card.appendChild(row('Posts', d.instance?.stats?.status_count ?? '—'));
+ card.appendChild(row('Federated peers', d.federated_peers ?? '—'));
+ card.appendChild(row('Authenticated', d.authenticated_as ? d.authenticated_as.acct : '(no token)'));
+ el.appendChild(card);
+ } catch (e) { el.appendChild(err('Cannot reach Pixelfed.')); }
+ }
+
+ async function loadFeed() {
+ const el = document.getElementById('pf-feed'); clear(el);
+ try {
+ const res = await fetch('/api/pixelfed/feed'); const d = await res.json();
+ if (d.error) { el.appendChild(err(d.error)); return; }
+ if (!d.items || d.items.length === 0) {
+ const i = document.createElement('div'); i.className = 'np-idle';
+ i.textContent = 'No recent posts. Follow some accounts to populate the home timeline.';
+ el.appendChild(i); return;
+ }
+ for (const p of d.items) {
+ const c = document.createElement('div'); c.className = 'pf-post';
+ const h = document.createElement('div'); h.className = 'pf-post-head';
+ const who = document.createElement('b'); who.textContent = '@' + (p.acct || 'unknown');
+ h.appendChild(who);
+ if (p.media_count > 0) {
+ const m = document.createElement('span'); m.className = 'pf-badge';
+ m.textContent = p.media_count + ' photo' + (p.media_count === 1 ? '' : 's');
+ h.appendChild(m);
+ }
+ c.appendChild(h);
+ if (p.content_excerpt) {
+ const body = document.createElement('div'); body.className = 'pf-post-body';
+ body.textContent = p.content_excerpt;
+ c.appendChild(body);
+ }
+ const meta = document.createElement('div'); meta.className = 'pf-post-meta';
+ meta.textContent = (p.favs || 0) + ' likes · ' + (p.replies || 0) + ' replies · ' + (p.visibility || 'public');
+ c.appendChild(meta);
+ el.appendChild(c);
+ }
+ } catch (e) { el.appendChild(err('Cannot load feed: ' + e.message)); }
+ }
+
+ loadStatus();
+ loadFeed();
+ `;
+}
+
+function styles() {
+ return `
+ .pf-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; }
+ .pf-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; }
+ .pf-section { margin-bottom: 1.8rem; }
+ .pf-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase;
+ letter-spacing: 0.05em; margin: 0 0 0.7rem; }
+ .pf-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
+ border-radius: 10px; padding: 1rem; }
+ .pf-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); }
+ .pf-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 160px; }
+ .pf-post { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
+ border-radius: 8px; padding: .6rem .9rem; margin-bottom: .4rem; }
+ .pf-post-head { display: flex; gap: .5rem; align-items: baseline; }
+ .pf-post-head b { color: var(--crow-text-primary); font-size: .9rem; }
+ .pf-badge { font-size: .7rem; color: var(--crow-accent);
+ background: var(--crow-bg); padding: 1px 6px; border-radius: 10px; }
+ .pf-post-body { font-size: .85rem; color: var(--crow-text-secondary); margin-top: .2rem; }
+ .pf-post-meta { font-size: .75rem; color: var(--crow-text-muted); margin-top: .3rem; }
+ .pf-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; }
+ .pf-notes li { margin-bottom: .3rem; }
+ .pf-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; }
+ `;
+}
diff --git a/bundles/pixelfed/panel/routes.js b/bundles/pixelfed/panel/routes.js
new file mode 100644
index 0000000..c0ad48b
--- /dev/null
+++ b/bundles/pixelfed/panel/routes.js
@@ -0,0 +1,76 @@
+/**
+ * Pixelfed panel API routes — status + recent home-timeline posts.
+ */
+
+import { Router } from "express";
+
+const URL_BASE = () => (process.env.PIXELFED_URL || "http://pixelfed:80").replace(/\/+$/, "");
+const TOKEN = () => process.env.PIXELFED_ACCESS_TOKEN || "";
+const HOSTNAME = () => process.env.PIXELFED_HOSTNAME || "";
+const TIMEOUT = 15_000;
+
+async function pf(path, { query, noAuth } = {}) {
+ const ctl = new AbortController();
+ const t = setTimeout(() => ctl.abort(), TIMEOUT);
+ try {
+ const qs = query
+ ? "?" +
+ Object.entries(query)
+ .filter(([, v]) => v != null && v !== "")
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+ .join("&")
+ : "";
+ const headers = {};
+ if (!noAuth && TOKEN()) headers.Authorization = `Bearer ${TOKEN()}`;
+ const r = await fetch(`${URL_BASE()}${path}${qs}`, { signal: ctl.signal, headers });
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
+ const text = await r.text();
+ return text ? JSON.parse(text) : {};
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+export default function pixelfedRouter(authMiddleware) {
+ const router = Router();
+
+ router.get("/api/pixelfed/status", authMiddleware, async (_req, res) => {
+ try {
+ const instance = await pf("/api/v1/instance").catch(() => null);
+ const peers = await pf("/api/v1/instance/peers").catch(() => []);
+ const account = TOKEN() ? await pf("/api/v1/accounts/verify_credentials").catch(() => null) : null;
+ res.json({
+ hostname: HOSTNAME(),
+ instance: instance ? {
+ uri: instance.uri, title: instance.title, version: instance.version, stats: instance.stats,
+ } : null,
+ federated_peers: Array.isArray(peers) ? peers.length : null,
+ authenticated_as: account ? { acct: account.acct, id: account.id } : null,
+ });
+ } catch (err) {
+ res.json({ error: `Cannot reach Pixelfed: ${err.message}` });
+ }
+ });
+
+ router.get("/api/pixelfed/feed", authMiddleware, async (_req, res) => {
+ try {
+ if (!TOKEN()) return res.json({ error: "PIXELFED_ACCESS_TOKEN not set" });
+ const items = await pf("/api/v1/timelines/home", { query: { limit: 12 } });
+ res.json({
+ items: (Array.isArray(items) ? items : []).map((p) => ({
+ id: p.id,
+ acct: p.account?.acct,
+ content_excerpt: (p.content || "").replace(/<[^>]+>/g, "").slice(0, 240),
+ media_count: (p.media_attachments || []).length,
+ visibility: p.visibility,
+ favs: p.favourites_count,
+ replies: p.replies_count,
+ })),
+ });
+ } catch (err) {
+ res.json({ error: err.message });
+ }
+ });
+
+ return router;
+}
diff --git a/bundles/pixelfed/scripts/backup.sh b/bundles/pixelfed/scripts/backup.sh
new file mode 100755
index 0000000..607f0c4
--- /dev/null
+++ b/bundles/pixelfed/scripts/backup.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# Pixelfed backup: pg_dump + storage dir (uploads, caches excluded).
+# S3-backed media NOT captured — operator's S3 provider is the durability layer.
+set -euo pipefail
+
+STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
+BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/pixelfed"
+DATA_DIR="${PIXELFED_DATA_DIR:-$HOME/.crow/pixelfed}"
+
+mkdir -p "$BACKUP_ROOT"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+# Postgres dump
+if docker ps --format '{{.Names}}' | grep -qw crow-pixelfed-postgres; then
+ docker exec -e PGPASSWORD="${PIXELFED_DB_PASSWORD:-}" crow-pixelfed-postgres \
+ pg_dump -U pixelfed -Fc -f /tmp/pixelfed-${STAMP}.pgcustom pixelfed
+ docker cp "crow-pixelfed-postgres:/tmp/pixelfed-${STAMP}.pgcustom" "$WORK/pixelfed.pgcustom"
+ docker exec crow-pixelfed-postgres rm "/tmp/pixelfed-${STAMP}.pgcustom"
+fi
+
+# Storage dir (exclude framework cache/logs — regenerable)
+tar -C "$DATA_DIR" \
+ --exclude='./storage/framework/cache' \
+ --exclude='./storage/framework/sessions' \
+ --exclude='./storage/logs' \
+ --exclude='./storage/debugbar' \
+ -cf "$WORK/pixelfed-storage.tar" storage uploads 2>/dev/null || true
+
+OUT="${BACKUP_ROOT}/pixelfed-${STAMP}.tar.zst"
+if command -v zstd >/dev/null 2>&1; then
+ tar -C "$WORK" -cf - . | zstd -T0 -19 -o "$OUT"
+else
+ OUT="${BACKUP_ROOT}/pixelfed-${STAMP}.tar.gz"
+ tar -C "$WORK" -czf "$OUT" .
+fi
+echo "wrote $OUT ($(du -h "$OUT" | cut -f1))"
+echo "NOTE: S3-backed media (if configured) is NOT in this archive."
+echo " APP_KEY is embedded in .env — keep the .env file backed up separately."
diff --git a/bundles/pixelfed/scripts/configure-storage.mjs b/bundles/pixelfed/scripts/configure-storage.mjs
new file mode 100755
index 0000000..a60b772
--- /dev/null
+++ b/bundles/pixelfed/scripts/configure-storage.mjs
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+/**
+ * Pixelfed storage wiring.
+ *
+ * Reads PIXELFED_S3_* from the bundle's .env, runs F.0's
+ * storage-translators.pixelfed() to get Pixelfed's AWS_* + FILESYSTEM_CLOUD
+ * + PF_ENABLE_CLOUD schema, and appends the translated vars to the .env
+ * file so compose picks them up on the next `up`.
+ *
+ * If PIXELFED_S3_ENDPOINT is not set, exits 0 (on-disk storage — no-op).
+ *
+ * Invoked by scripts/post-install.sh. Safe to re-run (managed block is
+ * delimited by `# crow-pixelfed-storage BEGIN` / `END`).
+ */
+
+import { readFileSync, writeFileSync, existsSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ENV_PATH = resolve(__dirname, "..", ".env");
+
+function parseEnv(text) {
+ const out = {};
+ for (const line of text.split("\n")) {
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+ if (m) out[m[1]] = m[2].replace(/^"|"$/g, "");
+ }
+ return out;
+}
+
+function loadEnv() {
+ if (!existsSync(ENV_PATH)) return {};
+ return parseEnv(readFileSync(ENV_PATH, "utf8"));
+}
+
+async function main() {
+ const env = loadEnv();
+ const endpoint = env.PIXELFED_S3_ENDPOINT;
+ const bucket = env.PIXELFED_S3_BUCKET;
+ const accessKey = env.PIXELFED_S3_ACCESS_KEY;
+ const secretKey = env.PIXELFED_S3_SECRET_KEY;
+ const region = env.PIXELFED_S3_REGION || "us-east-1";
+
+ if (!endpoint) {
+ console.log("[configure-storage] PIXELFED_S3_ENDPOINT not set — using on-disk storage.");
+ return;
+ }
+ if (!bucket || !accessKey || !secretKey) {
+ console.error("[configure-storage] PIXELFED_S3_ENDPOINT is set but bucket/access/secret are missing — refusing partial config.");
+ process.exit(1);
+ }
+
+ let translate;
+ try {
+ const mod = await import(resolve(__dirname, "..", "..", "..", "servers", "gateway", "storage-translators.js"));
+ translate = mod.translate;
+ } catch {
+ console.error("[configure-storage] Cannot load storage-translators.js — falling back to inline mapping.");
+ translate = (_, crow) => ({
+ FILESYSTEM_CLOUD: "s3",
+ PF_ENABLE_CLOUD: "true",
+ AWS_ACCESS_KEY_ID: crow.accessKey,
+ AWS_SECRET_ACCESS_KEY: crow.secretKey,
+ AWS_DEFAULT_REGION: crow.region || "us-east-1",
+ AWS_BUCKET: crow.bucket,
+ AWS_URL: crow.endpoint,
+ AWS_ENDPOINT: crow.endpoint,
+ AWS_USE_PATH_STYLE_ENDPOINT: "true",
+ });
+ }
+
+ const mapped = translate("pixelfed", { endpoint, bucket, accessKey, secretKey, region });
+
+ const BEGIN = "# crow-pixelfed-storage BEGIN (managed by scripts/configure-storage.mjs — do not edit)";
+ const END = "# crow-pixelfed-storage END";
+ const block = [BEGIN, ...Object.entries(mapped).map(([k, v]) => `${k}=${v}`), END, ""].join("\n");
+
+ let cur = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, "utf8") : "";
+ if (cur.includes(BEGIN)) {
+ cur = cur.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}\\n?`), "");
+ }
+ if (cur.length && !cur.endsWith("\n")) cur += "\n";
+ writeFileSync(ENV_PATH, cur + block);
+ console.log(`[configure-storage] Wrote ${Object.keys(mapped).length} translated S3 env vars to ${ENV_PATH}.`);
+ console.log("[configure-storage] Restart the compose stack so app + horizon pick up the new vars:");
+ console.log(" docker compose -f bundles/pixelfed/docker-compose.yml up -d --force-recreate");
+}
+
+main().catch((err) => {
+ console.error(`[configure-storage] Failed: ${err.message}`);
+ process.exit(1);
+});
diff --git a/bundles/pixelfed/scripts/post-install.sh b/bundles/pixelfed/scripts/post-install.sh
new file mode 100755
index 0000000..1610402
--- /dev/null
+++ b/bundles/pixelfed/scripts/post-install.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# Pixelfed post-install hook.
+#
+# 1. Wait for crow-pixelfed to report healthy (first boot runs migrations
+# + key:generate + storage:link — can take 2+ minutes).
+# 2. Optionally translate PIXELFED_S3_* into AWS_* + FILESYSTEM_CLOUD via
+# configure-storage.mjs.
+# 3. Verify crow-federation network attachment.
+# 4. Print next-step guidance (admin user creation, Caddy site, PAT).
+
+set -euo pipefail
+
+BUNDLE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+ENV_FILE="${BUNDLE_DIR}/.env"
+if [ -f "$ENV_FILE" ]; then
+ set -a; . "$ENV_FILE"; set +a
+fi
+
+echo "Waiting for Pixelfed to report healthy (up to 180s)…"
+for i in $(seq 1 36); do
+ if docker inspect crow-pixelfed --format '{{.State.Health.Status}}' 2>/dev/null | grep -qw healthy; then
+ echo " → healthy"
+ break
+ fi
+ sleep 5
+done
+
+# Translate S3 vars if configured
+if [ -n "${PIXELFED_S3_ENDPOINT:-}" ]; then
+ echo "PIXELFED_S3_ENDPOINT detected — translating to AWS_* + FILESYSTEM_CLOUD via storage-translators…"
+ if command -v node >/dev/null 2>&1; then
+ node "${BUNDLE_DIR}/scripts/configure-storage.mjs" || {
+ echo "WARN: configure-storage.mjs failed; media will stay on-disk until S3 env vars are written manually." >&2
+ }
+ else
+ echo "WARN: node not available on PATH — cannot run configure-storage.mjs. S3 not wired." >&2
+ fi
+fi
+
+if ! docker inspect crow-pixelfed --format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -qw crow-federation; then
+ echo "WARN: crow-pixelfed is not on the crow-federation network — Caddy federation sites will not reach it by service name" >&2
+fi
+
+cat < (_toolId, handler) => handler;
+ }
+ try {
+ const db = await import("../../../servers/db.js");
+ getDb = db.createDbClient;
+ } catch {
+ getDb = null;
+ }
+ try {
+ const notif = await import("../../../servers/shared/notifications.js");
+ createNotification = notif.createNotification;
+ } catch {
+ createNotification = null;
+ }
+}
+
+async function pfFetch(path, { method = "GET", body, query, noAuth, timeoutMs = 20_000, rawForm } = {}) {
+ const qs = query
+ ? "?" +
+ Object.entries(query)
+ .filter(([, v]) => v != null && v !== "")
+ .map(([k, v]) =>
+ Array.isArray(v)
+ ? v.map((x) => `${encodeURIComponent(k + "[]")}=${encodeURIComponent(x)}`).join("&")
+ : `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
+ )
+ .join("&")
+ : "";
+ const url = `${PIXELFED_URL}${path}${qs}`;
+ const headers = {};
+ if (!noAuth && PIXELFED_ACCESS_TOKEN) {
+ headers.Authorization = `Bearer ${PIXELFED_ACCESS_TOKEN}`;
+ }
+ let payload;
+ if (rawForm) {
+ payload = rawForm;
+ } else if (body) {
+ headers["Content-Type"] = "application/json";
+ payload = JSON.stringify(body);
+ }
+ const ctl = new AbortController();
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
+ try {
+ const res = await fetch(url, { method, headers, body: payload, signal: ctl.signal });
+ const text = await res.text();
+ if (!res.ok) {
+ const snippet = text.slice(0, 600);
+ if (res.status === 401) throw new Error("Pixelfed auth failed (401). Create an OAuth PAT in Settings → Development, paste into PIXELFED_ACCESS_TOKEN.");
+ if (res.status === 403) throw new Error(`Pixelfed forbidden (403)${snippet ? ": " + snippet : ""}`);
+ throw new Error(`Pixelfed ${res.status} ${res.statusText}${snippet ? " — " + snippet : ""}`);
+ }
+ if (!text) return {};
+ try { return JSON.parse(text); } catch { return { raw: text }; }
+ } catch (err) {
+ if (err.name === "AbortError") throw new Error(`Pixelfed request timed out: ${path}`);
+ if (err.cause?.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) {
+ throw new Error(`Cannot reach Pixelfed at ${PIXELFED_URL}. Verify crow-pixelfed is up and on the crow-federation network.`);
+ }
+ throw err;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+function requireAuth() {
+ if (!PIXELFED_ACCESS_TOKEN) {
+ return { content: [{ type: "text", text: "Error: PIXELFED_ACCESS_TOKEN required. Generate an OAuth PAT from Settings → Development in the Pixelfed web UI." }] };
+ }
+ return null;
+}
+
+async function queueModerationAction(bundle, actionType, payload) {
+ if (!getDb) {
+ return {
+ status: "queued_offline",
+ reason: "Crow database not reachable from bundle — moderation queue unavailable. Action NOT applied.",
+ requested: { action_type: actionType, payload },
+ };
+ }
+ const db = getDb();
+ try {
+ const now = Math.floor(Date.now() / 1000);
+ const expiresAt = now + 72 * 3600;
+ const payloadJson = JSON.stringify(payload);
+ const { createHash } = await import("node:crypto");
+ const idempotencyKey = createHash("sha256").update(`${bundle}:${actionType}:${payloadJson}`).digest("hex");
+
+ const existing = await db.execute({
+ sql: "SELECT id, expires_at, status FROM moderation_actions WHERE idempotency_key = ?",
+ args: [idempotencyKey],
+ });
+ if (existing.rows.length > 0) {
+ return {
+ status: "queued_duplicate",
+ action_id: Number(existing.rows[0].id),
+ previous_status: existing.rows[0].status,
+ };
+ }
+
+ const inserted = await db.execute({
+ sql: `INSERT INTO moderation_actions
+ (bundle_id, action_type, payload_json, requested_by,
+ requested_at, expires_at, status, idempotency_key)
+ VALUES (?, ?, ?, 'ai', ?, ?, 'pending', ?)
+ RETURNING id`,
+ args: [bundle, actionType, payloadJson, now, expiresAt, idempotencyKey],
+ });
+ const actionId = Number(inserted.rows[0].id);
+
+ if (createNotification) {
+ try {
+ await createNotification(db, {
+ title: `${bundle} moderation action awaiting confirmation`,
+ body: `${actionType} — review and confirm in the Nest panel before ${new Date(expiresAt * 1000).toLocaleString()}`,
+ type: "system",
+ source: bundle,
+ priority: "high",
+ action_url: `/dashboard/${bundle}?action=${actionId}`,
+ });
+ } catch {}
+ }
+
+ return { status: "queued", action_id: actionId, expires_at: expiresAt };
+ } catch (err) {
+ if (/no such table.*moderation_actions/i.test(err.message)) {
+ return { status: "queued_unavailable", reason: "moderation_actions table not present — lands with F.11." };
+ }
+ throw err;
+ } finally {
+ try { db.close(); } catch {}
+ }
+}
+
+function textResponse(obj) {
+ return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
+}
+
+function errResponse(err) {
+ return { content: [{ type: "text", text: `Error: ${err.message || String(err)}` }] };
+}
+
+async function resolveAccount(handleOrId) {
+ if (/^\d+$/.test(handleOrId)) return { id: handleOrId, acct: null };
+ const out = await pfFetch("/api/v2/search", {
+ query: { q: handleOrId.replace(/^@/, ""), type: "accounts", resolve: "true", limit: 1 },
+ });
+ return (out.accounts || [])[0] || null;
+}
+
+export async function createPixelfedServer(options = {}) {
+ await loadSharedDeps();
+
+ const server = new McpServer(
+ { name: "crow-pixelfed", version: "1.0.0" },
+ { instructions: options.instructions },
+ );
+
+ const limiter = wrapRateLimited ? wrapRateLimited({ db: getDb ? getDb() : null }) : (_, h) => h;
+
+ // --- pf_status ---
+ server.tool(
+ "pf_status",
+ "Report Pixelfed instance health: reachability, version, stats, federation peer count, authenticated account.",
+ {},
+ async () => {
+ try {
+ const [instance, peers, account] = await Promise.all([
+ pfFetch("/api/v1/instance").catch(() => null),
+ pfFetch("/api/v1/instance/peers").catch(() => []),
+ PIXELFED_ACCESS_TOKEN ? pfFetch("/api/v1/accounts/verify_credentials").catch(() => null) : Promise.resolve(null),
+ ]);
+ return textResponse({
+ instance: instance ? {
+ uri: instance.uri, title: instance.title, version: instance.version,
+ registrations: instance.registrations, stats: instance.stats,
+ } : null,
+ hostname: PIXELFED_HOSTNAME || null,
+ authenticated_as: account ? { id: account.id, acct: account.acct, display_name: account.display_name } : null,
+ federated_peers: Array.isArray(peers) ? peers.length : null,
+ has_access_token: Boolean(PIXELFED_ACCESS_TOKEN),
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ },
+ );
+
+ // --- pf_post_photo ---
+ server.tool(
+ "pf_post_photo",
+ "Upload a photo and publish it as a status. Uploads via POST /api/v1/media then POST /api/v1/statuses. Pass file_path OR file_base64+filename. Rate-limited: 10/hour.",
+ {
+ file_path: z.string().max(4096).optional(),
+ file_base64: z.string().max(50_000_000).optional(),
+ filename: z.string().max(500).optional(),
+ caption: z.string().max(5000).optional().describe("Status body (shown below the image)."),
+ alt_text: z.string().max(1500).optional().describe("Media alt text for screen readers. Strongly recommended."),
+ visibility: z.enum(["public", "unlisted", "private", "direct"]).optional(),
+ spoiler_text: z.string().max(500).optional().describe("Content warning shown before the image."),
+ sensitive: z.boolean().optional().describe("Hide image behind a 'sensitive content' tap-to-reveal."),
+ },
+ limiter("pf_post_photo", async (args) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ let buf, name;
+ if (args.file_path) {
+ buf = await readFile(args.file_path);
+ name = args.filename || basename(args.file_path);
+ } else if (args.file_base64) {
+ buf = Buffer.from(args.file_base64, "base64");
+ name = args.filename || `upload-${Date.now()}.jpg`;
+ } else {
+ return { content: [{ type: "text", text: "Error: must pass file_path or file_base64+filename." }] };
+ }
+ const form = new FormData();
+ form.append("file", new Blob([buf]), name);
+ if (args.alt_text) form.append("description", args.alt_text);
+ const media = await pfFetch("/api/v1/media", { method: "POST", rawForm: form, timeoutMs: 120_000 });
+ const body = {
+ status: args.caption || "",
+ media_ids: [media.id],
+ visibility: args.visibility || "public",
+ ...(args.spoiler_text ? { spoiler_text: args.spoiler_text } : {}),
+ ...(args.sensitive != null ? { sensitive: args.sensitive } : {}),
+ };
+ const status = await pfFetch("/api/v1/statuses", { method: "POST", body });
+ return textResponse({
+ id: status.id, url: status.url, visibility: status.visibility,
+ media_id: media.id, media_url: media.url, created_at: status.created_at,
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- pf_feed ---
+ server.tool(
+ "pf_feed",
+ "Fetch a timeline. home = follows; public = local+federated; local = this instance; notifications = mentions/likes/follows. Rate-limited: 60/hour.",
+ {
+ source: z.enum(["home", "public", "local", "notifications"]),
+ limit: z.number().int().min(1).max(40).optional(),
+ since_id: z.string().max(50).optional(),
+ max_id: z.string().max(50).optional(),
+ },
+ limiter("pf_feed", async ({ source, limit, since_id, max_id }) => {
+ try {
+ if (source !== "public" && !PIXELFED_ACCESS_TOKEN) {
+ return { content: [{ type: "text", text: "Error: non-public timelines require PIXELFED_ACCESS_TOKEN." }] };
+ }
+ const path =
+ source === "home" ? "/api/v1/timelines/home"
+ : source === "public" ? "/api/v1/timelines/public"
+ : source === "local" ? "/api/v1/timelines/public"
+ : "/api/v1/notifications";
+ const query = { limit: limit ?? 20, since_id, max_id };
+ if (source === "local") query.local = "true";
+ const items = await pfFetch(path, { query, noAuth: source === "public" && !PIXELFED_ACCESS_TOKEN });
+ const summary = (Array.isArray(items) ? items : []).map((it) =>
+ source === "notifications"
+ ? { id: it.id, type: it.type, account: it.account?.acct, status_id: it.status?.id, created_at: it.created_at }
+ : {
+ id: it.id, acct: it.account?.acct, url: it.url,
+ media_count: (it.media_attachments || []).length,
+ media_urls: (it.media_attachments || []).map((m) => m.url).slice(0, 4),
+ content_excerpt: (it.content || "").replace(/<[^>]+>/g, "").slice(0, 240),
+ created_at: it.created_at, visibility: it.visibility,
+ favs: it.favourites_count, replies: it.replies_count, reblogs: it.reblogs_count,
+ },
+ );
+ return textResponse({ count: summary.length, items: summary });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- pf_search ---
+ server.tool(
+ "pf_search",
+ "Search accounts / hashtags / statuses. Remote queries resolve via WebFinger. Rate-limited: 60/hour.",
+ {
+ query: z.string().min(1).max(500),
+ type: z.enum(["accounts", "hashtags", "statuses"]).optional(),
+ limit: z.number().int().min(1).max(40).optional(),
+ resolve: z.boolean().optional(),
+ },
+ limiter("pf_search", async ({ query, type, limit, resolve }) => {
+ try {
+ const out = await pfFetch("/api/v2/search", {
+ query: { q: query, type, limit: limit ?? 10, resolve: resolve ? "true" : undefined },
+ });
+ return textResponse(out);
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- pf_follow / pf_unfollow ---
+ server.tool(
+ "pf_follow",
+ "Follow an account by handle (@user@domain) or local account ID. Rate-limited: 30/hour.",
+ { handle: z.string().min(1).max(320) },
+ limiter("pf_follow", async ({ handle }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const acct = await resolveAccount(handle);
+ if (!acct) return { content: [{ type: "text", text: `No account found for ${handle}` }] };
+ const rel = await pfFetch(`/api/v1/accounts/${encodeURIComponent(acct.id)}/follow`, { method: "POST" });
+ return textResponse({ following: rel.following, requested: rel.requested });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ server.tool(
+ "pf_unfollow",
+ "Unfollow an account.",
+ { handle: z.string().min(1).max(320) },
+ limiter("pf_unfollow", async ({ handle }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const acct = await resolveAccount(handle);
+ if (!acct) return { content: [{ type: "text", text: `No account found for ${handle}` }] };
+ const rel = await pfFetch(`/api/v1/accounts/${encodeURIComponent(acct.id)}/unfollow`, { method: "POST" });
+ return textResponse({ following: rel.following });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- User-level moderation (inline) ---
+ server.tool(
+ "pf_block_user",
+ "Block an account system-wide (the authenticated user no longer sees their posts). Rate-limited: 5/hour.",
+ { handle: z.string().min(1).max(320), confirm: z.literal("yes") },
+ limiter("pf_block_user", async ({ handle }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const acct = await resolveAccount(handle);
+ if (!acct) return { content: [{ type: "text", text: `No account found for ${handle}` }] };
+ const rel = await pfFetch(`/api/v1/accounts/${acct.id}/block`, { method: "POST" });
+ return textResponse({ blocking: rel.blocking });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ server.tool(
+ "pf_mute_user",
+ "Mute an account (hide posts but still federate). Rate-limited: 5/hour.",
+ { handle: z.string().min(1).max(320), confirm: z.literal("yes") },
+ limiter("pf_mute_user", async ({ handle }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const acct = await resolveAccount(handle);
+ if (!acct) return { content: [{ type: "text", text: `No account found for ${handle}` }] };
+ const rel = await pfFetch(`/api/v1/accounts/${acct.id}/mute`, { method: "POST" });
+ return textResponse({ muting: rel.muting });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ // --- Instance-level moderation (QUEUED) ---
+ server.tool(
+ "pf_block_domain",
+ "Block an entire remote domain (no federation, no media fetch). QUEUED — requires operator confirmation in the Nest panel.",
+ { domain: z.string().min(1).max(253), reason: z.string().max(500).optional(), confirm: z.literal("yes") },
+ async ({ domain, reason }) => {
+ const queued = await queueModerationAction("pixelfed", "block_domain", { domain, reason: reason || "" });
+ return textResponse(queued);
+ },
+ );
+
+ server.tool(
+ "pf_defederate",
+ "Defederate from a remote domain (block + purge cached content + sever follows). QUEUED — requires operator confirmation.",
+ { domain: z.string().min(1).max(253), reason: z.string().max(500).optional(), confirm: z.literal("yes") },
+ async ({ domain, reason }) => {
+ const queued = await queueModerationAction("pixelfed", "defederate", { domain, reason: reason || "" });
+ return textResponse(queued);
+ },
+ );
+
+ server.tool(
+ "pf_review_reports",
+ "List pending moderation reports (admin-only).",
+ { limit: z.number().int().min(1).max(100).optional() },
+ async ({ limit }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const reports = await pfFetch("/api/v1/admin/reports", { query: { limit: limit ?? 20, resolved: "false" } });
+ const summary = (Array.isArray(reports) ? reports : []).map((r) => ({
+ id: r.id, account: r.account?.acct, target_account: r.target_account?.acct,
+ reason: r.category || r.comment, created_at: r.created_at,
+ }));
+ return textResponse({ count: summary.length, reports: summary });
+ } catch (err) {
+ return errResponse(err);
+ }
+ },
+ );
+
+ server.tool(
+ "pf_report_remote",
+ "File a moderation report to a remote server about an account. Rate-limited: 5/hour.",
+ {
+ handle: z.string().min(1).max(320),
+ reason: z.string().min(1).max(1000),
+ forward: z.boolean().optional(),
+ },
+ limiter("pf_report_remote", async ({ handle, reason, forward }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const acct = await resolveAccount(handle);
+ if (!acct) return { content: [{ type: "text", text: `No account found for ${handle}` }] };
+ const out = await pfFetch("/api/v1/reports", { method: "POST", body: { account_id: acct.id, comment: reason, forward: forward !== false } });
+ return textResponse({ report_id: out.id, forwarded: forward !== false });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ server.tool(
+ "pf_import_blocklist",
+ "Import a domain blocklist (IFTAS / Bad Space / custom URL). QUEUED — requires operator confirmation. Rate-limited: 2/hour.",
+ { source: z.string().min(1).max(500), confirm: z.literal("yes") },
+ limiter("pf_import_blocklist", async ({ source }) => {
+ const canonical = {
+ iftas: "https://connect.iftas.org/library/iftas-documentation/iftas-do-not-interact-list/",
+ "bad-space": "https://badspace.org/domain-block.csv",
+ };
+ const url = canonical[source] || source;
+ const queued = await queueModerationAction("pixelfed", "import_blocklist", { source: url });
+ return textResponse(queued);
+ }),
+ );
+
+ // --- pf_media_prune ---
+ server.tool(
+ "pf_media_prune",
+ "Manually trigger a prune of remote media older than N days. The scheduled horizon job handles this on a recurring cadence; this lets operators force an aggressive pass. Rate-limited: 2/hour.",
+ {
+ older_than_days: z.number().int().min(1).max(365).optional(),
+ confirm: z.literal("yes"),
+ },
+ limiter("pf_media_prune", async ({ older_than_days }) => {
+ try {
+ const authErr = requireAuth(); if (authErr) return authErr;
+ const days = older_than_days ?? Number(process.env.PIXELFED_MEDIA_RETENTION_DAYS || 14);
+ const out = await pfFetch("/api/v1/admin/media/prune", { method: "POST", body: { older_than_days: days } }).catch(() => null);
+ return textResponse({
+ requested_days: days,
+ response: out,
+ note: out ? null : "Admin endpoint unavailable on this Pixelfed version — scheduled horizon job still handles pruning on the PIXELFED_MEDIA_RETENTION_DAYS cadence.",
+ });
+ } catch (err) {
+ return errResponse(err);
+ }
+ }),
+ );
+
+ return server;
+}
diff --git a/bundles/pixelfed/skills/pixelfed.md b/bundles/pixelfed/skills/pixelfed.md
new file mode 100644
index 0000000..e997eaa
--- /dev/null
+++ b/bundles/pixelfed/skills/pixelfed.md
@@ -0,0 +1,131 @@
+---
+name: pixelfed
+description: Pixelfed — federated photo-sharing on ActivityPub. Post photos, browse timelines, follow remote accounts, moderate.
+triggers:
+ - "pixelfed"
+ - "photo post"
+ - "fediverse photo"
+ - "share photo"
+ - "instagram alternative"
+ - "photo feed"
+ - "post picture"
+tools:
+ - pf_status
+ - pf_post_photo
+ - pf_feed
+ - pf_search
+ - pf_follow
+ - pf_unfollow
+ - pf_block_user
+ - pf_mute_user
+ - pf_block_domain
+ - pf_defederate
+ - pf_review_reports
+ - pf_report_remote
+ - pf_import_blocklist
+ - pf_media_prune
+---
+
+# Pixelfed — federated photo-sharing
+
+Pixelfed is the fediverse's Instagram-alternative: upload photos, browse a chronological feed, follow accounts on any ActivityPub-compatible server (Mastodon, GoToSocial, Funkwhale, other Pixelfed pods). Its REST API is Mastodon v1/v2 compatible, so tool patterns match the GoToSocial bundle closely.
+
+## Hardware
+
+Gated by F.0's hardware check. Refused below **1.5 GB effective RAM after committed bundles**, warned below 8 GB total. Disk grows with your library + remote-media cache: 10-50 GB within weeks of active federation is typical. The horizon queue worker is memory-hot when processing image transforms.
+
+## Storage: on-disk or S3
+
+Default: media lives in `~/.crow/pixelfed/storage/` + `~/.crow/pixelfed/uploads/`. To route to MinIO or external S3, set these in `.env` before install:
+
+```
+PIXELFED_S3_ENDPOINT=https://minio.example.com
+PIXELFED_S3_BUCKET=pixelfed-media
+PIXELFED_S3_ACCESS_KEY=...
+PIXELFED_S3_SECRET_KEY=...
+```
+
+`scripts/post-install.sh` detects these and runs `scripts/configure-storage.mjs`, which uses F.0's `storage-translators.pixelfed()` to write the `AWS_*` + `FILESYSTEM_CLOUD=s3` + `PF_ENABLE_CLOUD=true` envelope Pixelfed actually reads. Same pattern as F.4 Funkwhale.
+
+## First-run bootstrap
+
+1. Generate a Laravel APP_KEY (32-byte random) and paste into `.env` as `PIXELFED_APP_KEY`. One easy way: `openssl rand -base64 32 | head -c 32`.
+2. After install, expose via Caddy:
+ ```
+ caddy_add_federation_site {
+ domain: "photos.example.com",
+ upstream: "pixelfed:80",
+ profile: "activitypub"
+ }
+ ```
+3. Create the admin user:
+ ```bash
+ docker exec -it crow-pixelfed php artisan user:create
+ ```
+4. Log in at https://photos.example.com/, go to **Settings → Development → New Application** (grant `read write follow push`), then generate a Personal Access Token.
+5. Paste that token into `.env` as `PIXELFED_ACCESS_TOKEN` and restart:
+ ```
+ crow bundle restart pixelfed
+ ```
+
+## Common workflows
+
+### Post a photo
+
+```
+pf_post_photo {
+ "file_path": "/home/kev/photos/2026/sunset.jpg",
+ "caption": "Dusk over the ridge",
+ "alt_text": "Orange and purple sky over a forested ridge at sunset",
+ "visibility": "public"
+}
+```
+
+Pixelfed enforces EXIF stripping by default (privacy). Alt text is strongly encouraged — screen readers and search rely on it.
+
+### Browse + search
+
+```
+pf_feed { "source": "home", "limit": 20 }
+pf_search { "query": "landscape", "type": "hashtags" }
+pf_search { "query": "@alice@mastodon.social", "resolve": true }
+```
+
+### Follow remote accounts
+
+```
+pf_follow { "handle": "@bob@photog.example" }
+```
+
+First federated follow on a given remote server takes several seconds (WebFinger + actor fetch); subsequent follows to that server are fast.
+
+## Moderation
+
+**Moderation is not optional on a federated photo server.** Before opening registration or joining large hubs:
+
+1. Import a baseline blocklist:
+ ```
+ pf_import_blocklist { "source": "iftas", "confirm": "yes" }
+ ```
+ QUEUED — confirm in the Nest panel within 72h.
+
+2. Configure IFTAS / Bad Space feed refresh (operator task until F.11 exposes schedule hooks).
+
+- **Inline (rate-limited, fires immediately):** `pf_block_user`, `pf_mute_user`, `pf_report_remote`.
+- **Queued (operator confirms in Nest within 72h):** `pf_block_domain`, `pf_defederate`, `pf_import_blocklist`.
+- **Disk management:** `pf_media_prune { older_than_days: 7, confirm: "yes" }` forces an aggressive pass beyond the scheduled horizon job.
+
+**CSAM / illegal imagery**: zero tolerance. The instance admin has legal liability in most jurisdictions. If you receive a federated post containing such material, take the instance offline (`crow bundle stop pixelfed`), preserve logs, and contact a lawyer + the relevant national cybertip hotline before taking any other action.
+
+## Cross-app notes
+
+- **Blog cross-posting**: Pixelfed's API surfaces post URLs that WriteFreely and GoToSocial can embed (OEmbed preview works). For scheduled crosspost: wait for F.12's cross-app bridge work.
+- **Sharing integration**: remote Pixelfed accounts you follow will appear as `contacts` with `external_source = 'pixelfed'` once F.11 identity attestation lands.
+
+## Troubleshooting
+
+- **"Cannot reach Pixelfed"** — `docker ps | grep crow-pixelfed`. First boot runs Laravel migrations + key-generate; can take 2+ minutes.
+- **Horizon not processing uploads** — `docker logs crow-pixelfed-horizon`. Redis connectivity is the usual culprit.
+- **"413 Payload Too Large"** — bump `PIXELFED_MAX_PHOTO_SIZE` (KB, default 15000 = 15 MB) and restart. The internal nginx in the `zknt/pixelfed` image honors the env var.
+- **Disk filling** — federated cache. Lower `PIXELFED_MEDIA_RETENTION_DAYS` or run `pf_media_prune` manually.
+- **Federation posts take forever** — horizon queue backlog. Check queue depth in the web UI's **Admin → Horizon** dashboard.
diff --git a/registry/add-ons.json b/registry/add-ons.json
index 91a2b1a..087b36b 100644
--- a/registry/add-ons.json
+++ b/registry/add-ons.json
@@ -3193,6 +3193,51 @@
"webUI": null,
"notes": "Six containers (api + celeryworker + celerybeat + nginx + postgres + redis). Expose via caddy_add_federation_site { domain: FUNKWHALE_HOSTNAME, upstream: 'funkwhale-nginx:80', profile: 'activitypub' }. Audio storage on-disk by default; set FUNKWHALE_S3_* to route to MinIO/external S3 via storage-translators."
},
+ {
+ "id": "pixelfed",
+ "name": "Pixelfed",
+ "description": "Federated photo-sharing over ActivityPub — Instagram-alternative on the fediverse. Publish photos that remote Mastodon/GoToSocial/Funkwhale followers see in their timelines.",
+ "type": "bundle",
+ "version": "1.0.0",
+ "author": "Crow",
+ "category": "federated-media",
+ "tags": ["pixelfed", "activitypub", "fediverse", "photos", "federated", "instagram-alt"],
+ "icon": "image",
+ "docker": { "composefile": "docker-compose.yml" },
+ "server": {
+ "command": "node",
+ "args": ["server/index.js"],
+ "envKeys": ["PIXELFED_URL", "PIXELFED_ACCESS_TOKEN", "PIXELFED_HOSTNAME"]
+ },
+ "panel": "panel/pixelfed.js",
+ "panelRoutes": "panel/routes.js",
+ "skills": ["skills/pixelfed.md"],
+ "consent_required": true,
+ "requires": {
+ "env": ["PIXELFED_HOSTNAME", "PIXELFED_DB_PASSWORD", "PIXELFED_APP_KEY"],
+ "bundles": ["caddy"],
+ "min_ram_mb": 1500,
+ "recommended_ram_mb": 3000,
+ "min_disk_mb": 10000,
+ "recommended_disk_mb": 100000
+ },
+ "env_vars": [
+ { "name": "PIXELFED_HOSTNAME", "description": "Public domain for this Pixelfed pod (subdomain).", "required": true },
+ { "name": "PIXELFED_DB_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true },
+ { "name": "PIXELFED_APP_KEY", "description": "Laravel application key (32+ random bytes).", "required": true, "secret": true },
+ { "name": "PIXELFED_ACCESS_TOKEN", "description": "OAuth2 PAT (Settings → Development → New Application).", "required": false, "secret": true },
+ { "name": "PIXELFED_OPEN_REGISTRATION", "description": "Allow new signups (true/false).", "default": "false", "required": false },
+ { "name": "PIXELFED_MEDIA_RETENTION_DAYS", "description": "Remote media cache retention.", "default": "14", "required": false },
+ { "name": "PIXELFED_S3_ENDPOINT", "description": "Optional S3-compatible endpoint. When set, configure-storage.mjs routes media via storage-translators.pixelfed().", "required": false },
+ { "name": "PIXELFED_S3_BUCKET", "description": "S3 bucket.", "required": false },
+ { "name": "PIXELFED_S3_ACCESS_KEY", "description": "S3 access key.", "required": false, "secret": true },
+ { "name": "PIXELFED_S3_SECRET_KEY", "description": "S3 secret key.", "required": false, "secret": true },
+ { "name": "PIXELFED_S3_REGION", "description": "S3 region.", "default": "us-east-1", "required": false }
+ ],
+ "ports": [],
+ "webUI": null,
+ "notes": "Four containers (app + horizon + postgres + redis). zknt/pixelfed image runs nginx+PHP-FPM under supervisord. Expose via caddy_add_federation_site { domain: PIXELFED_HOSTNAME, upstream: 'pixelfed:80', profile: 'activitypub' }. Admin user via `docker exec -it crow-pixelfed php artisan user:create`."
+ },
{
"id": "developer-kit",
"name": "Developer Kit",
diff --git a/skills/superpowers.md b/skills/superpowers.md
index b37edb5..00972f1 100644
--- a/skills/superpowers.md
+++ b/skills/superpowers.md
@@ -83,6 +83,7 @@ This is the master routing skill. Consult this **before every task** to determin
| "writefreely", "federated blog", "long-form post", "publish article", "blog to fediverse" | "writefreely", "blog federado", "artículo largo", "publicar al fediverso" | writefreely | crow-writefreely |
| "matrix", "dendrite", "join #room:server", "send @user:server", "e2ee chat", "matrix room" | "matrix", "dendrite", "unirse a #sala:servidor", "mensaje a @usuario:servidor", "chat e2ee" | matrix-dendrite | crow-matrix-dendrite |
| "funkwhale", "federated music", "upload track", "follow channel", "music library", "fediverse audio", "podcast", "playlist" | "funkwhale", "música federada", "subir pista", "seguir canal", "biblioteca musical", "audio fediverso", "podcast", "lista de reproducción" | funkwhale | crow-funkwhale |
+| "pixelfed", "post photo", "share picture", "photo feed", "fediverse photo", "instagram alternative" | "pixelfed", "publicar foto", "compartir imagen", "feed de fotos", "foto fediverso", "alternativa instagram" | pixelfed | crow-pixelfed |
| "tutor me", "teach me", "quiz me", "help me understand" | "enséñame", "explícame", "evalúame" | tutoring | crow-memory |
| "wrap up", "summarize session", "what did we do" | "resumir sesión", "qué hicimos" | session-summary | crow-memory |
| "change language", "speak in..." | "cambiar idioma", "háblame en..." | i18n | crow-memory |