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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ Add-on skills (activated when corresponding add-on is installed):
- `knowledge-base.md` — Multilingual knowledge base: create, edit, publish, search, verify resources, share articles, LAN discovery
- `maker-lab.md` — STEM education companion for kids: scaffolded AI tutor, hint-ladder pedagogy, age-banded personas (kid/tween/adult), solo/family/classroom modes, guest sidecar
- `gotosocial.md` — GoToSocial ActivityPub microblog: post, follow, search, moderate (block_user/mute inline; defederate/block_domain/import_blocklist queued for operator confirmation), media prune, federation health
- `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)
- `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS
- `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download
- `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read
Expand Down
30 changes: 30 additions & 0 deletions bundles/writefreely/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# WriteFreely — required config

# Public domain dedicated to WriteFreely (subdomain; ActivityPub actors
# are URL-keyed so subpath mounts break federation).
WF_HOST=blog.example.com

# Internal URL the Crow MCP server uses. Leave as-is when running over
# the shared crow-federation docker network.
WF_URL=http://writefreely:8080

# API access token for the admin account. After first-run web setup,
# obtain via:
# curl -X POST https://<WF_HOST>/api/auth/login \
# -H 'Content-Type: application/json' \
# -d '{"alias":"<admin>","pass":"<password>"}'
# Copy the returned access_token here. Without a token the MCP tools
# work in read-only mode against public posts.
WF_ACCESS_TOKEN=

# Default collection (blog) alias. Single-user mode creates an implicit
# collection you can leave blank here. Multi-user installs require an
# explicit alias per post; set this to your primary collection.
WF_COLLECTION_ALIAS=

# Single-user mode: simpler setup, one implicit collection, the admin
# IS the blog. Multi-user (false): Medium-like multi-author platform.
WF_SINGLE_USER=true

# Host data directory override.
# WF_DATA_DIR=/mnt/ssd/writefreely
106 changes: 106 additions & 0 deletions bundles/writefreely/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# WriteFreely — ActivityPub blogging platform.
#
# Deployment: no host port publish. Caddy reaches writefreely:8080 by
# docker service name over the crow-federation network.
#
# Data at ~/.crow/writefreely/ — SQLite + uploads + keys. Migration to
# MySQL requires manual compose edit.

networks:
crow-federation:
external: true
default:

services:
writefreely:
image: writeas/writefreely:0.15
container_name: crow-writefreely
networks:
- default
- crow-federation
environment:
# WriteFreely reads its config from /go/keys/config.ini. The
# entrypoint below seeds that file on first run using the env
# vars below, then execs into writefreely.
WF_HOST: ${WF_HOST}
WF_SINGLE_USER: ${WF_SINGLE_USER:-true}
WF_DB_TYPE: sqlite3
WF_DB_FILENAME: /go/keys/writefreely.db
WF_PORT: "8080"
WF_BIND_ADDRESS: 0.0.0.0
# Behind Caddy: let WriteFreely know the public scheme is HTTPS so
# generated absolute URLs (ActivityPub actors, webfinger) use it.
WF_PUBLIC_SCHEME: https
volumes:
- ${WF_DATA_DIR:-~/.crow/writefreely}:/go/keys
entrypoint:
- sh
- -c
- |
set -e
CFG=/go/keys/config.ini
if [ ! -f "$$CFG" ]; then
cat > "$$CFG" <<INI
[server]
hidden_host =
port = $${WF_PORT}
bind = $${WF_BIND_ADDRESS}
tls_cert_path =
tls_key_path =
autocert =
templates_parent_dir = /go/src/code.as/core/writefreely
static_parent_dir = /go/src/code.as/core/writefreely
pages_parent_dir = /go/keys
keys_parent_dir = /go/keys
hash_seed = crow-managed
gopher_port = 0

[database]
type = $${WF_DB_TYPE}
filename = $${WF_DB_FILENAME}
username =
password =
database =
host =
port = 3306
tls = false

[app]
site_name = WriteFreely on Crow
site_description = A federated blog powered by Crow.
host = https://$${WF_HOST}
theme = write
disable_js = false
webfonts = true
landing =
single_user = $${WF_SINGLE_USER}
open_registration = false
min_username_len = 3
max_blogs = 1
federation = true
public_stats = true
private = false
local_timeline = true
user_invites =
default_visibility = public
update_checks = false
disable_password_auth = false

[oauth.generic]
INI
fi
# Init DB on first run
if [ ! -f /go/keys/writefreely.db ]; then
/go/bin/writefreely --init-db -c "$$CFG"
/go/bin/writefreely --gen-keys -c "$$CFG"
fi
exec /go/bin/writefreely -c "$$CFG"
init: true
mem_limit: 512m
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
66 changes: 66 additions & 0 deletions bundles/writefreely/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"id": "writefreely",
"name": "WriteFreely",
"version": "1.0.0",
"description": "Lightweight, minimalist ActivityPub blogging platform. Single-binary, single-SQLite-file footprint — ideal for long-form writing federated to the fediverse. Complements GoToSocial (microblog) and Crow's own blog.",
"type": "bundle",
"author": "Crow",
"category": "federated-social",
"tags": ["activitypub", "fediverse", "blog", "long-form", "federated", "writing"],
"icon": "file-text",
"docker": { "composefile": "docker-compose.yml" },
"server": {
"command": "node",
"args": ["server/index.js"],
"envKeys": ["WF_URL", "WF_ACCESS_TOKEN", "WF_COLLECTION_ALIAS"]
},
"panel": "panel/writefreely.js",
"panelRoutes": "panel/routes.js",
"skills": ["skills/writefreely.md"],
"consent_required": true,
"install_consent_messages": {
"en": "WriteFreely joins the fediverse: your blog becomes publicly addressable at the domain you configure, and posts marked public are federated via ActivityPub to following Mastodon / GoToSocial / other instances. Replicated posts cannot be fully recalled — deletions may not reach every server that boosted your content. WriteFreely itself does not moderate remote content (it is publish-only); inbound federation is limited to follow-from-remote events, so moderation surface is narrow. If your instance is reported for abuse, the normal defederation risks apply — a poisoned domain cannot easily be rehabilitated.",
"es": "WriteFreely se une al fediverso: tu blog será públicamente direccionable en el dominio que configures, y las publicaciones marcadas como públicas se federan vía ActivityPub a instancias que te sigan (Mastodon, GoToSocial, etc.). Las publicaciones replicadas no pueden recuperarse completamente — las eliminaciones pueden no llegar a todos los servidores que hicieron boost de tu contenido. WriteFreely en sí mismo no modera contenido remoto (es solo de publicación); la federación entrante se limita a eventos de seguimiento, por lo que la superficie de moderación es estrecha. Si tu instancia es reportada por abuso, aplican los riesgos normales de defederación — un dominio envenenado no puede rehabilitarse fácilmente."
},
"requires": {
"env": ["WF_HOST"],
"bundles": ["caddy"],
"min_ram_mb": 256,
"recommended_ram_mb": 512,
"min_disk_mb": 500,
"recommended_disk_mb": 5000
},
"env_vars": [
{
"name": "WF_HOST",
"description": "Public domain for this WriteFreely instance (must be a subdomain; subpath mounts break ActivityPub).",
"required": true
},
{
"name": "WF_URL",
"description": "Internal URL the Crow MCP server uses to reach WriteFreely (over the crow-federation docker network).",
"default": "http://writefreely:8080",
"required": false
},
{
"name": "WF_ACCESS_TOKEN",
"description": "API access token for the admin account. Obtain via POST /api/auth/login after first-run web setup (or from the WriteFreely CLI).",
"required": false,
"secret": true
},
{
"name": "WF_COLLECTION_ALIAS",
"description": "Default collection (blog) alias for post operations. Single-user blogs typically have one collection; multi-user installs require explicit alias per post.",
"required": false
},
{
"name": "WF_SINGLE_USER",
"description": "Set to true for single-user blog mode (simpler setup, one implicit collection). false for multi-user Medium-like mode.",
"default": "true",
"required": false
}
],
"ports": [],
"webUI": null,
"notes": "No host port publish. Expose via Caddy after install: caddy_add_federation_site { domain: WF_HOST, upstream: 'writefreely:8080', profile: 'activitypub' }. On first-run the admin account is created via the web UI; there is no CLI bootstrap."
}
11 changes: 11 additions & 0 deletions bundles/writefreely/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "crow-writefreely",
"version": "1.0.0",
"description": "WriteFreely MCP server — create, publish, and federate long-form blog posts over ActivityPub",
"type": "module",
"main": "server/index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.24.0"
}
}
71 changes: 71 additions & 0 deletions bundles/writefreely/panel/routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* WriteFreely panel API routes — read-only status + recent posts.
*/

import { Router } from "express";

const WF_URL = () => (process.env.WF_URL || "http://writefreely:8080").replace(/\/+$/, "");
const WF_TOKEN = () => process.env.WF_ACCESS_TOKEN || "";
const WF_COLL = () => process.env.WF_COLLECTION_ALIAS || "";
const TIMEOUT = 10_000;

async function wf(path, { noAuth } = {}) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), TIMEOUT);
try {
const headers = { Accept: "application/json" };
if (!noAuth && WF_TOKEN()) headers.Authorization = `Token ${WF_TOKEN()}`;
const r = await fetch(`${WF_URL()}${path}`, { signal: ctl.signal, headers });
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
const text = await r.text();
if (!text) return {};
const parsed = JSON.parse(text);
return parsed?.data !== undefined && parsed?.code ? parsed.data : parsed;
} finally {
clearTimeout(t);
}
}

export default function writefreelyRouter(authMiddleware) {
const router = Router();

router.get("/api/writefreely/status", authMiddleware, async (_req, res) => {
try {
const me = WF_TOKEN() ? await wf("/api/me").catch(() => null) : null;
const colls = WF_TOKEN() ? await wf("/api/me/collections").catch(() => []) : [];
res.json({
instance_url: WF_URL(),
has_token: Boolean(WF_TOKEN()),
authenticated_as: me?.username || null,
collections: Array.isArray(colls) ? colls.map((c) => ({ alias: c.alias, title: c.title, posts: c.total_posts })) : [],
default_collection: WF_COLL() || null,
});
} catch (err) {
res.json({ error: `Cannot reach WriteFreely: ${err.message}` });
}
});

router.get("/api/writefreely/recent", authMiddleware, async (req, res) => {
try {
const coll = (req.query.collection || WF_COLL() || "").toString();
if (!coll) return res.json({ error: "collection alias required" });
const data = await wf(`/api/collections/${encodeURIComponent(coll)}/posts?page=1`, { noAuth: true });
const posts = data?.posts || data || [];
res.json({
collection: coll,
posts: (Array.isArray(posts) ? posts : []).slice(0, 10).map((p) => ({
id: p.id,
slug: p.slug,
title: p.title || "(untitled)",
created: p.created,
views: p.views,
url: `${WF_URL()}/${coll}/${p.slug}`,
})),
});
} catch (err) {
res.json({ error: err.message });
}
});

return router;
}
Loading
Loading