Skip to content
Merged
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
49 changes: 18 additions & 31 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,56 +3,43 @@
# --- Host / timezone ---
GENERIC_TIMEZONE=Europe/Zurich

# --- n8n URL (Mac / LAN callers) ---
N8N_HOST=192.168.0.26
# --- n8n URL (local default; use the server DNS name/IP for LAN callers) ---
N8N_HOST=localhost
N8N_PORT=5678
N8N_PROTOCOL=http
N8N_BIND_ADDRESS=127.0.0.1
N8N_HOST_PORT=5678
WEBHOOK_URL=http://192.168.0.26:5678/
N8N_WEBHOOK_URL=http://localhost:5678/
N8N_SECURE_COOKIE=false
N8N_PROXY_HOPS=0

# Encryption key for credentials at rest.
# Reusing the existing n8n_data volume: copy encryptionKey from
# docker compose exec -u node n8n cat /home/node/.n8n/config
# Fresh volume: generate with openssl rand -hex 16 and re-import credentials.
# Fresh volume: `scripts/init.sh` generates a 256-bit key.
N8N_ENCRYPTION_KEY=

# Instance owner (provisioned via N8N_INSTANCE_OWNER_* on n8n start).
# Run ./scripts/ensure-n8n-owner.sh before `docker compose up` to bcrypt-hash
# the password into secrets/n8n_owner.env for Compose.
# The n8n entrypoint hashes N8N_OWNER_PASSWORD; n8n-reconcile logs in with
# the same values to import workflows.
N8N_OWNER_EMAIL=
N8N_OWNER_PASSWORD=
# N8N_OWNER_FIRST_NAME=Syndicator
# N8N_OWNER_LAST_NAME=Owner

# Optional override for workflow publish. When empty, bootstrap creates or
# rotates secrets/n8n_api_key (label syndicator-bootstrap).
# N8N_API_KEY=

# Optional: owner user id for import ownership. Bootstrap auto-detects the
# global:owner user from SQLite when empty.
# N8N_OWNER_USER_ID=

# --- SFTP (published to host; internal compose hostname is always "sftp") ---
# --- SFTP (published to host) ---
SFTP_BIND_ADDRESS=127.0.0.1
SFTP_PUBLISH_PORT=2222
# SFTP_KEYS_DIR=./sftp/keys
# Optional client private key used by `bin/syndicator verify`.
# SFTP_CLIENT_KEY_FILE=./secrets/sftp_client_ed25519

# --- Credential secrets (rendered into templates, then deleted) ---
# --- Credential secrets ---
OPENAI_API_KEY=

POSTIZ_API_KEY=

# n8n FTP/SFTP credential (host must be the compose service name)
SFTP_HOST=sftp
SFTP_USERNAME=sftp
# Path to the private key n8n uses to reach the sftp service (PEM/OpenSSH).
# Created automatically by scripts/ensure-sftp-keys.sh (also run by bootstrap).
SFTP_PRIVATE_KEY_FILE=./secrets/sftp_n8n_ed25519

# --- Image pins (optional; update.sh uses :stable by default) ---
# N8N_BASE_IMAGE=docker.n8n.io/n8nio/n8n:stable
# FFMPEG_IMAGE=mwader/static-ffmpeg:7.1.1
# POSTIZ_NODE_VERSION=0.2.17
# FFMPEG_STUDIO_NODE_VERSION=1.0.0

# --- update.sh ---
# UPDATE_LOG=/var/log/syndicator-update.log
# --- Build/runtime overrides (normally leave unset) ---
# SYNDICATOR_IMAGE_TAG=local
# PYAUTOFLIP_WARM_MODELS=1
# SFTP_PLATFORM=linux/amd64
31 changes: 31 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

- package-ecosystem: docker-compose
directory: /
schedule:
interval: weekly

- package-ecosystem: docker
directory: /n8n
schedule:
interval: weekly

- package-ecosystem: docker
directory: /pyautoflip
schedule:
interval: weekly

- package-ecosystem: npm
directory: /n8n
schedule:
interval: weekly

- package-ecosystem: pip
directory: /pyautoflip
schedule:
interval: weekly
73 changes: 73 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: CI

on:
pull_request:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Validate repository manifests
run: |
python3 -m unittest discover -s tests -p 'test_*.py'
bash tests/test-init.sh

- name: Validate shell scripts
run: |
shell_files=(bin/syndicator scripts/*.sh tests/*.sh tests/integration/*.sh sftp/*.sh n8n/*.sh)
bash -n "${shell_files[@]}"
shellcheck "${shell_files[@]}"

- name: Render Compose configuration
run: bash tests/validate-compose.sh

- name: Audit locked community nodes
working-directory: n8n
run: npm audit

build-images:
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7

- name: Build application images
run: |
bash tests/validate-compose.sh build n8n pyautoflip

- name: Exercise production reframe path
run: bash tests/integration/reframe.sh

- name: Exercise isolated stack twice
run: bash tests/integration/stack.sh

build-arm64:
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4

- name: Build application images for Linux arm64
run: |
docker buildx build --platform linux/arm64 ./n8n
docker buildx build \
--platform linux/arm64 \
--build-arg PYAUTOFLIP_WARM_MODELS=0 \
./pyautoflip
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ __pycache__/
.pytest_cache/
.ruff_cache/
dist/
node_modules/

# Secrets
.env
config.local.yaml
.mypy_cache/

# Compose secrets / local keys / update log
# Compose secrets / local keys
secrets/*
!secrets/.gitkeep
sftp/keys/*
!sftp/keys/.gitkeep
update.log
116 changes: 66 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Syndicator

Syndicator takes a blog post and:
1. Generate a static web site translated to languages EN, FR, ES, SP, IT, and Pirate Speak
2. Distribute the blog post to social media platforms Instagram, Facebook, Youtube, and X.
1. Generate a static web site translated to EN, DE, ES, FR, IT, and Pirate Speak
2. Distribute the blog post to social media platforms Instagram, Facebook, YouTube, and X.

It uses AI extensively for various aspects like translation, post text generation, and media cropping.

Expand All @@ -24,14 +24,14 @@ ASCII context (kept for LLM / text-only readers):
Syndicator provides the `syndicate` interface specified in this document.

* Syndicator uses [Postiz](https://postiz.com/) to schedule social media posts.
* Syndicator uses [OpenAI](https://openai.com/) for KI tasks.
* Syndicator uses [OpenAI](https://openai.com/) for AI tasks.
* Syndicator uses [Hugo](https://gohugo.io/) to generate static blog post site.

## syndicate interface

Callers invoke syndicate by:

1. uploading medias to SFTP (port `2222`, key-only; authorize a public key in `sftp/keys/`, or reuse `secrets/sftp_n8n_ed25519` from `./scripts/ensure-sftp-keys.sh`)
1. uploading medias to SFTP (port `2222`, key-only; authorize a public key in `sftp/keys/`)
2. POSTing JSON to the Blog Post Publish and Reel Publish webhook.
3. The webhook responds with HTTP 2xx as soon as the request is accepted and continues asynchronously.

Expand Down Expand Up @@ -156,71 +156,87 @@ Once Syndicator has finished processing Blog Post Publish the static Hugo post c
## Setup

```bash
cp .env.example .env
# Fill secrets: N8N_ENCRYPTION_KEY, N8N_OWNER_EMAIL, N8N_OWNER_PASSWORD, OpenAI, Postiz
./scripts/ensure-sftp-keys.sh
./scripts/ensure-n8n-owner.sh
scripts/init.sh
# Fill the values requested in .env, then:
docker compose up -d --build
./scripts/bootstrap-n8n.sh
bin/syndicator verify
```

Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). Bootstrap logs in with `N8N_OWNER_EMAIL` / `N8N_OWNER_PASSWORD` to create or reuse an API key at `secrets/n8n_api_key` (or uses `N8N_API_KEY` if set), then imports credentials/workflows and publishes webhooks. UI login uses the same owner credentials.
`init.sh` creates `.env` and an encryption key. Compose builds and starts the stack. `verify` reconciles n8n credentials and workflows inside Compose, then checks n8n, webhook registration, pyautoflip, and SFTP. It is safe to run repeatedly; an unchanged reconcile is skipped.

`ensure-sftp-keys.sh` writes `secrets/sftp_n8n_ed25519` (private) and `sftp/keys/n8n.pub` (public); bootstrap runs it too. `ensure-n8n-owner.sh` writes `secrets/n8n_owner.env` (bcrypt hash for Compose); bootstrap runs that as well. Extra client keys: copy any `.pub` into `sftp/keys/` and `docker compose restart sftp`. Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`.
Owner account is provisioned from env on n8n start (`N8N_INSTANCE_OWNER_*`). After n8n is healthy, the `n8n-reconcile` service logs in with `N8N_OWNER_EMAIL` / `N8N_OWNER_PASSWORD`, imports credentials and workflows from git, and publishes webhooks. UI login uses the same owner credentials.

The `files-init` Compose service chowns the shared `n8n_files` volume to uid/gid `1000` on each `up` so n8n and pyautoflip can write under `/files`.
## Update Worfklows
Authorize callers by copying a `.pub` into `sftp/keys/` and recreating the SFTP service (`docker compose up -d --force-recreate sftp`). Host keys live in the `sftp_host_keys` volume (generated on first start). Connect on port `2222` as user `sftp`.

`./scripts/export-workflows.sh` exports all workflows from n8n into workflows/ folder in this repo
Published ports bind to loopback by default. Read the [operations runbook](docs/operations.md) before enabling LAN or internet access.

## Automatic updates
The `files-init` Compose service chowns the shared `n8n_files` and `sftp_data` volumes to uid/gid `1000` on each `up` so n8n, pyautoflip, and SFTP can write.

`scripts/update.sh` rebuilds with `--pull`, recreates containers, prunes old images, leaves volumes alone. Covers **n8n and pyautoflip**.
## Update workflows

Logs default to `update.log` (`UPDATE_LOG` to override).
`bin/syndicator export` exports sanitized workflows from n8n into `n8n/workflows/`.

## Updates and recovery

Instances are disposable. `.env`, SFTP host keys, and authorized client keys are identity; everything else can be rebuilt from git.

Pull a reviewed revision and run `docker compose up -d --build --pull always`, then `bin/syndicator verify`. If verify fails, bring the stack down, fix the checkout, and start again.

Disaster recovery is a new instance: reprovide `.env`, run `scripts/init.sh` and `docker compose up -d --build`, then `bin/syndicator verify`. Regenerate SFTP keys unless you kept them outside Syndicator. Callers may need to accept a new SSH host key and re-upload files.

## Architecture

The workflow engine, n8n, orchestrates all blog post processing via modular workflows. The most important non-functional requirements are automation and maintainability, as the goal is to minimize time spent managing social media platforms. The initial version of Syndicator was "custom-made" by LLMs, but quickly became unmaintainable. This experience highlighted the need to adopt a workflow engine and decompose the blog post processing into simple, easy-to-understand nodes. This approach not only streamlines debugging and scaling, but also leverages a higher-level runtime environment.
The workflow engine, n8n, orchestrates all blog post processing via modular workflows. The most important non-functional requirements are repeatability, testability, automation, and maintainability. The initial custom pipeline became difficult to change, which motivated decomposing processing into visible workflow nodes.

However, this comes with increased setup complexity—which is why everything is containerized, aiming for a "one-click" deployment to spin up new instances, including workflow instantiation and authentication setup. While achieving this seamless setup remains a work in progress, it is still uncertain whether the chosen technology stack can fully deliver on this vision.
Compose remains the application boundary because it isolates three different runtimes and provides the same topology on macOS and Linux. Instantiate with Compose; `bin/syndicator` covers verify, export, and logs. The rationale and rejected alternatives are recorded in [ADR 0001](docs/adr/0001-deployment-model.md); disposable instances are [ADR 0002](docs/adr/0002-disposable-instances.md).

## Software Design

Software design is split into **instantiation** (how an instance is built and started) and **runtime structure** (what runs once the stack is up).

### Instantiation

The repo is the blueprint for a containerized instance: Compose defines the stack, scripts bootstrap credentials and import workflows, and the rest is source material those steps consume.

| Piece | Role |
|-------|------|
| `docker-compose.yml` | Compose stack: files-init + SFTP + n8n + pyautoflip |
| `.env.example` | Env template for secrets and host paths |
| `n8n/Dockerfile` | Custom n8n image (`ffmpeg` + community node seed) |
| `sftp/Dockerfile` | atmoz/sftp wrapper (host keys volume, key sync, chown) |
| `scripts/` | ensure-sftp-keys / ensure-n8n-owner / bootstrap / export / update |
| `n8n/workflows/` | Importable workflow exports (source of truth) |
| `n8n/credentials/` | Credential templates (stable IDs; secrets from `.env`) |
| `pyautoflip/` | Image/build context for the reframe sidecar |
| `sftp/keys/` | Authorized client public keys (refreshed into `authorized_keys` on each sftp start) |
| `systemd/*` | Optional host timer for updates |
To start an instance you need this repository and a filled-in `.env`. `docker compose up --build` builds the images and starts sftp, n8n, and pyautoflip. n8n comes up with an owner account from `.env`, but not yet with Syndicator's workflows. `n8n-reconcile` then logs into that n8n, creates credentials from `.env`, imports the workflow JSON from git, and publishes the webhooks. After that the instance matches this checkout.

```mermaid
flowchart LR
Git["Git checkout"]
Env[".env"]
subgraph instantiate ["instantiate"]
Init["files-init"]
SFTP["sftp"]
N8N["n8n"]
PyAF["pyautoflip"]
Recon["n8n-reconcile"]
end
Git --> instantiate
Git -->|workflows, credential templates| Recon
Env -->|secrets, owner| N8N
Env --> Recon
Init -->|chown volumes| SFTP
Init --> N8N
Init --> PyAF
N8N -->|healthy| Recon
Recon -->|import + publish webhooks| N8N
```
docker-compose.yml
.env.example
n8n/Dockerfile
n8n/workflows/
n8n/credentials/*.template.json
pyautoflip/
sftp/
scripts/{ensure-sftp-keys,ensure-n8n-owner,bootstrap,export,update}.sh
systemd/*
```

| Component | Role |
|-----------|------|
| Git checkout | Blueprint: Compose file, Dockerfiles, workflow JSON, credential templates, SFTP startup hook, authorized `.pub` keys |
| `.env` | Instance identity: encryption key, owner login, API keys, bind addresses. Created from `.env.example` by `scripts/init.sh` |
| `files-init` | One-shot: chowns shared `n8n_files` and `sftp_data` to uid/gid `1000` so n8n, pyautoflip, and SFTP can write |
| `sftp` | Starts with `sftp/setup.sh`: durable host keys in `sftp_host_keys`, client keys from `sftp/keys/` |
| `n8n` | Custom image (`ffmpeg`, community nodes). On start, hashes `N8N_OWNER_PASSWORD` and provisions the owner from env. SQLite lives in `n8n_data` |
| `pyautoflip` | Custom image; shares `sftp_data` at `/syndicator` with n8n |
| `n8n-reconcile` | Compose profile `reconcile`, not a long-running service. Logs in as owner, renders credential templates from `.env`, imports workflows from git, publishes webhooks |

`bin/syndicator verify` is the operator gate after `docker compose up`: it waits for n8n, runs reconcile, then checks health, webhook registration, pyautoflip, and SFTP. Export and logs are the other CLI commands; they are not part of instantiate.

Git remains source of truth for workflows. n8n's volume is disposable; a new instance re-imports from git. See [ADR 0001](docs/adr/0001-deployment-model.md) and [ADR 0002](docs/adr/0002-disposable-instances.md).

### Runtime structure

Once instantiated, three services collaborate: callers reach **sftp** (files) and **n8n** (webhooks); n8n drives SFTP, **pyautoflip**, and external APIs.
Once instantiated, three services collaborate: callers reach **sftp** (files) and **n8n** (webhooks); n8n reads and writes the shared SFTP volume, **pyautoflip**, and external APIs.

```mermaid
flowchart LR
Expand All @@ -232,8 +248,8 @@ flowchart LR
end
Caller -->|key auth SFTP| SFTP
Caller -->|webhooks| N8N
N8N -->|FTP host=sftp| SFTP
N8N -->|HTTP /reframe on /files| PyAF
N8N -->|shared volume /syndicator| SFTP
N8N -->|HTTP /reframe on /syndicator| PyAF
N8N --> OpenAI["OpenAI"]
N8N --> Postiz["Postiz"]
N8N --> Hugo["Hugo site tree"]
Expand All @@ -242,13 +258,13 @@ flowchart LR
| Service | Role |
|---------|------|
| `sftp` | Key-only SFTP on port `2222`; chroot home with `/syndicator/…`; host keys in `sftp_host_keys` |
| `n8n` | Workflow engine; SQLite in `n8n_data`; shares `n8n_files` → `/files` with pyautoflip |
| `pyautoflip` | Reel reframing sidecar (`HTTP /reframe` on `/files`) |
| `n8n` | Workflow engine; SQLite in `n8n_data`; shares `n8n_files` → `/files` with pyautoflip and `sftp_data` → `/syndicator` |
| `pyautoflip` | Reel reframing sidecar (`HTTP /reframe` on `/syndicator`) |

| Workflow | Role |
|----------|------|
| Blog Post Publish | Webhook `/publish` → Hugo adapt + social feature adapt |
| Reel Publish | Webhook `/reel` → adapt → caption → Postiz |
| Blog Post Publish | Webhook `/webhook/publish` → Hugo adapt + social feature adapt |
| Reel Publish | Webhook `/webhook/reel` → adapt → caption → Postiz |

For brevity, subworkflows invoked by these workflows are not listed here.

Loading