diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1a2ba62 --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# ThoughtSync production settings. Copy to `.env` and edit: +# +# cp .env.example .env +# +# Only POSTGRES_PASSWORD has no default — compose refuses to start without it. +# Everything else here is optional. Anything NOT in this file (site name, signups, +# attachment limits, trash retention, link previews) is configured in the admin +# Settings UI and stored in the database, not here. + +# --- required --------------------------------------------------------------- + +# Generate one and keep it: changing it later means also changing it inside the +# database, or Postgres will reject the app's connection. +# +# openssl rand -base64 24 | tr -d '/+=' | head -c 32 +# +# Stick to letters and digits. This value goes into a connection URL, so a `@`, +# `/`, `:` or `#` in it will be misparsed as URL structure rather than password. +POSTGRES_PASSWORD= + +# --- optional --------------------------------------------------------------- + +# Which build to run. +# +# latest tracks the `main` branch — the production line (default) +# dev tracks the `dev` branch — newer, less settled +# pins one exact build; every push publishes one, and this is +# the rollback lever when an upgrade misbehaves +# +# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing, +# check which branch it actually landed on before assuming a bug. +#THOUGHTSYNC_TAG=latest + +# The host port the app is published on. +#THOUGHTSYNC_PORT=5000 + +# Which interface to bind. The default (all interfaces) is what lets desktop +# clients on your network reach the server. Behind a reverse proxy, set this to +# 127.0.0.1 so only the proxy can talk to it. +#THOUGHTSYNC_BIND=0.0.0.0 + +# Database identity. Changing these AFTER the first start does not rename anything +# that already exists — the volume keeps whatever the first run created. +#POSTGRES_USER=thoughtsync +#POSTGRES_DB=thoughtsync + +# --- a note on HTTPS -------------------------------------------------------- +# +# The app marks its session cookie Secure automatically when a request arrives over +# HTTPS, directly or via a proxy setting X-Forwarded-Proto — no setting needed. +# +# Worth knowing if you use the desktop app: typing a bare hostname there defaults to +# https://, deliberately, so a device token never crosses the wire in cleartext by +# accident. Serving over plain HTTP means typing the `http://` yourself. diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index bc158ef..dbb595d 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -91,8 +91,11 @@ jobs: else echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts." fi + version="$(sh ../packaging/build-version.sh)" + echo "Building version $version" cargo tauri build \ --config '{"build":{"beforeBuildCommand":""}}' \ + --config "{\"version\":\"$version\"}" \ --config "$updater" working-directory: desktop/src-tauri @@ -217,12 +220,23 @@ jobs: # the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link). # Frontend already built above; skip the beforeBuildCommand rebuild. - name: Tauri build (NSIS installer) + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | + version="$(sh ../packaging/build-version.sh)" + echo "Building version $version" + updater='{}' + if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + updater='{"bundle":{"createUpdaterArtifacts":true}}' + fi cargo tauri build \ --runner cargo-xwin \ --target x86_64-pc-windows-msvc \ --bundles nsis \ - --config '{"build":{"beforeBuildCommand":""}}' + --config '{"build":{"beforeBuildCommand":""}}' \ + --config "{\"version\":\"$version\"}" \ + --config "$updater" working-directory: desktop/src-tauri - name: Upload installer @@ -292,8 +306,10 @@ jobs: echo "manifest to write. Add the secret to enable in-app updates." exit 0 fi - # The version the bundles carry, read from the crate rather than guessed. - version="$(grep -m1 '^version' desktop/src-tauri/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" + # The SAME helper the bundles were built with — a second derivation here + # could drift, and a manifest whose version doesn't match the binary it + # points at is an updater that never settles. + version="$(sh desktop/packaging/build-version.sh)" if [ "${GITHUB_REF_NAME}" = "dev" ]; then export RELEASE_TAG=dev export RELEASE_NOTES="Development build from ${GITHUB_SHA}" diff --git a/desktop/packaging/build-version.sh b/desktop/packaging/build-version.sh new file mode 100755 index 0000000..9f3dd5b --- /dev/null +++ b/desktop/packaging/build-version.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env sh +# +# Echo the version this build should carry. One definition, used in three places +# (both bundle jobs and the manifest writer) — if they ever disagreed, the app would +# compare its own version against a manifest describing a different build, and the +# updater would either offer nothing or loop forever offering the same thing. +# +# WHY DEV BUILDS NEED THEIR OWN VERSION AT ALL: +# an updater decides by comparing semver. Every dev build carries the version in +# Cargo.toml, so without this they'd all be `0.1.0` — an installed build would see a +# manifest advertising the version it already has, conclude it was current, and never +# update. The rolling channel needs a number that actually rises. +# +# The CI run number is that number: monotonic, already unique per build, and it needs +# no state carried between runs. `0.1.0` + run 2932 becomes `0.1.2932`. +# +# Plain semver on purpose, NOT a `-dev.N` prerelease tag: prerelease versions sort +# BELOW the release they qualify (`0.1.0-dev.5` < `0.1.0`), so a tagged build would +# never update to a newer dev one, and Windows installer metadata wants a numeric +# X.Y.Z anyway. Bumping the minor in Cargo.toml still wins over any dev build on the +# old line, which is the ordering you want: 0.2.0 > 0.1.2932. +set -eu + +CARGO_TOML="$(dirname "$0")/../src-tauri/Cargo.toml" +base="$(grep -m1 '^version' "$CARGO_TOML" | sed -E 's/.*"([^"]+)".*/\1/')" + +# Dev builds only. Anything else (a v* tag, main) ships the version as written. +if [ "${GITHUB_REF_NAME:-}" = "dev" ] && [ -n "${GITHUB_RUN_NUMBER:-}" ]; then + printf '%s.%s\n' "${base%.*}" "$GITHUB_RUN_NUMBER" +else + printf '%s\n' "$base" +fi diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 6b7b7a6..dada888 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -32,7 +32,7 @@ "endpoints": [ "https://git.fabledsword.com/bvandeusen/thoughtsync/releases/download/stable/latest.json" ], - "pubkey": "", + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkwRTk2RkVBMkY2RDlCNkEKUldScW0yMHY2bS9wa0VBdWFpM3c1d2trQnlNVUJXUUtwZXBzQjduM3FRVzdGa3dXNGxObkZFV28K", "windows": { "installMode": "passive" } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 669a328..e788cba 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,7 +5,11 @@ # docker compose -f docker-compose.dev.yml up # # Then open http://localhost:5173 (the Vite dev server proxies /api to the backend). -# (docker-compose.yml, by contrast, builds + runs the production image on :5000.) +# +# docker-compose.yml — the DEFAULT file — is the production stack instead: it pulls +# the published image, keeps Postgres off the host network, and expects a .env. This +# one builds nothing and is deliberately insecure-by-convenience (weak password, +# Postgres published on 5432) because it is meant for a laptop, not a deployment. services: db: image: postgres:16-alpine diff --git a/docker-compose.yml b/docker-compose.yml index 56de89c..26ed5ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,34 +1,98 @@ -# Local two-service stack (app + Postgres). Provided for convenience — per family -# rule 12 the agent does NOT start this; run it yourself with `docker compose up`. +# ThoughtSync — PRODUCTION stack (app + Postgres). +# +# This is the default compose file: `docker compose up -d` runs a real deployment +# from the published image. Development lives in docker-compose.dev.yml (hot-reload, +# builds from source). +# +# cp .env.example .env # then set POSTGRES_PASSWORD +# docker compose up -d +# +# Per family rule 12 the agent does NOT start this — run it yourself. +# +# Upgrades: docker compose pull && docker compose up -d +# Rollback: set THOUGHTSYNC_TAG to a commit sha in .env, then the same two commands. +# Every push publishes an immutable : image for exactly this. +# +# Schema migrations run automatically at container start (see the Dockerfile CMD), +# so an upgrade is just a pull and a restart. Take a backup first anyway: +# +# docker compose exec -T db pg_dump -U thoughtsync thoughtsync > backup.sql +# +# Attachments are files, not rows — they live in the `thoughtsync-data` volume and +# a pg_dump does NOT contain them. Back up both or you'll restore notes whose images +# are gone. + services: db: image: postgres:16-alpine + restart: unless-stopped environment: - POSTGRES_USER: thoughtsync - POSTGRES_PASSWORD: thoughtsync - POSTGRES_DB: thoughtsync + POSTGRES_USER: ${POSTGRES_USER:-thoughtsync} + # No default on purpose. A production compose that ships a known password is + # how self-hosted databases end up in search engines; compose fails fast here + # instead, with the message below. + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env — see .env.example} + POSTGRES_DB: ${POSTGRES_DB:-thoughtsync} volumes: + # Volume names kept from the previous compose file so an existing deployment + # upgrades in place. Renaming them would silently start against an empty + # database while the old one sat there, orphaned and looking like data loss. - thoughtsync-db:/var/lib/postgresql/data - ports: - - "5432:5432" + # Deliberately NOT published to the host. The app reaches Postgres over the + # compose network; exposing 5432 only widens the attack surface. If you need + # psql, `docker compose exec db psql -U thoughtsync` gets you there without it. healthcheck: - test: ["CMD-SHELL", "pg_isready -U thoughtsync"] - interval: 5s + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-thoughtsync} -d ${POSTGRES_DB:-thoughtsync}"] + interval: 10s timeout: 5s retries: 10 + start_period: 30s + logging: &logging + driver: json-file + options: + # Unbounded container logs are a slow-motion disk-full outage on a + # long-running self-hosted box. + max-size: "10m" + max-file: "3" app: - build: . + # :latest tracks `main`. Set THOUGHTSYNC_TAG=dev in .env to follow the + # development line instead, or a commit sha to pin exactly. + image: git.fabledsword.com/bvandeusen/thoughtsync:${THOUGHTSYNC_TAG:-latest} restart: unless-stopped depends_on: db: condition: service_healthy environment: - THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:thoughtsync@db:5432/thoughtsync + # The only required application setting. Everything else a person might want + # to tune lives in the admin Settings UI, backed by the database (rule 25). + THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-thoughtsync}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-thoughtsync} volumes: + # Uploaded attachments. /var/thoughtsync is fixed in the app (Config.DATA_DIR), + # not configurable — mount it or lose every image on container recreation. - thoughtsync-data:/var/thoughtsync ports: - - "5000:5000" + # Default binds every interface, which is what lets desktop clients on the LAN + # reach it. Behind a reverse proxy, set THOUGHTSYNC_BIND=127.0.0.1 so only the + # proxy can talk to it. + - "${THOUGHTSYNC_BIND:-0.0.0.0}:${THOUGHTSYNC_PORT:-5000}:5000" + healthcheck: + # python rather than curl: the runtime image is python:3.12-slim and carries no + # HTTP client binary. Hits the app's own /api/health. + test: + - CMD + - python + - -c + - | + import sys, urllib.request + sys.exit(0 if urllib.request.urlopen("http://127.0.0.1:5000/api/health", timeout=5).status == 200 else 1) + interval: 30s + timeout: 10s + retries: 5 + # Generous: the container waits for Postgres and runs migrations before it + # serves anything, and a first boot builds the whole schema. + start_period: 60s + logging: *logging volumes: thoughtsync-db: