From 30c3f135ae3df262ae8865e4dc2ab761d01a96e3 Mon Sep 17 00:00:00 2001 From: Kevin Hopper Date: Sun, 12 Apr 2026 15:04:09 -0500 Subject: [PATCH] =?UTF-8?q?F.5:=20Pixelfed=20bundle=20=E2=80=94=20federate?= =?UTF-8?q?d=20photo-sharing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second federated-media bundle. Stacked on F.4 (Funkwhale). Reuses F.4's storage-translator wiring pattern — Pixelfed uses AWS_* + FILESYSTEM_CLOUD=s3 + PF_ENABLE_CLOUD=true per F.0's translator map. Pixelfed is the fediverse's Instagram-alternative: upload photos, browse a chronological feed, follow accounts on any ActivityPub server (Mastodon, GoToSocial, Funkwhale, other Pixelfed pods). Its REST API is Mastodon v1/v2 compatible — most verbs cross over cleanly from the GoToSocial bundle (F.1), which is why this one lands cleanly in a single bundle rather than forcing a new API abstraction. Bundle (bundles/pixelfed/): - manifest.json consent_required with EN/ES covering federation reach, remote-media cache growth (10-50 GB within weeks under active federation), CSAM/moderation responsibility (non-optional on a federated photo server), and hardware gate (min 1.5 GB effective RAM, warn <8 GB). Declares PIXELFED_S3_* optionals that activate the storage-translator wiring. - docker-compose.yml zknt/pixelfed:0.12 (nginx+PHP-FPM+supervisord all-in-one) + horizon queue worker + postgres:15 + redis:7. app + horizon both on crow-federation so they can reach DB/redis peers AND be reached by Caddy. Blank-default AWS_* passthrough so configure-storage.mjs can populate them at install. mem_limits: app=1500m, horizon=768m, postgres=512m, redis=256m. Media retention wired to PIXELFED_MEDIA_RETENTION_DAYS env var (default 14). - server/server.js 14 MCP tools per the federated-media verb taxonomy: pf_status, pf_post_photo (upload + status in one call), pf_feed (home/public/local/notifications), pf_search, pf_follow/pf_unfollow, pf_block_user/pf_mute_user (inline, rate-limited), pf_block_domain/pf_defederate/ pf_import_blocklist (QUEUED via moderation_actions + notification), pf_review_reports, pf_report_remote, pf_media_prune. resolveAccount() helper does the WebFinger-via-search dance shared with F.1. All z.string() fields bounded with .max(). loadSharedDeps() pattern from GoToSocial so monorepo + installed-mode both work. - server/index.js stdio transport. - panel/pixelfed.js + panel/routes.js Nest panel — instance status + home-timeline recent posts. XSS-safe (textContent/createElement). API routes serve /api/pixelfed/{status,feed}. - skills/pixelfed.md first-run workflow (APP_KEY gen, Caddy, admin user, PAT), photo-post/feed/search/follow examples, moderation + CSAM warning, troubleshooting (horizon, 413 payload, disk pressure). - scripts/post-install.sh 180s health wait, invokes configure-storage.mjs if PIXELFED_S3_ENDPOINT is set, prints Caddy + admin + PAT + baseline-blocklist-import steps. - scripts/configure-storage.mjs imports storage-translators.pixelfed() and writes a managed block to .env. Same shape as F.4's helper — falls back to inline map in installed-mode. - scripts/backup.sh pg_dump + storage/uploads tar (excludes framework cache/logs/sessions — regenerable). S3-backed media NOT in scope. - package.json MCP + zod deps only. Integrations with shipped F-series: - F.0 storage-translators.pixelfed() activated at install time when PIXELFED_S3_* is set. Same pattern as F.4 — second consumer validates the translator surface. - F.0 rate limiter post_photo, feed, search, follow, unfollow, blocks, mutes, report_remote, import_blocklist, media_prune. - F.0 hardware gate min/recommended RAM + disk per manifest schema. - F.1 GoToSocial Mastodon-compatible API overlap confirmed the verb taxonomy generalizes. pf_search / pf_feed / pf_follow are near-identical to gts_* equivalents; resolveAccount() is deduplicated here but the same WebFinger-via-search pattern. - F.1 federated-media category wiring (extensions.js, nav-registry.js, i18n) already present — no new wiring needed. Human-in-the-loop moderation: - 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. Writes moderation_actions row + surfaces a Crow notification; returns {status: "queued", action_id, expires_at} to the AI. Confirmation UI lands with F.11. Image tag policy: - zknt/pixelfed:0.12 floats within 0.12.x; the CVE feed + release notes were checked at implementation time. Pin will be re-verified before merge and bumped if a more current minor is out. Registry / discovery surface: - registry/add-ons.json entry inserted before developer-kit. - skills/superpowers.md trigger row added between funkwhale and tutoring (EN+ES: pixelfed, post photo, share picture, photo feed, fediverse photo, instagram alternative). - CLAUDE.md Skills Reference entry added after funkwhale.md. Verified: - node --check on all JS files (server, panel, configure-storage) - bash -n on both shell scripts - MCP server boots via createPixelfedServer() with no env set - docker compose config parses with required env set - JSON parse on manifest, package, registry - npm run check passes Next in the roll-out: - F.6 Lemmy (link aggregator, 1 GB min) - F.7 Mastodon (flagship AP, 3 GB min — heaviest small-AP) - F.8 PeerTube (video, needs S3 + aggressive transcoding policy) - F.11 identity attestation, F.12 cross-app bridging --- CLAUDE.md | 1 + bundles/pixelfed/docker-compose.yml | 186 +++++++ bundles/pixelfed/manifest.json | 51 ++ bundles/pixelfed/package.json | 11 + bundles/pixelfed/panel/pixelfed.js | 140 +++++ bundles/pixelfed/panel/routes.js | 76 +++ bundles/pixelfed/scripts/backup.sh | 39 ++ .../pixelfed/scripts/configure-storage.mjs | 93 ++++ bundles/pixelfed/scripts/post-install.sh | 71 +++ bundles/pixelfed/server/index.js | 8 + bundles/pixelfed/server/server.js | 509 ++++++++++++++++++ bundles/pixelfed/skills/pixelfed.md | 131 +++++ registry/add-ons.json | 45 ++ skills/superpowers.md | 1 + 14 files changed, 1362 insertions(+) create mode 100644 bundles/pixelfed/docker-compose.yml create mode 100644 bundles/pixelfed/manifest.json create mode 100644 bundles/pixelfed/package.json create mode 100644 bundles/pixelfed/panel/pixelfed.js create mode 100644 bundles/pixelfed/panel/routes.js create mode 100755 bundles/pixelfed/scripts/backup.sh create mode 100755 bundles/pixelfed/scripts/configure-storage.mjs create mode 100755 bundles/pixelfed/scripts/post-install.sh create mode 100755 bundles/pixelfed/server/index.js create mode 100644 bundles/pixelfed/server/server.js create mode 100644 bundles/pixelfed/skills/pixelfed.md 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

+ +
+

Status

+
Loading…
+
+ +
+

Recent Posts (home timeline)

+
Loading…
+
+ +
+

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 |