CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s
On a LAN the login form is reachable by people you already trust. Exposed, it is reachable by everyone, and nothing in front of it was counting. Three credential routes — /login, /register and /device-login — now throttle. Every attempt is counted against BOTH the account and the calling address, and either can refuse it. The account key is the one that matters and the one that cannot be forged: it stops stuffing against a known email no matter how many addresses the attempts arrive from. The address key bounds one source spraying many accounts, and is best-effort by nature — behind a proxy it comes from X-Forwarded-For, which a caller can set to anything if the app is exposed directly. That is exactly why it isn't the only key. The check runs BEFORE the password is verified, which is the other half of what this protects. bcrypt is deliberately slow; an unauthenticated caller who can trigger it without limit has a CPU exhaustion primitive as well as a guessing one. Sliding rather than fixed windows, because a fixed one lets twice the limit through across a boundary. Bucket count is capped so a rotating forged header can't turn the limiter into the exhaustion it prevents. A sign-in against an email with no account now spends a real bcrypt against a throwaway hash first. Without it "no such account" returned in microseconds while a wrong password took ~100ms, which is a reliable oracle for which emails are registered here. Every response carries a CSP with script-src 'self', object-src 'none' and frame-ancestors 'none', plus nosniff, a referrer policy and a permissions policy. The app has no inline and no third-party scripts, so this concedes nothing; the exceptions are honest — inline STYLE (Vue writes it itself for v-show and the FLIP), and remote images (a link preview renders the og:image of an arbitrary host, over either scheme, since a LAN install is served over http). HSTS only where the request already arrived over TLS, and scoped to the one host: no includeSubDomains, no preload, neither of which is this app's to commit. X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's Secure flag and HSTS are the same question, and answering it twice is how the two drift apart. docs/public-hosting.md is the rest of it: the four things only the operator can do (close registration, terminate TLS and forward the scheme, stop publishing the app port, back up the attachment volume as well as the database), and an honest list of what the app does NOT have — no email verification, no password reset, no second factor, no per-user quota, no audit log. Those aren't blockers for an instance whose accounts are people you know. They're the reason not to leave signups open to strangers.
127 lines
4.6 KiB
Markdown
127 lines
4.6 KiB
Markdown
# ThoughtSync
|
|
|
|
Self-hosted personal thought-capture web app in the **FabledSword** family — a
|
|
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
|
|
under a second, designed to grow into a lightweight second brain (labels, search,
|
|
`[[wiki-links]]`, graph).
|
|
|
|
- **Stack:** Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI.
|
|
- **Auth:** native email + password (signed-cookie sessions). Multi-user with the
|
|
family owner + direct-share + group-share ACL.
|
|
- **Web-first.** An Android companion is a later, conditional milestone.
|
|
|
|
## Layout
|
|
|
|
```
|
|
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
|
|
alembic/ async migrations (schema built via `alembic upgrade head`)
|
|
tests/ DB-free unit tests (pytest)
|
|
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
|
|
Dockerfile 2-stage build (Vue -> python:3.12-slim runtime)
|
|
docker-compose.yml local app + Postgres stack
|
|
```
|
|
|
|
## Development
|
|
|
|
Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`):
|
|
|
|
```sh
|
|
pip install -e ".[dev]"
|
|
alembic upgrade head
|
|
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
|
|
```
|
|
|
|
Frontend (proxies `/api` to `:5000`):
|
|
|
|
```sh
|
|
cd frontend
|
|
npm install
|
|
npm run dev # http://localhost:5173
|
|
```
|
|
|
|
Or run it in Docker. **Hot-reload dev stack** (edit code, changes reload live) —
|
|
Postgres + backend + Vite dev server, app at http://localhost:5173:
|
|
|
|
```sh
|
|
docker compose -f docker-compose.dev.yml up
|
|
```
|
|
|
|
Or the **full production image** (SPA baked in) at http://localhost:5000:
|
|
|
|
```sh
|
|
docker compose up --build
|
|
```
|
|
|
|
## Deploy (self-host)
|
|
|
|
A complete two-container stack (app + Postgres) you can copy and run. Save it as
|
|
`docker-compose.yml`, change the two `CHANGE_ME` passwords (they must match), then
|
|
`docker compose up -d`:
|
|
|
|
```yaml
|
|
services:
|
|
db:
|
|
image: postgres:16-alpine
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: thoughtsync
|
|
POSTGRES_PASSWORD: CHANGE_ME # change this
|
|
POSTGRES_DB: thoughtsync
|
|
volumes:
|
|
- thoughtsync-db:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
|
|
app:
|
|
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
|
|
restart: unless-stopped
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
|
|
volumes:
|
|
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
|
|
ports:
|
|
- "5000:5000"
|
|
|
|
volumes:
|
|
thoughtsync-db:
|
|
thoughtsync-data:
|
|
```
|
|
|
|
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
|
|
|
|
- **Only `THOUGHTSYNC_DATABASE_URL` is required.** `THOUGHTSYNC_SECRET_KEY` is optional; if
|
|
unset, a signing key is generated and persisted in the database (sessions survive restarts).
|
|
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
|
|
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
|
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
|
|
`:<git-sha>` (immutable, for pinning / rollback).
|
|
- **Putting it on the public internet:** there are four things to do first — close
|
|
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
|
|
port, and back up the attachment volume as well as the database. See
|
|
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
|
|
hardens on its own and what it deliberately doesn't.
|
|
- **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the
|
|
browser's install button) for an app-like window. Browsers only offer install over a
|
|
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
|
|
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
|
|
|
|
## Milestones
|
|
|
|
- **M0 — Foundation & Identity** ✅: Quart+Vue+Postgres skeleton, native auth,
|
|
sharing-ACL spine, Fabled-Git CI.
|
|
- **M1 — Capture Core** ✅: note model + masonry board (quick-add, colors, pin,
|
|
edit-in-place, archive, trash+restore).
|
|
- **M1.5 — Roles & DB-backed Settings** ✅: first user is admin, admin Settings UI,
|
|
`DATABASE_URL` is the only required env.
|
|
- M2 — Organize: labels/tags, search, checklists, attachments.
|
|
- M3 — Second-brain seeds: `[[wiki-links]]`, backlinks, graph view, reminders.
|
|
- M4 — Launch hardening: responsive/PWA, settings UI, polish.
|
|
|
|
Work happens on `dev`; `main` is protected (PR-only).
|