Skip to content

Repository files navigation

FeedEcho

Self-hosted RSS feed cross-poster. Route items from RSS, Atom, and JSON feeds to Mastodon accounts using configurable templates.

Built as a replacement for Echofeed, which shut down in August 2026.

Features

  • RSS/Atom/JSON feed support via feedparser
  • Mastodon OAuth — connect accounts with one click, no manual token creation
  • Template engine with variables: {{ title }}, {{ link }}, {{ summary }}, {{ content }}, {{ author }}, {{ date }}, {{ date:iso }}, {{ date:short }}, {{ hashtags }}
  • Multiple accounts — post to multiple Mastodon instances
  • Per-feed poll intervals — each feed checked on its own schedule
  • Post history with success/failure tracking and error messages
  • Visibility settings — public, unlisted, private, direct
  • Mobile-responsive — tables convert to cards, forms stack, 44px touch targets
  • Idempotent posting — failed posts are retried, duplicates are prevented
  • Auto-initialization — feeds set their baseline on first fetch, no manual init needed
  • Email destination — echo to email via SMTP in addition to Mastodon

Tech Stack

  • Backend: Python + FastAPI
  • Database: SQLite (WAL mode)
  • Frontend: Jinja2 server-rendered templates + vanilla JS
  • Feed parsing: feedparser (RSS/Atom) + native JSON Feed parser
  • Scheduler: APScheduler (background feed checker)
  • HTTP client: httpx

Quick Start

Docker (recommended)

Pre-built multi-arch images (amd64 + arm64) are published to GHCR on every release — no local build needed:

mkdir feedecho && cd feedecho

cat > .env <<'EOF'
FEEDCHO_AUTH_TOKEN=change-me-to-a-long-random-string
FEEDCHO_CALLBACK_URL=http://localhost:8453/oauth/callback
EOF

curl -O https://raw.githubusercontent.com/jcrabapple/feedecho/master/docker-compose.yml
# Then edit docker-compose.yml: comment out `build: .` and uncomment the `image:` line
docker compose up -d

Or clone the repo and build locally:

git clone https://github.com/jcrabapple/feedecho.git
cd feedecho

# Set your access token (required) and public URL (for Mastodon OAuth)
cat > .env <<'EOF'
FEEDCHO_AUTH_TOKEN=change-me-to-a-long-random-string
FEEDCHO_CALLBACK_URL=http://localhost:8453/oauth/callback
EOF

docker compose up -d

Open http://localhost:8453 and log in with your FEEDCHO_AUTH_TOKEN.

Data lives in the feedecho-data volume (/app/data in the container) — your feeds, accounts, and history survive docker compose up -d --build rebuilds.

Plain Docker without compose:

docker build -t feedecho .
docker run -d --name feedecho \
  -p 8453:8453 \
  -v feedecho-data:/app/data \
  -e FEEDCHO_AUTH_TOKEN=change-me-to-a-long-random-string \
  -e FEEDCHO_CALLBACK_URL=http://localhost:8453/oauth/callback \
  feedecho

Environment variables

Variable Required Purpose
FEEDCHO_AUTH_TOKEN yes (for any real deployment) Shared-secret login for the web UI. If unset, auth is disabled — only safe on localhost.
FEEDCHO_CALLBACK_URL for Mastodon OAuth Public callback URL, e.g. https://feedecho.example.com/oauth/callback. Must match the URL reachable by your browser.
FEEDCHO_DB_PATH no SQLite path (default /app/data/feedecho.db in Docker, ./feedecho.db otherwise)
FEEDCHO_STATE_SECRET no OAuth state signing secret (defaults to FEEDCHO_AUTH_TOKEN)

Behind a reverse proxy (nginx, Caddy, Traefik), point the proxy at port 8453 and set FEEDCHO_CALLBACK_URL to the public HTTPS URL.

From source

git clone https://github.com/jcrabapple/feedecho.git
cd feedecho
python -m venv .venv
source .venv/bin/activate
pip install fastapi "uvicorn[standard]" jinja2 python-multipart feedparser httpx apscheduler
FEEDCHO_AUTH_TOKEN=your-token python -m uvicorn app:app --host 0.0.0.0 --port 8453

NixOS

FeedEcho ships a Nix flake and a NixOS module. See nix/README.md for full instructions.

{
  inputs.feedecho.url = "github:jcrabapple/feedecho";
  outputs = { self, nixpkgs, feedecho, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      modules = [
        feedecho.nixosModules.default
        {
          services.feedecho = {
            enable = true;
            authTokenFile = "/run/secrets/feedecho-token";
            callbackUrl = "https://feedecho.example.com/oauth/callback";
          };
        }
      ];
    };
  };
}

Usage

  1. Add a Mastodon account — Go to /accounts, enter your instance URL, click "Connect Account". OAuth handles the rest.
  2. Add a feed — Go to /feeds, paste an RSS/Atom/JSON feed URL.
  3. Create an echo — Go to /echoes, select a feed + account, write a template like {{ title }} {{ link }}.
  4. Watch it run — The scheduler checks feeds every 2 minutes and posts new items.

Template Variables

Variable Description
{{ title }} Post title
{{ link }} Post URL
{{ summary }} Post summary/excerpt
{{ content }} Full post content (HTML stripped to plain text)
{{ author }} Author name
{{ date }} Publication date (raw)
{{ date:iso }} ISO 8601 date (2024-01-15T09:30:00)
{{ date:short }} Short date (2024-01-15)
{{ hashtags }} Feed tags as #hashtags

Code Breakdown

FeedEcho is ~1,600 lines of Python across 8 modules. No framework magic, no ORMs, no build step. Here's what each piece does:

app.py (613 lines) — Web server and routes

The FastAPI application. Defines every HTTP route: dashboard, feed CRUD, account management, echo CRUD, post history, settings, and the OAuth callback endpoints. Renders Jinja2 templates server-side. Also starts/stops the background scheduler on app startup/shutdown. This is the only module that talks to the user's browser.

database.py (124 lines) — SQLite layer

Creates and manages 7 tables: accounts (Mastodon connections), feeds (RSS sources), echoes (feed-to-destination mappings), email_accounts, settings (key-value config like SMTP), posted_items (post history with status tracking), and oauth_apps (cached OAuth client credentials per instance). Uses SQLite WAL mode for concurrent read/write. Includes lightweight migrations (column additions, schema resets) so existing databases upgrade in place. The unique index on posted_items(echo_id, item_id) enforces the pending-row dedup pattern.

feed_parser.py (212 lines) — Feed fetching and normalization

Fetches feed URLs via httpx with a 10 MB size cap (prevents OOM from hostile feeds). Parses RSS/Atom via feedparser and JSON Feed natively. Normalizes all feed formats into a common item shape: id, title, link, summary, content, author, date, tags. Strips HTML to plain text (Mastodon statuses are plain text). Synthesizes stable item IDs from content hashes when feeds lack GUIDs. The get_new_items() function implements cursor-based new-item detection: on first run it sets a baseline (no backlog posting), and if the cursor scrolled off the feed, it posts only the newest item to avoid spam.

scheduler.py (302 lines) — Background feed checker

The core dispatch engine. Runs on APScheduler (every 2 minutes). For each due feed: fetch new items, find enabled echoes, render templates, dispatch to Mastodon or email. Uses a pending-row pattern for idempotent posting: each (echo, item) pair is claimed via INSERT OR IGNORE with status='pending' before dispatch, then UPDATEd to success or failed after. The unique index prevents duplicate claims. The cursor only advances past items where all echoes succeeded, so failed posts are retried on the next poll. All network I/O happens outside DB transactions to avoid lock contention.

mastodon.py (67 lines) — Mastodon API client

Thin httpx wrapper around three Mastodon REST endpoints: POST /api/v1/statuses (post), GET /api/v1/accounts/verify_credentials (validate token), and the connection test helper. No state, no caching, no surprises.

oauth.py (110 lines) — Mastodon OAuth 2.0 flow

Implements the full OAuth dance: register an app on the target instance (POST /api/v1/apps), build the authorize URL with a CSRF state token, exchange the callback code for an access token (POST /oauth/token). Caches OAuth app credentials per instance in the oauth_apps table so re-registration isn't needed. The state parameter carries a random token plus the instance URL so the callback knows which instance to exchange with.

template_engine.py (76 lines) — Template rendering

Regex-based variable substitution. Replaces {{ variable }} placeholders with feed item data. Supports 9 variables including two date format variants. No eval, no code execution — pure string replacement. Tags are sanitized to alphanumeric for hashtag safety.

email_sender.py (99 lines) — SMTP email dispatch

Sends rendered template content as plain-text email. Reads SMTP config (host, port, username, password, TLS mode) from the settings table. Supports both implicit TLS (port 465) and STARTTLS (port 587). Includes a connection test helper.

templates/ — Jinja2 HTML templates

9 templates: base.html (layout + nav), dashboard.html (overview stats), feeds.html, accounts.html, echoes.html, history.html (post log), settings.html (SMTP config), login.html (shared-secret auth), 404.html. All use Jinja2 autoescaping.

static/ — CSS and JavaScript

style.css (mobile-responsive, table-to-card at 640px breakpoint) and app.js (inline echo editing, account test buttons, feed preview). Vanilla JS, no frameworks, no build step.

tests/ — 62 pytest tests

Four test modules covering the database layer, feed parser (item detection, HTML stripping, truncation, date parsing), template engine (variable substitution, date formatting, hashtag generation), and security features (SSRF protection, OAuth state signing).

Security

FeedEcho handles OAuth tokens and posts to your Mastodon accounts. Here's what it does and doesn't do:

SSRF protection

Feed URLs are validated before fetching. The SSRF filter blocks:

  • Non-http(s) schemes (file://, gopher://, etc.)
  • Direct IP addresses in private ranges (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x, ::1, fc00::, fe80::)
  • Hostnames that resolve to private/internal IPs (DNS resolution is checked before the request is made)

This prevents a user from pointing FeedEcho at cloud metadata endpoints (169.254.169.254), internal services, or localhost.

Web UI authentication

FeedEcho supports optional shared-secret authentication via the FEEDCHO_AUTH_TOKEN environment variable:

  • If set: all requests must include the token as either a cookie (set by the login page at /login) or an X-Auth-Token header (for API/programmatic access). Unauthenticated browser requests are redirected to /login; API requests get 401.
  • If unset: auth is disabled (original behavior). The app is open to anyone who can reach the port.

The OAuth callback endpoints (/oauth/connect, /oauth/callback) are exempt from auth so Mastodon's redirect flow works without a cookie. The HMAC-signed state token provides CSRF protection on those endpoints.

OAuth flow

  • The OAuth state parameter is HMAC-signed (hmac.compare_digest, SHA-256). Format: <nonce>|<instance>|<signature>. The signature covers the nonce and instance, preventing CSRF and tampering with the instance field. A forged state token without the secret is rejected.
  • The callback URL is configurable via the FEEDCHO_CALLBACK_URL environment variable. If unset, it defaults to https://feedecho.example.com/oauth/callback. Self-hosters should set this to their public URL.

Secrets handling

  • Mastodon OAuth tokens are stored in the SQLite database (accounts.access_token). The database file is local to the server. There is no encryption at rest — if an attacker gains filesystem access, they can read the tokens.
  • SMTP passwords are stored in the settings table in plaintext. They are masked (********) when sent to the browser on the settings and accounts pages. The save endpoint skips password updates when the mask placeholder is submitted, so the existing password is preserved.
  • OAuth client secrets (per-instance app credentials) are cached in the oauth_apps table in plaintext.
  • FeedEcho does not log tokens, passwords, or secrets to the application log. Log messages contain echo IDs, feed names, and error messages only.
  • The FEEDCHO_AUTH_TOKEN env var doubles as the HMAC signing key for OAuth state tokens if set, so a single secret secures both layers.

Input handling

  • Feed content from external RSS/Atom/JSON feeds is treated as untrusted. HTML is stripped to plain text before posting to Mastodon. Feed item titles and URLs are never rendered as HTML in the UI without Jinja2 autoescaping.
  • Template variables are substituted via regex — there is no eval() or code execution path. A malformed template produces empty or garbled output, not a security hole.
  • Feed fetches are capped at 10 MB to prevent memory exhaustion from hostile feeds.
  • The inline echo editor in app.js stores original row HTML in an in-memory Map rather than serializing it into a DOM attribute, avoiding an XSS vector that was present in an earlier version.

Network

  • All outbound HTTP uses httpx with a 30-second timeout. FeedEcho makes requests to: the feed URL (user-provided), the Mastodon instance API (user-provided), and the SMTP server (admin-configured). No telemetry, no phone-home, no analytics.
  • Even without FEEDCHO_AUTH_TOKEN, FeedEcho is designed to run behind a reverse proxy or tunnel (Cloudflare Tunnel, nginx, etc.) with access control at the network layer. The built-in auth is a lightweight fallback for when a reverse proxy isn't available.

Configuration

Environment variable Purpose Default
FEEDCHO_AUTH_TOKEN Shared-secret auth token (enables login page + API auth, also signs OAuth state) Unset (auth disabled)
FEEDCHO_CALLBACK_URL Public URL for OAuth callback https://feedecho.example.com/oauth/callback
FEEDCHO_DB_PATH Path to SQLite database ./feedecho.db

What FeedEcho does NOT do

  • Does not encrypt secrets at rest (tokens and passwords are plaintext in SQLite)
  • Does not rate-limit its own feed polling (relies on APScheduler intervals)
  • Does not validate SSL certificates beyond httpx defaults
  • Does not sandbox feed parsing (feedparser runs in-process)

If any of these are a concern for your deployment, wrap FeedEcho behind an authenticated reverse proxy and restrict filesystem access to the database file.

Deployment

systemd user service

[Unit]
Description=FeedEcho
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=%h/feedecho
ExecStart=%h/feedecho/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8453
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

Cloudflare Tunnel

For public access with HTTPS, use a Cloudflare Tunnel:

cloudflared tunnel create feedecho
cloudflared tunnel route dns <TUNNEL_ID> feedecho.yourdomain.com

cat > ~/.cloudflared/feedecho.yml << 'EOF'
tunnel: feedecho
credentials-file: ~/.cloudflared/<TUNNEL_ID>.json

ingress:
  - hostname: feedecho.yourdomain.com
    service: http://127.0.0.1:8453
  - service: http_status:404
EOF

If using OAuth, set FEEDCHO_CALLBACK_URL to your public URL:

export FEEDCHO_CALLBACK_URL="https://feedecho.yourdomain.com/oauth/callback"
export FEEDCHO_AUTH_TOKEN="your-secret-token"  # optional: enable web UI auth

Testing

source .venv/bin/activate
python -m pytest tests/ -v

Project Structure

feedecho/
├── app.py              # FastAPI app — routes, auth middleware, OAuth callbacks
├── database.py         # SQLite layer (7 tables)
├── feed_parser.py     # RSS/Atom/JSON feed fetching + SSRF protection
├── mastodon.py        # Mastodon API client
├── oauth.py           # Mastodon OAuth 2.0 flow (HMAC-signed state)
├── scheduler.py       # APScheduler background feed checker
├── template_engine.py # Variable substitution
├── email_sender.py    # SMTP email dispatch
├── templates/         # Jinja2 HTML templates (9)
├── static/            # CSS + JS
└── tests/             # 62 tests (pytest)

License

MIT

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages