diff --git a/CLAUDE.md b/CLAUDE.md index f8103b5..86c9761 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -468,6 +468,7 @@ Add-on skills (activated when corresponding add-on is installed): - `funkwhale.md` — Funkwhale federated music pod: library listing, search, upload, follow remote channels/libraries, playlists, listening history, moderation (block_user/mute inline; block_domain/defederate queued), media prune; on-disk or S3 audio storage via storage-translators.funkwhale() - `pixelfed.md` — Pixelfed federated photo-sharing: post photos (upload+status), feed, search, follow, moderation (block_user/mute inline; block_domain/defederate/import_blocklist queued), admin reports, remote reporting, media prune; Mastodon-compatible REST API; on-disk or S3 media via storage-translators.pixelfed() - `lemmy.md` — Lemmy federated link aggregator: status, list/follow/unfollow communities, post (link + body), comment, feed (Subscribed/Local/All), search, moderation (block_user/block_community inline; block_instance/defederate queued), admin reports, pict-rs media prune; Lemmy v3 REST API; community-scoped federation +- `mastodon.md` — Mastodon federated microblog (flagship ActivityPub): status, post, post_with_media (async media upload), feed (home/public/local/notifications), search, follow/unfollow, moderation (block_user/mute inline; defederate/import_blocklist queued admin), admin reports, remote reporting, media prune (tootctl); Mastodon v1/v2 reference API; on-disk or S3 media via storage-translators.mastodon() - `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/mastodon/docker-compose.yml b/bundles/mastodon/docker-compose.yml new file mode 100644 index 0000000..6fa7e18 --- /dev/null +++ b/bundles/mastodon/docker-compose.yml @@ -0,0 +1,207 @@ +# Mastodon — flagship ActivityPub microblogging server. +# +# Five-container bundle: web (Rails/Puma) + streaming (Node) + sidekiq +# (background jobs) + postgres + redis. web, streaming, and sidekiq on +# crow-federation (Caddy reverse-proxies to web + streaming); DB/redis +# on default. +# +# Data: +# ~/.crow/mastodon/postgres/ Postgres data dir +# ~/.crow/mastodon/redis/ Redis persistence +# ~/.crow/mastodon/system/ Active-storage media uploads (if on-disk) +# +# Media: on-disk by default. Set MASTODON_S3_* to route to MinIO / +# external S3 — storage-translators.mastodon() maps to S3_* envelope +# (documented at https://docs.joinmastodon.org/admin/optional/object-storage/). +# configure-storage.mjs in scripts/ handles this at install time. +# +# Image: ghcr.io/mastodon/mastodon:v4.3.0 (pin semver at impl time; +# check release feed + CVE advisories before merge). Mastodon 4.3+ +# splits web and streaming into separate images +# (ghcr.io/mastodon/mastodon-streaming). + +networks: + crow-federation: + external: true + default: + +services: + postgres: + image: postgres:15-alpine + container_name: crow-mastodon-postgres + networks: + - default + environment: + POSTGRES_USER: mastodon + POSTGRES_PASSWORD: ${MASTODON_DB_PASSWORD} + POSTGRES_DB: mastodon_production + volumes: + - ${MASTODON_DATA_DIR:-~/.crow/mastodon}/postgres:/var/lib/postgresql/data + init: true + mem_limit: 512m + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mastodon -d mastodon_production"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + redis: + image: redis:7-alpine + container_name: crow-mastodon-redis + networks: + - default + volumes: + - ${MASTODON_DATA_DIR:-~/.crow/mastodon}/redis:/data + init: true + mem_limit: 256m + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + + web: + image: ghcr.io/mastodon/mastodon:v4.3.0 + container_name: crow-mastodon-web + networks: + - default + - crow-federation + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + RAILS_ENV: production + NODE_ENV: production + LOCAL_DOMAIN: ${MASTODON_LOCAL_DOMAIN} + WEB_DOMAIN: ${MASTODON_WEB_DOMAIN:-} + ALTERNATE_DOMAINS: "" + SINGLE_USER_MODE: ${MASTODON_SINGLE_USER_MODE:-false} + SECRET_KEY_BASE: ${MASTODON_SECRET_KEY_BASE} + OTP_SECRET: ${MASTODON_OTP_SECRET} + VAPID_PRIVATE_KEY: ${MASTODON_VAPID_PRIVATE_KEY} + VAPID_PUBLIC_KEY: ${MASTODON_VAPID_PUBLIC_KEY} + DB_HOST: postgres + DB_USER: mastodon + DB_NAME: mastodon_production + DB_PASS: ${MASTODON_DB_PASSWORD} + DB_PORT: "5432" + REDIS_HOST: redis + REDIS_PORT: "6379" + BIND: 0.0.0.0 + PORT: "3000" + TRUSTED_PROXY_IP: "0.0.0.0/0" + # Media retention (sidekiq scheduled job reads this) + MEDIA_CACHE_RETENTION_PERIOD: ${MASTODON_MEDIA_RETENTION_DAYS:-14} + # S3 (empty unless configure-storage.mjs populated them) + S3_ENABLED: ${S3_ENABLED:-false} + S3_BUCKET: ${S3_BUCKET:-} + S3_REGION: ${S3_REGION:-us-east-1} + S3_PROTOCOL: ${S3_PROTOCOL:-} + S3_HOSTNAME: ${S3_HOSTNAME:-} + S3_ENDPOINT: ${S3_ENDPOINT:-} + S3_FORCE_SINGLE_REQUEST: ${S3_FORCE_SINGLE_REQUEST:-true} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + # SMTP (optional) + SMTP_SERVER: ${MASTODON_SMTP_SERVER:-} + SMTP_PORT: ${MASTODON_SMTP_PORT:-587} + SMTP_LOGIN: ${MASTODON_SMTP_LOGIN:-} + SMTP_PASSWORD: ${MASTODON_SMTP_PASSWORD:-} + SMTP_FROM_ADDRESS: ${MASTODON_SMTP_FROM_ADDRESS:-} + volumes: + - ${MASTODON_DATA_DIR:-~/.crow/mastodon}/system:/mastodon/public/system + command: > + bash -c "bin/rails db:migrate && bin/rails assets:precompile && + bundle exec puma -C config/puma.rb" + init: true + mem_limit: 2g + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 180s + + streaming: + image: ghcr.io/mastodon/mastodon-streaming:v4.3.0 + container_name: crow-mastodon-streaming + networks: + - default + - crow-federation + depends_on: + web: + condition: service_healthy + environment: + NODE_ENV: production + LOCAL_DOMAIN: ${MASTODON_LOCAL_DOMAIN} + WEB_DOMAIN: ${MASTODON_WEB_DOMAIN:-} + DB_HOST: postgres + DB_USER: mastodon + DB_NAME: mastodon_production + DB_PASS: ${MASTODON_DB_PASSWORD} + DB_PORT: "5432" + REDIS_HOST: redis + REDIS_PORT: "6379" + BIND: 0.0.0.0 + PORT: "4000" + STREAMING_CLUSTER_NUM: "1" + init: true + mem_limit: 512m + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:4000/api/v1/streaming/health >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s + + sidekiq: + image: ghcr.io/mastodon/mastodon:v4.3.0 + container_name: crow-mastodon-sidekiq + networks: + - default + - crow-federation + depends_on: + web: + condition: service_healthy + environment: + RAILS_ENV: production + LOCAL_DOMAIN: ${MASTODON_LOCAL_DOMAIN} + WEB_DOMAIN: ${MASTODON_WEB_DOMAIN:-} + SECRET_KEY_BASE: ${MASTODON_SECRET_KEY_BASE} + OTP_SECRET: ${MASTODON_OTP_SECRET} + VAPID_PRIVATE_KEY: ${MASTODON_VAPID_PRIVATE_KEY} + VAPID_PUBLIC_KEY: ${MASTODON_VAPID_PUBLIC_KEY} + DB_HOST: postgres + DB_USER: mastodon + DB_NAME: mastodon_production + DB_PASS: ${MASTODON_DB_PASSWORD} + DB_PORT: "5432" + REDIS_HOST: redis + REDIS_PORT: "6379" + MEDIA_CACHE_RETENTION_PERIOD: ${MASTODON_MEDIA_RETENTION_DAYS:-14} + S3_ENABLED: ${S3_ENABLED:-false} + S3_BUCKET: ${S3_BUCKET:-} + S3_REGION: ${S3_REGION:-us-east-1} + S3_PROTOCOL: ${S3_PROTOCOL:-} + S3_HOSTNAME: ${S3_HOSTNAME:-} + S3_ENDPOINT: ${S3_ENDPOINT:-} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + SMTP_SERVER: ${MASTODON_SMTP_SERVER:-} + SMTP_PORT: ${MASTODON_SMTP_PORT:-587} + SMTP_LOGIN: ${MASTODON_SMTP_LOGIN:-} + SMTP_PASSWORD: ${MASTODON_SMTP_PASSWORD:-} + SMTP_FROM_ADDRESS: ${MASTODON_SMTP_FROM_ADDRESS:-} + volumes: + - ${MASTODON_DATA_DIR:-~/.crow/mastodon}/system:/mastodon/public/system + command: ["bundle", "exec", "sidekiq"] + init: true + mem_limit: 1500m + restart: unless-stopped diff --git a/bundles/mastodon/manifest.json b/bundles/mastodon/manifest.json new file mode 100644 index 0000000..944311b --- /dev/null +++ b/bundles/mastodon/manifest.json @@ -0,0 +1,59 @@ +{ + "id": "mastodon", + "name": "Mastodon", + "version": "1.0.0", + "description": "The flagship ActivityPub server — federated microblogging at scale. Heaviest of the small-AP bundles. Hosts a public (or invite-only) Mastodon instance with the full v1/v2 API surface, web UI, and Sidekiq background job processing.", + "type": "bundle", + "author": "Crow", + "category": "federated-social", + "tags": ["mastodon", "activitypub", "fediverse", "microblog", "federated", "flagship"], + "icon": "globe", + "docker": { "composefile": "docker-compose.yml" }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["MASTODON_URL", "MASTODON_ACCESS_TOKEN", "MASTODON_LOCAL_DOMAIN"] + }, + "panel": "panel/mastodon.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/mastodon.md"], + "consent_required": true, + "install_consent_messages": { + "en": "Mastodon joins the public fediverse over ActivityPub — your instance becomes publicly addressable at the domain you configure (LOCAL_DOMAIN appears in user handles @user@example.com and is IMMUTABLE after first boot — changing it effectively abandons all existing federation identities). Any content you publish can be replicated to federated servers and cannot be fully recalled. Mastodon's remote-media cache can reach 10-100 GB within weeks under an active follow graph — automatic pruning (14-day default) is enabled but on busy instances you may need S3 storage to stay ahead of local disk. Mastodon is hardware-gated: refused on hosts with <8 GB total RAM — the 3 GB container-minimum is only realistic when the instance isn't federated with many busy actors. Runs 4 containers (web, streaming, sidekiq, plus postgres/redis); typical idle ~3 GB, under load 6 GB+. Hosting illegal content or enabling harassment is your legal responsibility — major hubs may defederate your instance quickly.", + "es": "Mastodon se une al fediverso público vía ActivityPub — tu instancia será direccionable en el dominio que configures (LOCAL_DOMAIN aparece en handles como @usuario@ejemplo.com y es INMUTABLE tras el primer arranque — cambiarlo abandona efectivamente todas las identidades de federación existentes). Cualquier contenido publicado puede replicarse a servidores federados y no puede recuperarse completamente. El caché de medios remotos de Mastodon puede alcanzar 10-100 GB en semanas con un grafo de seguimiento activo — el recorte automático (14 días por defecto) está habilitado pero en instancias activas puede ser necesario almacenamiento S3 para mantener el disco local. Mastodon está limitado por hardware: rechazado en hosts con <8 GB de RAM total — el mínimo de 3 GB por contenedor solo es realista cuando la instancia no está federada con muchos actores activos. Ejecuta 4 contenedores (web, streaming, sidekiq, más postgres/redis); típico ~3 GB en reposo, 6 GB+ bajo carga. Hospedar contenido ilegal o permitir acoso es tu responsabilidad legal — los hubs principales pueden dejar de federarse rápidamente." + }, + "requires": { + "env": ["MASTODON_LOCAL_DOMAIN", "MASTODON_DB_PASSWORD", "MASTODON_SECRET_KEY_BASE", "MASTODON_OTP_SECRET", "MASTODON_VAPID_PRIVATE_KEY", "MASTODON_VAPID_PUBLIC_KEY"], + "bundles": ["caddy"], + "min_ram_mb": 3000, + "recommended_ram_mb": 6000, + "min_disk_mb": 20000, + "recommended_disk_mb": 500000 + }, + "env_vars": [ + { "name": "MASTODON_LOCAL_DOMAIN", "description": "Public domain that appears in handles (@user@). IMMUTABLE after first boot — changing breaks all federation identities.", "required": true }, + { "name": "MASTODON_WEB_DOMAIN", "description": "Domain where the web UI lives (if using apex-delegation this differs from LOCAL_DOMAIN; otherwise same).", "required": false }, + { "name": "MASTODON_DB_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true }, + { "name": "MASTODON_SECRET_KEY_BASE", "description": "Rails secret_key_base (128 hex chars). Generate: `docker run --rm ghcr.io/mastodon/mastodon:v4.3 bundle exec rake secret`.", "required": true, "secret": true }, + { "name": "MASTODON_OTP_SECRET", "description": "2FA OTP secret (128 hex chars). Generate same way as SECRET_KEY_BASE.", "required": true, "secret": true }, + { "name": "MASTODON_VAPID_PRIVATE_KEY", "description": "Web Push VAPID private key. Generate: `docker run --rm ghcr.io/mastodon/mastodon:v4.3 bundle exec rake mastodon:webpush:generate_vapid_key` → gives both keys.", "required": true, "secret": true }, + { "name": "MASTODON_VAPID_PUBLIC_KEY", "description": "Web Push VAPID public key (pair of above).", "required": true }, + { "name": "MASTODON_ACCESS_TOKEN", "description": "Admin access token (Settings → Development → New Application, grant read/write/admin:read/admin:write).", "required": false, "secret": true }, + { "name": "MASTODON_URL", "description": "Internal URL the MCP server uses to reach Mastodon's web container (default http://mastodon-web:3000 over crow-federation).", "default": "http://mastodon-web:3000", "required": false }, + { "name": "MASTODON_MEDIA_RETENTION_DAYS", "description": "Remote media cache retention in days (default 14; 7 on Pi-class hosts).", "default": "14", "required": false }, + { "name": "MASTODON_SINGLE_USER_MODE", "description": "Skip the landing page and redirect to the admin profile.", "default": "false", "required": false }, + { "name": "MASTODON_S3_ENDPOINT", "description": "Optional S3-compatible endpoint for media storage. If set with bucket/access/secret, media goes to S3 via storage-translators.mastodon().", "required": false }, + { "name": "MASTODON_S3_BUCKET", "description": "S3 bucket for media.", "required": false }, + { "name": "MASTODON_S3_ACCESS_KEY", "description": "S3 access key.", "required": false, "secret": true }, + { "name": "MASTODON_S3_SECRET_KEY", "description": "S3 secret key.", "required": false, "secret": true }, + { "name": "MASTODON_S3_REGION", "description": "S3 region.", "default": "us-east-1", "required": false }, + { "name": "MASTODON_SMTP_SERVER", "description": "Outbound SMTP server (required for registration confirmations + password reset).", "required": false }, + { "name": "MASTODON_SMTP_PORT", "description": "SMTP port (default 587).", "default": "587", "required": false }, + { "name": "MASTODON_SMTP_LOGIN", "description": "SMTP username.", "required": false }, + { "name": "MASTODON_SMTP_PASSWORD", "description": "SMTP password.", "required": false, "secret": true }, + { "name": "MASTODON_SMTP_FROM_ADDRESS", "description": "From address for outbound mail (e.g. Mastodon ).", "required": false } + ], + "ports": [], + "webUI": null, + "notes": "Five containers (web + streaming + sidekiq + postgres + redis). Expose via caddy_add_federation_site { domain: MASTODON_LOCAL_DOMAIN, upstream: 'mastodon-web:3000', profile: 'activitypub-mastodon' } — the mastodon profile also wires the streaming path (/api/v1/streaming → mastodon-streaming:4000) and static asset caching. Admin via `docker exec -it crow-mastodon-web bin/tootctl accounts create admin --email --confirmed --role Admin`." +} diff --git a/bundles/mastodon/package.json b/bundles/mastodon/package.json new file mode 100644 index 0000000..d6bb07a --- /dev/null +++ b/bundles/mastodon/package.json @@ -0,0 +1,11 @@ +{ + "name": "crow-mastodon", + "version": "1.0.0", + "description": "Mastodon MCP server — statuses, timelines, search, follows, admin moderation", + "type": "module", + "main": "server/index.js", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + } +} diff --git a/bundles/mastodon/panel/mastodon.js b/bundles/mastodon/panel/mastodon.js new file mode 100644 index 0000000..80255cf --- /dev/null +++ b/bundles/mastodon/panel/mastodon.js @@ -0,0 +1,141 @@ +/** + * Crow's Nest Panel — Mastodon: instance status + home timeline + peer count. + * XSS-safe (textContent / createElement only). + */ + +export default { + id: "mastodon", + name: "Mastodon", + icon: "globe", + route: "/dashboard/mastodon", + navOrder: 77, + category: "federated-social", + + async handler(req, res, { layout }) { + const content = ` + +
+

Mastodon flagship federated microblog

+ +
+

Status

+
Loading…
+
+ +
+

Recent (home timeline)

+
Loading…
+
+ +
+

Notes

+
    +
  • LOCAL_DOMAIN is immutable — changing it after first boot abandons federation identity.
  • +
  • Remote media cache can reach 10-100 GB within weeks. Sidekiq runs a scheduled prune at MEDIA_CACHE_RETENTION_PERIOD.
  • +
  • Admin tools need the OAuth token to carry admin:read + admin:write scopes.
  • +
+
+
+ + `; + res.send(layout({ title: "Mastodon", 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 = 'md-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('md-status'); clear(el); + try { + const res = await fetch('/api/mastodon/status'); const d = await res.json(); + if (d.error) { el.appendChild(err(d.error)); return; } + const card = document.createElement('div'); card.className = 'md-card'; + card.appendChild(row('Domain', d.local_domain || d.title || '(unset)')); + card.appendChild(row('Version', d.version || '—')); + card.appendChild(row('Users', d.users ?? '—')); + card.appendChild(row('Statuses', d.statuses ?? '—')); + card.appendChild(row('Known domains', d.domains ?? '—')); + card.appendChild(row('Federated peers', d.federated_peers ?? '—')); + card.appendChild(row('Registrations', d.registrations_open ? 'open' : 'closed')); + card.appendChild(row('Authenticated', d.authenticated_as ? d.authenticated_as.acct : '(no token)')); + el.appendChild(card); + } catch (e) { el.appendChild(err('Cannot reach Mastodon.')); } + } + + async function loadFeed() { + const el = document.getElementById('md-feed'); clear(el); + try { + const res = await fetch('/api/mastodon/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 = 'md-post'; + const h = document.createElement('div'); h.className = 'md-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 = 'md-badge'; + m.textContent = p.media_count + ' media'; + h.appendChild(m); + } + c.appendChild(h); + if (p.content_excerpt) { + const body = document.createElement('div'); body.className = 'md-post-body'; + body.textContent = p.content_excerpt; + c.appendChild(body); + } + const meta = document.createElement('div'); meta.className = 'md-post-meta'; + meta.textContent = (p.favs || 0) + ' favs · ' + (p.replies || 0) + ' replies · ' + (p.reblogs || 0) + ' boosts · ' + (p.visibility || 'public'); + c.appendChild(meta); + el.appendChild(c); + } + } catch (e) { el.appendChild(err('Cannot load feed: ' + e.message)); } + } + + loadStatus(); + loadFeed(); + `; +} + +function styles() { + return ` + .md-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; } + .md-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; } + .md-section { margin-bottom: 1.8rem; } + .md-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase; + letter-spacing: 0.05em; margin: 0 0 0.7rem; } + .md-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 10px; padding: 1rem; } + .md-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); } + .md-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 160px; } + .md-post { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 8px; padding: .6rem .9rem; margin-bottom: .4rem; } + .md-post-head { display: flex; gap: .5rem; align-items: baseline; } + .md-post-head b { color: var(--crow-text-primary); font-size: .9rem; } + .md-badge { font-size: .7rem; color: var(--crow-accent); + background: var(--crow-bg); padding: 1px 6px; border-radius: 10px; } + .md-post-body { font-size: .85rem; color: var(--crow-text-secondary); margin-top: .2rem; } + .md-post-meta { font-size: .75rem; color: var(--crow-text-muted); margin-top: .3rem; } + .md-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; } + .md-notes li { margin-bottom: .3rem; } + .md-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/mastodon/panel/routes.js b/bundles/mastodon/panel/routes.js new file mode 100644 index 0000000..17d1b93 --- /dev/null +++ b/bundles/mastodon/panel/routes.js @@ -0,0 +1,80 @@ +/** + * Mastodon panel API routes — status + home timeline. + */ + +import { Router } from "express"; + +const URL_BASE = () => (process.env.MASTODON_URL || "http://mastodon-web:3000").replace(/\/+$/, ""); +const TOKEN = () => process.env.MASTODON_ACCESS_TOKEN || ""; +const LOCAL_DOMAIN = () => process.env.MASTODON_LOCAL_DOMAIN || ""; +const TIMEOUT = 15_000; + +async function md(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 mastodonRouter(authMiddleware) { + const router = Router(); + + router.get("/api/mastodon/status", authMiddleware, async (_req, res) => { + try { + const instance = await md("/api/v2/instance", { noAuth: true }).catch(() => md("/api/v1/instance", { noAuth: true }).catch(() => null)); + const peers = await md("/api/v1/instance/peers").catch(() => []); + const account = TOKEN() ? await md("/api/v1/accounts/verify_credentials").catch(() => null) : null; + res.json({ + local_domain: LOCAL_DOMAIN(), + title: instance?.title, + version: instance?.version, + users: instance?.usage?.users?.active_month ?? instance?.stats?.user_count, + statuses: instance?.stats?.status_count, + domains: instance?.stats?.domain_count, + registrations_open: instance?.registrations?.enabled ?? instance?.registrations, + 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 Mastodon: ${err.message}` }); + } + }); + + router.get("/api/mastodon/feed", authMiddleware, async (_req, res) => { + try { + if (!TOKEN()) return res.json({ error: "MASTODON_ACCESS_TOKEN not set" }); + const items = await md("/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, + reblogs: p.reblogs_count, + })), + }); + } catch (err) { + res.json({ error: err.message }); + } + }); + + return router; +} diff --git a/bundles/mastodon/scripts/backup.sh b/bundles/mastodon/scripts/backup.sh new file mode 100755 index 0000000..aac0374 --- /dev/null +++ b/bundles/mastodon/scripts/backup.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Mastodon backup: pg_dump + system/ (on-disk media only) + .env (secrets!). +# S3-backed media NOT captured here — operator's S3 provider owns durability. +set -euo pipefail + +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/mastodon" +DATA_DIR="${MASTODON_DATA_DIR:-$HOME/.crow/mastodon}" + +mkdir -p "$BACKUP_ROOT" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Postgres dump +if docker ps --format '{{.Names}}' | grep -qw crow-mastodon-postgres; then + docker exec -e PGPASSWORD="${MASTODON_DB_PASSWORD:-}" crow-mastodon-postgres \ + pg_dump -U mastodon -Fc -f /tmp/mastodon-${STAMP}.pgcustom mastodon_production + docker cp "crow-mastodon-postgres:/tmp/mastodon-${STAMP}.pgcustom" "$WORK/mastodon.pgcustom" + docker exec crow-mastodon-postgres rm "/tmp/mastodon-${STAMP}.pgcustom" +fi + +# On-disk media (exclude cache subdir — regenerable from remote) +if [ -d "$DATA_DIR/system" ]; then + tar -C "$DATA_DIR" --exclude='./system/cache' -cf "$WORK/mastodon-system.tar" system 2>/dev/null || true +fi + +OUT="${BACKUP_ROOT}/mastodon-${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}/mastodon-${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 " SECRET_KEY_BASE / OTP_SECRET / VAPID keys live in .env — back up .env SEPARATELY" +echo " and keep it encrypted. LOSS of SECRET_KEY_BASE invalidates all 2FA tokens + sessions." diff --git a/bundles/mastodon/scripts/configure-storage.mjs b/bundles/mastodon/scripts/configure-storage.mjs new file mode 100755 index 0000000..919dd3e --- /dev/null +++ b/bundles/mastodon/scripts/configure-storage.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * Mastodon storage wiring. + * + * Reads MASTODON_S3_* from the bundle's .env, runs F.0's + * storage-translators.mastodon() to get Mastodon's S3_* envelope, and + * appends the translated vars to .env so compose picks them up on next up. + * + * No-op when MASTODON_S3_ENDPOINT is unset (on-disk storage). + * Managed block: `# crow-mastodon-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.MASTODON_S3_ENDPOINT; + const bucket = env.MASTODON_S3_BUCKET; + const accessKey = env.MASTODON_S3_ACCESS_KEY; + const secretKey = env.MASTODON_S3_SECRET_KEY; + const region = env.MASTODON_S3_REGION || "us-east-1"; + + if (!endpoint) { + console.log("[configure-storage] MASTODON_S3_ENDPOINT not set — using on-disk storage."); + return; + } + if (!bucket || !accessKey || !secretKey) { + console.error("[configure-storage] MASTODON_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."); + const urlParts = (u) => { + const m = u.match(/^(https?):\/\/([^/]+)/i); + return m ? { scheme: m[1], authority: m[2] } : { scheme: "https", authority: u }; + }; + translate = (_, crow) => { + const { scheme, authority } = urlParts(crow.endpoint); + return { + S3_ENABLED: "true", + S3_BUCKET: crow.bucket, + AWS_ACCESS_KEY_ID: crow.accessKey, + AWS_SECRET_ACCESS_KEY: crow.secretKey, + S3_REGION: crow.region || "us-east-1", + S3_PROTOCOL: scheme, + S3_HOSTNAME: authority, + S3_ENDPOINT: crow.endpoint, + S3_FORCE_SINGLE_REQUEST: "true", + }; + }; + } + + const mapped = translate("mastodon", { endpoint, bucket, accessKey, secretKey, region }); + + const BEGIN = "# crow-mastodon-storage BEGIN (managed by scripts/configure-storage.mjs — do not edit)"; + const END = "# crow-mastodon-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 compose so web + sidekiq pick up the new vars:"); + console.log(" docker compose -f bundles/mastodon/docker-compose.yml up -d --force-recreate"); +} + +main().catch((err) => { + console.error(`[configure-storage] Failed: ${err.message}`); + process.exit(1); +}); diff --git a/bundles/mastodon/scripts/post-install.sh b/bundles/mastodon/scripts/post-install.sh new file mode 100755 index 0000000..ee4e118 --- /dev/null +++ b/bundles/mastodon/scripts/post-install.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Mastodon post-install hook. +# +# 1. Wait for web container healthy (first boot = migrations + asset precompile). +# 2. Optionally translate MASTODON_S3_* via configure-storage.mjs. +# 3. Verify federation-network attachment on web + streaming. +# 4. Print next-step guidance (Caddy site, admin creation, PAT, optional +# single-user-mode toggle). + +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 Mastodon web to report healthy (up to 240s — first-boot migrations + asset precompile)…" +for i in $(seq 1 48); do + if docker inspect crow-mastodon-web --format '{{.State.Health.Status}}' 2>/dev/null | grep -qw healthy; then + echo " → web healthy" + break + fi + sleep 5 +done + +echo "Waiting for Mastodon streaming to report healthy (up to 60s)…" +for i in $(seq 1 12); do + if docker inspect crow-mastodon-streaming --format '{{.State.Health.Status}}' 2>/dev/null | grep -qw healthy; then + echo " → streaming healthy" + break + fi + sleep 5 +done + +if [ -n "${MASTODON_S3_ENDPOINT:-}" ]; then + echo "MASTODON_S3_ENDPOINT detected — translating to S3_* envelope 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 stays on-disk until S3 env vars are written manually." >&2 + } + else + echo "WARN: node not on PATH — cannot run configure-storage.mjs. S3 not wired." >&2 + fi +fi + +for c in crow-mastodon-web crow-mastodon-streaming crow-mastodon-sidekiq; do + if ! docker inspect "$c" --format '{{range $k, $_ := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null | grep -qw crow-federation; then + echo "WARN: $c is not on the crow-federation network — Caddy federation sites will not reach it by service name" >&2 + fi +done + +cat < --confirmed --role Admin + + 2. Expose via Caddy (one-time): + caddy_add_federation_site { + domain: "${MASTODON_LOCAL_DOMAIN:-mastodon.example.com}", + upstream: "mastodon-web:3000", + profile: "activitypub-mastodon" + } + The activitypub-mastodon profile wires /api/v1/streaming to + mastodon-streaming:4000 and sets Mastodon's static-asset cache headers. + + 3. Log in at https://${MASTODON_LOCAL_DOMAIN:-}/. + Settings → Development → New Application, grant + read write follow push admin:read admin:write + then generate an access token. Paste into .env as + MASTODON_ACCESS_TOKEN, then: + crow bundle restart mastodon + + 4. Before opening registration or federating widely, import a baseline + moderation blocklist: + mastodon_import_blocklist { source: "iftas", confirm: "yes" } + (queued — confirm in the Nest panel within 72h) + + 5. Verify: + mastodon_status {} + +EOF diff --git a/bundles/mastodon/server/index.js b/bundles/mastodon/server/index.js new file mode 100755 index 0000000..71c3428 --- /dev/null +++ b/bundles/mastodon/server/index.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMastodonServer } from "./server.js"; + +const server = await createMastodonServer(); +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/bundles/mastodon/server/server.js b/bundles/mastodon/server/server.js new file mode 100644 index 0000000..a0a606a --- /dev/null +++ b/bundles/mastodon/server/server.js @@ -0,0 +1,603 @@ +/** + * Mastodon MCP Server + * + * Mastodon is the reference implementation of the v1/v2 Mastodon API. + * F.1 (GoToSocial) and F.5 (Pixelfed) mirror this surface — this bundle + * exposes the same verb taxonomy against the real thing, adding admin + * endpoints that GTS/PF implement only partially (tootctl-style + * moderation, federation_relationships). + * + * Tools (federated-social verb taxonomy): + * mastodon_status, mastodon_post, mastodon_post_with_media, + * mastodon_feed, mastodon_search, + * mastodon_follow, mastodon_unfollow, + * mastodon_block_user, mastodon_mute_user (inline, rate-limited), + * mastodon_block_domain, mastodon_defederate, + * mastodon_import_blocklist (QUEUED, admin-destructive), + * mastodon_review_reports, mastodon_report_remote, + * mastodon_media_prune + * + * Rate limiting + moderation queue: same pattern as F.1/F.5/F.6. + * + * Deduplication note: + * resolveAccount(), queueModerationAction(), and the request helpers + * are deliberately duplicated from F.1/F.5 rather than extracted to + * servers/shared/. The three copies stay local so each bundle remains + * installable-standalone without a shared-helper dependency. When a + * fourth Mastodon-compatible bundle lands (Akkoma? Iceshrimp?), hoist. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; + +const MASTODON_URL = (process.env.MASTODON_URL || "http://mastodon-web:3000").replace(/\/+$/, ""); +const MASTODON_ACCESS_TOKEN = process.env.MASTODON_ACCESS_TOKEN || ""; +const MASTODON_LOCAL_DOMAIN = process.env.MASTODON_LOCAL_DOMAIN || ""; + +let wrapRateLimited = null; +let getDb = null; +let createNotification = null; + +async function loadSharedDeps() { + try { + const rl = await import("../../../servers/shared/rate-limiter.js"); + wrapRateLimited = rl.wrapRateLimited; + } catch { + wrapRateLimited = () => (_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 mdFetch(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 = `${MASTODON_URL}${path}${qs}`; + const headers = {}; + if (!noAuth && MASTODON_ACCESS_TOKEN) { + headers.Authorization = `Bearer ${MASTODON_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("Mastodon auth failed (401). Create an OAuth PAT in Settings → Development and paste into MASTODON_ACCESS_TOKEN."); + if (res.status === 403) throw new Error(`Mastodon forbidden (403)${snippet ? ": " + snippet : ""}`); + throw new Error(`Mastodon ${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(`Mastodon request timed out: ${path}`); + if (err.cause?.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) { + throw new Error(`Cannot reach Mastodon at ${MASTODON_URL}. Verify crow-mastodon-web is up and on the crow-federation network.`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +function requireAuth() { + if (!MASTODON_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: MASTODON_ACCESS_TOKEN required. Create an OAuth PAT in Settings → Development with scopes read/write (admin tools also need admin:read/admin:write)." }] }; + } + 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 }; + const out = await mdFetch("/api/v2/search", { + query: { q: handleOrId.replace(/^@/, ""), type: "accounts", resolve: "true", limit: 1 }, + }); + return (out.accounts || [])[0] || null; +} + +export async function createMastodonServer(options = {}) { + await loadSharedDeps(); + + const server = new McpServer( + { name: "crow-mastodon", version: "1.0.0" }, + { instructions: options.instructions }, + ); + + const limiter = wrapRateLimited ? wrapRateLimited({ db: getDb ? getDb() : null }) : (_, h) => h; + + // --- mastodon_status --- + server.tool( + "mastodon_status", + "Report Mastodon instance health: reachability, version, user/post/domain stats, admin account, federation peer count, media cache retention setting.", + {}, + async () => { + try { + const [instance, peers, account] = await Promise.all([ + mdFetch("/api/v2/instance", { noAuth: true }).catch(() => mdFetch("/api/v1/instance", { noAuth: true }).catch(() => null)), + mdFetch("/api/v1/instance/peers").catch(() => []), + MASTODON_ACCESS_TOKEN ? mdFetch("/api/v1/accounts/verify_credentials").catch(() => null) : Promise.resolve(null), + ]); + return textResponse({ + local_domain: MASTODON_LOCAL_DOMAIN || null, + url: MASTODON_URL, + title: instance?.title || null, + version: instance?.version || null, + users: instance?.usage?.users?.active_month ?? instance?.stats?.user_count ?? null, + statuses: instance?.stats?.status_count ?? null, + domains: instance?.stats?.domain_count ?? null, + federated_peers: Array.isArray(peers) ? peers.length : null, + registrations_open: instance?.registrations?.enabled ?? instance?.registrations ?? null, + authenticated_as: account ? { id: account.id, acct: account.acct, display_name: account.display_name } : null, + has_access_token: Boolean(MASTODON_ACCESS_TOKEN), + }); + } catch (err) { + return errResponse(err); + } + }, + ); + + // --- mastodon_post --- + server.tool( + "mastodon_post", + "Publish a status (toot). Content is public by default unless visibility is narrowed. Rate-limited: 10/hour per conversation.", + { + status: z.string().min(1).max(5000).describe("Status body. Mastodon enforces a 500-char default; admins can raise it. Remote servers may truncate."), + visibility: z.enum(["public", "unlisted", "private", "direct"]).optional(), + spoiler_text: z.string().max(500).optional(), + in_reply_to_id: z.string().max(50).optional(), + language: z.string().length(2).optional(), + sensitive: z.boolean().optional(), + }, + limiter("mastodon_post", async (args) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const body = { + status: args.status, + visibility: args.visibility || "public", + ...(args.spoiler_text ? { spoiler_text: args.spoiler_text } : {}), + ...(args.in_reply_to_id ? { in_reply_to_id: args.in_reply_to_id } : {}), + ...(args.language ? { language: args.language } : {}), + ...(args.sensitive != null ? { sensitive: args.sensitive } : {}), + }; + const out = await mdFetch("/api/v1/statuses", { method: "POST", body }); + return textResponse({ id: out.id, url: out.url, uri: out.uri, visibility: out.visibility, created_at: out.created_at }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- mastodon_post_with_media --- + server.tool( + "mastodon_post_with_media", + "Upload an image/video and publish it as a status. Uploads via POST /api/v2/media (async processing) 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(), + alt_text: z.string().max(1500).optional().describe("Media alt text (strongly recommended)."), + visibility: z.enum(["public", "unlisted", "private", "direct"]).optional(), + spoiler_text: z.string().max(500).optional(), + sensitive: z.boolean().optional(), + }, + limiter("mastodon_post_with_media", 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); + // Mastodon 4.x uses /api/v2/media for async upload. Endpoint returns 202 with id + // when processing is not yet complete; we poll briefly for 'processed' state. + let media = await mdFetch("/api/v2/media", { method: "POST", rawForm: form, timeoutMs: 180_000 }); + if (media?.id && media?.url == null) { + // Still processing — poll for up to 30s + for (let i = 0; i < 15; i++) { + await new Promise((r) => setTimeout(r, 2000)); + const check = await mdFetch(`/api/v1/media/${media.id}`).catch(() => null); + if (check?.url) { media = check; break; } + } + } + 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 mdFetch("/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 }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- mastodon_feed --- + server.tool( + "mastodon_feed", + "Fetch a timeline. home = follows; public = local+federated; local = this instance; notifications = mentions/favs/boosts/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("mastodon_feed", async ({ source, limit, since_id, max_id }) => { + try { + if (source !== "public" && !MASTODON_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: non-public timelines require MASTODON_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 mdFetch(path, { query, noAuth: source === "public" && !MASTODON_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, + content_excerpt: (it.content || "").replace(/<[^>]+>/g, "").slice(0, 240), + media_count: (it.media_attachments || []).length, + 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); + } + }), + ); + + // --- mastodon_search --- + server.tool( + "mastodon_search", + "Search accounts/hashtags/statuses. Remote queries resolve via WebFinger when resolve=true. 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("mastodon_search", async ({ query, type, limit, resolve }) => { + try { + const out = await mdFetch("/api/v2/search", { + query: { q: query, type, limit: limit ?? 10, resolve: resolve ? "true" : undefined }, + }); + return textResponse(out); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- mastodon_follow / unfollow --- + server.tool( + "mastodon_follow", + "Follow an account by handle (@user@domain) or local account ID. Rate-limited: 30/hour.", + { handle: z.string().min(1).max(320) }, + limiter("mastodon_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 mdFetch(`/api/v1/accounts/${encodeURIComponent(acct.id)}/follow`, { method: "POST" }); + return textResponse({ following: rel.following, requested: rel.requested, showing_reblogs: rel.showing_reblogs }); + } catch (err) { + return errResponse(err); + } + }), + ); + + server.tool( + "mastodon_unfollow", + "Unfollow an account.", + { handle: z.string().min(1).max(320) }, + limiter("mastodon_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 mdFetch(`/api/v1/accounts/${encodeURIComponent(acct.id)}/unfollow`, { method: "POST" }); + return textResponse({ following: rel.following }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- User-level moderation (inline, rate-limited) --- + server.tool( + "mastodon_block_user", + "Block an account (hide their posts + block DMs from them). Rate-limited: 5/hour.", + { handle: z.string().min(1).max(320), confirm: z.literal("yes") }, + limiter("mastodon_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 mdFetch(`/api/v1/accounts/${acct.id}/block`, { method: "POST" }); + return textResponse({ blocking: rel.blocking }); + } catch (err) { + return errResponse(err); + } + }), + ); + + server.tool( + "mastodon_mute_user", + "Mute an account (hide posts but still federate). Rate-limited: 5/hour.", + { + handle: z.string().min(1).max(320), + notifications: z.boolean().optional().describe("Also mute notifications from this user (default true)."), + duration_seconds: z.number().int().min(0).max(86400 * 365).optional().describe("Temporary mute duration; 0 = permanent."), + confirm: z.literal("yes"), + }, + limiter("mastodon_mute_user", async ({ handle, notifications, duration_seconds }) => { + 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 body = { + ...(notifications != null ? { notifications } : {}), + ...(duration_seconds != null ? { duration: duration_seconds } : {}), + }; + const rel = await mdFetch(`/api/v1/accounts/${acct.id}/mute`, { method: "POST", body }); + return textResponse({ muting: rel.muting, muting_notifications: rel.muting_notifications }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- Instance-level moderation (QUEUED) --- + server.tool( + "mastodon_block_domain", + "Block a remote domain at the user level (hide all accounts from that domain for the authenticated user). For instance-wide defederation use mastodon_defederate. Rate-limited: 5/hour.", + { domain: z.string().min(3).max(253), confirm: z.literal("yes") }, + limiter("mastodon_block_domain", async ({ domain }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const out = await mdFetch(`/api/v1/domain_blocks?domain=${encodeURIComponent(domain)}`, { method: "POST" }); + return textResponse({ blocked_domain: domain, response: out }); + } catch (err) { + return errResponse(err); + } + }), + ); + + server.tool( + "mastodon_defederate", + "Instance-wide defederation — admin-only. Uses the admin/domain_blocks endpoint with severity=suspend. QUEUED — requires operator confirmation in the Nest panel before firing.", + { + domain: z.string().min(3).max(253), + reason: z.string().max(1000).optional(), + severity: z.enum(["silence", "suspend", "noop"]).optional().describe("silence = hide from timelines; suspend = full defederation; default suspend."), + reject_media: z.boolean().optional(), + reject_reports: z.boolean().optional(), + confirm: z.literal("yes"), + }, + async ({ domain, reason, severity, reject_media, reject_reports }) => { + const queued = await queueModerationAction("mastodon", "defederate", { + domain, + reason: reason || "", + severity: severity || "suspend", + reject_media: reject_media ?? true, + reject_reports: reject_reports ?? true, + }); + return textResponse(queued); + }, + ); + + server.tool( + "mastodon_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("mastodon_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("mastodon", "import_blocklist", { source: url }); + return textResponse(queued); + }), + ); + + // --- Admin reports --- + server.tool( + "mastodon_review_reports", + "List pending moderation reports (admin-only). Read-only summary.", + { limit: z.number().int().min(1).max(100).optional() }, + async ({ limit }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const reports = await mdFetch("/api/v1/admin/reports", { query: { limit: limit ?? 20, resolved: "false" } }); + const summary = (Array.isArray(reports) ? reports : []).map((r) => ({ + id: r.id, + account: r.account?.username, + target_account: r.target_account?.username, + category: r.category, + comment: r.comment, + forwarded: r.forwarded, + created_at: r.created_at, + })); + return textResponse({ count: summary.length, reports: summary }); + } catch (err) { + return errResponse(err); + } + }, + ); + + server.tool( + "mastodon_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), + status_ids: z.array(z.string().max(50)).max(10).optional().describe("Specific status IDs to attach to the report."), + forward: z.boolean().optional(), + category: z.enum(["spam", "legal", "violation", "other"]).optional(), + }, + limiter("mastodon_report_remote", async ({ handle, reason, status_ids, forward, category }) => { + 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 body = { + account_id: acct.id, + comment: reason, + forward: forward !== false, + ...(status_ids ? { status_ids } : {}), + ...(category ? { category } : {}), + }; + const out = await mdFetch("/api/v1/reports", { method: "POST", body }); + return textResponse({ report_id: out.id, forwarded: body.forward }); + } catch (err) { + return errResponse(err); + } + }), + ); + + // --- mastodon_media_prune --- + server.tool( + "mastodon_media_prune", + "Manually trigger pruning of cached remote media older than N days. The sidekiq scheduler handles this on a recurring cadence (MEDIA_CACHE_RETENTION_PERIOD env); 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("mastodon_media_prune", async ({ older_than_days }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const days = older_than_days ?? Number(process.env.MASTODON_MEDIA_RETENTION_DAYS || 14); + // Mastodon has no HTTP API for media prune — surface the tootctl + // invocation the operator should run. Return structured so the + // caller knows to exec it. + return textResponse({ + requested_days: days, + next_steps: [ + `docker exec crow-mastodon-web bin/tootctl media remove --days ${days}`, + "(optional, more aggressive) --prune-profiles to drop cached remote avatars", + "Scheduled sidekiq job 'Scheduler::MediaCleanupScheduler' handles this automatically at MEDIA_CACHE_RETENTION_PERIOD cadence.", + ], + note: "Mastodon deliberately keeps media prune as a CLI rather than an HTTP admin endpoint to prevent accidental mass-deletion via API. The scheduler is the normal path; this tool surfaces the manual escape hatch.", + }); + } catch (err) { + return errResponse(err); + } + }), + ); + + return server; +} diff --git a/bundles/mastodon/skills/mastodon.md b/bundles/mastodon/skills/mastodon.md new file mode 100644 index 0000000..96a2c76 --- /dev/null +++ b/bundles/mastodon/skills/mastodon.md @@ -0,0 +1,154 @@ +--- +name: mastodon +description: Mastodon — flagship federated microblog over ActivityPub. Toot, timelines, follow remote accounts, moderation, admin. +triggers: + - "mastodon" + - "toot" + - "fediverse" + - "activitypub" + - "follow @user@" + - "federated timeline" + - "mastodon instance" +tools: + - mastodon_status + - mastodon_post + - mastodon_post_with_media + - mastodon_feed + - mastodon_search + - mastodon_follow + - mastodon_unfollow + - mastodon_block_user + - mastodon_mute_user + - mastodon_block_domain + - mastodon_defederate + - mastodon_import_blocklist + - mastodon_review_reports + - mastodon_report_remote + - mastodon_media_prune +--- + +# Mastodon — the flagship ActivityPub microblog + +Mastodon is the reference Mastodon-API implementation. This bundle runs a full-fat instance — web (Rails/Puma), streaming (Node), sidekiq (background jobs), postgres, redis — on the shared `crow-federation` network. If you already ran F.1 GoToSocial, many tools here will look familiar: the verb taxonomy is identical, and Mastodon just exposes a larger admin surface. + +## Hardware — this is the heaviest small-AP bundle + +Gated by F.0's hardware check. Refused below **3 GB effective RAM after committed bundles**; warns below 8 GB total. Typical idle ~3 GB; under active federation 6 GB+. Media cache grows 10-100 GB within weeks without S3 storage. If you're running on a Pi or a 4 GB VPS, this bundle is not a good fit — consider F.1 GoToSocial instead. + +## LOCAL_DOMAIN is immutable + +`MASTODON_LOCAL_DOMAIN` appears in user handles (`@user@example.com`) and in every ActivityPub actor/object URL. **Once the instance federates with anyone, changing LOCAL_DOMAIN abandons every federated identity.** Pick the domain you intend to keep forever before first boot. If you need domain-delegation (federation on the apex, web UI on a subdomain), set `MASTODON_WEB_DOMAIN` and use Caddy's `matrix-server`-style `.well-known/webfinger` delegation (the F.0 caddy helper supports it). + +## Generate the crypto secrets + +Mastodon needs three secrets in `.env` before first boot. Generate them with the image itself: + +```bash +# SECRET_KEY_BASE + OTP_SECRET (128 hex chars each): +docker run --rm ghcr.io/mastodon/mastodon:v4.3.0 bundle exec rake secret +docker run --rm ghcr.io/mastodon/mastodon:v4.3.0 bundle exec rake secret + +# VAPID keypair (for Web Push): +docker run --rm ghcr.io/mastodon/mastodon:v4.3.0 bundle exec rake mastodon:webpush:generate_vapid_key +# → paste the two lines into MASTODON_VAPID_PRIVATE_KEY + MASTODON_VAPID_PUBLIC_KEY +``` + +## First-run bootstrap + +1. Populate `.env` with all six required secrets + `MASTODON_LOCAL_DOMAIN` + `MASTODON_DB_PASSWORD`. Optionally add `MASTODON_SMTP_*` for registration email. +2. Install. The entrypoint runs `db:migrate` + `assets:precompile` on first boot (2-3 minutes). +3. Expose via Caddy: + ``` + caddy_add_federation_site { + domain: "mastodon.example.com", + upstream: "mastodon-web:3000", + profile: "activitypub-mastodon" + } + ``` + The `activitypub-mastodon` profile wires `/api/v1/streaming` → `mastodon-streaming:4000` and sets the static-asset cache headers Mastodon expects. +4. Create the admin account: + ```bash + docker exec -it crow-mastodon-web \ + bin/tootctl accounts create admin \ + --email you@example.com --confirmed --role Admin + ``` +5. Log in at https://mastodon.example.com/. Go to **Settings → Development → New Application**, grant `read write follow push admin:read admin:write`, copy the access token into `.env` as `MASTODON_ACCESS_TOKEN`, then: + ``` + crow bundle restart mastodon + ``` + +## Storage: on-disk or S3 + +On-disk by default (`~/.crow/mastodon/system/`). To route to MinIO / external S3, set these in `.env` before install: + +``` +MASTODON_S3_ENDPOINT=https://minio.example.com +MASTODON_S3_BUCKET=mastodon-media +MASTODON_S3_ACCESS_KEY=... +MASTODON_S3_SECRET_KEY=... +``` + +`scripts/post-install.sh` detects these and runs `scripts/configure-storage.mjs`, which uses F.0's `storage-translators.mastodon()` to emit the `S3_*` envelope Mastodon actually reads. This is load-bearing on any active instance — without S3, local disk consumption scales with follow count. + +## Common workflows + +### Toot + +``` +mastodon_post { + "status": "Hello from Crow", + "visibility": "public" +} +``` + +### Post a photo + +``` +mastodon_post_with_media { + "file_path": "/home/kev/photos/sunset.jpg", + "caption": "Dusk over the ridge", + "alt_text": "Orange and purple sky over a forested ridge", + "visibility": "public" +} +``` + +Mastodon 4.x uses async media upload (`POST /api/v2/media` returns 202 for large files); the tool polls for completion before publishing the status. Timeout ~30s. + +### Follow remote accounts + +``` +mastodon_follow { "handle": "@alice@mastodon.social" } +``` + +First follow against a given remote instance does WebFinger + actor fetch (5-30s); subsequent follows are fast. + +### Timelines + search + +``` +mastodon_feed { "source": "home", "limit": 20 } +mastodon_feed { "source": "local", "limit": 10 } +mastodon_search { "query": "#photography", "type": "hashtags" } +mastodon_search { "query": "@bob@example.com", "resolve": true } +``` + +## Moderation + +- **Inline user-level (rate-limited):** `mastodon_block_user`, `mastodon_mute_user`, `mastodon_block_domain` (user-scoped — hides all accounts from a domain for THIS user, not instance-wide). +- **Queued instance-level (operator confirms in Nest within 72h):** `mastodon_defederate` (admin `/api/v1/admin/domain_blocks` — choose severity: `silence`, `suspend`, or `noop`), `mastodon_import_blocklist` (IFTAS / Bad Space / custom URL). +- **Admin read-only:** `mastodon_review_reports` (open reports), `mastodon_report_remote` (file a report against a remote account, optionally with status_ids attached). +- **Media management:** `mastodon_media_prune` surfaces the `bin/tootctl media remove --days N` recipe — Mastodon keeps media prune as CLI rather than HTTP API to prevent accidental mass deletion. + +## Cross-app integration + +- **F.1 GoToSocial, F.5 Pixelfed**: all three speak the same Mastodon API. Crow contacts discovered on any of them federate together. F.7 validates the verb taxonomy scales; duplicate `resolveAccount()` code across the three bundles is deliberate for installed-standalone deployments. +- **F.3 Matrix-Dendrite**: no direct federation (AP vs Matrix are different protocols), but F.12's `matrix-bridges` bundle can bridge Mastodon toots into Matrix rooms via `mautrix-twitter`-style bridges (lands with F.12). +- **Blog cross-posting**: WriteFreely / Pixelfed / Funkwhale content URLs Mastodon can embed inline (ActivityPub OEmbed). + +## Troubleshooting + +- **"Cannot reach Mastodon"** — first boot runs migrations + asset precompile (2-3 min). `docker logs crow-mastodon-web`. Healthcheck has 180s start_period for this reason. +- **"401 auth failed"** — your token doesn't have the right scopes. For admin tools (`mastodon_defederate`, `mastodon_review_reports`), the OAuth application must include `admin:read` + `admin:write`. +- **"413 Payload Too Large" on media upload** — Mastodon's default is 10 MB images / 40 MB video. Override via nginx / Caddy `request_body_max`. The bundled compose doesn't override it. +- **Sidekiq queue growing** — `docker logs crow-mastodon-sidekiq`. DB performance is the usual bottleneck; check postgres memory / disk. +- **Federation delivery retrying forever** — classic fediverse failure mode. `bin/tootctl accounts cull` prunes dead remote accounts; scheduled sidekiq job also handles this. +- **Disk filling fast** — remote media cache. Run `mastodon_media_prune`, lower `MASTODON_MEDIA_RETENTION_DAYS` (default 14), or enable S3 storage. diff --git a/registry/add-ons.json b/registry/add-ons.json index 52acb43..1ac443f 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -3278,6 +3278,60 @@ "webUI": null, "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. Admin registers via the web setup wizard on first boot." }, + { + "id": "mastodon", + "name": "Mastodon", + "description": "The flagship ActivityPub server — federated microblogging at scale. Full v1/v2 Mastodon API, web UI, Sidekiq background jobs, streaming API. Heaviest of the small-AP bundles.", + "type": "bundle", + "version": "1.0.0", + "author": "Crow", + "category": "federated-social", + "tags": ["mastodon", "activitypub", "fediverse", "microblog", "federated", "flagship"], + "icon": "globe", + "docker": { "composefile": "docker-compose.yml" }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["MASTODON_URL", "MASTODON_ACCESS_TOKEN", "MASTODON_LOCAL_DOMAIN"] + }, + "panel": "panel/mastodon.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/mastodon.md"], + "consent_required": true, + "requires": { + "env": ["MASTODON_LOCAL_DOMAIN", "MASTODON_DB_PASSWORD", "MASTODON_SECRET_KEY_BASE", "MASTODON_OTP_SECRET", "MASTODON_VAPID_PRIVATE_KEY", "MASTODON_VAPID_PUBLIC_KEY"], + "bundles": ["caddy"], + "min_ram_mb": 3000, + "recommended_ram_mb": 6000, + "min_disk_mb": 20000, + "recommended_disk_mb": 500000 + }, + "env_vars": [ + { "name": "MASTODON_LOCAL_DOMAIN", "description": "Public domain in handles (@user@). IMMUTABLE after first federation.", "required": true }, + { "name": "MASTODON_WEB_DOMAIN", "description": "Web UI domain (if delegating federation).", "required": false }, + { "name": "MASTODON_DB_PASSWORD", "description": "Postgres password.", "required": true, "secret": true }, + { "name": "MASTODON_SECRET_KEY_BASE", "description": "Rails secret_key_base (128 hex). rake secret.", "required": true, "secret": true }, + { "name": "MASTODON_OTP_SECRET", "description": "2FA OTP secret (128 hex). rake secret.", "required": true, "secret": true }, + { "name": "MASTODON_VAPID_PRIVATE_KEY", "description": "Web Push VAPID private key.", "required": true, "secret": true }, + { "name": "MASTODON_VAPID_PUBLIC_KEY", "description": "Web Push VAPID public key.", "required": true }, + { "name": "MASTODON_ACCESS_TOKEN", "description": "OAuth PAT (read/write/admin:read/admin:write).", "required": false, "secret": true }, + { "name": "MASTODON_MEDIA_RETENTION_DAYS", "description": "Remote media cache retention.", "default": "14", "required": false }, + { "name": "MASTODON_SINGLE_USER_MODE", "description": "Skip landing page, redirect to admin profile.", "default": "false", "required": false }, + { "name": "MASTODON_S3_ENDPOINT", "description": "Optional S3-compatible endpoint. When set, configure-storage.mjs routes media via storage-translators.mastodon().", "required": false }, + { "name": "MASTODON_S3_BUCKET", "description": "S3 bucket.", "required": false }, + { "name": "MASTODON_S3_ACCESS_KEY", "description": "S3 access key.", "required": false, "secret": true }, + { "name": "MASTODON_S3_SECRET_KEY", "description": "S3 secret key.", "required": false, "secret": true }, + { "name": "MASTODON_S3_REGION", "description": "S3 region.", "default": "us-east-1", "required": false }, + { "name": "MASTODON_SMTP_SERVER", "description": "Outbound SMTP for confirmations.", "required": false }, + { "name": "MASTODON_SMTP_PORT", "description": "SMTP port.", "default": "587", "required": false }, + { "name": "MASTODON_SMTP_LOGIN", "description": "SMTP username.", "required": false }, + { "name": "MASTODON_SMTP_PASSWORD", "description": "SMTP password.", "required": false, "secret": true }, + { "name": "MASTODON_SMTP_FROM_ADDRESS", "description": "From address.", "required": false } + ], + "ports": [], + "webUI": null, + "notes": "Five containers (web + streaming + sidekiq + postgres + redis). ghcr.io/mastodon/mastodon:v4.3.0 + ghcr.io/mastodon/mastodon-streaming:v4.3.0. Expose via caddy_add_federation_site { domain: MASTODON_LOCAL_DOMAIN, upstream: 'mastodon-web:3000', profile: 'activitypub-mastodon' }. Admin via `bin/tootctl accounts create admin --email --confirmed --role Admin`." + }, { "id": "developer-kit", "name": "Developer Kit", diff --git a/skills/superpowers.md b/skills/superpowers.md index 4a6aeca..3416376 100644 --- a/skills/superpowers.md +++ b/skills/superpowers.md @@ -85,6 +85,7 @@ This is the master routing skill. Consult this **before every task** to determin | "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 | | "lemmy", "link aggregator", "reddit alternative", "subscribe community", "post link", "fediverse discussion", "upvote" | "lemmy", "agregador enlaces", "alternativa reddit", "suscribir comunidad", "publicar enlace", "discusión fediverso" | lemmy | crow-lemmy | +| "mastodon", "toot", "fediverse", "activitypub", "@user@server", "federated timeline", "boost", "mastodon instance" | "mastodon", "tootear", "fediverso", "activitypub", "@usuario@servidor", "linea temporal federada", "reblog" | mastodon | crow-mastodon | | "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 |