diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 649b97364..e4b68dc8f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -5,3 +5,9 @@ set -e cd "$(git rev-parse --show-toplevel)" make lint make check-content +# Extra Go lint when api/ paths are staged (make lint already runs api-lint if go present) +if git diff --cached --name-only | grep -q '^api/'; then + if command -v go >/dev/null 2>&1; then + make api-lint + fi +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 770249bc0..aae6fbde4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,3 +116,21 @@ jobs: - name: Content quality gates run: make quality continue-on-error: true + + api: + runs-on: ubuntu-latest + defaults: + run: + working-directory: api + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: api/go.mod + cache-dependency-path: api/go.sum + - name: Lint + run: cd .. && make api-lint + - name: Test + run: cd .. && make api-test + - name: Build + run: CGO_ENABLED=0 go build -o /tmp/htlb-api ./cmd/htlb-api diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml new file mode 100644 index 000000000..774636ef7 --- /dev/null +++ b/.github/workflows/deploy-api.yml @@ -0,0 +1,50 @@ +name: deploy-api +# Lint/test/build htlb-api and rsync binary to dlgrv (api.dlgrv.com). + +on: + push: + branches: [main] + paths: + - 'api/**' + - 'deploy/htlb-api.service' + - 'deploy/scripts/deploy-api.sh' + - '.github/workflows/deploy-api.yml' + workflow_dispatch: + +concurrency: + group: deploy-api + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: api/go.mod + cache-dependency-path: api/go.sum + - name: Lint + run: make api-lint + - name: Test + run: make api-test + - name: Build linux/amd64 + run: make api-build + - name: Setup SSH + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + test -n "$DEPLOY_SSH_KEY" + mkdir -p ~/.ssh + echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true + - name: Deploy binary + env: + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + HEALTH_URL: https://api.dlgrv.com/api/htlb/v1/health + run: | + DEPLOY_USER="$DEPLOY_USER" DEPLOY_HOST="$DEPLOY_HOST" HEALTH_URL="$HEALTH_URL" \ + bash deploy/scripts/deploy-api.sh deploy diff --git a/.github/workflows/deploy-book.yml b/.github/workflows/deploy-book.yml new file mode 100644 index 000000000..5d5441ff3 --- /dev/null +++ b/.github/workflows/deploy-book.yml @@ -0,0 +1,56 @@ +name: deploy-book +# Build static book and rsync to dlgrv (book.dlgrv.com). + +on: + push: + branches: [main] + paths: + - 'book/**' + - 'site/**' + - 'forge/site/**' + - 'README*.md' + - 'docs/research/**' + - 'translate/langs.json' + - '.github/workflows/deploy-book.yml' + - 'deploy/scripts/deploy-book.sh' + workflow_dispatch: + +concurrency: + group: deploy-book + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + env: + PYTHONPATH: ${{ github.workspace }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install deps + run: | + python3 -m venv .venv + .venv/bin/pip install -r requirements-dev.txt + - name: Build + stage + run: | + .venv/bin/python3 forge/site/build_pages.py + .venv/bin/python3 forge/site/pages_artifact.py + test -f .publish/en/index.html + - name: Setup SSH + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + test -n "$DEPLOY_SSH_KEY" + mkdir -p ~/.ssh + echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true + - name: Rsync to dlgrv + env: + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + DEPLOY_USER="$DEPLOY_USER" DEPLOY_HOST="$DEPLOY_HOST" bash deploy/scripts/deploy-book.sh diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5c5b5cb10..e3b292cb2 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,5 +1,5 @@ name: pages -# Deploy to GitHub Pages only after workflow "test" succeeds on main. +# GitHub Pages is a redirect stub to https://book.dlgrv.com (canonical book host). on: workflow_run: @@ -24,25 +24,51 @@ jobs: (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') runs-on: ubuntu-latest - env: - PYTHONPATH: ${{ github.workspace }} steps: - - uses: actions/checkout@v4 - with: - # workflow_run checkout defaults to default branch tip; pin to the - # commit that passed tests when triggered by workflow_run. - ref: ${{ github.event.workflow_run.head_sha || github.sha }} - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - name: Install deps - run: pip install -r requirements-dev.txt - - name: Build site pages - run: python3 forge/site/build_pages.py - - name: Stage Pages artifact - run: python3 forge/site/pages_artifact.py - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + - name: Stage redirect stub + run: | + mkdir -p .publish + cat > .publish/index.html <<'HTML' + + + + + + + Moved to book.dlgrv.com + + + +

This book now lives at book.dlgrv.com.

+ + + HTML + # Also redirect common locale paths via meta pages + for lang in en ru zh es pt ar id; do + mkdir -p ".publish/$lang" + cat > ".publish/$lang/index.html" < + + + +

Continue

+ HTML + done + mkdir -p .publish/login + cat > .publish/login/index.html <<'HTML' + + + + +

Continue

+ HTML + - uses: actions/upload-pages-artifact@v3 with: path: .publish diff --git a/.gitignore b/.gitignore index cf5d2400e..effe4dc06 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,9 @@ venv/ # archived agent session notes (not part of the live pipeline) docs/agent-sessions/ + +# htlb-api local state +api/.local/ +api/pb_data/ +api/bin/ +api/coverage.out diff --git a/Makefile b/Makefile index 5b16e2fec..d5d33ee71 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,11 @@ RUFF = .venv/bin/ruff DJLINT = .venv/bin/djlint YAMLLINT = .venv/bin/yamllint -.PHONY: help sync-upstream digest assemble verify verify-all wave repair status lint format test test-integration ci og og-html update-readme hooks check-commit-msg pages-artifact serve web-build quality style lt check-content check-links ebook-deps ebook-test ebook-epub ebook-pdf ebooks +.PHONY: help sync-upstream digest assemble verify verify-all wave repair status lint format test test-integration ci og og-html update-readme hooks check-commit-msg pages-artifact serve web-build quality style lt check-content check-links ebook-deps ebook-test ebook-epub ebook-pdf ebooks api-dev api-test api-lint api-format api-build api-smoke api-vuln api-cover + +GOLANGCI_LINT_VERSION := v2.1.6 +GOLANGCI_LINT := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) +API_DIR := api # Locale list must stay in sync with translate/langs.json (registry). OG_HTML = forge/og/en.html forge/og/ru.html forge/og/es.html forge/og/zh.html forge/og/pt.html forge/og/ar.html forge/og/id.html @@ -35,6 +39,10 @@ ci: ## Local CI ≈ GitHub test job (tests+lint+links+content+pages+artifact) @echo "=== Pages artifact ===" $(PY) forge/site/pages_artifact.py @test -f .publish/en/index.html && test -f .publish/README.md && test -d .publish/book + @echo "=== API ===" + $(MAKE) api-lint + $(MAKE) api-test + cd $(API_DIR) && CGO_ENABLED=0 go build -o /tmp/htlb-api-ci ./cmd/htlb-api hooks: ## Install local git hooks (commit-msg + pre-commit lint/content + pre-push tests) git config core.hooksPath .githooks @@ -59,6 +67,35 @@ quality: ## Content quality (readability + style --book). Usage: make quality [ $(PY) translate/shelf/style_check.py --book --lang $$lang --strict || exit 1; \ done + +# --- htlb-api (Go / PocketBase) --- +api-dev: ## Run API locally on :8090 + cd $(API_DIR) && HTLB_DEV=1 HTLB_VOTE_SALT=$${HTLB_VOTE_SALT:-dev} HTLB_CORS_ORIGINS=$${HTLB_CORS_ORIGINS:-http://127.0.0.1:8000,http://localhost:8000} go run ./cmd/htlb-api serve --http=127.0.0.1:8090 + +api-test: ## Go unit/integration tests (CGO_ENABLED=0) + cd $(API_DIR) && CGO_ENABLED=0 go test ./... + +api-cover: ## Go coverage report + cd $(API_DIR) && CGO_ENABLED=0 go test ./... -coverprofile=coverage.out + cd $(API_DIR) && go tool cover -func=coverage.out | tail -n 1 + +api-lint: ## golangci-lint + go mod tidy -diff + cd $(API_DIR) && go mod tidy -diff + cd $(API_DIR) && go run $(GOLANGCI_LINT) run ./... + +api-format: ## gofumpt via golangci-lint formatters + cd $(API_DIR) && gofmt -w $$(find . -name '*.go' -not -path './.local/*') + +api-build: ## Build linux/amd64 static binary to api/bin/htlb-api + mkdir -p $(API_DIR)/bin + cd $(API_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=$$(git rev-parse --short HEAD)" -o bin/htlb-api ./cmd/htlb-api + +api-smoke: ## Smoke against ephemeral binary + pb_data + bash $(API_DIR)/scripts/smoke.sh + +api-vuln: ## govulncheck + cd $(API_DIR) && go run golang.org/x/vuln/cmd/govulncheck@latest ./... + help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' @@ -190,10 +227,12 @@ lint: ## All code linters (must match CI) $(DJLINT) site/index.html --lint $(YAMLLINT) .github/workflows/ shellcheck translate/steps/translate/*.sh + @if command -v go >/dev/null 2>&1; then $(MAKE) api-lint; else echo "skip api-lint (go not on PATH)"; fi test: ## Run unit tests (excludes integration) $(PY) -m pytest translate/validate/tests/ translate/llm/tests/ forge/site/tests/ -v --ignore=translate/validate/tests/integration node --test forge/ebook/book.test.mjs + @if command -v go >/dev/null 2>&1; then $(MAKE) api-test; else echo "skip api-test (go not on PATH)"; fi ebook-deps: ## Install forge/ebook npm dependencies npm ci --prefix forge/ebook diff --git a/api/.golangci.yml b/api/.golangci.yml new file mode 100644 index 000000000..0d5d320a9 --- /dev/null +++ b/api/.golangci.yml @@ -0,0 +1,22 @@ +version: "2" +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - gosec + - errorlint + - bodyclose + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling +formatters: + enable: + - gofumpt + - goimports diff --git a/api/README.md b/api/README.md new file mode 100644 index 000000000..fc73e8182 --- /dev/null +++ b/api/README.md @@ -0,0 +1,45 @@ +# htlb-api + +Go backend for HowToLiveBetter interactive features (PocketBase as a library). + +## Public API + +Base: `https://api.dlgrv.com/api/htlb/v1` + +| Method | Path | Auth | +|--------|------|------| +| GET | `/health` | none | +| POST | `/guest/session` | none | +| POST | `/guest/claim-code` | `X-HTLB-Sync` | +| POST | `/guest/claim` | body `{code}` | +| GET/POST | `/useful` | optional / required | +| GET/PUT/DELETE | `/bookmarks` | guest or user | +| GET/PUT | `/reading` | guest or user | +| POST | `/merge` | `Authorization` + `X-HTLB-Sync` | + +Guest credential: header `X-HTLB-Sync` only. User JWT: `Authorization: Bearer …`. + +OAuth: Google + GitHub only (password auth disabled). Configure via env: + +- `HTLB_OAUTH_GOOGLE_CLIENT_ID` / `HTLB_OAUTH_GOOGLE_CLIENT_SECRET` +- `HTLB_OAUTH_GITHUB_CLIENT_ID` / `HTLB_OAUTH_GITHUB_CLIENT_SECRET` +- `HTLB_CORS_ORIGINS` (comma-separated) +- `HTLB_VOTE_SALT` +- `HTLB_DATA_DIR` (default `./pb_data`) + +## Local + +```bash +cd api +export HTLB_VOTE_SALT=dev +export HTLB_CORS_ORIGINS=http://127.0.0.1:8000 +make -C .. api-dev +``` + +## Build + +```bash +CGO_ENABLED=0 go build -o bin/htlb-api ./cmd/htlb-api +``` + +Attribution: PocketBase (MIT) — see `deploy/THIRD_PARTY.md`. diff --git a/api/cmd/htlb-api/main.go b/api/cmd/htlb-api/main.go new file mode 100644 index 000000000..f8c5c3273 --- /dev/null +++ b/api/cmd/htlb-api/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "log" + "os" + + internalapp "github.com/dlgrv/HowToLiveBetter/api/internal/app" + "github.com/dlgrv/HowToLiveBetter/api/internal/config" + _ "github.com/dlgrv/HowToLiveBetter/api/internal/migrations" + "github.com/pocketbase/pocketbase" + _ "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/plugins/migratecmd" +) + +// Version is overridden via -ldflags at release build time. +var Version = "dev" + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + if cfg.Version == "dev" && Version != "dev" { + cfg.Version = Version + } + + pbCfg := pocketbase.Config{ + DefaultDev: os.Getenv("HTLB_DEV") == "1", + } + if cfg.DataDir != "" { + pbCfg.DefaultDataDir = cfg.DataDir + } + if enc := os.Getenv("HTLB_ENCRYPTION_ENV"); enc != "" { + pbCfg.DefaultEncryptionEnv = enc + } + + app := pocketbase.NewWithConfig(pbCfg) + + migrateDir := cfg.MigrationsDir + if migrateDir == "" { + migrateDir = "internal/migrations" + } + migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{ + Automigrate: false, + Dir: migrateDir, + }) + + internalapp.Bind(app, cfg) + + if err := app.Start(); err != nil { + log.Fatal(err) + } +} diff --git a/api/go.mod b/api/go.mod new file mode 100644 index 000000000..6dcf4d469 --- /dev/null +++ b/api/go.mod @@ -0,0 +1,41 @@ +module github.com/dlgrv/HowToLiveBetter/api + +go 1.23.12 + +require ( + github.com/pocketbase/dbx v1.11.0 + github.com/pocketbase/pocketbase v0.28.4 +) + +require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/disintegration/imaging v1.6.2 // indirect + github.com/domodwyer/mailyak/v3 v3.6.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/ganigeorgiev/fexpr v0.5.0 // indirect + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/cast v1.9.2 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect + golang.org/x/image v0.28.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + modernc.org/libc v1.65.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.0 // indirect +) diff --git a/api/go.sum b/api/go.sum new file mode 100644 index 000000000..2b9bf53b6 --- /dev/null +++ b/api/go.sum @@ -0,0 +1,123 @@ +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= +github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= +github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= +github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU= +github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= +github.com/pocketbase/pocketbase v0.28.4 h1:RmhWXDcfKrFM9/W0G0Zrlv4eKBM8/s/v4SQKytjgD20= +github.com/pocketbase/pocketbase v0.28.4/go.mod h1:jSuN93vE/oeJVOz2D2ZxcYyr2bYNmDOMCUkM+JhyJQ0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= +golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= +modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc= +modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= +modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/api/internal/app/bind.go b/api/internal/app/bind.go new file mode 100644 index 000000000..f2769a685 --- /dev/null +++ b/api/internal/app/bind.go @@ -0,0 +1,106 @@ +package app + +import ( + "net/http" + "os" + "strings" + "time" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/dlgrv/HowToLiveBetter/api/internal/config" + "github.com/dlgrv/HowToLiveBetter/api/internal/guest" + "github.com/dlgrv/HowToLiveBetter/api/internal/library" + "github.com/dlgrv/HowToLiveBetter/api/internal/merge" + "github.com/dlgrv/HowToLiveBetter/api/internal/useful" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +// Bind wires custom routes and runtime auth settings onto the PocketBase app. +func Bind(pb *pocketbase.PocketBase, cfg config.Config) { + pb.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error { + if err := e.Next(); err != nil { + return err + } + return applyOAuth(e.App, cfg) + }) + + pb.OnServe().BindFunc(func(e *core.ServeEvent) error { + e.Router.Bind(apis.CORS(apis.CORSConfig{ + AllowOrigins: cfg.CORSOrigins, + AllowMethods: []string{ + http.MethodGet, http.MethodHead, http.MethodPut, + http.MethodPatch, http.MethodPost, http.MethodDelete, http.MethodOptions, + }, + AllowHeaders: []string{ + "Accept", "Authorization", "Content-Type", "X-HTLB-Sync", + }, + AllowCredentials: false, + })) + + az := authz.Resolver{App: e.App} + gSvc := guest.Service{App: e.App, GuestTTL: cfg.GuestTTL, ClaimTTL: cfg.ClaimTTL} + uSvc := useful.Service{App: e.App, Salt: cfg.VoteSalt} + lSvc := library.Service{App: e.App} + mSvc := merge.Service{App: e.App, Library: lSvc} + + g := e.Router.Group("/api/htlb/v1") + g.GET("/health", func(re *core.RequestEvent) error { + return re.JSON(http.StatusOK, map[string]any{ + "ok": true, + "version": cfg.Version, + "time": time.Now().UTC().Format(time.RFC3339), + }) + }) + guest.Register(g, guest.Handlers{Svc: gSvc, Az: az}) + useful.Register(g, useful.Handlers{Svc: uSvc, Az: az}) + library.Register(g, library.Handlers{Svc: lSvc, Az: az}) + merge.Register(g, merge.Handlers{Svc: mSvc, Az: az}) + + return e.Next() + }) +} + +func applyOAuth(app core.App, cfg config.Config) error { + _ = cfg + users, err := app.FindCollectionByNameOrId("users") + if err != nil { + return err + } + denyAPI(users) + users.PasswordAuth.Enabled = false + + googleID := strings.TrimSpace(os.Getenv("HTLB_OAUTH_GOOGLE_CLIENT_ID")) + googleSecret := strings.TrimSpace(os.Getenv("HTLB_OAUTH_GOOGLE_CLIENT_SECRET")) + githubID := strings.TrimSpace(os.Getenv("HTLB_OAUTH_GITHUB_CLIENT_ID")) + githubSecret := strings.TrimSpace(os.Getenv("HTLB_OAUTH_GITHUB_CLIENT_SECRET")) + + providers := []core.OAuth2ProviderConfig{} + if googleID != "" && googleSecret != "" { + providers = append(providers, core.OAuth2ProviderConfig{ + Name: "google", + ClientId: googleID, + ClientSecret: googleSecret, + }) + } + if githubID != "" && githubSecret != "" { + providers = append(providers, core.OAuth2ProviderConfig{ + Name: "github", + ClientId: githubID, + ClientSecret: githubSecret, + }) + } + users.OAuth2.Providers = providers + users.OAuth2.Enabled = len(providers) > 0 + return app.Save(users) +} + +func denyAPI(c *core.Collection) { + c.ListRule = nil + c.ViewRule = nil + c.CreateRule = nil + c.UpdateRule = nil + c.DeleteRule = nil + c.ManageRule = nil +} diff --git a/api/internal/authz/owner.go b/api/internal/authz/owner.go new file mode 100644 index 000000000..f30aa1df8 --- /dev/null +++ b/api/internal/authz/owner.go @@ -0,0 +1,78 @@ +package authz + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "strings" + "time" + + "github.com/pocketbase/dbx" + + "github.com/pocketbase/pocketbase/core" +) + +const HeaderSync = "X-HTLB-Sync" + +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") +) + +type Kind string + +const ( + KindGuest Kind = "guest" + KindUser Kind = "user" +) + +type Owner struct { + Kind Kind + ID string +} + +type Resolver struct { + App core.App +} + +func HashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func (r Resolver) Resolve(e *core.RequestEvent) (Owner, error) { + if e.Auth != nil { + return Owner{Kind: KindUser, ID: e.Auth.Id}, nil + } + raw := strings.TrimSpace(e.Request.Header.Get(HeaderSync)) + if raw == "" { + return Owner{}, ErrUnauthorized + } + hash := HashToken(raw) + rec, err := r.App.FindFirstRecordByFilter( + "guest_sessions", + "tokenHash = {:h} && status = 'active'", + dbx.Params{"h": hash}, + ) + if err != nil { + return Owner{}, ErrUnauthorized + } + exp := rec.GetDateTime("expiresAt").Time() + if !exp.IsZero() && time.Now().After(exp) { + return Owner{}, ErrUnauthorized + } + stored := rec.GetString("tokenHash") + if subtle.ConstantTimeCompare([]byte(stored), []byte(hash)) != 1 { + return Owner{}, ErrUnauthorized + } + return Owner{Kind: KindGuest, ID: rec.Id}, nil +} + +func (r Resolver) ResolveOptional(e *core.RequestEvent) (Owner, bool) { + o, err := r.Resolve(e) + if err != nil { + return Owner{}, false + } + return o, true +} diff --git a/api/internal/authz/owner_test.go b/api/internal/authz/owner_test.go new file mode 100644 index 000000000..721325395 --- /dev/null +++ b/api/internal/authz/owner_test.go @@ -0,0 +1,66 @@ +package authz_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/dlgrv/HowToLiveBetter/api/internal/testutil" + "github.com/pocketbase/pocketbase/core" +) + +func TestResolveUnauthorized(t *testing.T) { + app := testutil.NewApp(t) + e := &core.RequestEvent{App: app} + e.Request = httptest.NewRequest(http.MethodGet, "/", nil) + _, err := authz.Resolver{App: app}.Resolve(e) + if !errors.Is(err, authz.ErrUnauthorized) { + t.Fatalf("got %v", err) + } +} + +func TestResolveGuest(t *testing.T) { + app := testutil.NewApp(t) + token, owner := testutil.MakeGuest(t, app) + e := &core.RequestEvent{App: app} + e.Request = httptest.NewRequest(http.MethodGet, "/", nil) + e.Request.Header.Set(authz.HeaderSync, token) + got, err := authz.Resolver{App: app}.Resolve(e) + if err != nil { + t.Fatal(err) + } + if got != owner { + t.Fatalf("got %+v want %+v", got, owner) + } +} + +func TestResolveExpiredGuest(t *testing.T) { + app := testutil.NewApp(t) + token, owner := testutil.MakeGuest(t, app) + testutil.ExpireGuest(t, app, owner.ID) + e := &core.RequestEvent{App: app} + e.Request = httptest.NewRequest(http.MethodGet, "/", nil) + e.Request.Header.Set(authz.HeaderSync, token) + _, err := authz.Resolver{App: app}.Resolve(e) + if !errors.Is(err, authz.ErrUnauthorized) { + t.Fatalf("got %v", err) + } +} + +func TestUserAuthTakesPriority(t *testing.T) { + app := testutil.NewApp(t) + token, _ := testutil.MakeGuest(t, app) + user := testutil.MakeUser(t, app, "u@example.com") + e := &core.RequestEvent{App: app, Auth: user} + e.Request = httptest.NewRequest(http.MethodGet, "/", nil) + e.Request.Header.Set(authz.HeaderSync, token) + got, err := authz.Resolver{App: app}.Resolve(e) + if err != nil { + t.Fatal(err) + } + if got.Kind != authz.KindUser || got.ID != user.Id { + t.Fatalf("got %+v", got) + } +} diff --git a/api/internal/config/config.go b/api/internal/config/config.go new file mode 100644 index 000000000..8f0f5e3e6 --- /dev/null +++ b/api/internal/config/config.go @@ -0,0 +1,82 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + CORSOrigins []string + GuestTTL time.Duration + ClaimTTL time.Duration + VoteSalt string + Listen string + DataDir string + MigrationsDir string + Version string +} + +func Load() (Config, error) { + cfg := Config{ + CORSOrigins: splitCSV(env("HTLB_CORS_ORIGINS", "http://127.0.0.1:8000,http://localhost:8000")), + GuestTTL: hoursEnv("HTLB_GUEST_TTL_HOURS", 90*24), + ClaimTTL: minutesEnv("HTLB_CLAIM_TTL_MINUTES", 10), + VoteSalt: env("HTLB_VOTE_SALT", "dev-only-change-me"), + Listen: env("HTLB_LISTEN", "127.0.0.1:8090"), + DataDir: env("HTLB_DATA_DIR", ""), + MigrationsDir: env("HTLB_MIGRATIONS_DIR", ""), + Version: env("HTLB_VERSION", "dev"), + } + if cfg.VoteSalt == "" { + return cfg, fmt.Errorf("HTLB_VOTE_SALT is required") + } + if len(cfg.CORSOrigins) == 0 { + return cfg, fmt.Errorf("HTLB_CORS_ORIGINS is required") + } + return cfg, nil +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func splitCSV(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func hoursEnv(k string, defHours int) time.Duration { + v := os.Getenv(k) + if v == "" { + return time.Duration(defHours) * time.Hour + } + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return time.Duration(defHours) * time.Hour + } + return time.Duration(n) * time.Hour +} + +func minutesEnv(k string, defMin int) time.Duration { + v := os.Getenv(k) + if v == "" { + return time.Duration(defMin) * time.Minute + } + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return time.Duration(defMin) * time.Minute + } + return time.Duration(n) * time.Minute +} diff --git a/api/internal/config/config_test.go b/api/internal/config/config_test.go new file mode 100644 index 000000000..ab3dd9c99 --- /dev/null +++ b/api/internal/config/config_test.go @@ -0,0 +1,20 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/dlgrv/HowToLiveBetter/api/internal/config" +) + +func TestLoadDefaults(t *testing.T) { + os.Unsetenv("HTLB_CORS_ORIGINS") + os.Unsetenv("HTLB_VOTE_SALT") + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + if len(cfg.CORSOrigins) == 0 || cfg.VoteSalt == "" { + t.Fatalf("%+v", cfg) + } +} diff --git a/api/internal/guest/handlers.go b/api/internal/guest/handlers.go new file mode 100644 index 000000000..16421e7f3 --- /dev/null +++ b/api/internal/guest/handlers.go @@ -0,0 +1,65 @@ +package guest + +import ( + "net/http" + "time" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/router" +) + +type Handlers struct { + Svc Service + Az authz.Resolver +} + +func Register(g *router.RouterGroup[*core.RequestEvent], h Handlers) { + g.POST("/guest/session", h.createSession) + g.POST("/guest/claim-code", h.createClaimCode) + g.POST("/guest/claim", h.claim) +} + +func (h Handlers) createSession(e *core.RequestEvent) error { + token, exp, err := h.Svc.CreateSession() + if err != nil { + return e.InternalServerError("failed to create guest session", err) + } + return e.JSON(http.StatusOK, map[string]any{ + "token": token, + "expiresAt": exp.UTC().Format(time.RFC3339), + }) +} + +func (h Handlers) createClaimCode(e *core.RequestEvent) error { + owner, err := h.Az.Resolve(e) + if err != nil || owner.Kind != authz.KindGuest { + return e.UnauthorizedError("guest credential required", nil) + } + code, exp, err := h.Svc.CreateClaimCode(owner.ID) + if err != nil { + return e.InternalServerError("failed to create claim code", err) + } + return e.JSON(http.StatusOK, map[string]any{ + "code": code, + "expiresAt": exp.UTC().Format(time.RFC3339), + }) +} + +func (h Handlers) claim(e *core.RequestEvent) error { + var body struct { + Code string `json:"code"` + } + if err := e.BindBody(&body); err != nil || body.Code == "" { + return e.BadRequestError("code is required", nil) + } + token, exp, err := h.Svc.Claim(body.Code) + if err != nil { + return apis.NewApiError(http.StatusUnauthorized, err.Error(), nil) + } + return e.JSON(http.StatusOK, map[string]any{ + "token": token, + "expiresAt": exp.UTC().Format(time.RFC3339), + }) +} diff --git a/api/internal/guest/service.go b/api/internal/guest/service.go new file mode 100644 index 000000000..b8708be80 --- /dev/null +++ b/api/internal/guest/service.go @@ -0,0 +1,142 @@ +package guest + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/types" +) + +var ( + ErrInvalidClaim = errors.New("invalid or expired claim code") + ErrClaimUsed = errors.New("claim code already used") +) + +type Service struct { + App core.App + GuestTTL time.Duration + ClaimTTL time.Duration +} + +func (s Service) CreateSession() (token string, expiresAt time.Time, err error) { + token, err = randomToken(32) + if err != nil { + return "", time.Time{}, err + } + col, err := s.App.FindCollectionByNameOrId("guest_sessions") + if err != nil { + return "", time.Time{}, err + } + expiresAt = time.Now().Add(s.GuestTTL) + rec := core.NewRecord(col) + rec.Set("tokenHash", authz.HashToken(token)) + rec.Set("status", "active") + dt, err := types.ParseDateTime(expiresAt) + if err != nil { + return "", time.Time{}, err + } + rec.Set("expiresAt", dt) + if err := s.App.Save(rec); err != nil { + return "", time.Time{}, err + } + return token, expiresAt, nil +} + +func (s Service) CreateClaimCode(guestID string) (code string, expiresAt time.Time, err error) { + code, err = randomToken(18) + if err != nil { + return "", time.Time{}, err + } + col, err := s.App.FindCollectionByNameOrId("guest_claims") + if err != nil { + return "", time.Time{}, err + } + expiresAt = time.Now().Add(s.ClaimTTL) + rec := core.NewRecord(col) + rec.Set("codeHash", authz.HashToken(code)) + rec.Set("guestId", guestID) + rec.Set("status", "pending") + dt, _ := types.ParseDateTime(expiresAt) + rec.Set("expiresAt", dt) + if err := s.App.Save(rec); err != nil { + return "", time.Time{}, err + } + return code, expiresAt, nil +} + +func (s Service) Claim(code string) (token string, expiresAt time.Time, err error) { + hash := authz.HashToken(code) + claim, err := s.App.FindFirstRecordByFilter( + "guest_claims", + "codeHash = {:h}", + dbx.Params{"h": hash}, + ) + if err != nil { + return "", time.Time{}, ErrInvalidClaim + } + if claim.GetString("status") != "pending" { + return "", time.Time{}, ErrClaimUsed + } + exp := claim.GetDateTime("expiresAt").Time() + if time.Now().After(exp) { + return "", time.Time{}, ErrInvalidClaim + } + guestID := claim.GetString("guestId") + guest, err := s.App.FindRecordById("guest_sessions", guestID) + if err != nil { + return "", time.Time{}, ErrInvalidClaim + } + if guest.GetString("status") != "active" { + return "", time.Time{}, ErrInvalidClaim + } + // Issue a fresh long-lived token (rotate) bound to same guest session id + token, err = randomToken(32) + if err != nil { + return "", time.Time{}, err + } + expiresAt = time.Now().Add(s.GuestTTL) + err = s.App.RunInTransaction(func(txApp core.App) error { + g, err := txApp.FindRecordById("guest_sessions", guestID) + if err != nil { + return err + } + g.Set("tokenHash", authz.HashToken(token)) + dt, _ := types.ParseDateTime(expiresAt) + g.Set("expiresAt", dt) + if err := txApp.Save(g); err != nil { + return err + } + c, err := txApp.FindRecordById("guest_claims", claim.Id) + if err != nil { + return err + } + c.Set("status", "used") + return txApp.Save(c) + }) + if err != nil { + return "", time.Time{}, fmt.Errorf("claim: %w", err) + } + return token, expiresAt, nil +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// HashClaim is exported for tests. +func HashClaim(code string) string { + sum := sha256.Sum256([]byte(code)) + return hex.EncodeToString(sum[:]) +} diff --git a/api/internal/guest/service_test.go b/api/internal/guest/service_test.go new file mode 100644 index 000000000..f3e8cecba --- /dev/null +++ b/api/internal/guest/service_test.go @@ -0,0 +1,46 @@ +package guest_test + +import ( + "errors" + "testing" + "time" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/dlgrv/HowToLiveBetter/api/internal/guest" + "github.com/dlgrv/HowToLiveBetter/api/internal/testutil" + "github.com/pocketbase/dbx" +) + +func TestCreateAndClaim(t *testing.T) { + app := testutil.NewApp(t) + svc := guest.Service{App: app, GuestTTL: 24 * time.Hour, ClaimTTL: 10 * time.Minute} + token, _, err := svc.CreateSession() + if err != nil { + t.Fatal(err) + } + if len(token) < 20 { + t.Fatalf("token too short: %q", token) + } + rec, err := app.FindFirstRecordByFilter("guest_sessions", "tokenHash = {:h}", dbx.Params{"h": authz.HashToken(token)}) + if err != nil { + t.Fatal(err) + } + if rec.GetString("tokenHash") != authz.HashToken(token) { + t.Fatal("hash mismatch") + } + code, _, err := svc.CreateClaimCode(rec.Id) + if err != nil { + t.Fatal(err) + } + newTok, _, err := svc.Claim(code) + if err != nil { + t.Fatal(err) + } + if newTok == token { + t.Fatal("expected rotated token") + } + _, _, err = svc.Claim(code) + if !errors.Is(err, guest.ErrClaimUsed) { + t.Fatalf("want used, got %v", err) + } +} diff --git a/api/internal/library/handlers.go b/api/internal/library/handlers.go new file mode 100644 index 000000000..97e23d862 --- /dev/null +++ b/api/internal/library/handlers.go @@ -0,0 +1,102 @@ +package library + +import ( + "net/http" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/router" +) + +type Handlers struct { + Svc Service + Az authz.Resolver +} + +func Register(g *router.RouterGroup[*core.RequestEvent], h Handlers) { + g.GET("/bookmarks", h.listBookmarks) + g.PUT("/bookmarks", h.putBookmark) + g.DELETE("/bookmarks", h.deleteBookmark) + g.GET("/reading", h.getReading) + g.PUT("/reading", h.putReading) +} + +func (h Handlers) requireOwner(e *core.RequestEvent) (authz.Owner, error) { + owner, err := h.Az.Resolve(e) + if err != nil { + return owner, e.UnauthorizedError("credential required", nil) + } + return owner, nil +} + +func (h Handlers) listBookmarks(e *core.RequestEvent) error { + owner, err := h.requireOwner(e) + if err != nil { + return err + } + items, err := h.Svc.ListBookmarks(owner) + if err != nil { + return e.InternalServerError("list failed", err) + } + return e.JSON(http.StatusOK, map[string]any{"items": items}) +} + +func (h Handlers) putBookmark(e *core.RequestEvent) error { + owner, err := h.requireOwner(e) + if err != nil { + return err + } + var body struct { + EntryID string `json:"entryId"` + Note string `json:"note"` + } + if err := e.BindBody(&body); err != nil || body.EntryID == "" { + return e.BadRequestError("entryId required", nil) + } + if err := h.Svc.UpsertBookmark(owner, body.EntryID, body.Note); err != nil { + return e.InternalServerError("save failed", err) + } + return e.JSON(http.StatusOK, map[string]any{"ok": true}) +} + +func (h Handlers) deleteBookmark(e *core.RequestEvent) error { + owner, err := h.requireOwner(e) + if err != nil { + return err + } + entryID := e.Request.URL.Query().Get("entryId") + if entryID == "" { + return e.BadRequestError("entryId required", nil) + } + if err := h.Svc.DeleteBookmark(owner, entryID); err != nil { + return e.InternalServerError("delete failed", err) + } + return e.JSON(http.StatusOK, map[string]any{"ok": true}) +} + +func (h Handlers) getReading(e *core.RequestEvent) error { + owner, err := h.requireOwner(e) + if err != nil { + return err + } + r, err := h.Svc.GetReading(owner) + if err != nil { + return e.InternalServerError("get failed", err) + } + return e.JSON(http.StatusOK, r) +} + +func (h Handlers) putReading(e *core.RequestEvent) error { + owner, err := h.requireOwner(e) + if err != nil { + return err + } + var body Reading + if err := e.BindBody(&body); err != nil || body.Chapter == "" { + return e.BadRequestError("chapter required", nil) + } + if err := h.Svc.PutReading(owner, body.Chapter, body.Offset); err != nil { + return e.InternalServerError("save failed", err) + } + return e.JSON(http.StatusOK, map[string]any{"ok": true}) +} diff --git a/api/internal/library/service.go b/api/internal/library/service.go new file mode 100644 index 000000000..687ef4c3f --- /dev/null +++ b/api/internal/library/service.go @@ -0,0 +1,123 @@ +package library + +import ( + "fmt" + "strings" + + "github.com/pocketbase/dbx" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/pocketbase/pocketbase/core" +) + +type Service struct { + App core.App +} + +type Bookmark struct { + EntryID string `json:"entryId"` + Note string `json:"note,omitempty"` +} + +type Reading struct { + Chapter string `json:"chapter"` + Offset float64 `json:"offset"` +} + +func ownerFilter(owner authz.Owner) (string, dbx.Params) { + return "ownerKind = {:k} && ownerId = {:i}", dbx.Params{ + "k": string(owner.Kind), + "i": owner.ID, + } +} + +func (s Service) ListBookmarks(owner authz.Owner) ([]Bookmark, error) { + f, p := ownerFilter(owner) + recs, err := s.App.FindRecordsByFilter("bookmarks", f, "", 0, 0, p) + if err != nil { + return nil, err + } + out := make([]Bookmark, 0, len(recs)) + for _, r := range recs { + out = append(out, Bookmark{ + EntryID: r.GetString("entryId"), + Note: r.GetString("note"), + }) + } + return out, nil +} + +func (s Service) UpsertBookmark(owner authz.Owner, entryID, note string) error { + entryID = strings.TrimSpace(entryID) + if entryID == "" { + return fmt.Errorf("entryId required") + } + col, err := s.App.FindCollectionByNameOrId("bookmarks") + if err != nil { + return err + } + f, p := ownerFilter(owner) + p["e"] = entryID + rec, err := s.App.FindFirstRecordByFilter("bookmarks", f+" && entryId = {:e}", p) + if err != nil { + rec = core.NewRecord(col) + rec.Set("ownerKind", string(owner.Kind)) + rec.Set("ownerId", owner.ID) + rec.Set("entryId", entryID) + } + rec.Set("note", note) + return s.App.Save(rec) +} + +func (s Service) DeleteBookmark(owner authz.Owner, entryID string) error { + f, p := ownerFilter(owner) + p["e"] = entryID + rec, err := s.App.FindFirstRecordByFilter("bookmarks", f+" && entryId = {:e}", p) + if err != nil { + return nil + } + return s.App.Delete(rec) +} + +func (s Service) GetReading(owner authz.Owner) (Reading, error) { + f, p := ownerFilter(owner) + rec, err := s.App.FindFirstRecordByFilter("reading_positions", f, p) + if err != nil { + return Reading{}, nil + } + return Reading{ + Chapter: rec.GetString("chapter"), + Offset: rec.GetFloat("offset"), + }, nil +} + +func (s Service) PutReading(owner authz.Owner, chapter string, offset float64) error { + chapter = strings.TrimSpace(chapter) + if chapter == "" { + return fmt.Errorf("chapter required") + } + col, err := s.App.FindCollectionByNameOrId("reading_positions") + if err != nil { + return err + } + f, p := ownerFilter(owner) + rec, err := s.App.FindFirstRecordByFilter("reading_positions", f, p) + if err != nil { + rec = core.NewRecord(col) + rec.Set("ownerKind", string(owner.Kind)) + rec.Set("ownerId", owner.ID) + } + rec.Set("chapter", chapter) + rec.Set("offset", offset) + return s.App.Save(rec) +} + +// ExportForMerge returns guest data for merge into a user. +func (s Service) ExportForMerge(owner authz.Owner) (bookmarks []Bookmark, reading Reading, err error) { + bookmarks, err = s.ListBookmarks(owner) + if err != nil { + return nil, Reading{}, err + } + reading, err = s.GetReading(owner) + return bookmarks, reading, err +} diff --git a/api/internal/library/service_test.go b/api/internal/library/service_test.go new file mode 100644 index 000000000..a3faa6f30 --- /dev/null +++ b/api/internal/library/service_test.go @@ -0,0 +1,42 @@ +package library_test + +import ( + "testing" + + "github.com/dlgrv/HowToLiveBetter/api/internal/library" + "github.com/dlgrv/HowToLiveBetter/api/internal/testutil" +) + +func TestBookmarksIsolation(t *testing.T) { + app := testutil.NewApp(t) + _, a := testutil.MakeGuest(t, app) + _, b := testutil.MakeGuest(t, app) + svc := library.Service{App: app} + if err := svc.UpsertBookmark(a, "e1", "note"); err != nil { + t.Fatal(err) + } + items, err := svc.ListBookmarks(b) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatalf("leak: %+v", items) + } + items, err = svc.ListBookmarks(a) + if err != nil || len(items) != 1 || items[0].EntryID != "e1" { + t.Fatalf("%v %+v", err, items) + } +} + +func TestReadingUpsert(t *testing.T) { + app := testutil.NewApp(t) + _, o := testutil.MakeGuest(t, app) + svc := library.Service{App: app} + if err := svc.PutReading(o, "02", 0.5); err != nil { + t.Fatal(err) + } + r, err := svc.GetReading(o) + if err != nil || r.Chapter != "02" || r.Offset != 0.5 { + t.Fatalf("%v %+v", err, r) + } +} diff --git a/api/internal/merge/handlers.go b/api/internal/merge/handlers.go new file mode 100644 index 000000000..6c76df7af --- /dev/null +++ b/api/internal/merge/handlers.go @@ -0,0 +1,43 @@ +package merge + +import ( + "net/http" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/router" +) + +type Handlers struct { + Svc Service + Az authz.Resolver +} + +func Register(g *router.RouterGroup[*core.RequestEvent], h Handlers) { + g.POST("/merge", h.merge) +} + +func (h Handlers) merge(e *core.RequestEvent) error { + if e.Auth == nil { + return e.UnauthorizedError("user login required", nil) + } + raw := e.Request.Header.Get(authz.HeaderSync) + if raw == "" { + return e.BadRequestError("X-HTLB-Sync required for merge", nil) + } + hash := authz.HashToken(raw) + guest, err := h.Svc.App.FindFirstRecordByFilter( + "guest_sessions", + "tokenHash = {:h} && status = 'active'", + dbx.Params{"h": hash}, + ) + if err != nil { + return e.UnauthorizedError("invalid guest credential", nil) + } + res, err := h.Svc.MergeGuestIntoUser(guest.Id, e.Auth.Id) + if err != nil { + return e.BadRequestError(err.Error(), nil) + } + return e.JSON(http.StatusOK, res) +} diff --git a/api/internal/merge/service.go b/api/internal/merge/service.go new file mode 100644 index 000000000..29d183075 --- /dev/null +++ b/api/internal/merge/service.go @@ -0,0 +1,75 @@ +package merge + +import ( + "fmt" + + "github.com/pocketbase/dbx" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/dlgrv/HowToLiveBetter/api/internal/library" + "github.com/pocketbase/pocketbase/core" +) + +type Service struct { + App core.App + Library library.Service +} + +type Result struct { + BookmarksMerged int `json:"bookmarksMerged"` + ReadingMerged bool `json:"readingMerged"` +} + +func (s Service) MergeGuestIntoUser(guestID, userID string) (Result, error) { + var res Result + err := s.App.RunInTransaction(func(txApp core.App) error { + guest, err := txApp.FindRecordById("guest_sessions", guestID) + if err != nil { + return fmt.Errorf("guest not found") + } + if guest.GetString("status") != "active" { + return fmt.Errorf("guest already merged or revoked") + } + lib := library.Service{App: txApp} + gOwner := authz.Owner{Kind: authz.KindGuest, ID: guestID} + uOwner := authz.Owner{Kind: authz.KindUser, ID: userID} + + bms, reading, err := lib.ExportForMerge(gOwner) + if err != nil { + return err + } + for _, bm := range bms { + if err := lib.UpsertBookmark(uOwner, bm.EntryID, bm.Note); err != nil { + return err + } + res.BookmarksMerged++ + } + if reading.Chapter != "" { + if err := lib.PutReading(uOwner, reading.Chapter, reading.Offset); err != nil { + return err + } + res.ReadingMerged = true + } + + // Re-home useful votes voterKeys stay salted; also copy owner fields for audit + votes, err := txApp.FindRecordsByFilter("useful_votes", + "ownerKind = 'guest' && ownerId = {:i}", "", 0, 0, + dbx.Params{"i": guestID}, + ) + if err != nil { + return err + } + for _, v := range votes { + v.Set("ownerKind", "user") + v.Set("ownerId", userID) + if err := txApp.Save(v); err != nil { + return err + } + } + + guest.Set("status", "merged") + guest.Set("mergedToUserId", userID) + return txApp.Save(guest) + }) + return res, err +} diff --git a/api/internal/merge/service_test.go b/api/internal/merge/service_test.go new file mode 100644 index 000000000..3236d1f00 --- /dev/null +++ b/api/internal/merge/service_test.go @@ -0,0 +1,44 @@ +package merge_test + +import ( + "testing" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/dlgrv/HowToLiveBetter/api/internal/library" + "github.com/dlgrv/HowToLiveBetter/api/internal/merge" + "github.com/dlgrv/HowToLiveBetter/api/internal/testutil" + "github.com/dlgrv/HowToLiveBetter/api/internal/useful" +) + +func TestMergeGuestIntoUser(t *testing.T) { + app := testutil.NewApp(t) + _, gOwner := testutil.MakeGuest(t, app) + user := testutil.MakeUser(t, app, "merge@example.com") + lib := library.Service{App: app} + _ = lib.UpsertBookmark(gOwner, "bm1", "") + _ = lib.PutReading(gOwner, "03", 0.2) + uSvc := useful.Service{App: app, Salt: "s"} + _, _ = uSvc.Toggle(gOwner, "e1", true) + + svc := merge.Service{App: app, Library: lib} + res, err := svc.MergeGuestIntoUser(gOwner.ID, user.Id) + if err != nil { + t.Fatal(err) + } + if res.BookmarksMerged != 1 || !res.ReadingMerged { + t.Fatalf("%+v", res) + } + uOwner := authz.Owner{Kind: authz.KindUser, ID: user.Id} + items, _ := lib.ListBookmarks(uOwner) + if len(items) != 1 { + t.Fatalf("bookmarks %+v", items) + } + rec, err := app.FindRecordById("guest_sessions", gOwner.ID) + if err != nil || rec.GetString("status") != "merged" { + t.Fatalf("status %v %v", err, rec) + } + _, err = svc.MergeGuestIntoUser(gOwner.ID, user.Id) + if err == nil { + t.Fatal("expected second merge to fail") + } +} diff --git a/api/internal/migrations/1760000001_htlb_collections.go b/api/internal/migrations/1760000001_htlb_collections.go new file mode 100644 index 000000000..6cb6e7879 --- /dev/null +++ b/api/internal/migrations/1760000001_htlb_collections.go @@ -0,0 +1,150 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" +) + +func init() { + core.AppMigrations.Register(upHTLBCollections, downHTLBCollections, "1760000001_htlb_collections.go") +} + +func denyAll(c *core.Collection) { + c.ListRule = nil + c.ViewRule = nil + c.CreateRule = nil + c.UpdateRule = nil + c.DeleteRule = nil +} + +func upHTLBCollections(app core.App) error { + if err := createGuestSessions(app); err != nil { + return err + } + if err := createGuestClaims(app); err != nil { + return err + } + if err := createUsefulVotes(app); err != nil { + return err + } + if err := createBookmarks(app); err != nil { + return err + } + if err := createReadingPositions(app); err != nil { + return err + } + return lockUsers(app) +} + +func downHTLBCollections(app core.App) error { + for _, name := range []string{ + "reading_positions", "bookmarks", "useful_votes", "guest_claims", "guest_sessions", + } { + col, err := app.FindCollectionByNameOrId(name) + if err != nil { + continue + } + if err := app.Delete(col); err != nil { + return err + } + } + return nil +} + +func createGuestSessions(app core.App) error { + c := core.NewBaseCollection("guest_sessions") + denyAll(c) + c.Fields.Add( + &core.TextField{Name: "tokenHash", Required: true, Max: 128}, + &core.SelectField{ + Name: "status", Required: true, MaxSelect: 1, + Values: []string{"active", "merged", "revoked"}, + }, + &core.DateField{Name: "expiresAt", Required: true}, + &core.TextField{Name: "mergedToUserId", Max: 50}, + ) + c.AddIndex("idx_guest_sessions_tokenHash", true, "tokenHash", "") + return app.Save(c) +} + +func createGuestClaims(app core.App) error { + c := core.NewBaseCollection("guest_claims") + denyAll(c) + c.Fields.Add( + &core.TextField{Name: "codeHash", Required: true, Max: 128}, + &core.TextField{Name: "guestId", Required: true, Max: 50}, + &core.SelectField{ + Name: "status", Required: true, MaxSelect: 1, + Values: []string{"pending", "used"}, + }, + &core.DateField{Name: "expiresAt", Required: true}, + ) + c.AddIndex("idx_guest_claims_codeHash", true, "codeHash", "") + return app.Save(c) +} + +func createUsefulVotes(app core.App) error { + c := core.NewBaseCollection("useful_votes") + denyAll(c) + c.Fields.Add( + &core.TextField{Name: "entryId", Required: true, Max: 120}, + &core.TextField{Name: "voterKey", Required: true, Max: 128}, + &core.BoolField{Name: "useful"}, + &core.SelectField{ + Name: "ownerKind", Required: true, MaxSelect: 1, + Values: []string{"guest", "user"}, + }, + &core.TextField{Name: "ownerId", Required: true, Max: 50}, + ) + c.AddIndex("idx_useful_votes_entry_voter", true, "entryId, voterKey", "") + c.AddIndex("idx_useful_votes_entry", false, "entryId", "") + return app.Save(c) +} + +func createBookmarks(app core.App) error { + c := core.NewBaseCollection("bookmarks") + denyAll(c) + c.Fields.Add( + &core.SelectField{ + Name: "ownerKind", Required: true, MaxSelect: 1, + Values: []string{"guest", "user"}, + }, + &core.TextField{Name: "ownerId", Required: true, Max: 50}, + &core.TextField{Name: "entryId", Required: true, Max: 120}, + &core.TextField{Name: "note", Max: 500}, + ) + c.AddIndex("idx_bookmarks_owner_entry", true, "ownerKind, ownerId, entryId", "") + return app.Save(c) +} + +func createReadingPositions(app core.App) error { + c := core.NewBaseCollection("reading_positions") + denyAll(c) + c.Fields.Add( + &core.SelectField{ + Name: "ownerKind", Required: true, MaxSelect: 1, + Values: []string{"guest", "user"}, + }, + &core.TextField{Name: "ownerId", Required: true, Max: 50}, + &core.TextField{Name: "chapter", Required: true, Max: 80}, + &core.NumberField{Name: "offset"}, + ) + c.AddIndex("idx_reading_owner", true, "ownerKind, ownerId", "") + return app.Save(c) +} + +func lockUsers(app core.App) error { + users, err := app.FindCollectionByNameOrId("users") + if err != nil { + return err + } + denyAll(users) + users.PasswordAuth.Enabled = false + // OAuth providers are enabled at runtime when client credentials exist. + users.OAuth2.Enabled = false + users.OAuth2.Providers = []core.OAuth2ProviderConfig{ + {Name: "google"}, + {Name: "github"}, + } + users.ManageRule = nil + return app.Save(users) +} diff --git a/api/internal/migrations/migrations_test.go b/api/internal/migrations/migrations_test.go new file mode 100644 index 000000000..93191c328 --- /dev/null +++ b/api/internal/migrations/migrations_test.go @@ -0,0 +1,33 @@ +package migrations_test + +import ( + "testing" + + "github.com/dlgrv/HowToLiveBetter/api/internal/testutil" +) + +func TestCollectionsLocked(t *testing.T) { + app := testutil.NewApp(t) + for _, name := range []string{ + "guest_sessions", "guest_claims", "useful_votes", "bookmarks", "reading_positions", "users", + } { + col, err := app.FindCollectionByNameOrId(name) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if col.ListRule != nil || col.CreateRule != nil || col.UpdateRule != nil || col.DeleteRule != nil || col.ViewRule != nil { + t.Fatalf("%s rules not fully locked", name) + } + } + users, _ := app.FindCollectionByNameOrId("users") + if users.PasswordAuth.Enabled { + t.Fatal("password auth should be disabled") + } +} + +func TestReApplyMigrations(t *testing.T) { + app := testutil.NewApp(t) + if err := app.RunAllMigrations(); err != nil { + t.Fatal(err) + } +} diff --git a/api/internal/testutil/app.go b/api/internal/testutil/app.go new file mode 100644 index 000000000..dd2e87255 --- /dev/null +++ b/api/internal/testutil/app.go @@ -0,0 +1,99 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/dlgrv/HowToLiveBetter/api/internal/guest" + _ "github.com/dlgrv/HowToLiveBetter/api/internal/migrations" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + _ "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/tools/types" +) + +// NewApp boots an empty PocketBase app with HTLB migrations applied. +func NewApp(t *testing.T) core.App { + t.Helper() + dir := t.TempDir() + app := core.NewBaseApp(core.BaseAppConfig{ + DataDir: dir, + EncryptionEnv: "htlb_test_encryption", + }) + // 32+ chars secret for encryption env if used + _ = os.Setenv("htlb_test_encryption", "0123456789abcdef0123456789abcdef") + if err := app.Bootstrap(); err != nil { + t.Fatalf("bootstrap: %v", err) + } + if err := app.RunAllMigrations(); err != nil { + t.Fatalf("migrations: %v", err) + } + t.Cleanup(func() { + _ = app.ResetBootstrapState() + _ = os.RemoveAll(dir) + }) + return app +} + +// MakeGuest creates an active guest session and returns raw token + owner. +func MakeGuest(t *testing.T, app core.App) (token string, owner authz.Owner) { + t.Helper() + svc := guest.Service{App: app, GuestTTL: 24 * time.Hour, ClaimTTL: 10 * time.Minute} + tok, _, err := svc.CreateSession() + if err != nil { + t.Fatalf("guest session: %v", err) + } + hash := authz.HashToken(tok) + rec, err := app.FindFirstRecordByFilter("guest_sessions", "tokenHash = {:h}", dbx.Params{"h": hash}) + if err != nil { + t.Fatalf("find guest: %v", err) + } + return tok, authz.Owner{Kind: authz.KindGuest, ID: rec.Id} +} + +// MakeUser creates a users auth record (password path for tests only). +func MakeUser(t *testing.T, app core.App, email string) *core.Record { + t.Helper() + col, err := app.FindCollectionByNameOrId("users") + if err != nil { + t.Fatalf("users: %v", err) + } + // Temporarily allow password for seed + col.PasswordAuth.Enabled = true + col.PasswordAuth.IdentityFields = []string{"email"} + if err := app.Save(col); err != nil { + t.Fatalf("enable password: %v", err) + } + rec := core.NewRecord(col) + rec.SetEmail(email) + rec.SetPassword("test-password-long") + rec.SetVerified(true) + if err := app.Save(rec); err != nil { + t.Fatalf("save user: %v", err) + } + col.PasswordAuth.Enabled = false + _ = app.Save(col) + return rec +} + +// ExpireGuest sets expiresAt in the past. +func ExpireGuest(t *testing.T, app core.App, guestID string) { + t.Helper() + rec, err := app.FindRecordById("guest_sessions", guestID) + if err != nil { + t.Fatal(err) + } + dt, _ := types.ParseDateTime(time.Now().Add(-time.Hour)) + rec.Set("expiresAt", dt) + if err := app.Save(rec); err != nil { + t.Fatal(err) + } +} + +// DataDir returns the app data directory path for smoke tests. +func DataDir(app core.App) string { + return filepath.Clean(app.DataDir()) +} diff --git a/api/internal/useful/handlers.go b/api/internal/useful/handlers.go new file mode 100644 index 000000000..7476f1826 --- /dev/null +++ b/api/internal/useful/handlers.go @@ -0,0 +1,78 @@ +package useful + +import ( + "net/http" + "strings" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/router" +) + +type Handlers struct { + Svc Service + Az authz.Resolver +} + +func Register(g *router.RouterGroup[*core.RequestEvent], h Handlers) { + g.GET("/useful", h.get) + g.POST("/useful", h.post) +} + +func (h Handlers) get(e *core.RequestEvent) error { + ids := e.Request.URL.Query().Get("entryIds") + if ids == "" { + ids = e.Request.URL.Query().Get("entryId") + } + parts := splitIDs(ids) + if len(parts) == 0 { + return e.BadRequestError("entryId or entryIds required", nil) + } + owner, ok := h.Az.ResolveOptional(e) + counts, err := h.Svc.Counts(parts) + if err != nil { + return e.InternalServerError("count failed", err) + } + items := make([]map[string]any, 0, len(parts)) + for _, id := range parts { + item := map[string]any{"entryId": id, "count": counts[id]} + if ok { + v, err := h.Svc.Get(owner, id) + if err == nil { + item["useful"] = v.Useful + } + } + items = append(items, item) + } + return e.JSON(http.StatusOK, map[string]any{"items": items}) +} + +func (h Handlers) post(e *core.RequestEvent) error { + owner, err := h.Az.Resolve(e) + if err != nil { + return e.UnauthorizedError("credential required", nil) + } + var body struct { + EntryID string `json:"entryId"` + Useful *bool `json:"useful"` + } + if err := e.BindBody(&body); err != nil || body.EntryID == "" || body.Useful == nil { + return e.BadRequestError("entryId and useful required", nil) + } + res, err := h.Svc.Toggle(owner, body.EntryID, *body.Useful) + if err != nil { + return e.InternalServerError("vote failed", err) + } + return e.JSON(http.StatusOK, res) +} + +func splitIDs(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/api/internal/useful/service.go b/api/internal/useful/service.go new file mode 100644 index 000000000..3e7e49ebb --- /dev/null +++ b/api/internal/useful/service.go @@ -0,0 +1,103 @@ +package useful + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "github.com/pocketbase/dbx" + + "github.com/dlgrv/HowToLiveBetter/api/internal/authz" + "github.com/pocketbase/pocketbase/core" +) + +type Service struct { + App core.App + Salt string +} + +type VoteResult struct { + EntryID string `json:"entryId"` + Useful bool `json:"useful"` + Count int `json:"count"` +} + +func voterKey(owner authz.Owner, salt string) string { + raw := string(owner.Kind) + ":" + owner.ID + ":" + salt + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func (s Service) Count(entryID string) (int, error) { + records, err := s.App.FindRecordsByFilter("useful_votes", + "entryId = {:e} && useful = true", "", 0, 0, + dbx.Params{"e": entryID}, + ) + if err != nil { + return 0, err + } + return len(records), nil +} + +func (s Service) Get(owner authz.Owner, entryID string) (VoteResult, error) { + vk := voterKey(owner, s.Salt) + rec, err := s.App.FindFirstRecordByFilter( + "useful_votes", + "entryId = {:e} && voterKey = {:v}", + dbx.Params{"e": entryID, "v": vk}, + ) + useful := false + if err == nil { + useful = rec.GetBool("useful") + } + n, err := s.Count(entryID) + if err != nil { + return VoteResult{}, err + } + return VoteResult{EntryID: entryID, Useful: useful, Count: n}, nil +} + +func (s Service) Toggle(owner authz.Owner, entryID string, useful bool) (VoteResult, error) { + entryID = strings.TrimSpace(entryID) + if entryID == "" { + return VoteResult{}, fmt.Errorf("entryId required") + } + vk := voterKey(owner, s.Salt) + col, err := s.App.FindCollectionByNameOrId("useful_votes") + if err != nil { + return VoteResult{}, err + } + err = s.App.RunInTransaction(func(txApp core.App) error { + rec, err := txApp.FindFirstRecordByFilter( + "useful_votes", + "entryId = {:e} && voterKey = {:v}", + dbx.Params{"e": entryID, "v": vk}, + ) + if err != nil { + rec = core.NewRecord(col) + rec.Set("entryId", entryID) + rec.Set("voterKey", vk) + rec.Set("ownerKind", string(owner.Kind)) + rec.Set("ownerId", owner.ID) + } + rec.Set("useful", useful) + return txApp.Save(rec) + }) + if err != nil { + return VoteResult{}, err + } + return s.Get(owner, entryID) +} + +func (s Service) Counts(entryIDs []string) (map[string]int, error) { + out := make(map[string]int, len(entryIDs)) + for _, id := range entryIDs { + n, err := s.Count(id) + if err != nil { + return nil, err + } + out[id] = n + } + return out, nil +} diff --git a/api/internal/useful/service_test.go b/api/internal/useful/service_test.go new file mode 100644 index 000000000..e4cd2baa7 --- /dev/null +++ b/api/internal/useful/service_test.go @@ -0,0 +1,35 @@ +package useful_test + +import ( + "testing" + + "github.com/dlgrv/HowToLiveBetter/api/internal/testutil" + "github.com/dlgrv/HowToLiveBetter/api/internal/useful" +) + +func TestToggleIdempotent(t *testing.T) { + app := testutil.NewApp(t) + _, owner := testutil.MakeGuest(t, app) + svc := useful.Service{App: app, Salt: "test-salt"} + r1, err := svc.Toggle(owner, "entry-1", true) + if err != nil { + t.Fatal(err) + } + if !r1.Useful || r1.Count != 1 { + t.Fatalf("%+v", r1) + } + r2, err := svc.Toggle(owner, "entry-1", true) + if err != nil { + t.Fatal(err) + } + if r2.Count != 1 { + t.Fatalf("idempotent count want 1 got %d", r2.Count) + } + r3, err := svc.Toggle(owner, "entry-1", false) + if err != nil { + t.Fatal(err) + } + if r3.Useful || r3.Count != 0 { + t.Fatalf("%+v", r3) + } +} diff --git a/api/scripts/smoke.sh b/api/scripts/smoke.sh new file mode 100755 index 000000000..d64aec555 --- /dev/null +++ b/api/scripts/smoke.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIR="$(mktemp -d)" +trap 'kill ${PID:-} 2>/dev/null || true; rm -rf "$DIR"' EXIT +export HTLB_DATA_DIR="$DIR/pb_data" +export HTLB_VOTE_SALT=smoke +export HTLB_CORS_ORIGINS=http://127.0.0.1:8000 +export HTLB_LISTEN=127.0.0.1:18090 +cd "$ROOT" +CGO_ENABLED=0 go build -o "$DIR/htlb-api" ./cmd/htlb-api +"$DIR/htlb-api" serve --http=127.0.0.1:18090 & +PID=$! +for i in $(seq 1 40); do + if curl -fsS "http://127.0.0.1:18090/api/htlb/v1/health" >/dev/null 2>&1; then + break + fi + sleep 0.25 +done +curl -fsS "http://127.0.0.1:18090/api/htlb/v1/health" | grep -q '"ok":true' +TOKEN=$(curl -fsS -X POST "http://127.0.0.1:18090/api/htlb/v1/guest/session" | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') +code=$(curl -s -o /dev/null -w '%{http_code}' -H "X-HTLB-Sync: $TOKEN" "http://127.0.0.1:18090/api/htlb/v1/bookmarks") +test "$code" = "200" +code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:18090/api/htlb/v1/bookmarks") +test "$code" = "401" +echo "smoke ok" diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 000000000..2f285366a --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,17 @@ +# Deploy (book + API) + +Ops artifacts for **dlgrv** (`book.dlgrv.com`, `api.dlgrv.com`). + +Full checklist, secrets, OAuth, and rollback policy: [docs/pipeline/deploy-book-api.md](../docs/pipeline/deploy-book-api.md). + +| Path | Purpose | +|------|---------| +| `nginx/` | Site vhosts (TLS via certbot) | +| `htlb-api.service` | systemd unit for `htlb-api` | +| `htlb-api.env.example` | Production env template → `/etc/htlb-api.env` | +| `scripts/bootstrap-dlgrv.sh` | One-time host prep (user, dirs, nginx, unit) | +| `scripts/deploy-book.sh` | Rsync `.publish/` to the book docroot | +| `scripts/deploy-api.sh` | Binary release, backup, health, rollback | +| `THIRD_PARTY.md` | PocketBase MIT attribution | + +**Order:** bootstrap once → copy and edit `/etc/htlb-api.env` (never commit) → certbot → `deploy-api.sh` → `deploy-book.sh`. diff --git a/deploy/THIRD_PARTY.md b/deploy/THIRD_PARTY.md new file mode 100644 index 000000000..9710b84ee --- /dev/null +++ b/deploy/THIRD_PARTY.md @@ -0,0 +1,33 @@ +# Third-party components + +## PocketBase + +- **Project:** [github.com/pocketbase/pocketbase](https://github.com/pocketbase/pocketbase) +- **Version (pinned):** v0.28.4 (see `api/go.mod`) +- **License:** MIT + +`htlb-api` embeds PocketBase as a Go library (not a separate PocketBase binary). + +Copyright notice (MIT): + +``` +Copyright (c) 2022-present Gani Georgiev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/deploy/htlb-api.env.example b/deploy/htlb-api.env.example new file mode 100644 index 000000000..6df7180d9 --- /dev/null +++ b/deploy/htlb-api.env.example @@ -0,0 +1,20 @@ +# Copy to /etc/htlb-api.env (root:root, mode 600). Do not commit real values. + +HTLB_CORS_ORIGINS=https://book.dlgrv.com + +# Required in production — long random string (vote fingerprints). +HTLB_VOTE_SALT=CHANGE_ME + +# Optional tuning (defaults shown in api/internal/config). +# HTLB_GUEST_TTL_HOURS=2160 +# HTLB_CLAIM_TTL_MINUTES=10 + +# OAuth (configure providers in PocketBase admin after first deploy). +HTLB_OAUTH_GOOGLE_CLIENT_ID= +HTLB_OAUTH_GOOGLE_CLIENT_SECRET= +HTLB_OAUTH_GITHUB_CLIENT_ID= +HTLB_OAUTH_GITHUB_CLIENT_SECRET= + +# PocketBase field encryption: name of env var holding a 32-character key. +HTLB_ENCRYPTION_ENV=HTLB_PB_ENCRYPTION +# HTLB_PB_ENCRYPTION= diff --git a/deploy/htlb-api.service b/deploy/htlb-api.service new file mode 100644 index 000000000..bb2989232 --- /dev/null +++ b/deploy/htlb-api.service @@ -0,0 +1,20 @@ +[Unit] +Description=HowToLiveBetter API (htlb-api / PocketBase) +After=network-online.target +Wants=network-online.target + +[Service] +User=htlb +Group=htlb +WorkingDirectory=/opt/htlb-api +EnvironmentFile=-/etc/htlb-api.env +ExecStart=/opt/htlb-api/htlb-api serve --http=127.0.0.1:8090 --dir=/opt/htlb-api/pb_data +Restart=on-failure +RestartSec=5 + +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/opt/htlb-api + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx/api.dlgrv.com.conf b/deploy/nginx/api.dlgrv.com.conf new file mode 100644 index 000000000..8f506478d --- /dev/null +++ b/deploy/nginx/api.dlgrv.com.conf @@ -0,0 +1,54 @@ +# /etc/nginx/sites-available/api.dlgrv.com.conf +# TLS cert: shared Let's Encrypt line under live/book.dlgrv.com (SAN includes api). + +server { + listen 178.104.217.93:80; + listen 127.0.0.1:80; + server_name api.dlgrv.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 178.104.217.93:443 ssl; + listen 127.0.0.1:443 ssl; + server_name api.dlgrv.com; + + ssl_certificate /etc/letsencrypt/live/book.dlgrv.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/book.dlgrv.com/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + client_max_body_size 2m; + + location /_/ { + allow 127.0.0.1; + allow 10.0.0.0/8; + allow 172.16.0.0/12; + allow 192.168.0.0/16; + allow 100.64.0.0/10; + deny all; + + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/deploy/nginx/book.dlgrv.com.conf b/deploy/nginx/book.dlgrv.com.conf new file mode 100644 index 000000000..a8fb61213 --- /dev/null +++ b/deploy/nginx/book.dlgrv.com.conf @@ -0,0 +1,38 @@ +# /etc/nginx/sites-available/book.dlgrv.com.conf +# Bind public IP only — Tailscale may already own :80 on the TS address. +# Cert lives at /etc/letsencrypt/live/book.dlgrv.com/ (SAN also covers api). + +server { + listen 178.104.217.93:80; + listen 127.0.0.1:80; + server_name book.dlgrv.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 178.104.217.93:443 ssl; + listen 127.0.0.1:443 ssl; + server_name book.dlgrv.com; + + ssl_certificate /etc/letsencrypt/live/book.dlgrv.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/book.dlgrv.com/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + root /var/www/book.dlgrv.com/current; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + + location / { + try_files $uri $uri/ $uri.html /index.html; + } +} diff --git a/deploy/scripts/bootstrap-dlgrv.sh b/deploy/scripts/bootstrap-dlgrv.sh new file mode 100755 index 000000000..87dc927b0 --- /dev/null +++ b/deploy/scripts/bootstrap-dlgrv.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# One-time (idempotent) host prep for dlgrv. Run as root or via sudo. +set -euo pipefail + +# Works whether invoked from a full clone (…/deploy/scripts/) or a copied tree (/tmp/…/scripts/). +DEPLOY_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + echo "Run as root: sudo $0" >&2 + exit 1 +fi + +echo "==> system packages (nginx, certbot)" +if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y nginx certbot python3-certbot-nginx rsync curl +elif command -v dnf >/dev/null 2>&1; then + dnf install -y nginx certbot python3-certbot-nginx rsync curl +else + echo "Install nginx, certbot, rsync, and curl manually, then re-run." >&2 + exit 1 +fi + +echo "==> user htlb" +if ! id htlb &>/dev/null; then + useradd --system --home-dir /opt/htlb-api --shell /usr/sbin/nologin htlb +fi + +echo "==> user deploy (rsync from CI)" +if ! id deploy &>/dev/null; then + useradd --create-home --shell /bin/bash deploy +fi +# Restricted sudo for deploy path (binary restart + book docroot) +cat >/etc/sudoers.d/htlb-deploy <<'SUDO' +deploy ALL=(root) NOPASSWD: /bin/systemctl stop htlb-api.service, /bin/systemctl start htlb-api.service, /bin/systemctl restart htlb-api.service, /bin/systemctl status htlb-api.service, /bin/ln, /bin/chown, /bin/mkdir, /bin/tar, /bin/rm +SUDO +chmod 0440 /etc/sudoers.d/htlb-deploy + +echo "==> directories" +install -d -o htlb -g htlb -m 0750 /opt/htlb-api +install -d -o htlb -g htlb -m 0750 /opt/htlb-api/pb_data +install -d -o htlb -g htlb -m 0750 /opt/htlb-api/releases +install -d -o htlb -g htlb -m 0750 /opt/htlb-api/backups +install -d -o deploy -g deploy -m 0755 /var/www/book.dlgrv.com +install -d -o deploy -g deploy -m 0755 /var/www/book.dlgrv.com/releases +install -d -o deploy -g deploy -m 0755 /var/www/book.dlgrv.com/current-placeholder +echo 'book

awaiting first deploy

' >/var/www/book.dlgrv.com/current-placeholder/index.html +ln -sfn /var/www/book.dlgrv.com/current-placeholder /var/www/book.dlgrv.com/current +# deploy needs write on /opt/htlb-api/releases for rsync +usermod -aG htlb deploy +chmod 0775 /opt/htlb-api/releases +install -d -m 0755 /var/www/certbot +echo "Install deploy SSH public key into ~deploy/.ssh/authorized_keys before CI deploy." + +echo "==> env file template" +if [[ ! -f /etc/htlb-api.env ]]; then + install -m 0600 -o root -g root "$DEPLOY_DIR/htlb-api.env.example" /etc/htlb-api.env + echo "Edit /etc/htlb-api.env before starting htlb-api (secrets not in repo)." +fi + +echo "==> systemd unit" +install -m 0644 -o root -g root "$DEPLOY_DIR/htlb-api.service" /etc/systemd/system/htlb-api.service +systemctl daemon-reload +systemctl enable htlb-api.service + +echo "==> firewall (public HTTP/HTTPS; SSH already expected)" +if command -v ufw >/dev/null 2>&1; then + ufw allow OpenSSH >/dev/null 2>&1 || ufw allow 22/tcp >/dev/null 2>&1 || true + ufw allow 80/tcp >/dev/null 2>&1 || true + ufw allow 443/tcp >/dev/null 2>&1 || true +fi + +echo "==> nginx vhosts" +# Configs listen on the public IP (178.104.217.93) so they do not clash with +# Tailscale Serve on the TS address :80. +install -m 0644 -o root -g root "$DEPLOY_DIR/nginx/book.dlgrv.com.conf" /etc/nginx/sites-available/book.dlgrv.com.conf +install -m 0644 -o root -g root "$DEPLOY_DIR/nginx/api.dlgrv.com.conf" /etc/nginx/sites-available/api.dlgrv.com.conf +ln -sf /etc/nginx/sites-available/book.dlgrv.com.conf /etc/nginx/sites-enabled/book.dlgrv.com.conf +ln -sf /etc/nginx/sites-available/api.dlgrv.com.conf /etc/nginx/sites-enabled/api.dlgrv.com.conf +if [[ -f /etc/nginx/sites-enabled/default ]]; then + rm -f /etc/nginx/sites-enabled/default +fi +nginx -t +systemctl enable nginx +systemctl reload nginx || systemctl start nginx + +cat <<'EOF' + +==> TLS (after DNS A records point here) + + ufw allow 80/tcp; ufw allow 443/tcp + certbot certonly --webroot -w /var/www/certbot -d book.dlgrv.com -d api.dlgrv.com \ + --non-interactive --agree-tos --register-unsafely-without-email + # Ensure /etc/letsencrypt/options-ssl-nginx.conf + ssl-dhparams.pem exist, then + # install the HTTPS server blocks from deploy/nginx/*.conf and reload nginx. + +==> next steps + + 1. Edit /etc/htlb-api.env (HTLB_VOTE_SALT, OAuth client IDs) + 2. Place htlb-api binary: make api-build && deploy/scripts/deploy-api.sh + 3. Publish book: make pages-artifact && deploy/scripts/deploy-book.sh + 4. GitHub Environment production secrets: DEPLOY_SSH_KEY, DEPLOY_HOST=dlgrv (or IP), DEPLOY_USER=deploy + +EOF diff --git a/deploy/scripts/deploy-api.sh b/deploy/scripts/deploy-api.sh new file mode 100755 index 000000000..9259cae6f --- /dev/null +++ b/deploy/scripts/deploy-api.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Release htlb-api binary on the server: stop → backup → rsync → restart → health (rollback on failure). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BINARY_LOCAL="${BINARY_LOCAL:-$REPO_ROOT/api/bin/htlb-api}" +DEPLOY_USER="${DEPLOY_USER:-deploy}" +DEPLOY_HOST="${DEPLOY_HOST:-dlgrv}" +REMOTE_OPT="${REMOTE_OPT:-/opt/htlb-api}" +REMOTE_BIN="$REMOTE_OPT/htlb-api" +REMOTE_DATA="$REMOTE_OPT/pb_data" +REMOTE_RELEASES="$REMOTE_OPT/releases" +HEALTH_URL="${HEALTH_URL:-https://api.dlgrv.com/api/htlb/v1/health}" + +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +REMOTE_RELEASE="$REMOTE_RELEASES/$STAMP" + +usage() { + cat <&2 + return 1 +} + +backup_pb_data() { + echo "==> backup pb_data" + remote "sudo mkdir -p '$REMOTE_OPT/backups' && sudo tar -czf '$REMOTE_OPT/backups/pb_data-$STAMP.tgz' -C '$REMOTE_OPT' pb_data" +} + +do_deploy() { + if [[ ! -f "$BINARY_LOCAL" ]]; then + echo "Missing binary: $BINARY_LOCAL (run: make api-build)" >&2 + exit 1 + fi + + echo "==> stop htlb-api" + remote "sudo systemctl stop htlb-api.service" || true + + backup_pb_data + + echo "==> rsync binary → $REMOTE_RELEASE/htlb-api" + remote "mkdir -p '$REMOTE_RELEASE' '$REMOTE_RELEASES'" + rsync -az "$BINARY_LOCAL" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_RELEASE/htlb-api" + remote "chmod 0755 '$REMOTE_RELEASE/htlb-api'" + + echo "==> activate binary" + remote "sudo ln -sfn '$REMOTE_RELEASE/htlb-api' '$REMOTE_BIN' && sudo chown -h htlb:htlb '$REMOTE_BIN'" + + echo "==> start htlb-api" + remote "sudo systemctl start htlb-api.service" + + if health_check; then + echo "deploy-api ok: $STAMP" + remote "ls -1dt '$REMOTE_RELEASES'/* 2>/dev/null | tail -n +6 | xargs -r sudo rm -rf" || true + return 0 + fi + + echo "==> deploy failed — rolling back to previous release" >&2 + do_rollback_auto || true + exit 1 +} + +do_rollback_auto() { + local prev + prev="$(remote "ls -1dt '$REMOTE_RELEASES'/*/htlb-api 2>/dev/null | sed -n '2p'")" || true + if [[ -z "$prev" ]]; then + echo "no previous release to rollback" >&2 + return 1 + fi + do_rollback "$prev" +} + +do_rollback() { + local target="${1:-}" + if [[ -z "$target" ]]; then + target="$(remote "ls -1dt '$REMOTE_RELEASES'/*/htlb-api 2>/dev/null | sed -n '2p'")" + fi + if [[ -z "$target" ]]; then + echo "rollback: no target release" >&2 + exit 1 + fi + echo "==> rollback binary → $target" + remote "sudo systemctl stop htlb-api.service" || true + remote "sudo ln -sfn '$target' '$REMOTE_BIN'" + remote "sudo systemctl start htlb-api.service" + health_check + echo "rollback ok" +} + +cmd="${1:-deploy}" +case "$cmd" in + deploy) do_deploy ;; + rollback) do_rollback "${2:-}" ;; + health) health_check ;; + -h|--help|help) usage ;; + *) + echo "unknown command: $cmd" >&2 + usage + exit 1 + ;; +esac diff --git a/deploy/scripts/deploy-book.sh b/deploy/scripts/deploy-book.sh new file mode 100755 index 000000000..6a6c7c57b --- /dev/null +++ b/deploy/scripts/deploy-book.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Rsync local .publish/ to a release dir; flip current symlink (nginx root). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SOURCE="${SOURCE:-$REPO_ROOT/.publish}" +DEPLOY_USER="${DEPLOY_USER:-deploy}" +DEPLOY_HOST="${DEPLOY_HOST:-dlgrv}" +REMOTE_ROOT="${REMOTE_ROOT:-/var/www/book.dlgrv.com}" +RELEASES="${REMOTE_RELEASES:-$REMOTE_ROOT/releases}" + +if [[ ! -d "$SOURCE" ]]; then + echo "Missing artifact dir: $SOURCE (run: make pages-artifact)" >&2 + exit 1 +fi + +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +REMOTE_RELEASE="$RELEASES/$STAMP" + +echo "==> rsync $SOURCE/ → $DEPLOY_USER@$DEPLOY_HOST:$REMOTE_RELEASE/" +ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p '$RELEASES'" +rsync -az --delete --human-readable "$SOURCE/" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_RELEASE/" + +echo "==> activate release (symlink current)" +ssh "$DEPLOY_USER@$DEPLOY_HOST" "ln -sfn '$REMOTE_RELEASE' '$REMOTE_ROOT/current'" + +# Keep last 5 releases +ssh "$DEPLOY_USER@$DEPLOY_HOST" "ls -1dt '$RELEASES'/* 2>/dev/null | tail -n +6 | xargs -r rm -rf" || true + +echo "deploy-book ok: $STAMP" diff --git a/docs/pipeline/deploy-book-api.md b/docs/pipeline/deploy-book-api.md new file mode 100644 index 000000000..e0a973750 --- /dev/null +++ b/docs/pipeline/deploy-book-api.md @@ -0,0 +1,90 @@ +# Deploy: book.dlgrv.com + api.dlgrv.com + +Ops checklist for the interactive book stack (fork dlgrv). Product decisions live in [interactive-site-ideas.md](interactive-site-ideas.md). + +## Hosts + +| Host | Role | +|------|------| +| `https://book.dlgrv.com` | Static book (nginx → `/var/www/book.dlgrv.com`) | +| `https://book.dlgrv.com/login/` | Sign-in page (Google/GitHub); `?next=` returns to the book URL after OAuth | +| `https://api.dlgrv.com` | `htlb-api` (Go + PocketBase) behind nginx → `127.0.0.1:8090` | +| `https://dlgrv.github.io/HowToLiveBetter/` | Redirect stub → `book.dlgrv.com` | + +DNS (Cloudflare, grey cloud / DNS only): `book` and `api` A → dlgrv `178.104.217.93`. + +SSH host alias: `dlgrv` (see `~/.ssh/config`). Nginx binds the **public** IP only so it does not fight Tailscale Serve on `:80`. + +## Repo layout + +- [`api/`](../../api/) — Go module (`htlb-api`), custom routes `/api/htlb/v1` +- [`deploy/`](../../deploy/) — nginx, systemd, THIRD_PARTY +- Workflows: `deploy-book.yml`, `deploy-api.yml`; CI job `api` in `ci.yml` + +## Local API + +```bash +make api-dev # go run … serve --http=127.0.0.1:8090 --dir=api/.local/pb_data +make api-test +make api-lint +``` + +Env (see `deploy/htlb-api.env.example`): + +- `HTLB_CORS_ORIGINS` — comma list, e.g. `https://book.dlgrv.com,http://127.0.0.1:8000` +- `HTLB_GUEST_TTL_HOURS` — default `2160` (90d) +- `HTLB_VOTE_SALT` — server secret for useful fingerprints +- PocketBase encryption: `--encryptionEnv=HTLB_PB_ENCRYPTION` (32-char key in that env) + +## Guest sync contract + +- Long-lived token in `localStorage` key `htlb_sync_token` +- Header: `X-HTLB-Sync: ` (never `Authorization`) +- Cross-device sync is via OAuth account only (Google/GitHub); guest token stays on the browser until merge +- OAuth user uses `Authorization: Bearer `; on login call `POST /api/htlb/v1/merge` with guest token in `X-HTLB-Sync` + +## OAuth (manual, once) + +1. GitHub OAuth App + Google Cloud OAuth client +2. Redirect URI: `https://api.dlgrv.com/api/oauth2-redirect` +3. Enable only Google + GitHub in PocketBase admin (`/_/`); disable password auth +4. Restrict `/_/` in nginx (IP allowlist / VPN) + +## Secrets (GitHub Environment `production`) + +- `DEPLOY_SSH_KEY`, `DEPLOY_HOST`, `DEPLOY_USER` +- Never commit OAuth client secrets or encryption keys + +## Deploy order (API) + +1. `go test` / lint green +2. `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build` +3. Backup `pb_data` on server +4. Rsync binary → `/opt/htlb-api/htlb-api` +5. `systemctl restart htlb-api` +6. Smoke: `GET /api/htlb/v1/health`, guest session, 401 on bookmarks without token +7. On failure: restore previous binary + restart + +## Rollback (book) + +Release dirs + symlink (or keep previous `.publish` tarball). Atomic swap preferred over live `rsync --delete` into docroot. + +## Backup + +Nightly: `htlb-api` `CreateBackup` or copy `pb_data` (include WAL); encrypt (`age`); off-host copy; restore drill quarterly. + +## Cutover checklist (2026-10) + +Live: + +- `https://book.dlgrv.com/en/` — static book +- `https://api.dlgrv.com/api/htlb/v1/health` — API +- Guest: `POST /guest/session`, `X-HTLB-Sync` on `/bookmarks` +- GitHub Pages workflow publishes redirect stub → `book.dlgrv.com` + +Still manual: + +- GitHub Environment `production` secrets: `DEPLOY_SSH_KEY`, `DEPLOY_HOST`, `DEPLOY_USER=deploy` +- OAuth Google + GitHub client IDs/secrets in `/etc/htlb-api.env`, then `systemctl restart htlb-api` +- PocketBase first superuser: `ssh dlgrv` → `/opt/htlb-api/htlb-api superuser upsert EMAIL PASS` (admin UI `/_/` IP-restricted) +- Confirm github.io redirect after next Pages deploy on `main` diff --git a/docs/pipeline/interactive-site-ideas.md b/docs/pipeline/interactive-site-ideas.md new file mode 100644 index 000000000..d88f9b4f1 --- /dev/null +++ b/docs/pipeline/interactive-site-ideas.md @@ -0,0 +1,107 @@ +# Идеи интерактивности сайта HowToLiveBetter + +Черновик брейншторма + **актуальные решения** (fork dlgrv). Ops: [deploy-book-api.md](deploy-book-api.md). + +Контекст: книга на `https://book.dlgrv.com` (статика); API `https://api.dlgrv.com` — Go binary `htlb-api` (PocketBase as framework). GitHub Pages — редирект на book. + +--- + +## Цели (выбранные) + +| Код | Цель | +|-----|------| +| a | Помочь выбрать «что читать / делать сейчас» | +| b | Увидеть, что полезно другим (соцсигнал) | +| d | Удержание: повод вернуться на сайт | + +--- + +## Backend (committed) + +- **Стек:** Go module [`api/`](../../api/), PocketBase library, SQLite `pb_data`, nginx TLS. +- **Host:** `api.dlgrv.com` (не path на book). +- **Routes:** только `/api/htlb/v1/...` (авто-CRUD коллекций закрыт). +- **Auth:** только Google + GitHub OAuth; без email/password. +- **Guest:** `X-HTLB-Sync` + one-time `?sync=` claim; после OAuth — merge guest→user. +- **«Полезно»:** публично без аккаунта; антифрод на сервере. +- **CI/CD:** GitHub Actions → dlgrv (rsync book + binary). + +--- + +## Что в актуальном MVP (committed) + +### 1. Соцсигнал «Полезно» + +- Кнопка на карточке рядом с «Источники»; Top + «На этой неделе»; без `·`. +- Публичные голоса; rate limit / fingerprint на API. + +### 2. Закладки на бэке + +- После guest session или OAuth; фильтр в сайдбаре; локальный кэш + API. + +### 3. Позиция чтения на бэке + +- Debounce на API; resume кросс-девайс через guest/OAuth. + +### 4. Identity + +- Guest: `htlb_sync_token` + one-time claim link «Другое устройство». +- OAuth: Google / GitHub; merge при логине. +- «Полезно» без токена. + +### UI (v2) + +- Editorial minimal; graceful degrade если API down; без middle dot `·`. + +--- + +## Хостинг + +| Host | Роль | +|------|------| +| `book.dlgrv.com` | статика на dlgrv | +| `api.dlgrv.com` | htlb-api | +| `dlgrv.github.io/HowToLiveBetter` | redirect → book | + +--- + +## HTTP surface (кратко) + +Prefix: `https://api.dlgrv.com/api/htlb/v1` + +- `GET /health` +- `POST /guest/session`, `POST /guest/claim` +- `GET|POST /useful` +- `GET|PUT|DELETE /bookmarks`, `GET|PUT /reading` +- `POST /merge` (OAuth + guest token in body) + +CORS: `https://book.dlgrv.com` (+ localhost только в dev). + +--- + +## Явно не делаем (сейчас) + +- Password / email signup +- Комментарии, digests, мастер ситуации +- Cloudflare Worker как API +- Открытый PB collection CRUD +- Разделитель `·` в новом UI + +--- + +## Связанные пути + +- `api/` — Go backend +- `deploy/` — nginx, systemd +- `site/`, `forge/site/build_pages.py` — UI / HOST +- [deploy-book-api.md](deploy-book-api.md) + +--- + +## Краткая история решений + +1. Интерактив a/b/d → useful + bookmarks + position. +2. Backend: PocketBase Go framework на dlgrv (не CF Worker, не JS hooks). +3. Dual identity: guest claim + OAuth Google/GitHub only. +4. API host `api.dlgrv.com`; book на dlgrv; Pages = redirect. +5. Custom routes only; `X-HTLB-Sync` для guest. diff --git a/forge/site/build_pages.py b/forge/site/build_pages.py index b3080ce55..ead282357 100644 --- a/forge/site/build_pages.py +++ b/forge/site/build_pages.py @@ -33,7 +33,7 @@ V2_CSS_SRC = os.path.join(ROOT, "forge", "v2.css") V2_CSS_DST = os.path.join(SITE, "assets", "v2.css") -HOST = "https://dlgrv.github.io/HowToLiveBetter" +HOST = "https://book.dlgrv.com" ORIGIN_PAGES = "https://eternity4719.github.io/HowToLiveBetter/" ORIGIN_REPO = "https://github.com/eternity4719/HowToLiveBetter" @@ -349,6 +349,7 @@ def build_locale_page(src: str, bootstrap_tpl: str, v2css: str, lang_meta: dict) ) # Locale pages live in site/{lang}/; content is fetched from the flat Pages root. page = page.replace('href="README', 'href="../README').replace('href="book/', 'href="../book/') + page = page.replace('src="assets/interactive.js"', 'src="../assets/interactive.js"') # Skin is inlined (forge/v2.css); drop the router stylesheet link so it is not # resolved as site/{lang}/assets/v2.css. page = STYLESHEET_V2_RE.sub("", page, count=1) diff --git a/forge/v2.css b/forge/v2.css index c14d41e74..772618b73 100644 --- a/forge/v2.css +++ b/forge/v2.css @@ -168,6 +168,114 @@ html.dark .theme-btn .moon{display:none} .lang-menu button{display:block;width:100%;text-align:left;padding:var(--sp-2) var(--sp-3);border:0;border-radius:var(--radius-sm);background:none;color:var(--mut);font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans);cursor:pointer;transition:color .15s,background .15s} .lang-menu button:hover{color:var(--ink);background:var(--line)} +.account-dd{position:relative} +.account-dd>summary.icon-btn{list-style:none} +.account-dd>summary.icon-btn::-webkit-details-marker{display:none} +.account-menu{min-width:12rem} +.account-menu .account-sep{height:1px;background:var(--line);margin:var(--sp-1) 0} +.account-menu .account-note{display:block;padding:var(--sp-1) var(--sp-3);font:var(--fw-ui) var(--fs-caption,11px)/1.35 var(--sans);color:var(--faint)} +.account-dd[data-signed-in="1"]>summary{color:var(--ink)} + +/* ---------- login page (/login/) ---------- */ +body.login-page{ + position:relative; + isolation:isolate; + min-height:100vh; + display:flex;flex-direction:column; + overflow-x:clip; + background:var(--bg); +} +body.login-page::before, +body.login-page::after{ + content:""; + position:fixed; + left:0;right:0;top:0; + height:28vh; + pointer-events:none; + z-index:0; + transform-origin:50% 0%; + will-change:transform,opacity; +} +/* Soft wash — ellipse center sits above the viewport edge */ +body.login-page::before{ + background:radial-gradient(ellipse 58% 140% at 50% -45%, var(--line-soft) 0%, transparent 62%); + opacity:.78; + animation:login-glow-a 16s ease-in-out infinite; +} +/* Narrower highlight along the rim, also centered above the edge */ +body.login-page::after{ + height:14vh; + background:radial-gradient(ellipse 32% 160% at 50% -70%, color-mix(in srgb, var(--hover) 32%, transparent) 0%, transparent 58%); + opacity:.5; + animation:login-glow-b 20s ease-in-out infinite; +} +html.dark body.login-page::before{opacity:.7} +html.dark body.login-page::after{opacity:.42} +body.login-page > *{position:relative;z-index:1} +@keyframes login-glow-a{ + 0%,100%{transform:scaleX(1) scaleY(1);opacity:.62} + 50%{transform:scaleX(1.08) scaleY(1.12);opacity:.88} +} +@keyframes login-glow-b{ + 0%,100%{transform:translateX(0) scaleX(1);opacity:.36} + 40%{transform:translateX(8%) scaleX(1.15);opacity:.58} + 70%{transform:translateX(-7%) scaleX(.95);opacity:.44} +} +@media (prefers-reduced-motion:reduce){ + body.login-page::before, + body.login-page::after{animation:none} +} +.login-main{ + width:min(22.5rem,100%); + margin:auto; + padding:var(--sp-9) var(--sp-5) var(--sp-11); + display:flex;flex-direction:column;align-items:stretch;gap:var(--sp-4); +} +.login-main h1{ + margin:0; + font:var(--fw-strong) var(--fs-4)/var(--lh-tight) var(--serif); + color:var(--ink); + text-align:center; +} +.login-hint{ + margin:calc(var(--sp-2) * -1) 0 var(--sp-2); + font:var(--fw-ui) var(--fs-ui)/var(--lh-body) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-actions{display:flex;flex-direction:column;gap:var(--sp-3)} +.login-provider{ + display:flex;align-items:center;justify-content:center;gap:var(--sp-3); + width:100%;min-height:calc(var(--control-h) + var(--sp-2)); + padding:var(--sp-2) var(--sp-4); + border:var(--line-w) solid var(--line); + border-radius:var(--radius); + background:var(--pop-bg); + color:var(--ink); + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + cursor:pointer; + box-shadow:var(--shadow); + transition:border-color .15s,background .15s,color .15s; +} +.login-provider:hover{border-color:var(--line-soft);background:var(--line)} +.login-provider:focus-visible{outline:var(--focus-off) solid var(--hover);outline-offset:var(--focus-off)} +.login-provider-icon{ + display:grid;place-items:center; + width:var(--sp-5);height:var(--sp-5);flex:0 0 var(--sp-5); +} +.login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} +.login-provider-label{flex:0 1 auto} +.login-back{ + display:block; + align-self:center; + margin-top:var(--sp-2); + text-align:center; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--faint); +} +.login-back:hover{color:var(--hover)} + /* ---------- tooltip popup ---------- */ .backdrop{position:fixed;inset:0;background:rgba(0,0,0,.32);opacity:0;pointer-events:none;transition:opacity .2s;z-index:15} .backdrop.on{opacity:1;pointer-events:auto} diff --git a/site/ar/index.html b/site/ar/index.html index 51916f329..277a2b4cc 100644 --- a/site/ar/index.html +++ b/site/ar/index.html @@ -47,29 +47,29 @@ - + - + - + - + + + diff --git a/site/assets/interactive.js b/site/assets/interactive.js new file mode 100644 index 000000000..83546ca4c --- /dev/null +++ b/site/assets/interactive.js @@ -0,0 +1,367 @@ +/** + * HTLB interactive client — guest sync + optional OAuth (Google/GitHub). + * Guest: X-HTLB-Sync. User: Authorization Bearer. Never mix. + */ +(function () { + 'use strict'; + + var API = + (typeof window !== 'undefined' && window.__HTLB_API__) || + 'https://api.dlgrv.com/api/htlb/v1'; + var PB = + (typeof window !== 'undefined' && window.__HTLB_PB__) || + 'https://api.dlgrv.com'; + var SYNC_KEY = 'htlb_sync_token'; + var AUTH_KEY = 'htlb_auth_token'; + + function syncToken() { + try { + return localStorage.getItem(SYNC_KEY) || ''; + } catch (e) { + return ''; + } + } + function setSyncToken(t) { + try { + if (t) localStorage.setItem(SYNC_KEY, t); + else localStorage.removeItem(SYNC_KEY); + } catch (e) {} + } + function authToken() { + try { + return localStorage.getItem(AUTH_KEY) || ''; + } catch (e) { + return ''; + } + } + function setAuthToken(t) { + try { + if (t) localStorage.setItem(AUTH_KEY, t); + else localStorage.removeItem(AUTH_KEY); + } catch (e) {} + } + + function headers(extra) { + var h = Object.assign({ Accept: 'application/json', 'Content-Type': 'application/json' }, extra || {}); + var a = authToken(); + if (a) h.Authorization = 'Bearer ' + a; + var s = syncToken(); + if (s) h['X-HTLB-Sync'] = s; + return h; + } + + async function api(path, opts) { + opts = opts || {}; + var res = await fetch(API + path, { + method: opts.method || 'GET', + headers: headers(opts.headers), + body: opts.body ? JSON.stringify(opts.body) : undefined, + credentials: 'omit', + }); + if (!res.ok) { + var err = new Error('api ' + res.status); + err.status = res.status; + throw err; + } + if (res.status === 204) return null; + return res.json(); + } + + async function ensureGuest() { + if (syncToken()) return syncToken(); + var data = await api('/guest/session', { method: 'POST', body: {} }); + setSyncToken(data.token); + return data.token; + } + + async function toggleUseful(entryId, useful) { + await ensureGuest(); + return api('/useful', { method: 'POST', body: { entryId: entryId, useful: !!useful } }); + } + + async function toggleBookmark(entryId, on) { + await ensureGuest(); + if (on) return api('/bookmarks', { method: 'PUT', body: { entryId: entryId } }); + return api('/bookmarks?entryId=' + encodeURIComponent(entryId), { method: 'DELETE' }); + } + + async function mergeGuest() { + if (!authToken() || !syncToken()) return null; + return api('/merge', { method: 'POST', body: {} }); + } + + /** OAuth via PocketBase popup (Google / GitHub only). */ + function loginOAuth(provider) { + if (provider !== 'google' && provider !== 'github') { + throw new Error('unsupported provider'); + } + var w = 600; + var h = 700; + var left = (screen.width - w) / 2; + var top = (screen.height - h) / 2; + var popup = window.open( + 'about:blank', + 'htlb_oauth', + 'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top + ); + if (!popup) throw new Error('popup blocked'); + + // Load PocketBase UMD only for authWithOAuth2 + var s = document.createElement('script'); + s.src = 'https://cdn.jsdelivr.net/npm/pocketbase@0.28.4/dist/pocketbase.umd.js'; + s.onload = function () { + var PBCtor = window.PocketBase; + if (!PBCtor) { + popup.close(); + return; + } + var pb = new PBCtor(PB); + pb.collection('users') + .authWithOAuth2({ provider: provider, urlCallback: function (url) { popup.location = url; } }) + .then(function (auth) { + setAuthToken(auth.token); + return mergeGuest(); + }) + .then(function () { + try { popup.close(); } catch (e) {} + dispatchEvent(new CustomEvent('htlb:auth', { detail: { ok: true } })); + renderAccountMenu(); + }) + .catch(function (err) { + console.warn('oauth failed', err); + try { popup.close(); } catch (e) {} + }); + }; + document.head.appendChild(s); + } + + function logout() { + setAuthToken(''); + renderAccountMenu(); + } + + function labels() { + var t = typeof T === 'function' ? T() : {}; + return { + aria: t.accountAria || 'Account', + signIn: t.accountSignIn || 'Sign in', + google: t.accountGoogle || 'Continue with Google', + github: t.accountGithub || 'Continue with GitHub', + logout: t.accountLogout || 'Log out', + signedIn: t.accountSignedIn || 'Signed in', + hint: t.accountHint || 'Sign in so that on any of your devices you can continue reading where you left off, and keep which recommendations you marked as useful.', + }; + } + + function siteBase() { + return (typeof window !== 'undefined' && window.__HTLB_BASE__) || ''; + } + + function loginHref() { + return siteBase() + 'login/?next=' + encodeURIComponent(location.href); + } + + function closeAccount() { + var acc = document.getElementById('account'); + if (acc) acc.open = false; + } + + function menuButton(text, onClick) { + var b = document.createElement('button'); + b.type = 'button'; + b.setAttribute('role', 'menuitem'); + b.textContent = text; + b.addEventListener('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + closeAccount(); + onClick(); + }); + return b; + } + + function safeNextUrl() { + var p = new URLSearchParams(location.search); + var next = p.get('next') || ''; + if (!next) return ''; + try { + var u = new URL(next, location.origin); + if (u.origin !== location.origin) return ''; + return u.pathname + u.search + u.hash; + } catch (e) { + return ''; + } + } + + function defaultBookUrl() { + var lang = + (typeof window !== 'undefined' && window.__HTLB_LANG__) || + (function () { + try { + return localStorage.getItem('htlb-lang') || ''; + } catch (e) { + return ''; + } + })() || + 'en'; + if (!/^(en|ru|zh|es|pt|ar|id)$/.test(lang)) lang = 'en'; + return siteBase() + lang + '/'; + } + + function redirectAfterAuth() { + location.assign(safeNextUrl() || defaultBookUrl()); + } + + function initLoginPage() { + if (authToken()) { + redirectAfterAuth(); + return; + } + + ensureGuest().catch(function (e) { + console.warn('htlb login init', e); + }); + + var g = document.getElementById('login-google'); + var gh = document.getElementById('login-github'); + if (g) { + g.addEventListener('click', function () { + loginOAuth('google'); + }); + } + if (gh) { + gh.addEventListener('click', function () { + loginOAuth('github'); + }); + } + + addEventListener('htlb:auth', function (ev) { + if (ev && ev.detail && ev.detail.ok) redirectAfterAuth(); + }); + } + + function entryId(e) { + return e.id || String(e.sec) + '-' + String(e.n); + } + + function enhanceCards() { + if (!window.CARDS || !CARDS.length) return; + CARDS.forEach(function (card) { + var e = card.e; + var el = card.el; + if (!el || el.querySelector('.htlb-actions')) return; + var id = entryId(e); + e.id = id; + var bar = document.createElement('div'); + bar.className = 'htlb-actions'; + bar.style.cssText = 'display:flex;gap:.5rem;margin:.5rem 0;font-size:.85rem'; + var useful = document.createElement('button'); + useful.type = 'button'; + useful.textContent = 'Useful'; + useful.dataset.entry = id; + useful.addEventListener('click', function () { + var on = useful.dataset.on !== '1'; + toggleUseful(id, on) + .then(function (r) { + useful.dataset.on = r.useful ? '1' : '0'; + useful.textContent = r.useful ? 'Useful ✓ (' + r.count + ')' : 'Useful (' + r.count + ')'; + }) + .catch(function (err) { + console.warn(err); + }); + }); + var bm = document.createElement('button'); + bm.type = 'button'; + bm.textContent = 'Bookmark'; + bm.addEventListener('click', function () { + var on = bm.dataset.on !== '1'; + toggleBookmark(id, on) + .then(function () { + bm.dataset.on = on ? '1' : '0'; + bm.textContent = on ? 'Bookmarked ✓' : 'Bookmark'; + }) + .catch(function (err) { + console.warn(err); + }); + }); + bar.appendChild(useful); + bar.appendChild(bm); + var title = el.querySelector('h3, .t, .title') || el.firstElementChild; + if (title && title.parentNode) title.parentNode.insertBefore(bar, title.nextSibling); + else el.insertBefore(bar, el.firstChild); + }); + } + + function wireAccountLoginRedirect(acc) { + if (acc.dataset.htlbLoginGate === '1') return; + acc.dataset.htlbLoginGate = '1'; + acc.addEventListener( + 'click', + function (e) { + if (acc.dataset.signedIn === '1') return; + if (!e.target.closest('summary')) return; + e.preventDefault(); + e.stopImmediatePropagation(); + acc.open = false; + location.assign(loginHref()); + }, + true + ); + } + + function renderAccountMenu() { + var old = document.getElementById('htlb-account'); + if (old) old.remove(); + + var acc = document.getElementById('account'); + var menu = document.getElementById('account-menu'); + var btn = document.getElementById('account-btn'); + if (!acc || !menu) return; + + wireAccountLoginRedirect(acc); + + var L = labels(); + var signedIn = !!authToken(); + acc.dataset.signedIn = signedIn ? '1' : '0'; + if (!signedIn) acc.open = false; + if (btn) { + var label = signedIn ? L.aria : L.signIn; + btn.setAttribute('aria-label', label); + btn.setAttribute('title', label); + btn.setAttribute('aria-haspopup', signedIn ? 'menu' : 'false'); + } + + menu.innerHTML = ''; + if (!signedIn) return; + + var note = document.createElement('span'); + note.className = 'account-note'; + note.textContent = L.signedIn; + menu.appendChild(note); + menu.appendChild(menuButton(L.logout, logout)); + } + + async function init() { + try { + await ensureGuest(); + } catch (e) { + console.warn('htlb interactive init', e); + } + enhanceCards(); + renderAccountMenu(); + } + + window.HTLBInteractive = { + init: init, + ensureGuest: ensureGuest, + loginOAuth: loginOAuth, + logout: logout, + loginHref: loginHref, + initLoginPage: initLoginPage, + api: api, + }; + + addEventListener('htlb:ready', function () { + init(); + }); +})(); diff --git a/site/assets/v2.css b/site/assets/v2.css index c14d41e74..772618b73 100644 --- a/site/assets/v2.css +++ b/site/assets/v2.css @@ -168,6 +168,114 @@ html.dark .theme-btn .moon{display:none} .lang-menu button{display:block;width:100%;text-align:left;padding:var(--sp-2) var(--sp-3);border:0;border-radius:var(--radius-sm);background:none;color:var(--mut);font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans);cursor:pointer;transition:color .15s,background .15s} .lang-menu button:hover{color:var(--ink);background:var(--line)} +.account-dd{position:relative} +.account-dd>summary.icon-btn{list-style:none} +.account-dd>summary.icon-btn::-webkit-details-marker{display:none} +.account-menu{min-width:12rem} +.account-menu .account-sep{height:1px;background:var(--line);margin:var(--sp-1) 0} +.account-menu .account-note{display:block;padding:var(--sp-1) var(--sp-3);font:var(--fw-ui) var(--fs-caption,11px)/1.35 var(--sans);color:var(--faint)} +.account-dd[data-signed-in="1"]>summary{color:var(--ink)} + +/* ---------- login page (/login/) ---------- */ +body.login-page{ + position:relative; + isolation:isolate; + min-height:100vh; + display:flex;flex-direction:column; + overflow-x:clip; + background:var(--bg); +} +body.login-page::before, +body.login-page::after{ + content:""; + position:fixed; + left:0;right:0;top:0; + height:28vh; + pointer-events:none; + z-index:0; + transform-origin:50% 0%; + will-change:transform,opacity; +} +/* Soft wash — ellipse center sits above the viewport edge */ +body.login-page::before{ + background:radial-gradient(ellipse 58% 140% at 50% -45%, var(--line-soft) 0%, transparent 62%); + opacity:.78; + animation:login-glow-a 16s ease-in-out infinite; +} +/* Narrower highlight along the rim, also centered above the edge */ +body.login-page::after{ + height:14vh; + background:radial-gradient(ellipse 32% 160% at 50% -70%, color-mix(in srgb, var(--hover) 32%, transparent) 0%, transparent 58%); + opacity:.5; + animation:login-glow-b 20s ease-in-out infinite; +} +html.dark body.login-page::before{opacity:.7} +html.dark body.login-page::after{opacity:.42} +body.login-page > *{position:relative;z-index:1} +@keyframes login-glow-a{ + 0%,100%{transform:scaleX(1) scaleY(1);opacity:.62} + 50%{transform:scaleX(1.08) scaleY(1.12);opacity:.88} +} +@keyframes login-glow-b{ + 0%,100%{transform:translateX(0) scaleX(1);opacity:.36} + 40%{transform:translateX(8%) scaleX(1.15);opacity:.58} + 70%{transform:translateX(-7%) scaleX(.95);opacity:.44} +} +@media (prefers-reduced-motion:reduce){ + body.login-page::before, + body.login-page::after{animation:none} +} +.login-main{ + width:min(22.5rem,100%); + margin:auto; + padding:var(--sp-9) var(--sp-5) var(--sp-11); + display:flex;flex-direction:column;align-items:stretch;gap:var(--sp-4); +} +.login-main h1{ + margin:0; + font:var(--fw-strong) var(--fs-4)/var(--lh-tight) var(--serif); + color:var(--ink); + text-align:center; +} +.login-hint{ + margin:calc(var(--sp-2) * -1) 0 var(--sp-2); + font:var(--fw-ui) var(--fs-ui)/var(--lh-body) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-actions{display:flex;flex-direction:column;gap:var(--sp-3)} +.login-provider{ + display:flex;align-items:center;justify-content:center;gap:var(--sp-3); + width:100%;min-height:calc(var(--control-h) + var(--sp-2)); + padding:var(--sp-2) var(--sp-4); + border:var(--line-w) solid var(--line); + border-radius:var(--radius); + background:var(--pop-bg); + color:var(--ink); + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + cursor:pointer; + box-shadow:var(--shadow); + transition:border-color .15s,background .15s,color .15s; +} +.login-provider:hover{border-color:var(--line-soft);background:var(--line)} +.login-provider:focus-visible{outline:var(--focus-off) solid var(--hover);outline-offset:var(--focus-off)} +.login-provider-icon{ + display:grid;place-items:center; + width:var(--sp-5);height:var(--sp-5);flex:0 0 var(--sp-5); +} +.login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} +.login-provider-label{flex:0 1 auto} +.login-back{ + display:block; + align-self:center; + margin-top:var(--sp-2); + text-align:center; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--faint); +} +.login-back:hover{color:var(--hover)} + /* ---------- tooltip popup ---------- */ .backdrop{position:fixed;inset:0;background:rgba(0,0,0,.32);opacity:0;pointer-events:none;transition:opacity .2s;z-index:15} .backdrop.on{opacity:1;pointer-events:auto} diff --git a/site/en/index.html b/site/en/index.html index 6753443ed..cc58eaeb0 100644 --- a/site/en/index.html +++ b/site/en/index.html @@ -47,29 +47,29 @@ - + - + - + - + + + diff --git a/site/es/index.html b/site/es/index.html index 2cea28102..c6e6e332d 100644 --- a/site/es/index.html +++ b/site/es/index.html @@ -47,29 +47,29 @@ - + - + - + - + + + diff --git a/site/id/index.html b/site/id/index.html index 6e50638f2..6665a4ab6 100644 --- a/site/id/index.html +++ b/site/id/index.html @@ -47,29 +47,29 @@ - + - + - + - + + + diff --git a/site/index.html b/site/index.html index 3d475eddc..d427cdf69 100644 --- a/site/index.html +++ b/site/index.html @@ -55,13 +55,13 @@ - + - + + + diff --git a/site/login/index.html b/site/login/index.html new file mode 100644 index 000000000..380d255c5 --- /dev/null +++ b/site/login/index.html @@ -0,0 +1,168 @@ + + + + + +Sign in · HowToLiveBetter + + + + + + + + +
+

Sign in

+ + + + + +
+ + + + + + diff --git a/site/pt/index.html b/site/pt/index.html index 9390095ff..d210a9e1c 100644 --- a/site/pt/index.html +++ b/site/pt/index.html @@ -47,29 +47,29 @@ - + - + - + - + + + diff --git a/site/ru/index.html b/site/ru/index.html index e4dee2304..16737363f 100644 --- a/site/ru/index.html +++ b/site/ru/index.html @@ -47,29 +47,29 @@ - + - + - + - + + + diff --git a/site/zh/index.html b/site/zh/index.html index 90ce15f73..8b42432ff 100644 --- a/site/zh/index.html +++ b/site/zh/index.html @@ -47,29 +47,29 @@ - + - + - + - + + +