Compare commits

...

37 Commits

Author SHA1 Message Date
Christoph K.
4d7ff23457 Fix CI test: mount DEPLOY_DIR instead of subdirectory
Some checks failed
Deploy to NAS / deploy (push) Failing after 14s
Docker daemon can't bind-mount a subdirectory that it can't independently
verify — mount the parent and set -w /src/backend instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:57:32 +02:00
Christoph K.
86627f94b1 Add public feed, admin area, self-registration, visibility & hashtags
Some checks failed
Deploy to NAS / deploy (push) Failing after 26s
- Public feed (/) with infinite scroll via Intersection Observer
- Self-registration (/register)
- Admin area (/admin/entries, /admin/users) with user management
- journal_entries: visibility (public/private) + hashtags fields
- users: is_admin flag
- DB schema updated (recreate DB to apply)
- CI: run go test via docker run (golang:1.25-alpine) — fixes 'go not found'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:53:31 +02:00
Christoph K.
034d16e059 Add Go backend unit and handler tests; wire test step into CI
Some checks failed
Deploy to NAS / deploy (push) Failing after 14s
- Introduce store interfaces (TrackpointStorer, StopStorer, SuggestionStorer)
  in internal/db/interfaces.go so handlers can be tested without a real DB.
- Refactor HandleSingleTrackpoint, HandleBatchTrackpoints, HandleListDays,
  HandleListTrackpoints, HandleListStops, HandleListSuggestions to accept
  the new interfaces instead of concrete *db.*Store pointers (no behaviour
  change; concrete types satisfy the interfaces implicitly).
- internal/api/ingest_test.go: 13 handler tests covering happy path,
  invalid JSON, invalid timestamp, missing event_id/device_id, out-of-range
  lat/lon, empty/oversized batch, store errors, and idempotency (single + batch).
- internal/api/query_test.go: 14 handler tests covering missing query params
  (400) and empty-result-is-array guarantees for all four query endpoints.
- internal/auth/auth_test.go: 5 unit tests for HashPassword / VerifyPassword
  (correct password, wrong password, empty password, malformed hash, salt
  uniqueness).
- internal/db/trackpoints_test.go: 6 unit tests for the validateTrackpoint
  helper (happy path, missing fields, coordinate bounds, invalid source).
- .gitea/workflows/deploy.yml: add "Test" step (go test ./...) before
  "Build & Deploy" so a failing test aborts the deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 19:07:02 +02:00
Christoph K.
d1649ddfce Add OpenAPI 3.1 spec for REST API
All checks were successful
Deploy to NAS / deploy (push) Successful in 39s
Covers all endpoints: health, trackpoint ingest (single + batch),
query (days, trackpoints, stops, suggestions), journal entry creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 19:03:03 +02:00
Christoph K.
45875502f8 Move infra/ content to doc/deployment.md; delete infra/ directory
All checks were successful
Deploy to NAS / deploy (push) Successful in 34s
docker-compose.yml lives in infra repo; deployment docs belong in doc/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:50:42 +02:00
Christoph K.
156917ece1 Slim down infra/: keep only project-specific deployment docs
All checks were successful
Deploy to NAS / deploy (push) Successful in 34s
General infra setup (NAS, act_runner, Gitea Actions reference)
moved to separate infra repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:49:17 +02:00
Christoph K.
ad65102fdc Update infra docs with complete working setup
All checks were successful
Deploy to NAS / deploy (push) Successful in 44s
- README.md: rewrite with accurate setup steps including all lessons learned
- CLAUDE.md: update with working CI/CD patterns and known pitfalls
- gitea-actions.md: new file documenting Gitea Actions usage, expressions,
  act_runner config, and troubleshooting for future projects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:46:55 +02:00
Christoph K.
e2419411fa Fix health check: use wget instead of curl (docker:latest has no curl)
All checks were successful
Deploy to NAS / deploy (push) Successful in 46s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:37:49 +02:00
Christoph K.
286b48247e Fix session cookie: disable Secure flag for HTTP deployment
Some checks failed
Deploy to NAS / deploy (push) Failing after 2m12s
Secure: true requires HTTPS — cookie was not sent back on HTTP requests,
breaking the session after login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:28:42 +02:00
Christoph K.
36766592d3 Mount NAS docker dir into job container
Some checks failed
Deploy to NAS / deploy (push) Failing after 2m8s
Allows git clone, .env write and docker compose to operate
on the actual NAS filesystem, not the container's tmpfs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:16:13 +02:00
Christoph K.
e240dfe39c Use vars.DEPLOY_DIR instead of secrets.DEPLOY_DIR
Some checks failed
Deploy to NAS / deploy (push) Failing after 11s
DEPLOY_DIR is a Variable in Gitea, not a Secret.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:14:17 +02:00
Christoph K.
cd1b8e23d2 Hardcode deploy dir path; remove DEPLOY_DIR secret dependency
Some checks failed
Deploy to NAS / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:13:03 +02:00
Christoph K.
691b49da40 Remove duplicate docker socket mount from workflow
Some checks failed
Deploy to NAS / deploy (push) Failing after 13s
act_runner propagates the socket automatically from the runner container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:11:37 +02:00
Christoph K.
7f4a5a63c1 Use docker:latest container for deploy job
Some checks failed
Deploy to NAS / deploy (push) Failing after 7s
Runs job steps in docker:latest image with host socket mounted —
no need to mount host Docker binary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:06:38 +02:00
Christoph K.
58729ad511 Fix docker path on Synology NAS
Some checks failed
Deploy to NAS / deploy (push) Failing after 5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:02:37 +02:00
Christoph K.
4b0be2b5f9 Simplify deploy workflow: remove submodule handling
Some checks failed
Deploy to NAS / deploy (push) Failing after 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:00:10 +02:00
Christoph K.
d0b0b4f8bd Convert backend from submodule to regular directory
Some checks failed
Deploy to NAS / deploy (push) Failing after 4s
Remove submodule tracking; backend is now a plain directory in the repo.
Also update deploy workflow: remove --recurse-submodules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:59:50 +02:00
Christoph K.
0bb7758a2f Fix workflow: use self-hosted runner label
Some checks failed
Deploy to NAS / deploy (push) Failing after 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:55:59 +02:00
Christoph K.
a00610b831 ci: trigger deploy workflow
Some checks failed
Deploy to NAS / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:55:14 +02:00
Christoph K.
db6726d6d7 Fix checkout: enable recursive submodule cloning
Some checks failed
Deploy to NAS / deploy (push) Failing after 46s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:43:34 +02:00
Christoph K.
0e4b01bcf2 delte
Some checks failed
Deploy to NAS / deploy (push) Failing after 52s
2026-04-07 16:25:15 +02:00
Christoph K.
dc671117f5 Fix workflow runner label to match act_runner defaults
Some checks failed
Deploy to NAS / deploy (push) Failing after 3m11s
Runner registered with default labels (ubuntu-latest) instead of
self-hosted — update runs-on to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:17:52 +02:00
Christoph K.
579597776c Add infra/docker-compose.yml for shared postgres stack
Some checks failed
Deploy to NAS / deploy (push) Has been cancelled
Move compose config from README into its own file; README now references it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:03:43 +02:00
Christoph K.
1fb015b0fc Remove pgAdmin from shared infra setup
Start without UI; pgAdmin can be added later as optional service.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:57:52 +02:00
Christoph K.
2a74ea3702 Update backend submodule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:46:53 +02:00
Christoph K.
257d1e4062 Add Gitea CI/CD pipeline and shared infra setup
- docker-compose.yml: remove bundled postgres, connect to shared
  postgres via host-gateway:5433, add uploads volume, configurable port
- .gitea/workflows/deploy.yml: Gitea Actions workflow for automated
  deploy on push to main
- infra/README.md: step-by-step setup guide for NAS deployment
  (shared postgres, pgAdmin, act_runner, Gitea secrets)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:42:58 +02:00
Christoph K.
b865e5a283 Update backend submodule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 10:19:27 +02:00
Christoph K.
de6e387c7b Update gitignore files for all components
Root: add OS/IDE/secrets patterns
webapp: add .vite/, .tsbuildinfo, .env.local
backend: add /createuser binary, bin/, uploads/ (submodule bump)
app: remove redundant entries, add *.hprof, *.class

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 09:57:31 +02:00
Christoph K.
23506bab7d Replace hardcoded DB credentials with env vars in docker-compose
Adds .env.example as a template and .gitignore to exclude the actual
.env file, preventing accidental credential commits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 09:54:18 +02:00
Christoph K.
a49416854e Remove nginx/webapp container; single Go server serves SPA + API
- Add root Dockerfile: node build → copy dist into Go embed path → distroless binary
- Update docker-compose: one service (api on :9050), DB renamed ralph→pamietnik
- Remove references to RALPH/reisejournal across all docs and configs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:32:04 +02:00
Christoph K.
d1436abca8 Add Mermaid diagrams to architecture doc (implementation status, risk quadrant, production readiness)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:24:34 +02:00
Christoph K.
9775a22473 Add arc42 architecture documentation in doc/architecture.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:20:01 +02:00
Christoph K.
5abfa29e91 Remove migrate service from docker-compose; update README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:05:22 +02:00
Christoph K.
bf89ef01c7 Add hashtag requirements (REQ-TAG-*) and backlog tasks (T073-T087)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:59:23 +02:00
Christoph K.
bc2fa7b966 Apply typewriter/editorial UI style using system fonts only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:57:03 +02:00
Christoph K.
48ff7104da Fix migrate service: use entrypoint instead of command
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:54:54 +02:00
Christoph K.
909e9b6813 Fix unused import in days-page.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:36:51 +02:00
81 changed files with 5403 additions and 95 deletions

View File

@@ -3,7 +3,7 @@ name: code-reviewer
description: Prüft Codequalität, Lesbarkeit und Konsistenz. Vor Commits einsetzen.
---
Du bist Code-Reviewer für das Projekt Pamietnik (RALPH).
Du bist Code-Reviewer für das Projekt Pamietnik.
## Checkliste Go (Backend)

View File

@@ -3,7 +3,7 @@ name: dokumentar
description: Pflegt Markdown-Dokumentation und Mermaid-Diagramme. Bei neuen Features, Architekturänderungen oder wenn Doku und Code auseinanderlaufen.
---
Du bist Dokumentar für das Projekt Pamietnik (RALPH).
Du bist Dokumentar für das Projekt Pamietnik.
## Zu pflegende Dokumente

View File

@@ -3,7 +3,7 @@ name: programmierer
description: Schreibt und ändert Code für Features und Bug-Fixes. Für Go-Backend und Android/Kotlin. Einsetzen bei konkreten Implementierungsaufgaben.
---
Du bist Programmierer für das Projekt Pamietnik (RALPH).
Du bist Programmierer für das Projekt Pamietnik.
## Stack

View File

@@ -3,7 +3,7 @@ name: security-reviewer
description: Prüft OWASP Top 10, Dependency-Schwachstellen und Secrets. Vor Releases und bei Änderungen an externen APIs oder Auth-Code einsetzen.
---
Du bist Security-Reviewer für das Projekt Pamietnik (RALPH).
Du bist Security-Reviewer für das Projekt Pamietnik.
## Prüf-Befehle

View File

@@ -3,7 +3,7 @@ name: software-architekt
description: Analysiert Struktur, Abhängigkeiten und Architekturentscheidungen. Einsetzen vor größeren Änderungen, neuen Modulen oder wenn Komponenten-Grenzen unklar sind.
---
Du bist Software-Architekt für das Projekt Pamietnik (RALPH): Go-Backend + Android-App (Kotlin/Compose).
Du bist Software-Architekt für das Projekt Pamietnik: Go-Backend + Android-App (Kotlin/Compose).
## Deine Aufgaben

View File

@@ -3,7 +3,7 @@ name: tester
description: Schreibt und führt Unit- und Integrationstests aus. Nach jeder Code-Änderung einsetzen.
---
Du bist Tester für das Projekt Pamietnik (RALPH).
Du bist Tester für das Projekt Pamietnik.
## Test-Befehle

View File

@@ -0,0 +1,40 @@
name: Deploy to NAS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: self-hosted
container:
image: docker:latest
options: -v /volume2/docker:/volume2/docker
steps:
- name: Pull code
run: |
if [ -d "${{ vars.DEPLOY_DIR }}/.git" ]; then
git -C ${{ vars.DEPLOY_DIR }} pull
else
git clone http://192.168.1.4:3000/christoph/pamietnik.git ${{ vars.DEPLOY_DIR }}
fi
- name: Write .env
run: printf 'DB_PASSWORD=%s\n' '${{ secrets.DB_PASSWORD }}' > ${{ vars.DEPLOY_DIR }}/.env
- name: Test
run: |
docker run --rm \
-v ${{ vars.DEPLOY_DIR }}:/src \
-w /src/backend \
golang:1.25-alpine \
go test ./...
- name: Build & Deploy
run: docker compose -f ${{ vars.DEPLOY_DIR }}/docker-compose.yml up --build -d
- name: Health check
run: |
sleep 15
wget -qO- http://192.168.1.4:9050/healthz || exit 1

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Environment / Secrets
.env
*.env
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**Pamietnik** (Codename: RALPH) is a life/travel journal consisting of three components:
**Pamietnik** is a life/travel journal consisting of three components:
- `app/` — Android app (Kotlin + Jetpack Compose)
- `backend/` — Go REST API + server-side rendered Web UI
- `README.md` — single source of truth for architecture, requirements, and backlog
@@ -28,7 +28,7 @@ go run ./cmd/server # starts API on :8080 (default)
```
**Env vars** (with defaults):
- `DATABASE_URL``postgres://ralph:ralph@localhost:5432/ralph?sslmode=disable`
- `DATABASE_URL``postgres://pamietnik:pamietnik@localhost:5432/pamietnik?sslmode=disable`
- `LISTEN_ADDR``:8080`
- `UPLOAD_DIR``./uploads`
@@ -93,7 +93,7 @@ cd app
### Android Architecture
```
de.jacek.reisejournal/
de.jacek.pamietnik/
domain/ Trackpoint domain model
data/ Room entities, DAOs, local DB
service/ Background location foreground service

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# Stage 1: Build Vite SPA
FROM node:22-alpine AS webapp-builder
WORKDIR /webapp
COPY webapp/package.json webapp/package-lock.json ./
RUN npm ci
COPY webapp/ ./
RUN npm run build
# Stage 2: Build Go server
FROM golang:1.25-alpine AS go-builder
WORKDIR /app
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
# Inject built SPA into embed path
COPY --from=webapp-builder /webapp/dist ./internal/api/webapp/
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
RUN CGO_ENABLED=0 GOOS=linux go build -o /createuser ./cmd/createuser
# Stage 3: Minimal runtime image
FROM gcr.io/distroless/static-debian12
COPY --from=go-builder /server /server
COPY --from=go-builder /createuser /createuser
ENTRYPOINT ["/server"]

View File

@@ -1,13 +1,17 @@
Pamietnik ist eine Lebens/Reisejournal (Android +Webapp + Go Server) — Single Source of Truth
Pamietnik ist eine Journal App (Android + Webapp + Go Server)
1. Zielbild
Eine Android-App oder Webui erstellt ein Reisejournal, loggt Standortdaten im Background, cached offline, und lädt bevorzugt per HTTP zu einem Go-Server hoch; zusätzlich ist ein Datei-Export aus der App möglich.
Die Applikation besteht aus folgenden Komponenten
Der Server stellt zusätzlich eine Website bereit (Login erforderlich), auf der pro Tag die gespeicherten Punkte angezeigt werden.
- Backend in go programmiert mit Postgres DB und REST API
- Webapp, eine simple im schlichten design umgesetzte Web App
- Android app loggt Standortdaten im Background, cached offline, und lädt per HTTP backend
Fuer Nutzung ist ein Account und Login notwendig.
Das Backend soll basierend auf Logdaten Vorschläge für bestimmte Standorte machen (z. B. wenn man sich länger an einem Ort aufhält). Die Standortinformationen (Reverse-Geocoding/Place-Info) sollen über eine kostenlose API bezogen werden.
In der App soll es möglich sein, manuell Punkte hinzuzufügen (ohne GPS).
Es möglich sein, manuell Punkte hinzuzufügen (ohne GPS).
2. Festlegungen (Decisions)
DEC-CLIENT-01: Android (Kotlin), UI mit Jetpack Compose.
@@ -114,6 +118,19 @@ REQ-OPENAPI-04: Cookie-basierte Auth für Web-Endpoints SOLL im OpenAPI über ty
3.11 Google Play / Transparenz
REQ-PRIV-01: Bei Standortzugriff im Hintergrund MUSS eine klare In-App-Erklärung/Disclosure vorhanden sein; Background-Location ist zu begründen und sauber zu deklarieren.
3.13 Hashtags
REQ-TAG-01: Trackpoints und manuelle Punkte MÜSSEN mit einem oder mehreren Hashtags versehen werden können (z. B. `#restaurant`, `#aussicht`, `#hotel`).
REQ-TAG-02: Hashtags MÜSSEN lokal in SQLite/Room gespeichert und beim Upload zum Server übertragen werden (als Teil des Trackpoint-Schemas, Feld `tags: string[]`).
REQ-TAG-03: Der Server MUSS Hashtags persistieren und pro Trackpoint/Stop abfragbar machen.
REQ-TAG-04: Die Webapp MUSS eine Filteransicht bieten, die alle Trackpoints eines Tages oder Trips nach einem oder mehreren Hashtags filtert.
REQ-TAG-05: Hashtags SOLLEN bei der Eingabe in der App als Vorschläge angezeigt werden (aus bereits verwendeten Tags des Nutzers).
REQ-TAG-06: Die Webapp SOLL eine Tag-Übersicht bieten, die alle verwendeten Hashtags des Nutzers mit Häufigkeit auflistet.
3.12 Dokumentationsstil
REQ-DOC-STYLE-01: Alle wesentlichen Anforderungen, Architekturentscheidungen, API-Contracts, Testanforderungen und Tasks MÜSSEN in diesem Markdown gepflegt werden.
@@ -362,6 +379,10 @@ GET /v1/stops?date=YYYY-MM-DD
GET /v1/suggestions?date=YYYY-MM-DD
GET /v1/tags (alle Tags des Nutzers mit Häufigkeit)
GET /v1/trackpoints?tag=restaurant (Filter nach Hashtag)
Web UI Routes (serverseitig gerendert):
GET /login
@@ -391,6 +412,8 @@ source: "gps" | "manual" (optional; Default: gps)
note (string, optional; nur bei manuellen Punkten)
tags (string[], optional; Hashtags ohne #-Präfix, z. B. ["restaurant", "aussicht"])
optional: accuracy_m, speed_mps, bearing_deg, altitude_m
Stop:
@@ -474,7 +497,7 @@ Nachteile:
Widerruf/Revocation, Rotation und Lebensdauer-Management erhöhen Komplexität.
Empfehlung für RALPH
Empfehlung für Pamietnik
Website: Session Cookie.
JWT optional später, falls API-first/SSO nötig wird.
@@ -550,6 +573,14 @@ REQ-TEST-GEO-02: Provider-Wechsel muss testbar sein (Config umstellen → andere
T084: Tests: Unit Tests für Validierung + Mapping; Integrationstest: Manual point → Queue → Upload → Ack.
Hashtags (Android):
T085: Room-Schema erweitern: tags-Feld (String-Array) in Trackpoint-Entity + Migration.
T086: UI: Hashtag-Eingabe in Compose (manueller Punkt + GPS-Punkt nachträglich taggen), Vorschläge aus vorhandenen Tags.
T087: Upload: tags-Feld in Batch-Payload einbeziehen.
8.3 Server (Go, HTTP + Web UI)
API / Persistenz:
@@ -593,6 +624,16 @@ OpenAPI:
T072: OpenAPI in CI validieren (Spec-Validation) und Änderungen nur via PR.
Hashtags (Server + Webapp):
T073: DB-Schema: tags-Spalte (text[]) in trackpoints-Tabelle + Migration + Index (GIN für Array-Suche).
T074: API: tags in Ingest-Endpoints annehmen und speichern; GET /v1/tags (Häufigkeit) und GET /v1/trackpoints?tag=... implementieren.
T075: Webapp: Tag-Filter in Tagesdetail; Tag-Übersicht (/tags); Tags bei Trackpoints anzeigen.
T076: OpenAPI: tags-Feld in Trackpoint-Schema + neue Endpoints dokumentieren.
8.4 Tests
T040: Use-Case-Matrix (Use Case → Testart).

9
app/.gitignore vendored
View File

@@ -1,9 +1,7 @@
*.iml
.gradle/
.idea/
/local.properties
/.idea/workspace.xml
/.idea/libraries
local.properties
.DS_Store
/build/
/captures/
@@ -11,6 +9,5 @@
.cxx/
*.keystore
!debug.keystore
local.properties
app/build/
app/.cxx/
*.hprof
*.class

View File

@@ -7,11 +7,11 @@ plugins {
}
android {
namespace = "de.jacek.reisejournal"
namespace = "de.jacek.pamietnik"
compileSdk = 35
defaultConfig {
applicationId = "de.jacek.reisejournal"
applicationId = "de.jacek.pamietnik"
minSdk = 26
targetSdk = 35
versionCode = 1

View File

@@ -1,4 +1,4 @@
package de.jacek.reisejournal
package de.jacek.pamietnik
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -11,6 +11,6 @@ class ExampleInstrumentedTest {
@Test
fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("de.jacek.reisejournal", appContext.packageName)
assertEquals("de.jacek.pamietnik", appContext.packageName)
}
}

View File

@@ -1,12 +1,12 @@
package de.jacek.reisejournal
package de.jacek.pamietnik
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint
import de.jacek.reisejournal.ui.navigation.NavGraph
import de.jacek.reisejournal.ui.theme.RalphTheme
import de.jacek.pamietnik.ui.navigation.NavGraph
import de.jacek.pamietnik.ui.theme.RalphTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {

View File

@@ -1,4 +1,4 @@
package de.jacek.reisejournal
package de.jacek.pamietnik
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
@@ -7,7 +7,7 @@ import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class RalphApp : Application(), Configuration.Provider {
class PamietnikApp : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory

View File

@@ -1,4 +1,4 @@
package de.jacek.reisejournal.domain
package de.jacek.pamietnik.domain
data class Trackpoint(
val eventId: String, // UUID, client-generated

View File

@@ -1,4 +1,4 @@
package de.jacek.reisejournal.ui.home
package de.jacek.pamietnik.ui.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -16,7 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import de.jacek.reisejournal.R
import de.jacek.pamietnik.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@@ -1,4 +1,4 @@
package de.jacek.reisejournal.ui.home
package de.jacek.pamietnik.ui.home
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel

View File

@@ -1,10 +1,10 @@
package de.jacek.reisejournal.ui.navigation
package de.jacek.pamietnik.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.jacek.reisejournal.ui.home.HomeScreen
import de.jacek.pamietnik.ui.home.HomeScreen
const val HOME_ROUTE = "home"

View File

@@ -1,4 +1,4 @@
package de.jacek.reisejournal.ui.theme
package de.jacek.pamietnik.ui.theme
import androidx.compose.ui.graphics.Color

View File

@@ -1,4 +1,4 @@
package de.jacek.reisejournal.ui.theme
package de.jacek.pamietnik.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme

View File

@@ -1,4 +1,4 @@
package de.jacek.reisejournal.ui.theme
package de.jacek.pamietnik.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle

View File

@@ -1,4 +1,4 @@
package de.jacek.reisejournal
package de.jacek.pamietnik
import org.junit.Test
import org.junit.Assert.*

View File

@@ -25,5 +25,5 @@ dependencyResolutionManagement {
}
}
rootProject.name = "reisejournal"
rootProject.name = "pamietnik"
include(":app")

Submodule backend deleted from 55d2ffc203

2
backend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
DATABASE_URL=postgres://ralph:ralph@localhost:5432/ralph?sslmode=disable
LISTEN_ADDR=:8080

29
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Binaries
/server
/migrate
/createuser
*.exe
# Build output
dist/
bin/
# Uploads
uploads/
# Environment / Secrets
.env
*.env
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
# Go test cache / coverage
*.test
*.out
coverage.html

109
backend/CLAUDE.md Normal file
View File

@@ -0,0 +1,109 @@
# CLAUDE.md — Pamietnik Backend (Go Server)
## Stack
Language: Go
DB: PostgreSQL
API-Doc: OpenAPI 3.1 (openapi.yaml)
Auth: Session Cookie (Web UI); API-Key oder JWT (Android Upload, TBD)
Hashing: Argon2id (Passwörter)
Geocoding: Nominatim (OSM) mit Cache + Rate-Limit; Provider austauschbar
Maps: OpenStreetMap Tiles (konfigurierbar, serverseitig)
Dev: docker-compose (API + PostgreSQL)
---
## Kern-Features (Backend)
1. REST API Ingest: Single + Batch Trackpoints (Idempotenz via event_id)
2. Idempotenz/Dedupe: Unique Key (device_id, event_id); Duplikate = 200 OK
3. Stop Detection: Aufenthalte erkennen (minDuration + radiusMeters konfigurierbar)
4. Suggestions: Aus Stops Vorschläge ableiten + speichern
5. Reverse-Geocoding: Nominatim gecached, Provider austauschbar via Config
6. Web UI: Login (Session Cookie), Tagesübersicht, Tagesdetail, Karte
7. Auth: Argon2id Passwort-Hashing, Session-Store in PostgreSQL
---
## API Endpoints
Ingest:
POST /v1/trackpoints <- Single Trackpoint
POST /v1/trackpoints:batch <- Batch Trackpoints
GET /healthz
GET /readyz
Query (Auth required):
GET /v1/days?from=YYYY-MM-DD&to=YYYY-MM-DD
GET /v1/trackpoints?date=YYYY-MM-DD
GET /v1/stops?date=YYYY-MM-DD
GET /v1/suggestions?date=YYYY-MM-DD
Web UI (Session Cookie, serverseitig gerendert):
GET /login
POST /login
POST /logout
GET /days
GET /days/{yyyy-mm-dd}
---
## Datenmodell (Kern)
Trackpoint:
event_id string (UUID, client-generated)
device_id string
trip_id string
timestamp RFC3339 oder epochMillis (TBD)
lat, lon float64
source "gps" | "manual"
note string (optional)
Stop:
stop_id string
device_id, trip_id
start_ts, end_ts
center_lat, center_lon
duration_s int
place_label string (optional, Nominatim)
Suggestion:
suggestion_id
stop_id
type "highlight" | "name_place" | "add_note"
title/text string
created_at, dismissed_at
---
## Architektur-Prinzipien
- Idempotenz zuerst: Kein Duplicate Insert, immer event_id prüfen
- Geocoding nur ereignisbasiert (pro Stop), niemals periodisch/bulk
- Geocoding-Provider über Config austauschbar (kein Hardcode)
- Sessions serverseitig in PostgreSQL (invalidierbar bei Logout)
- Stop Detection Parameter (minDuration, radiusMeters) konfigurierbar
- OpenAPI immer aktuell halten; Änderungen nur via PR + CI Validation
---
## Offene Entscheidungen (TBD)
- timestamp Format: epochMillis vs RFC3339
- Android Upload Auth: X-API-Key vs JWT
- Payload: JSON vs Protobuf
- Batch limits (max items, max bytes)
- Retention Policy (Trackpoints löschen nach X Tagen)
- Stop-Detection Parameter (Mindestdauer, Radius)
- Geocoding Provider: Nominatim public vs self-hosted vs Alternative
---
## Nächste Tasks (Reihenfolge)
- [ ] T024 REST API finalisieren (Endpoints, Fehlerformat, Limits)
- [ ] T027 PostgreSQL Schema + Migrationen + Indizes
- [ ] T028 Idempotenz implementieren (unique event_id pro device)
- [ ] T029 Observability (Logs/Metrics), Health/Ready
- [ ] T030 docker-compose lokal (API + PostgreSQL) + Minimal-Client
- [ ] T050 Auth-Konzept festlegen

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
RUN CGO_ENABLED=0 GOOS=linux go build -o /createuser ./cmd/createuser
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
COPY --from=builder /createuser /createuser
ENTRYPOINT ["/server"]

View File

@@ -0,0 +1,55 @@
// cmd/createuser creates a new user in the database.
// Usage: DATABASE_URL=... go run ./cmd/createuser <username> <password>
package main
import (
"context"
"fmt"
"os"
"github.com/jackc/pgx/v5"
"github.com/jacek/pamietnik/backend/internal/auth"
)
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: createuser <username> <password>")
os.Exit(1)
}
username := os.Args[1]
password := os.Args[2]
if len(password) < 8 {
fmt.Fprintln(os.Stderr, "password must be at least 8 characters")
os.Exit(1)
}
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
dsn = "postgres://pamietnik:pamietnik@localhost:5432/pamietnik?sslmode=disable"
}
conn, err := pgx.Connect(context.Background(), dsn)
if err != nil {
fmt.Fprintln(os.Stderr, "db error:", err)
os.Exit(1)
}
defer conn.Close(context.Background())
hash, err := auth.HashPassword(password)
if err != nil {
fmt.Fprintln(os.Stderr, "hash error:", err)
os.Exit(1)
}
_, err = conn.Exec(context.Background(),
`INSERT INTO users (username, password_hash) VALUES ($1, $2)`,
username, hash,
)
if err != nil {
fmt.Fprintln(os.Stderr, "insert error:", err)
os.Exit(1)
}
fmt.Printf("user '%s' created\n", username)
}

View File

@@ -0,0 +1,88 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jacek/pamietnik/backend/internal/api"
"github.com/jacek/pamietnik/backend/internal/auth"
"github.com/jacek/pamietnik/backend/internal/db"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
dsn := getenv("DATABASE_URL", "postgres://pamietnik:pamietnik@localhost:5432/pamietnik?sslmode=disable")
addr := getenv("LISTEN_ADDR", ":8080")
uploadDir := getenv("UPLOAD_DIR", "./uploads")
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
pool, err := db.NewPool(ctx, dsn)
if err != nil {
slog.Error("connect db", "err", err)
os.Exit(1)
}
defer pool.Close()
slog.Info("database connected")
if err := db.InitSchema(ctx, pool); err != nil {
slog.Error("init schema", "err", err)
os.Exit(1)
}
slog.Info("schema ready")
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
slog.Error("create upload dir", "err", err)
os.Exit(1)
}
authStore := auth.NewStore(pool)
tpStore := db.NewTrackpointStore(pool)
stopStore := db.NewStopStore(pool)
suggStore := db.NewSuggestionStore(pool)
journalStore := db.NewJournalStore(pool)
userStore := db.NewUserStore(pool)
router := api.NewRouter(authStore, tpStore, stopStore, suggStore, journalStore, userStore, uploadDir)
srv := &http.Server{
Addr: addr,
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
slog.Info("server starting", "addr", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
cancel()
}
}()
<-ctx.Done()
slog.Info("shutting down")
shutCtx, shutCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutCancel()
if err := srv.Shutdown(shutCtx); err != nil {
slog.Error("shutdown error", "err", err)
}
slog.Info("server stopped")
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

20
backend/go.mod Normal file
View File

@@ -0,0 +1,20 @@
module github.com/jacek/pamietnik/backend
go 1.25.7
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.8.0
golang.org/x/crypto v0.48.0
)
require (
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

87
backend/go.sum Normal file
View File

@@ -0,0 +1,87 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,137 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/jacek/pamietnik/backend/internal/db"
"github.com/jacek/pamietnik/backend/internal/domain"
)
// Compile-time check: *db.TrackpointStore must satisfy db.TrackpointStorer.
var _ db.TrackpointStorer = (*db.TrackpointStore)(nil)
type trackpointInput struct {
EventID string `json:"event_id"`
DeviceID string `json:"device_id"`
TripID string `json:"trip_id"`
Timestamp string `json:"timestamp"` // RFC3339
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Source string `json:"source"`
Note string `json:"note,omitempty"`
AccuracyM *float64 `json:"accuracy_m,omitempty"`
SpeedMps *float64 `json:"speed_mps,omitempty"`
BearingDeg *float64 `json:"bearing_deg,omitempty"`
AltitudeM *float64 `json:"altitude_m,omitempty"`
}
func (t trackpointInput) toDomain() (domain.Trackpoint, error) {
ts, err := time.Parse(time.RFC3339, t.Timestamp)
if err != nil {
return domain.Trackpoint{}, err
}
src := t.Source
if src == "" {
src = "gps"
}
return domain.Trackpoint{
EventID: t.EventID,
DeviceID: t.DeviceID,
TripID: t.TripID,
Timestamp: ts,
Lat: t.Lat,
Lon: t.Lon,
Source: src,
Note: t.Note,
AccuracyM: t.AccuracyM,
SpeedMps: t.SpeedMps,
BearingDeg: t.BearingDeg,
AltitudeM: t.AltitudeM,
}, nil
}
type batchResponse struct {
ServerTime string `json:"server_time"`
AcceptedIDs []string `json:"accepted_ids"`
Rejected []db.RejectedItem `json:"rejected"`
}
// HandleSingleTrackpoint handles POST /v1/trackpoints
func HandleSingleTrackpoint(store db.TrackpointStorer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input trackpointInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
point, err := input.toDomain()
if err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid timestamp: "+err.Error())
return
}
userID := userIDFromContext(r.Context())
accepted, rejected, err := store.UpsertBatch(r.Context(), userID, []domain.Trackpoint{point})
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
return
}
writeJSON(w, http.StatusOK, batchResponse{
ServerTime: time.Now().UTC().Format(time.RFC3339),
AcceptedIDs: accepted,
Rejected: rejected,
})
}
}
// HandleBatchTrackpoints handles POST /v1/trackpoints:batch
func HandleBatchTrackpoints(store db.TrackpointStorer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var inputs []trackpointInput
if err := json.NewDecoder(r.Body).Decode(&inputs); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid JSON")
return
}
if len(inputs) == 0 {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "empty batch")
return
}
if len(inputs) > 500 {
writeError(w, http.StatusBadRequest, "TOO_LARGE", "batch exceeds 500 items")
return
}
points := make([]domain.Trackpoint, 0, len(inputs))
var parseRejected []db.RejectedItem
for _, inp := range inputs {
p, err := inp.toDomain()
if err != nil {
parseRejected = append(parseRejected, db.RejectedItem{
EventID: inp.EventID,
Code: "INVALID_TIMESTAMP",
Message: err.Error(),
})
continue
}
points = append(points, p)
}
userID := userIDFromContext(r.Context())
accepted, rejected, err := store.UpsertBatch(r.Context(), userID, points)
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
return
}
rejected = append(rejected, parseRejected...)
writeJSON(w, http.StatusOK, batchResponse{
ServerTime: time.Now().UTC().Format(time.RFC3339),
AcceptedIDs: accepted,
Rejected: rejected,
})
}
}

View File

@@ -0,0 +1,380 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/jacek/pamietnik/backend/internal/db"
"github.com/jacek/pamietnik/backend/internal/domain"
)
// fakeTrackpointStore is an in-memory implementation of db.TrackpointStorer for tests.
type fakeTrackpointStore struct {
// stored maps event_id → Trackpoint (simulates unique constraint)
stored map[string]domain.Trackpoint
// forceErr causes UpsertBatch to return an error when set
forceErr error
}
func newFakeTrackpointStore() *fakeTrackpointStore {
return &fakeTrackpointStore{stored: make(map[string]domain.Trackpoint)}
}
func (f *fakeTrackpointStore) UpsertBatch(_ context.Context, _ string, points []domain.Trackpoint) ([]string, []db.RejectedItem, error) {
if f.forceErr != nil {
return nil, nil, f.forceErr
}
var accepted []string
var rejected []db.RejectedItem
for _, p := range points {
// Validate
if p.EventID == "" {
rejected = append(rejected, db.RejectedItem{EventID: p.EventID, Code: "VALIDATION_ERROR", Message: "event_id is required"})
continue
}
if p.DeviceID == "" {
rejected = append(rejected, db.RejectedItem{EventID: p.EventID, Code: "VALIDATION_ERROR", Message: "device_id is required"})
continue
}
if p.Lat < -90 || p.Lat > 90 {
rejected = append(rejected, db.RejectedItem{EventID: p.EventID, Code: "VALIDATION_ERROR", Message: "lat out of range"})
continue
}
if p.Lon < -180 || p.Lon > 180 {
rejected = append(rejected, db.RejectedItem{EventID: p.EventID, Code: "VALIDATION_ERROR", Message: "lon out of range"})
continue
}
// Idempotency: already stored → count as accepted (no duplicate insert)
if _, exists := f.stored[p.EventID]; !exists {
f.stored[p.EventID] = p
}
accepted = append(accepted, p.EventID)
}
return accepted, rejected, nil
}
func (f *fakeTrackpointStore) ListByDate(_ context.Context, _, _ string) ([]domain.Trackpoint, error) {
return nil, nil
}
func (f *fakeTrackpointStore) ListDays(_ context.Context, _, _, _ string) ([]domain.DaySummary, error) {
return nil, nil
}
// authContext injects a fake user_id into the request context, simulating a logged-in session.
func authContext(r *http.Request) *http.Request {
return r.WithContext(contextWithUserID(r.Context(), "user-test"))
}
// --- HandleSingleTrackpoint tests ---
func TestHandleSingleTrackpoint_HappyPath(t *testing.T) {
store := newFakeTrackpointStore()
handler := HandleSingleTrackpoint(store)
body := `{
"event_id": "evt-001",
"device_id": "dev-001",
"trip_id": "trip-1",
"timestamp": "2024-06-01T12:00:00Z",
"lat": 52.5,
"lon": 13.4,
"source": "gps"
}`
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp batchResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.AcceptedIDs) != 1 || resp.AcceptedIDs[0] != "evt-001" {
t.Errorf("expected accepted_ids=[evt-001], got %v", resp.AcceptedIDs)
}
if len(resp.Rejected) != 0 {
t.Errorf("expected no rejected, got %v", resp.Rejected)
}
}
func TestHandleSingleTrackpoint_InvalidJSON(t *testing.T) {
handler := HandleSingleTrackpoint(newFakeTrackpointStore())
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader("{bad json"))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rec.Code)
}
}
func TestHandleSingleTrackpoint_InvalidTimestamp(t *testing.T) {
handler := HandleSingleTrackpoint(newFakeTrackpointStore())
body := `{"event_id":"e1","device_id":"d1","timestamp":"not-a-date","lat":10,"lon":10}`
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rec.Code)
}
}
func TestHandleSingleTrackpoint_MissingEventID(t *testing.T) {
store := newFakeTrackpointStore()
handler := HandleSingleTrackpoint(store)
body := `{"device_id":"dev-1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10,"source":"gps"}`
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 (rejected in payload), got %d", rec.Code)
}
var resp batchResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.Rejected) == 0 {
t.Error("expected missing event_id to appear in rejected list")
}
}
func TestHandleSingleTrackpoint_InvalidLatLon(t *testing.T) {
cases := []struct{ lat, lon float64 }{
{91, 0},
{-91, 0},
{0, 181},
{0, -181},
}
for _, c := range cases {
body := fmt.Sprintf(`{"event_id":"e1","device_id":"d1","timestamp":"2024-01-01T00:00:00Z","lat":%v,"lon":%v,"source":"gps"}`, c.lat, c.lon)
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
req = authContext(req)
rec := httptest.NewRecorder()
HandleSingleTrackpoint(newFakeTrackpointStore()).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 with rejected payload for lat=%v lon=%v, got %d", c.lat, c.lon, rec.Code)
}
var resp batchResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.Rejected) == 0 {
t.Errorf("expected invalid lat/lon to appear in rejected list (lat=%v lon=%v)", c.lat, c.lon)
}
}
}
func TestHandleSingleTrackpoint_IdempotencyDuplicateEventID(t *testing.T) {
store := newFakeTrackpointStore()
handler := HandleSingleTrackpoint(store)
body := `{"event_id":"evt-dup","device_id":"dev-1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10,"source":"gps"}`
sendRequest := func() batchResponse {
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var resp batchResponse
json.NewDecoder(rec.Body).Decode(&resp)
return resp
}
r1 := sendRequest()
r2 := sendRequest()
// Both calls must succeed and return the same accepted_ids
if len(r1.AcceptedIDs) != 1 || r1.AcceptedIDs[0] != "evt-dup" {
t.Errorf("first call: expected [evt-dup], got %v", r1.AcceptedIDs)
}
if len(r2.AcceptedIDs) != 1 || r2.AcceptedIDs[0] != "evt-dup" {
t.Errorf("second call: expected [evt-dup] (idempotent), got %v", r2.AcceptedIDs)
}
// Store must not contain duplicate entries
if len(store.stored) != 1 {
t.Errorf("expected exactly 1 stored trackpoint, got %d", len(store.stored))
}
}
func TestHandleSingleTrackpoint_StoreError(t *testing.T) {
store := newFakeTrackpointStore()
store.forceErr = fmt.Errorf("connection reset")
handler := HandleSingleTrackpoint(store)
body := `{"event_id":"e1","device_id":"d1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10}`
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints", strings.NewReader(body))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on store error, got %d", rec.Code)
}
}
// --- HandleBatchTrackpoints tests ---
func validBatchBody(n int) string {
items := make([]string, n)
for i := range items {
items[i] = fmt.Sprintf(`{"event_id":"evt-%d","device_id":"dev-1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10,"source":"gps"}`, i)
}
return "[" + strings.Join(items, ",") + "]"
}
func TestHandleBatchTrackpoints_HappyPath(t *testing.T) {
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader(validBatchBody(3)))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp batchResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.AcceptedIDs) != 3 {
t.Errorf("expected 3 accepted, got %d", len(resp.AcceptedIDs))
}
}
func TestHandleBatchTrackpoints_EmptyBatch(t *testing.T) {
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader("[]"))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 for empty batch, got %d", rec.Code)
}
}
func TestHandleBatchTrackpoints_ExceedsLimit(t *testing.T) {
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader(validBatchBody(501)))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 for batch > 500, got %d", rec.Code)
}
var errResp errorResponse
json.NewDecoder(rec.Body).Decode(&errResp)
if errResp.Code != "TOO_LARGE" {
t.Errorf("expected code TOO_LARGE, got %q", errResp.Code)
}
}
func TestHandleBatchTrackpoints_InvalidJSON(t *testing.T) {
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader("{not array}"))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rec.Code)
}
}
func TestHandleBatchTrackpoints_PartialInvalidTimestamp(t *testing.T) {
handler := HandleBatchTrackpoints(newFakeTrackpointStore())
// First item has valid timestamp, second has invalid
body := `[
{"event_id":"e1","device_id":"d1","timestamp":"2024-01-01T00:00:00Z","lat":10,"lon":10},
{"event_id":"e2","device_id":"d1","timestamp":"not-a-date","lat":10,"lon":10}
]`
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", bytes.NewBufferString(body))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var resp batchResponse
json.NewDecoder(rec.Body).Decode(&resp)
if len(resp.AcceptedIDs) != 1 {
t.Errorf("expected 1 accepted, got %d", len(resp.AcceptedIDs))
}
if len(resp.Rejected) != 1 || resp.Rejected[0].EventID != "e2" {
t.Errorf("expected e2 in rejected, got %v", resp.Rejected)
}
}
func TestHandleBatchTrackpoints_IdempotencyBatchSentTwice(t *testing.T) {
store := newFakeTrackpointStore()
handler := HandleBatchTrackpoints(store)
body := validBatchBody(5)
sendBatch := func() batchResponse {
req := httptest.NewRequest(http.MethodPost, "/v1/trackpoints:batch", strings.NewReader(body))
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var resp batchResponse
json.NewDecoder(rec.Body).Decode(&resp)
return resp
}
r1 := sendBatch()
r2 := sendBatch()
if len(r1.AcceptedIDs) != 5 {
t.Errorf("first batch: expected 5 accepted, got %d", len(r1.AcceptedIDs))
}
if len(r2.AcceptedIDs) != 5 {
t.Errorf("second batch (idempotent): expected 5 accepted, got %d", len(r2.AcceptedIDs))
}
// No duplicates stored
if len(store.stored) != 5 {
t.Errorf("expected 5 unique stored trackpoints, got %d", len(store.stored))
}
// accepted_ids must be identical for both calls
for i := range r1.AcceptedIDs {
if r1.AcceptedIDs[i] != r2.AcceptedIDs[i] {
t.Errorf("accepted_ids differ at index %d: %q vs %q", i, r1.AcceptedIDs[i], r2.AcceptedIDs[i])
}
}
}

View File

@@ -0,0 +1,180 @@
package api
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/jacek/pamietnik/backend/internal/db"
"github.com/jacek/pamietnik/backend/internal/domain"
)
const (
maxUploadSize = 32 << 20 // 32 MB per request
maxSingleImage = 10 << 20 // 10 MB per image
)
var allowedMIME = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/heic": ".heic",
}
type JournalHandler struct {
store *db.JournalStore
uploadDir string
}
func NewJournalHandler(store *db.JournalStore, uploadDir string) *JournalHandler {
return &JournalHandler{store: store, uploadDir: uploadDir}
}
// HandleCreateEntry handles POST /entries (multipart/form-data).
func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "Formular zu groß", http.StatusRequestEntityTooLarge)
return
}
userID := userIDFromContext(r.Context())
date := strings.TrimSpace(r.FormValue("date"))
entryTime := strings.TrimSpace(r.FormValue("time"))
title := strings.TrimSpace(r.FormValue("title"))
description := strings.TrimSpace(r.FormValue("description"))
visibility := r.FormValue("visibility")
if visibility != "public" && visibility != "private" {
visibility = "private"
}
var hashtags []string
if raw := strings.TrimSpace(r.FormValue("hashtags")); raw != "" {
for _, tag := range strings.Split(raw, ",") {
tag = strings.TrimSpace(tag)
tag = strings.TrimPrefix(tag, "#")
if tag != "" {
hashtags = append(hashtags, tag)
}
}
}
if date == "" || entryTime == "" {
http.Error(w, "Datum und Uhrzeit sind Pflichtfelder", http.StatusBadRequest)
return
}
entry := domain.JournalEntry{
UserID: userID,
EntryDate: date,
EntryTime: entryTime,
Title: title,
Description: description,
Visibility: visibility,
Hashtags: hashtags,
}
if lat := r.FormValue("lat"); lat != "" {
var v float64
if _, err := fmt.Sscanf(lat, "%f", &v); err == nil {
entry.Lat = &v
}
}
if lon := r.FormValue("lon"); lon != "" {
var v float64
if _, err := fmt.Sscanf(lon, "%f", &v); err == nil {
entry.Lon = &v
}
}
saved, err := h.store.InsertEntry(r.Context(), entry)
if err != nil {
http.Error(w, "Datenbankfehler", http.StatusInternalServerError)
return
}
// Handle image uploads
if r.MultipartForm != nil && r.MultipartForm.File != nil {
files := r.MultipartForm.File["images"]
for _, fh := range files {
if fh.Size > maxSingleImage {
continue // skip oversized images silently
}
f, err := fh.Open()
if err != nil {
continue
}
// Detect MIME type from first 512 bytes
buf := make([]byte, 512)
n, _ := f.Read(buf)
mime := http.DetectContentType(buf[:n])
ext, ok := allowedMIME[mime]
if !ok {
f.Close()
continue
}
filename := saved.EntryID + "_" + fh.Filename
filename = sanitizeFilename(filename) + ext
destPath := filepath.Join(h.uploadDir, filename)
out, err := os.Create(destPath)
if err != nil {
f.Close()
continue
}
// Write already-read bytes + rest; clean up file on any write error
if _, err := out.Write(buf[:n]); err != nil {
out.Close()
f.Close()
os.Remove(destPath)
continue
}
if _, err := io.Copy(out, f); err != nil {
out.Close()
f.Close()
os.Remove(destPath)
continue
}
out.Close()
f.Close()
img := domain.JournalImage{
EntryID: saved.EntryID,
Filename: filename,
OriginalName: fh.Filename,
MimeType: mime,
SizeBytes: fh.Size,
}
if _, err := h.store.InsertImage(r.Context(), img); err != nil {
slog.Error("insert image", "entry_id", saved.EntryID, "filename", filename, "err", err)
os.Remove(destPath)
}
}
}
http.Redirect(w, r, "/days/"+date, http.StatusSeeOther)
}
// sanitizeFilename strips path separators and non-printable characters.
func sanitizeFilename(name string) string {
name = filepath.Base(name)
var b strings.Builder
for _, r := range name {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
s := b.String()
// strip extension — we append the detected one
if idx := strings.LastIndex(s, "."); idx > 0 {
s = s[:idx]
}
return s
}

View File

@@ -0,0 +1,76 @@
package api
import (
"context"
"net/http"
"github.com/jacek/pamietnik/backend/internal/auth"
"github.com/jacek/pamietnik/backend/internal/domain"
)
type contextKey string
const ctxUserID contextKey = "user_id"
const ctxUser contextKey = "user"
const sessionCookieName = "session"
// RequireAuth validates the session cookie and stores user info in context.
// On failure it redirects to /login for browser requests (text/html) or returns JSON 401.
func RequireAuth(authStore *auth.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := userFromRequest(r, authStore)
if err != nil {
redirectOrUnauthorized(w, r)
return
}
ctx := context.WithValue(r.Context(), ctxUserID, user.UserID)
ctx = context.WithValue(ctx, ctxUser, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// requireAdmin checks that the authenticated user is an admin.
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := r.Context().Value(ctxUser).(domain.User)
if !ok || !u.IsAdmin {
http.Redirect(w, r, "/days", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func userFromRequest(r *http.Request, authStore *auth.Store) (domain.User, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return domain.User{}, auth.ErrSessionNotFound
}
return authStore.GetUserBySession(r.Context(), cookie.Value)
}
func redirectOrUnauthorized(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept")
if len(accept) > 0 && (accept == "application/json" || r.Header.Get("X-Requested-With") == "XMLHttpRequest") {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "login required")
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func userIDFromContext(ctx context.Context) string {
v, _ := ctx.Value(ctxUserID).(string)
return v
}
func userFromContext(ctx context.Context) domain.User {
v, _ := ctx.Value(ctxUser).(domain.User)
return v
}
func contextWithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, ctxUserID, userID)
}

View File

@@ -0,0 +1,102 @@
package api
import (
"log/slog"
"net/http"
"github.com/jacek/pamietnik/backend/internal/db"
"github.com/jacek/pamietnik/backend/internal/domain"
)
// HandleListDays handles GET /v1/days?from=YYYY-MM-DD&to=YYYY-MM-DD
func HandleListDays(store db.TrackpointStorer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
from := r.URL.Query().Get("from")
to := r.URL.Query().Get("to")
if from == "" || to == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "from and to are required (YYYY-MM-DD)")
return
}
days, err := store.ListDays(r.Context(), userID, from, to)
if err != nil {
slog.Error("list days", "user_id", userID, "err", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
return
}
if days == nil {
days = []domain.DaySummary{}
}
writeJSON(w, http.StatusOK, days)
}
}
// HandleListTrackpoints handles GET /v1/trackpoints?date=YYYY-MM-DD
func HandleListTrackpoints(store db.TrackpointStorer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
date := r.URL.Query().Get("date")
if date == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "date is required (YYYY-MM-DD)")
return
}
points, err := store.ListByDate(r.Context(), userID, date)
if err != nil {
slog.Error("list trackpoints", "user_id", userID, "date", date, "err", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
return
}
if points == nil {
points = []domain.Trackpoint{}
}
writeJSON(w, http.StatusOK, points)
}
}
// HandleListStops handles GET /v1/stops?date=YYYY-MM-DD
func HandleListStops(store db.StopStorer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
date := r.URL.Query().Get("date")
if date == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "date is required (YYYY-MM-DD)")
return
}
stops, err := store.ListByDate(r.Context(), userID, date)
if err != nil {
slog.Error("list stops", "user_id", userID, "date", date, "err", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
return
}
if stops == nil {
stops = []domain.Stop{}
}
writeJSON(w, http.StatusOK, stops)
}
}
// HandleListSuggestions handles GET /v1/suggestions?date=YYYY-MM-DD
func HandleListSuggestions(store db.SuggestionStorer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
date := r.URL.Query().Get("date")
if date == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "date is required (YYYY-MM-DD)")
return
}
suggestions, err := store.ListByDate(r.Context(), userID, date)
if err != nil {
slog.Error("list suggestions", "user_id", userID, "date", date, "err", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "database error")
return
}
if suggestions == nil {
suggestions = []domain.Suggestion{}
}
writeJSON(w, http.StatusOK, suggestions)
}
}

View File

@@ -0,0 +1,318 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/jacek/pamietnik/backend/internal/db"
"github.com/jacek/pamietnik/backend/internal/domain"
)
// fakeQueryTrackpointStore implements db.TrackpointStorer for query handler tests.
type fakeQueryTrackpointStore struct {
days []domain.DaySummary
points []domain.Trackpoint
err error
}
func (f *fakeQueryTrackpointStore) UpsertBatch(_ context.Context, _ string, _ []domain.Trackpoint) ([]string, []db.RejectedItem, error) {
return nil, nil, nil
}
func (f *fakeQueryTrackpointStore) ListByDate(_ context.Context, _, _ string) ([]domain.Trackpoint, error) {
return f.points, f.err
}
func (f *fakeQueryTrackpointStore) ListDays(_ context.Context, _, _, _ string) ([]domain.DaySummary, error) {
return f.days, f.err
}
// fakeStopStore implements db.StopStorer.
type fakeStopStore struct {
stops []domain.Stop
err error
}
func (f *fakeStopStore) ListByDate(_ context.Context, _, _ string) ([]domain.Stop, error) {
return f.stops, f.err
}
// fakeSuggestionStore implements db.SuggestionStorer.
type fakeSuggestionStore struct {
suggestions []domain.Suggestion
err error
}
func (f *fakeSuggestionStore) ListByDate(_ context.Context, _, _ string) ([]domain.Suggestion, error) {
return f.suggestions, f.err
}
// --- HandleListDays ---
func TestHandleListDays_MissingFromParam(t *testing.T) {
handler := HandleListDays(&fakeQueryTrackpointStore{})
req := httptest.NewRequest(http.MethodGet, "/v1/days?to=2024-06-30", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 when 'from' missing, got %d", rec.Code)
}
}
func TestHandleListDays_MissingToParam(t *testing.T) {
handler := HandleListDays(&fakeQueryTrackpointStore{})
req := httptest.NewRequest(http.MethodGet, "/v1/days?from=2024-06-01", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 when 'to' missing, got %d", rec.Code)
}
}
func TestHandleListDays_BothParamsMissing(t *testing.T) {
handler := HandleListDays(&fakeQueryTrackpointStore{})
req := httptest.NewRequest(http.MethodGet, "/v1/days", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 when both params missing, got %d", rec.Code)
}
}
func TestHandleListDays_EmptyResultIsArray(t *testing.T) {
handler := HandleListDays(&fakeQueryTrackpointStore{days: nil})
req := httptest.NewRequest(http.MethodGet, "/v1/days?from=2024-06-01&to=2024-06-30", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
// Must be a JSON array, not null
body := strings.TrimSpace(rec.Body.String())
if !strings.HasPrefix(body, "[") {
t.Errorf("expected JSON array, got: %s", body)
}
}
func TestHandleListDays_ReturnsDays(t *testing.T) {
ts := time.Now()
store := &fakeQueryTrackpointStore{
days: []domain.DaySummary{
{Date: "2024-06-01", Count: 42, FirstTS: &ts, LastTS: &ts},
},
}
handler := HandleListDays(store)
req := httptest.NewRequest(http.MethodGet, "/v1/days?from=2024-06-01&to=2024-06-30", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var days []domain.DaySummary
json.NewDecoder(rec.Body).Decode(&days)
if len(days) != 1 || days[0].Date != "2024-06-01" || days[0].Count != 42 {
t.Errorf("unexpected response: %+v", days)
}
}
// --- HandleListTrackpoints ---
func TestHandleListTrackpoints_MissingDateParam(t *testing.T) {
handler := HandleListTrackpoints(&fakeQueryTrackpointStore{})
req := httptest.NewRequest(http.MethodGet, "/v1/trackpoints", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 when 'date' missing, got %d", rec.Code)
}
}
func TestHandleListTrackpoints_EmptyResultIsArray(t *testing.T) {
handler := HandleListTrackpoints(&fakeQueryTrackpointStore{points: nil})
req := httptest.NewRequest(http.MethodGet, "/v1/trackpoints?date=2024-06-01", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := strings.TrimSpace(rec.Body.String())
if !strings.HasPrefix(body, "[") {
t.Errorf("expected JSON array, got: %s", body)
}
}
func TestHandleListTrackpoints_ReturnsPoints(t *testing.T) {
store := &fakeQueryTrackpointStore{
points: []domain.Trackpoint{
{EventID: "e1", DeviceID: "d1", Lat: 52.5, Lon: 13.4, Source: "gps"},
{EventID: "e2", DeviceID: "d1", Lat: 52.6, Lon: 13.5, Source: "gps"},
},
}
handler := HandleListTrackpoints(store)
req := httptest.NewRequest(http.MethodGet, "/v1/trackpoints?date=2024-06-01", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var pts []domain.Trackpoint
json.NewDecoder(rec.Body).Decode(&pts)
if len(pts) != 2 {
t.Errorf("expected 2 trackpoints, got %d", len(pts))
}
}
// --- HandleListStops ---
func TestHandleListStops_MissingDateParam(t *testing.T) {
handler := HandleListStops(&fakeStopStore{})
req := httptest.NewRequest(http.MethodGet, "/v1/stops", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 when 'date' missing, got %d", rec.Code)
}
}
func TestHandleListStops_EmptyResultIsArray(t *testing.T) {
handler := HandleListStops(&fakeStopStore{stops: nil})
req := httptest.NewRequest(http.MethodGet, "/v1/stops?date=2024-06-01", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := strings.TrimSpace(rec.Body.String())
if !strings.HasPrefix(body, "[") {
t.Errorf("expected JSON array, got: %s", body)
}
}
func TestHandleListStops_ReturnsStops(t *testing.T) {
now := time.Now()
store := &fakeStopStore{
stops: []domain.Stop{
{StopID: "stop-1", DeviceID: "d1", StartTS: now, EndTS: now, CenterLat: 52.5, CenterLon: 13.4, DurationS: 600},
},
}
handler := HandleListStops(store)
req := httptest.NewRequest(http.MethodGet, "/v1/stops?date=2024-06-01", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var stops []domain.Stop
json.NewDecoder(rec.Body).Decode(&stops)
if len(stops) != 1 || stops[0].StopID != "stop-1" {
t.Errorf("unexpected response: %+v", stops)
}
}
// --- HandleListSuggestions ---
func TestHandleListSuggestions_MissingDateParam(t *testing.T) {
handler := HandleListSuggestions(&fakeSuggestionStore{})
req := httptest.NewRequest(http.MethodGet, "/v1/suggestions", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 when 'date' missing, got %d", rec.Code)
}
}
func TestHandleListSuggestions_EmptyResultIsArray(t *testing.T) {
handler := HandleListSuggestions(&fakeSuggestionStore{suggestions: nil})
req := httptest.NewRequest(http.MethodGet, "/v1/suggestions?date=2024-06-01", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := strings.TrimSpace(rec.Body.String())
if !strings.HasPrefix(body, "[") {
t.Errorf("expected JSON array, got: %s", body)
}
}
func TestHandleListSuggestions_ReturnsSuggestions(t *testing.T) {
now := time.Now()
store := &fakeSuggestionStore{
suggestions: []domain.Suggestion{
{SuggestionID: "sug-1", StopID: "stop-1", Type: "highlight", Title: "Nice spot", CreatedAt: now},
},
}
handler := HandleListSuggestions(store)
req := httptest.NewRequest(http.MethodGet, "/v1/suggestions?date=2024-06-01", nil)
req = authContext(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var suggestions []domain.Suggestion
json.NewDecoder(rec.Body).Decode(&suggestions)
if len(suggestions) != 1 || suggestions[0].SuggestionID != "sug-1" {
t.Errorf("unexpected response: %+v", suggestions)
}
}

View File

@@ -0,0 +1,21 @@
package api
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
type errorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Code: code, Message: message})
}

View File

@@ -0,0 +1,98 @@
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jacek/pamietnik/backend/internal/auth"
"github.com/jacek/pamietnik/backend/internal/db"
)
func NewRouter(
authStore *auth.Store,
tpStore *db.TrackpointStore,
stopStore *db.StopStore,
suggStore *db.SuggestionStore,
journalStore *db.JournalStore,
userStore *db.UserStore,
uploadDir string,
) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore)
journalHandler := NewJournalHandler(journalStore, uploadDir)
authMW := RequireAuth(authStore)
// Health
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
r.Get("/readyz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
// Ingest (session auth; Android API-Key auth TBD)
r.Group(func(r chi.Router) {
r.Use(authMW)
r.Post("/v1/trackpoints", HandleSingleTrackpoint(tpStore))
r.Post("/v1/trackpoints:batch", HandleBatchTrackpoints(tpStore))
})
// Query API (session auth)
r.Group(func(r chi.Router) {
r.Use(authMW)
r.Get("/v1/days", HandleListDays(tpStore))
r.Get("/v1/trackpoints", HandleListTrackpoints(tpStore))
r.Get("/v1/stops", HandleListStops(stopStore))
r.Get("/v1/suggestions", HandleListSuggestions(suggStore))
})
// Static assets (CSS etc.)
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS()))))
// Public routes (no auth required)
r.Get("/", webUI.HandleFeed)
r.Get("/feed", webUI.HandleFeedFragment)
r.Get("/register", webUI.HandleGetRegister)
r.Post("/register", webUI.HandlePostRegister)
r.Get("/login", webUI.HandleGetLogin)
r.Post("/login", webUI.HandlePostLogin)
r.Post("/logout", webUI.HandleLogout)
// Authenticated web routes
r.Group(func(r chi.Router) {
r.Use(authMW)
r.Get("/days", webUI.HandleDaysList)
r.Get("/days/redirect", webUI.HandleDaysRedirect)
r.Get("/days/{date}", webUI.HandleDayDetail)
r.Post("/entries", journalHandler.HandleCreateEntry)
})
// Admin routes
r.Group(func(r chi.Router) {
r.Use(authMW)
r.Use(requireAdmin)
r.Get("/admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/entries", http.StatusSeeOther)
})
r.Get("/admin/entries", webUI.HandleAdminEntries)
r.Get("/admin/users", webUI.HandleAdminUsers)
r.Post("/admin/users", webUI.HandleAdminCreateUser)
r.Delete("/admin/users/{id}", webUI.HandleAdminDeleteUser)
})
// Serve uploaded images
r.Handle("/uploads/*", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
// SPA (Vite webapp) — served under /app/*
spaPrefix := "/app"
r.Handle(spaPrefix, http.RedirectHandler(spaPrefix+"/", http.StatusMovedPermanently))
r.Handle(spaPrefix+"/*", http.StripPrefix(spaPrefix, SPAHandler(spaPrefix)))
return r
}

View File

@@ -0,0 +1,53 @@
package api
import (
"embed"
"io/fs"
"net/http"
"path/filepath"
"strings"
)
// spaFS holds the built Vite SPA.
// The directory backend/internal/api/webapp/ is populated by the Docker
// multi-stage build (node → copy dist → go build).
// A placeholder file keeps the embed valid when building without Docker.
//go:embed webapp
var spaFS embed.FS
// SPAHandler serves the Vite SPA under the given prefix (e.g. "/app").
// Static assets (paths with file extensions) are served directly.
// All other paths fall back to index.html for client-side routing.
func SPAHandler(prefix string) http.Handler {
sub, err := fs.Sub(spaFS, "webapp")
if err != nil {
return http.NotFoundHandler()
}
fileServer := http.FileServer(http.FS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Strip the mount prefix to get the file path
path := strings.TrimPrefix(r.URL.Path, prefix)
if path == "" || path == "/" {
// Serve index.html
r2 := r.Clone(r.Context())
r2.URL.Path = "/index.html"
fileServer.ServeHTTP(w, r2)
return
}
// Has a file extension → serve asset directly (JS, CSS, fonts, …)
if filepath.Ext(path) != "" {
r2 := r.Clone(r.Context())
r2.URL.Path = path
fileServer.ServeHTTP(w, r2)
return
}
// SPA route → serve index.html
r2 := r.Clone(r.Context())
r2.URL.Path = "/index.html"
fileServer.ServeHTTP(w, r2)
})
}

View File

@@ -0,0 +1,47 @@
// GPS button
document.getElementById('btn-gps')?.addEventListener('click', function () {
const status = document.getElementById('gps-status');
if (!navigator.geolocation) {
status.textContent = '// GPS nicht verfügbar';
return;
}
status.textContent = '// Standort wird ermittelt...';
navigator.geolocation.getCurrentPosition(
function (pos) {
document.getElementById('entry-lat').value = pos.coords.latitude.toFixed(6);
document.getElementById('entry-lon').value = pos.coords.longitude.toFixed(6);
status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)';
},
function (err) {
status.textContent = '// Fehler: ' + err.message;
},
{ enableHighAccuracy: true, timeout: 10000 }
);
});
// Set current time as default
(function () {
const input = document.getElementById('entry-time');
if (input && !input.value) {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
input.value = hh + ':' + mm;
}
})();
// Image preview
document.getElementById('image-input')?.addEventListener('change', function () {
const preview = document.getElementById('image-preview');
preview.innerHTML = '';
Array.from(this.files).forEach(function (file) {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = function (e) {
const img = document.createElement('img');
img.src = e.target.result;
preview.appendChild(img);
};
reader.readAsDataURL(file);
});
});

View File

@@ -0,0 +1,68 @@
/* Font + monochrome override */
:root {
--pico-font-family: 'Courier New', Courier, monospace;
--pico-font-size: 14px;
--pico-primary: #111;
--pico-primary-background: #111;
--pico-primary-border: #111;
--pico-primary-hover: #333;
--pico-primary-hover-background: #333;
--pico-primary-hover-border: #333;
--pico-primary-focus: rgba(0,0,0,.25);
--pico-primary-inverse: #fff;
--pico-primary-underline: rgba(0,0,0,.5);
}
h1 { font-size: 1.4rem; font-weight: normal; letter-spacing: .05em; }
h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; }
.err { color: #c00; }
.source-gps { color: #060; }
.source-manual { color: #888; }
/* Top bar */
.page-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.5rem; }
/* GPS row */
.gps-row { display: flex; gap: .4rem; align-items: center; }
.gps-row input { flex: 1; margin-bottom: 0; }
.gps-row button { white-space: nowrap; margin-bottom: 0; }
/* Two-column form */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
@media (max-width: 480px) { .form-row { grid-template-columns: 1fr; } }
/* Image preview */
.image-preview { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: .8rem; }
.image-preview img, .thumb { width: 80px; height: 80px; object-fit: cover; border: 1px solid var(--pico-muted-border-color); }
.thumb { width: 100px; height: 100px; display: block; }
/* Journal entry cards */
.entry-card {
border-left: 3px solid var(--pico-primary);
padding: .6rem 1rem;
margin-bottom: 1rem;
background: var(--pico-card-background-color);
border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0;
}
.entry-meta { font-size: .8rem; margin-bottom: .3rem; }
.entry-title { font-size: 1rem; margin-bottom: .3rem; }
.entry-desc { white-space: pre-wrap; font-size: .9rem; }
.entry-images { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
/* Login */
.login-box { max-width: 360px; margin: 4rem auto; }
/* Error message */
.error { color: #c44; }
/* Hashtags */
.hashtags { margin-top: .3rem; }
.tag { font-size: .75rem; background: var(--pico-muted-background-color); padding: .1rem .4rem; border-radius: 999px; margin-right: .2rem; }
/* Visibility badge */
.badge-public { font-size: .7rem; background: #264; color: #8f8; padding: .1rem .4rem; border-radius: 4px; vertical-align: middle; }
/* Delete button */
.btn-delete { background: none; border: 1px solid #c44; color: #c44; padding: .2rem .6rem; cursor: pointer; font-size: .8rem; border-radius: 4px; }
.btn-delete:hover { background: #c44; color: #fff; }

View File

@@ -0,0 +1,33 @@
{{define "admin_title"}}Einträge verwalten — Admin{{end}}
{{define "admin_content"}}
<h1>Einträge</h1>
<p><a href="/days">→ Neuen Eintrag anlegen (Tagesansicht)</a></p>
{{if .Entries}}
<figure>
<table>
<thead><tr><th>Datum</th><th>Zeit</th><th>Titel</th><th>Sichtbarkeit</th><th>Hashtags</th></tr></thead>
<tbody>
{{range .Entries}}
<tr>
<td><a href="/days/{{.EntryDate}}">{{.EntryDate}}</a></td>
<td>{{.EntryTime}}</td>
<td>{{if .Title}}{{.Title}}{{else}}<small></small>{{end}}</td>
<td>
{{if eq .Visibility "public"}}
<span class="badge-public">öffentlich</span>
{{else}}
<small>privat</small>
{{end}}
</td>
<td><small>{{join .Hashtags ", "}}</small></td>
</tr>
{{end}}
</tbody>
</table>
</figure>
{{else}}
<p><small>// Noch keine Einträge</small></p>
{{end}}
{{end}}

View File

@@ -0,0 +1,25 @@
{{define "admin_base"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "admin_title" .}}Admin{{end}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.slate.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<nav>
<strong>Admin</strong>
<span>
<a href="/admin/entries">Einträge</a> ·
<a href="/admin/users">Benutzer</a> ·
<a href="/days">← App</a>
</span>
</nav>
{{block "admin_content" .}}{{end}}
</main>
{{block "admin_scripts" .}}{{end}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,53 @@
{{define "admin_title"}}Benutzer verwalten — Admin{{end}}
{{define "admin_content"}}
<h1>Benutzer</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/admin/users" style="display:flex;gap:1rem;align-items:flex-end;flex-wrap:wrap">
<div>
<label>Benutzername</label>
<input type="text" name="username" required autocomplete="off">
</div>
<div>
<label>Passwort</label>
<input type="password" name="password" required autocomplete="new-password">
</div>
<button type="submit">Anlegen</button>
</form>
<figure>
<table>
<thead><tr><th>Benutzername</th><th>Admin</th><th>Erstellt</th><th></th></tr></thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.Username}}</td>
<td>{{if .IsAdmin}}✓{{end}}</td>
<td><small>{{.CreatedAt.Format "2006-01-02"}}</small></td>
<td>
{{if ne .UserID $.User.UserID}}
<button class="btn-delete" data-url="/admin/users/{{.UserID}}" data-name="{{.Username}}">Löschen</button>
{{else}}
<small>(du)</small>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</figure>
{{end}}
{{define "admin_scripts"}}
<script>
document.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Benutzer "' + btn.dataset.name + '" löschen?')) return;
fetch(btn.dataset.url, {method: 'DELETE'})
.then(function() { window.location.reload(); });
});
});
</script>
{{end}}

View File

@@ -0,0 +1,15 @@
{{define "base"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Reisejournal{{end}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.slate.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
{{block "content" .}}{{end}}
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,122 @@
{{define "title"}}{{.Date}} — Reisejournal{{end}}
{{define "content"}}
<main class="container">
<nav><a href="/days">← Alle Tage</a></nav>
<h1>{{.Date}}</h1>
<h2>Neuer Eintrag</h2>
<form method="post" action="/entries" enctype="multipart/form-data" class="entry-form">
<input type="hidden" name="date" value="{{.Date}}">
<div class="form-row">
<div class="form-col">
<label>Uhrzeit</label>
<input type="time" name="time" required id="entry-time">
</div>
<div class="form-col">
<label>Sichtbarkeit</label>
<select name="visibility">
<option value="private">Privat</option>
<option value="public">Öffentlich</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-col">
<label>GPS-Koordinaten <small>(optional)</small></label>
<div class="gps-row">
<input type="number" name="lat" id="entry-lat" step="any" placeholder="Breite">
<input type="number" name="lon" id="entry-lon" step="any" placeholder="Länge">
<button type="button" id="btn-gps" title="Aktuellen Standort ermitteln">&#9678; GPS</button>
</div>
<small id="gps-status"></small>
</div>
<div class="form-col">
<label>Hashtags <small>(kommagetrennt, optional)</small></label>
<input type="text" name="hashtags" placeholder="reise, essen, natur">
</div>
</div>
<label>Überschrift</label>
<input type="text" name="title" placeholder="Titel des Eintrags">
<label>Beschreibung</label>
<textarea name="description" rows="4" placeholder="Was ist passiert?"></textarea>
<label>Bilder <small>(optional, max. 10 MB pro Bild)</small></label>
<input type="file" name="images" multiple accept="image/*" id="image-input">
<div id="image-preview" class="image-preview"></div>
<button type="submit">Eintrag speichern</button>
</form>
<h2>Einträge <small>({{len .Entries}})</small></h2>
{{range .Entries}}
<div class="entry-card">
<div class="entry-meta">
<strong>{{.EntryTime}}</strong>
{{if eq .Visibility "public"}}<span class="badge-public">öffentlich</span>{{end}}
{{if .Lat}}<small> · &#9675; {{printf "%.5f" (deref .Lat)}}, {{printf "%.5f" (deref .Lon)}}</small>{{end}}
</div>
{{if .Title}}<div class="entry-title">{{.Title}}</div>{{end}}
{{if .Description}}<div class="entry-desc">{{.Description}}</div>{{end}}
{{if .Hashtags}}<div class="hashtags">{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}</div>{{end}}
{{if .Images}}
<div class="entry-images">
{{range .Images}}
<a href="/uploads/{{.Filename}}" target="_blank">
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
</a>
{{end}}
</div>
{{end}}
</div>
{{else}}
<p><small>// Noch keine Einträge</small></p>
{{end}}
<h2>Aufenthalte <small>({{len .Stops}})</small></h2>
{{if .Stops}}
<figure>
<table>
<thead><tr><th>Von</th><th>Bis</th><th>Dauer</th><th>Ort</th></tr></thead>
<tbody>
{{range .Stops}}
<tr>
<td>{{.StartTS.Format "15:04"}}</td>
<td>{{.EndTS.Format "15:04"}}</td>
<td><small>{{divInt .DurationS 60}} min</small></td>
<td>{{if .PlaceLabel}}{{.PlaceLabel}}{{else}}<small></small>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</figure>
{{else}}
<p><small>// Keine Aufenthalte</small></p>
{{end}}
<details>
<summary><small>Trackpunkte ({{len .Points}})</small></summary>
<figure>
<table>
<thead><tr><th>Zeit</th><th>Lat</th><th>Lon</th><th>Quelle</th></tr></thead>
<tbody>
{{range .Points}}
<tr>
<td>{{.Timestamp.Format "15:04:05"}}</td>
<td>{{printf "%.5f" .Lat}}</td>
<td>{{printf "%.5f" .Lon}}</td>
<td class="source-{{.Source}}">{{.Source}}</td>
</tr>
{{else}}
<tr><td colspan="4"><small>// Keine Punkte</small></td></tr>
{{end}}
</tbody>
</table>
</figure>
</details>
</main>
{{end}}
{{define "scripts"}}
<script src="/static/day.js"></script>
{{end}}
{{template "base" .}}

View File

@@ -0,0 +1,49 @@
{{define "title"}}Tage — Reisejournal{{end}}
{{define "content"}}
<main class="container">
<div class="page-header">
<h1>REISEJOURNAL</h1>
<span>
{{if .IsAdmin}}<a href="/admin">[ Admin ]</a> · {{end}}
<a href="/logout">[ Ausloggen ]</a>
</span>
</div>
<form method="get" action="/days/redirect">
<fieldset role="group">
<input type="date" name="date" id="nav-date" required>
<button type="submit">Tag öffnen</button>
</fieldset>
</form>
<h2>Reisetage</h2>
<figure>
<table>
<thead><tr><th>Datum</th><th>Punkte</th><th>Von</th><th>Bis</th></tr></thead>
<tbody>
{{range .Days}}
<tr>
<td><a href="/days/{{.Date}}">{{.Date}}</a></td>
<td>{{.Count}}</td>
<td><small>{{if .FirstTS}}{{.FirstTS.Format "15:04"}}{{end}}</small></td>
<td><small>{{if .LastTS}}{{.LastTS.Format "15:04"}}{{end}}</small></td>
</tr>
{{else}}
<tr><td colspan="4"><small>// Keine Daten vorhanden</small></td></tr>
{{end}}
</tbody>
</table>
</figure>
</main>
{{end}}
{{define "scripts"}}
<script>
var d = document.getElementById('nav-date');
if (d && !d.value) {
d.value = new Date().toISOString().slice(0, 10);
}
</script>
{{end}}
{{template "base" .}}

View File

@@ -0,0 +1,21 @@
{{define "title"}}Login — Reisejournal{{end}}
{{define "content"}}
<main class="container">
<article class="login-box">
<h1>REISEJOURNAL</h1>
{{if .Error}}<p class="err">// {{.Error}}</p>{{end}}
<form method="post" action="/login">
<label>Benutzername
<input name="username" autocomplete="username" value="{{.Username}}">
</label>
<label>Passwort
<input type="password" name="password" autocomplete="current-password">
</label>
<button type="submit">Einloggen</button>
</form>
</article>
</main>
{{end}}
{{template "base" .}}

View File

@@ -0,0 +1,72 @@
{{define "title"}}Journal — Öffentliche Einträge{{end}}
{{define "content"}}
<main class="container">
<nav>
<strong>Journal</strong>
<a href="/login">Anmelden</a>
</nav>
<div id="feed">
{{template "feed_items" .}}
</div>
</main>
{{end}}
{{define "feed_items"}}
{{range .Entries}}
<article class="entry-card">
<header>
<small>{{.EntryDate}} · {{.EntryTime}}</small>
{{if .Title}}<strong> · {{.Title}}</strong>{{end}}
</header>
{{if .Description}}<p>{{.Description}}</p>{{end}}
{{if .Images}}
<div class="entry-images">
{{range .Images}}
<a href="/uploads/{{.Filename}}" target="_blank">
<img src="/uploads/{{.Filename}}" alt="{{.OriginalName}}" class="thumb">
</a>
{{end}}
</div>
{{end}}
{{if .Hashtags}}
<footer class="hashtags">
{{range .Hashtags}}<span class="tag">#{{.}}</span> {{end}}
</footer>
{{end}}
</article>
{{else}}
<p><small>// Noch keine öffentlichen Einträge</small></p>
{{end}}
{{if .HasMore}}
<div id="sentinel" data-offset="{{.Offset}}"></div>
{{end}}
{{end}}
{{define "scripts"}}
<script>
(function() {
const sentinel = document.getElementById('sentinel');
if (!sentinel) return;
const obs = new IntersectionObserver(function(entries) {
if (!entries[0].isIntersecting) return;
obs.disconnect();
const offset = sentinel.dataset.offset;
fetch('/feed?offset=' + offset)
.then(r => r.text())
.then(html => {
sentinel.remove();
const div = document.createElement('div');
div.innerHTML = html;
document.getElementById('feed').append(...div.childNodes);
const next = document.getElementById('sentinel');
if (next) obs.observe(next);
});
});
obs.observe(sentinel);
})();
</script>
{{end}}
{{template "base" .}}

View File

@@ -0,0 +1,20 @@
{{define "title"}}Registrieren — Journal{{end}}
{{define "content"}}
<main class="container" style="max-width:400px;margin-top:4rem">
<h1>Konto erstellen</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/register">
<label>Benutzername</label>
<input type="text" name="username" value="{{.Username}}" required autofocus autocomplete="username">
<label>Passwort</label>
<input type="password" name="password" required autocomplete="new-password">
<label>Passwort bestätigen</label>
<input type="password" name="confirm" required autocomplete="new-password">
<button type="submit">Registrieren</button>
</form>
<p><small>Bereits registriert? <a href="/login">Anmelden</a></small></p>
</main>
{{end}}
{{template "base" .}}

View File

@@ -0,0 +1 @@
<!-- webapp placeholder: replaced at build time by Vite dist -->

View File

@@ -0,0 +1,361 @@
package api
import (
"bytes"
"embed"
"errors"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jacek/pamietnik/backend/internal/auth"
"github.com/jacek/pamietnik/backend/internal/db"
)
//go:embed static templates
var assets embed.FS
var funcMap = template.FuncMap{
"divInt": func(a, b int) int { return a / b },
"deref": func(p *float64) float64 {
if p == nil {
return 0
}
return *p
},
"join": strings.Join,
}
var tmpls = template.Must(
template.New("").Funcs(funcMap).ParseFS(assets, "templates/*.html"),
)
func staticFS() fs.FS {
sub, err := fs.Sub(assets, "static")
if err != nil {
panic(err)
}
return sub
}
// WebUI groups all web UI handlers.
type WebUI struct {
authStore *auth.Store
tpStore *db.TrackpointStore
stopStore *db.StopStore
journalStore *db.JournalStore
userStore *db.UserStore
}
func NewWebUI(a *auth.Store, tp *db.TrackpointStore, st *db.StopStore, j *db.JournalStore, u *db.UserStore) *WebUI {
return &WebUI{authStore: a, tpStore: tp, stopStore: st, journalStore: j, userStore: u}
}
func render(w http.ResponseWriter, page string, data any) {
t, err := tmpls.Clone()
if err == nil {
_, err = t.ParseFS(assets, "templates/"+page)
}
if err != nil {
slog.Error("template parse", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "base", data); err != nil {
slog.Error("template execute", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
func renderFragment(w http.ResponseWriter, page, block string, data any) {
t, err := tmpls.Clone()
if err == nil {
_, err = t.ParseFS(assets, "templates/"+page)
}
if err != nil {
slog.Error("template parse", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, block, data); err != nil {
slog.Error("template execute fragment", "page", page, "block", block, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
func renderAdmin(w http.ResponseWriter, page string, data any) {
// Parse layout (admin_base) + specific page fresh each request (no shared state between pages).
t, err := template.New("").Funcs(funcMap).ParseFS(assets,
"templates/admin/layout.html",
"templates/admin/"+page,
)
if err != nil {
slog.Error("template parse admin", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "admin_base", data); err != nil {
slog.Error("template execute admin", "page", page, "err", err)
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
// --- Auth ---
func (ui *WebUI) HandleGetLogin(w http.ResponseWriter, r *http.Request) {
render(w, "login.html", map[string]any{"Error": "", "Username": ""})
}
func (ui *WebUI) HandlePostLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
sess, err := ui.authStore.Login(r.Context(), username, password)
if err != nil {
msg := "Interner Fehler."
if errors.Is(err, auth.ErrInvalidCredentials) {
msg = "Ungültige Zugangsdaten."
}
render(w, "login.html", map[string]any{"Error": msg, "Username": username})
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sess.SessionID,
Path: "/",
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
Expires: sess.ExpiresAt,
})
http.Redirect(w, r, "/days", http.StatusSeeOther)
}
func (ui *WebUI) HandleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err == nil {
ui.authStore.Logout(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
Expires: time.Unix(0, 0),
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- Register ---
func (ui *WebUI) HandleGetRegister(w http.ResponseWriter, r *http.Request) {
render(w, "register.html", map[string]any{"Error": "", "Username": ""})
}
func (ui *WebUI) HandlePostRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
confirm := r.FormValue("confirm")
if username == "" || password == "" {
render(w, "register.html", map[string]any{"Error": "Benutzername und Passwort sind Pflichtfelder.", "Username": username})
return
}
if password != confirm {
render(w, "register.html", map[string]any{"Error": "Passwörter stimmen nicht überein.", "Username": username})
return
}
if err := ui.authStore.Register(r.Context(), username, password); err != nil {
msg := "Interner Fehler."
if errors.Is(err, auth.ErrUsernameTaken) {
msg = "Benutzername bereits vergeben."
}
render(w, "register.html", map[string]any{"Error": msg, "Username": username})
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- Public Feed ---
func (ui *WebUI) HandleFeed(w http.ResponseWriter, r *http.Request) {
entries, err := ui.journalStore.ListPublic(r.Context(), 20, 0)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
render(w, "public.html", map[string]any{
"Entries": entries,
"Offset": 20,
"HasMore": len(entries) == 20,
})
}
func (ui *WebUI) HandleFeedFragment(w http.ResponseWriter, r *http.Request) {
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
entries, err := ui.journalStore.ListPublic(r.Context(), 20, offset)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
renderFragment(w, "public.html", "feed_items", map[string]any{
"Entries": entries,
"Offset": offset + 20,
"HasMore": len(entries) == 20,
})
}
// --- Days ---
func (ui *WebUI) HandleDaysRedirect(w http.ResponseWriter, r *http.Request) {
date := strings.TrimSpace(r.URL.Query().Get("date"))
if date == "" {
http.Redirect(w, r, "/days", http.StatusSeeOther)
return
}
if _, err := time.Parse("2006-01-02", date); err != nil {
http.Redirect(w, r, "/days", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/days/"+date, http.StatusSeeOther)
}
func (ui *WebUI) HandleDaysList(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context())
now := time.Now().UTC()
from := now.AddDate(-20, 0, 0).Format("2006-01-02")
to := now.AddDate(0, 0, 1).Format("2006-01-02")
days, err := ui.tpStore.ListDays(r.Context(), user.UserID, from, to)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
render(w, "days.html", map[string]any{"Days": days, "IsAdmin": user.IsAdmin})
}
func (ui *WebUI) HandleDayDetail(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
date := chi.URLParam(r, "date")
if date == "" {
http.Error(w, "Datum fehlt", http.StatusBadRequest)
return
}
points, err := ui.tpStore.ListByDate(r.Context(), userID, date)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
stops, err := ui.stopStore.ListByDate(r.Context(), userID, date)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
entries, err := ui.journalStore.ListByDate(r.Context(), userID, date)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
render(w, "day.html", map[string]any{
"Date": date,
"Points": points,
"Stops": stops,
"Entries": entries,
})
}
// --- Admin ---
func (ui *WebUI) HandleAdminEntries(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context())
entries, err := ui.journalStore.ListByUser(r.Context(), user.UserID)
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
renderAdmin(w, "entries.html", map[string]any{"Entries": entries, "User": user})
}
func (ui *WebUI) HandleAdminUsers(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context())
users, err := ui.userStore.ListUsers(r.Context())
if err != nil {
http.Error(w, "Fehler beim Laden", http.StatusInternalServerError)
return
}
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": ""})
}
func (ui *WebUI) HandleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Ungültige Formulardaten", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
user := userFromContext(r.Context())
if username == "" || password == "" {
users, _ := ui.userStore.ListUsers(r.Context())
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": "Benutzername und Passwort erforderlich."})
return
}
if err := ui.authStore.Register(r.Context(), username, password); err != nil {
msg := "Interner Fehler."
if errors.Is(err, auth.ErrUsernameTaken) {
msg = "Benutzername bereits vergeben."
}
users, _ := ui.userStore.ListUsers(r.Context())
renderAdmin(w, "users.html", map[string]any{"Users": users, "User": user, "Error": msg})
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (ui *WebUI) HandleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
targetID := chi.URLParam(r, "id")
currentUser := userFromContext(r.Context())
if targetID == currentUser.UserID {
http.Error(w, "Eigenen Account nicht löschbar", http.StatusBadRequest)
return
}
if err := ui.userStore.DeleteUser(r.Context(), targetID); err != nil {
http.Error(w, "Fehler beim Löschen", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}

View File

@@ -0,0 +1,172 @@
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/argon2"
"github.com/jacek/pamietnik/backend/internal/domain"
)
const sessionDuration = 24 * time.Hour
var ErrInvalidCredentials = errors.New("invalid username or password")
var ErrSessionNotFound = errors.New("session not found or expired")
var ErrUsernameTaken = errors.New("username already taken")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// HashPassword returns an argon2id hash of the password.
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return fmt.Sprintf("$argon2id$%x$%x", salt, hash), nil
}
// VerifyPassword checks password against stored hash.
// Format: $argon2id$<saltHex>$<hashHex>
func VerifyPassword(password, stored string) bool {
parts := strings.Split(stored, "$")
// ["", "argon2id", "<saltHex>", "<hashHex>"]
if len(parts) != 4 || parts[1] != "argon2id" {
return false
}
salt, err := hex.DecodeString(parts[2])
if err != nil {
return false
}
expected, err := hex.DecodeString(parts[3])
if err != nil {
return false
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(hash, expected) == 1
}
// Login verifies credentials and creates a session.
func (s *Store) Login(ctx context.Context, username, password string) (domain.Session, error) {
var user domain.User
err := s.pool.QueryRow(ctx,
`SELECT user_id, username, password_hash FROM users WHERE username = $1`,
username,
).Scan(&user.UserID, &user.Username, &user.PasswordHash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Session{}, ErrInvalidCredentials
}
return domain.Session{}, err
}
if !VerifyPassword(password, user.PasswordHash) {
return domain.Session{}, ErrInvalidCredentials
}
sessionID, err := newSessionID()
if err != nil {
return domain.Session{}, fmt.Errorf("create session: %w", err)
}
now := time.Now().UTC()
sess := domain.Session{
SessionID: sessionID,
UserID: user.UserID,
CreatedAt: now,
ExpiresAt: now.Add(sessionDuration),
}
_, err = s.pool.Exec(ctx,
`INSERT INTO sessions (session_id, user_id, created_at, expires_at)
VALUES ($1, $2, $3, $4)`,
sess.SessionID, sess.UserID, sess.CreatedAt, sess.ExpiresAt,
)
if err != nil {
return domain.Session{}, err
}
return sess, nil
}
// GetSession validates a session and returns user_id.
func (s *Store) GetSession(ctx context.Context, sessionID string) (domain.Session, error) {
var sess domain.Session
err := s.pool.QueryRow(ctx,
`SELECT session_id, user_id, created_at, expires_at
FROM sessions
WHERE session_id = $1 AND expires_at > NOW()`,
sessionID,
).Scan(&sess.SessionID, &sess.UserID, &sess.CreatedAt, &sess.ExpiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Session{}, ErrSessionNotFound
}
return domain.Session{}, err
}
return sess, nil
}
// Logout deletes a session.
func (s *Store) Logout(ctx context.Context, sessionID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE session_id = $1`, sessionID)
if err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// Register creates a new user account. Returns ErrUsernameTaken if the username is already in use.
func (s *Store) Register(ctx context.Context, username, password string) error {
hash, err := HashPassword(password)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
_, err = s.pool.Exec(ctx,
`INSERT INTO users (username, password_hash) VALUES ($1, $2)`,
username, hash,
)
if err != nil && strings.Contains(err.Error(), "unique") {
return ErrUsernameTaken
}
return err
}
// GetUserBySession returns the full user (including is_admin) for a session.
func (s *Store) GetUserBySession(ctx context.Context, sessionID string) (domain.User, error) {
var u domain.User
err := s.pool.QueryRow(ctx,
`SELECT u.user_id, u.username, u.is_admin, u.created_at
FROM sessions s JOIN users u ON s.user_id = u.user_id
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
sessionID,
).Scan(&u.UserID, &u.Username, &u.IsAdmin, &u.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, ErrSessionNotFound
}
return domain.User{}, err
}
return u, nil
}
func newSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate session id: %w", err)
}
return hex.EncodeToString(b), nil
}

View File

@@ -0,0 +1,65 @@
package auth
import (
"testing"
)
func TestHashPassword_ProducesVerifiableHash(t *testing.T) {
hash, err := HashPassword("secret")
if err != nil {
t.Fatalf("HashPassword returned error: %v", err)
}
if hash == "" {
t.Fatal("HashPassword returned empty string")
}
if !VerifyPassword("secret", hash) {
t.Error("VerifyPassword returned false for correct password")
}
}
func TestHashPassword_TwoCallsProduceDifferentHashes(t *testing.T) {
h1, err1 := HashPassword("secret")
h2, err2 := HashPassword("secret")
if err1 != nil || err2 != nil {
t.Fatalf("HashPassword error: %v / %v", err1, err2)
}
// Different salts → different hashes
if h1 == h2 {
t.Error("expected distinct hashes for same password (due to random salt), got identical")
}
}
func TestVerifyPassword_WrongPassword(t *testing.T) {
hash, err := HashPassword("correct")
if err != nil {
t.Fatalf("HashPassword error: %v", err)
}
if VerifyPassword("wrong", hash) {
t.Error("VerifyPassword returned true for wrong password")
}
}
func TestVerifyPassword_EmptyPassword(t *testing.T) {
hash, err := HashPassword("notempty")
if err != nil {
t.Fatalf("HashPassword error: %v", err)
}
if VerifyPassword("", hash) {
t.Error("VerifyPassword returned true for empty password against non-empty hash")
}
}
func TestVerifyPassword_MalformedHash(t *testing.T) {
cases := []string{
"",
"notahash",
"$wrongalgo$aabb$ccdd",
"$argon2id$nothex$ccdd",
"$argon2id$aabb$nothex",
}
for _, h := range cases {
if VerifyPassword("secret", h) {
t.Errorf("VerifyPassword should return false for malformed hash %q", h)
}
}
}

41
backend/internal/db/db.go Normal file
View File

@@ -0,0 +1,41 @@
package db
import (
"context"
_ "embed"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed schema.sql
var schema string
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
cfg.MaxConns = 25
cfg.MinConns = 2
cfg.MaxConnLifetime = 15 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return pool, nil
}
// InitSchema applies the embedded schema.sql (idempotent via IF NOT EXISTS).
func InitSchema(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, schema); err != nil {
return fmt.Errorf("init schema: %w", err)
}
return nil
}

View File

@@ -0,0 +1,25 @@
package db
import (
"context"
"github.com/jacek/pamietnik/backend/internal/domain"
)
// TrackpointStorer is the interface consumed by HTTP handlers.
// The concrete TrackpointStore satisfies it.
type TrackpointStorer interface {
UpsertBatch(ctx context.Context, userID string, points []domain.Trackpoint) (accepted []string, rejected []RejectedItem, err error)
ListByDate(ctx context.Context, userID, date string) ([]domain.Trackpoint, error)
ListDays(ctx context.Context, userID, from, to string) ([]domain.DaySummary, error)
}
// StopStorer is the interface consumed by HTTP handlers.
type StopStorer interface {
ListByDate(ctx context.Context, userID, date string) ([]domain.Stop, error)
}
// SuggestionStorer is the interface consumed by HTTP handlers.
type SuggestionStorer interface {
ListByDate(ctx context.Context, userID, date string) ([]domain.Suggestion, error)
}

View File

@@ -0,0 +1,161 @@
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jacek/pamietnik/backend/internal/domain"
)
type JournalStore struct {
pool *pgxpool.Pool
}
func NewJournalStore(pool *pgxpool.Pool) *JournalStore {
return &JournalStore{pool: pool}
}
// InsertEntry creates a new journal entry and returns it with the generated entry_id.
func (s *JournalStore) InsertEntry(ctx context.Context, e domain.JournalEntry) (domain.JournalEntry, error) {
if e.Visibility == "" {
e.Visibility = "private"
}
if e.Hashtags == nil {
e.Hashtags = []string{}
}
err := s.pool.QueryRow(ctx,
`INSERT INTO journal_entries (user_id, entry_date, entry_time, title, description, lat, lon, visibility, hashtags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING entry_id, created_at`,
e.UserID, e.EntryDate, e.EntryTime, e.Title, e.Description, e.Lat, e.Lon, e.Visibility, e.Hashtags,
).Scan(&e.EntryID, &e.CreatedAt)
return e, err
}
// InsertImage attaches an image record to an entry.
func (s *JournalStore) InsertImage(ctx context.Context, img domain.JournalImage) (domain.JournalImage, error) {
err := s.pool.QueryRow(ctx,
`INSERT INTO journal_images (entry_id, filename, original_name, mime_type, size_bytes)
VALUES ($1, $2, $3, $4, $5)
RETURNING image_id, created_at`,
img.EntryID, img.Filename, img.OriginalName, img.MimeType, img.SizeBytes,
).Scan(&img.ImageID, &img.CreatedAt)
return img, err
}
// ListByDate returns all journal entries for a given date (YYYY-MM-DD), including their images.
func (s *JournalStore) ListByDate(ctx context.Context, userID, date string) ([]domain.JournalEntry, error) {
rows, err := s.pool.Query(ctx,
`SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at
FROM journal_entries
WHERE user_id = $1 AND entry_date = $2
ORDER BY entry_time`,
userID, date,
)
if err != nil {
return nil, err
}
defer rows.Close()
entries, err := collectEntries(rows)
if err != nil {
return nil, err
}
return s.attachImages(ctx, entries)
}
// ListPublic returns public journal entries ordered by created_at DESC, for infinite scroll.
func (s *JournalStore) ListPublic(ctx context.Context, limit, offset int) ([]domain.JournalEntry, error) {
rows, err := s.pool.Query(ctx,
`SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at
FROM journal_entries
WHERE visibility = 'public'
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`,
limit, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
entries, err := collectEntries(rows)
if err != nil {
return nil, err
}
return s.attachImages(ctx, entries)
}
// ListByUser returns all entries for a user, ordered by entry_date DESC, entry_time DESC.
func (s *JournalStore) ListByUser(ctx context.Context, userID string) ([]domain.JournalEntry, error) {
rows, err := s.pool.Query(ctx,
`SELECT entry_id, user_id, entry_date::text, entry_time::text, title, description, lat, lon, visibility, hashtags, created_at
FROM journal_entries
WHERE user_id = $1
ORDER BY entry_date DESC, entry_time DESC`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
entries, err := collectEntries(rows)
if err != nil {
return nil, err
}
return s.attachImages(ctx, entries)
}
func collectEntries(rows pgx.Rows) ([]domain.JournalEntry, error) {
var entries []domain.JournalEntry
for rows.Next() {
var e domain.JournalEntry
if err := rows.Scan(
&e.EntryID, &e.UserID, &e.EntryDate, &e.EntryTime,
&e.Title, &e.Description, &e.Lat, &e.Lon, &e.Visibility, &e.Hashtags, &e.CreatedAt,
); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// attachImages loads images for the given entries in a single query and populates .Images.
func (s *JournalStore) attachImages(ctx context.Context, entries []domain.JournalEntry) ([]domain.JournalEntry, error) {
if len(entries) == 0 {
return entries, nil
}
entryIDs := make([]string, len(entries))
for i, e := range entries {
entryIDs[i] = e.EntryID
}
imgRows, err := s.pool.Query(ctx,
`SELECT image_id, entry_id, filename, original_name, mime_type, size_bytes, created_at
FROM journal_images WHERE entry_id = ANY($1) ORDER BY created_at`,
entryIDs,
)
if err != nil {
return nil, err
}
defer imgRows.Close()
imgMap := make(map[string][]domain.JournalImage)
for imgRows.Next() {
var img domain.JournalImage
if err := imgRows.Scan(
&img.ImageID, &img.EntryID, &img.Filename, &img.OriginalName,
&img.MimeType, &img.SizeBytes, &img.CreatedAt,
); err != nil {
return nil, err
}
imgMap[img.EntryID] = append(imgMap[img.EntryID], img)
}
if err := imgRows.Err(); err != nil {
return nil, err
}
for i, e := range entries {
entries[i].Images = imgMap[e.EntryID]
}
return entries, nil
}

View File

@@ -0,0 +1,98 @@
-- Pamietnik database schema
-- Applied automatically at server startup via CREATE TABLE IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS trackpoints (
id BIGSERIAL PRIMARY KEY,
event_id TEXT NOT NULL,
device_id TEXT NOT NULL,
trip_id TEXT NOT NULL DEFAULT '',
ts TIMESTAMPTZ NOT NULL,
lat DOUBLE PRECISION NOT NULL,
lon DOUBLE PRECISION NOT NULL,
source TEXT NOT NULL DEFAULT 'gps',
note TEXT NOT NULL DEFAULT '',
accuracy_m DOUBLE PRECISION,
speed_mps DOUBLE PRECISION,
bearing_deg DOUBLE PRECISION,
altitude_m DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT trackpoints_device_event_uniq UNIQUE (device_id, event_id)
);
CREATE INDEX IF NOT EXISTS trackpoints_device_ts_idx ON trackpoints(device_id, ts);
CREATE INDEX IF NOT EXISTS trackpoints_ts_idx ON trackpoints(ts);
CREATE TABLE IF NOT EXISTS stops (
stop_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
device_id TEXT NOT NULL,
trip_id TEXT NOT NULL DEFAULT '',
start_ts TIMESTAMPTZ NOT NULL,
end_ts TIMESTAMPTZ NOT NULL,
center_lat DOUBLE PRECISION NOT NULL,
center_lon DOUBLE PRECISION NOT NULL,
duration_s INT NOT NULL,
place_label TEXT,
place_details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS stops_device_start_ts_idx ON stops(device_id, start_ts);
CREATE TABLE IF NOT EXISTS suggestions (
suggestion_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
stop_id TEXT NOT NULL REFERENCES stops(stop_id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
dismissed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS suggestions_stop_id_idx ON suggestions(stop_id);
CREATE TABLE IF NOT EXISTS journal_entries (
entry_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
entry_date DATE NOT NULL,
entry_time TIME NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
visibility TEXT NOT NULL DEFAULT 'private',
hashtags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS journal_entries_user_date_idx ON journal_entries(user_id, entry_date);
CREATE INDEX IF NOT EXISTS journal_entries_public_idx ON journal_entries(visibility, created_at DESC);
CREATE TABLE IF NOT EXISTS journal_images (
image_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
entry_id TEXT NOT NULL REFERENCES journal_entries(entry_id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT '',
size_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS journal_images_entry_id_idx ON journal_images(entry_id);

View File

@@ -0,0 +1,60 @@
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jacek/pamietnik/backend/internal/domain"
)
type StopStore struct {
pool *pgxpool.Pool
}
func NewStopStore(pool *pgxpool.Pool) *StopStore {
return &StopStore{pool: pool}
}
func (s *StopStore) ListByDate(ctx context.Context, userID, date string) ([]domain.Stop, error) {
rows, err := s.pool.Query(ctx, `
SELECT st.stop_id, st.device_id, st.trip_id,
st.start_ts, st.end_ts,
st.center_lat, st.center_lon, st.duration_s,
COALESCE(st.place_label, ''),
st.place_details
FROM stops st
JOIN devices d ON d.device_id = st.device_id
WHERE d.user_id = $1
AND DATE(st.start_ts AT TIME ZONE 'UTC') = $2::date
ORDER BY st.start_ts`,
userID, date,
)
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.Stop, error) {
var st domain.Stop
err := row.Scan(
&st.StopID, &st.DeviceID, &st.TripID,
&st.StartTS, &st.EndTS,
&st.CenterLat, &st.CenterLon, &st.DurationS,
&st.PlaceLabel, &st.PlaceDetails,
)
return st, err
})
}
func (s *StopStore) Insert(ctx context.Context, st domain.Stop) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO stops (stop_id, device_id, trip_id, start_ts, end_ts,
center_lat, center_lon, duration_s, place_label, place_details)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (stop_id) DO NOTHING`,
st.StopID, st.DeviceID, st.TripID, st.StartTS, st.EndTS,
st.CenterLat, st.CenterLon, st.DurationS, st.PlaceLabel, st.PlaceDetails,
)
return err
}

View File

@@ -0,0 +1,54 @@
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jacek/pamietnik/backend/internal/domain"
)
type SuggestionStore struct {
pool *pgxpool.Pool
}
func NewSuggestionStore(pool *pgxpool.Pool) *SuggestionStore {
return &SuggestionStore{pool: pool}
}
func (s *SuggestionStore) ListByDate(ctx context.Context, userID, date string) ([]domain.Suggestion, error) {
rows, err := s.pool.Query(ctx, `
SELECT sg.suggestion_id, sg.stop_id, sg.type, sg.title, sg.text,
sg.created_at, sg.dismissed_at
FROM suggestions sg
JOIN stops st ON st.stop_id = sg.stop_id
JOIN devices d ON d.device_id = st.device_id
WHERE d.user_id = $1
AND DATE(st.start_ts AT TIME ZONE 'UTC') = $2::date
ORDER BY sg.created_at`,
userID, date,
)
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.Suggestion, error) {
var sg domain.Suggestion
err := row.Scan(
&sg.SuggestionID, &sg.StopID, &sg.Type, &sg.Title, &sg.Text,
&sg.CreatedAt, &sg.DismissedAt,
)
return sg, err
})
}
func (s *SuggestionStore) Insert(ctx context.Context, sg domain.Suggestion) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO suggestions (suggestion_id, stop_id, type, title, text, created_at)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (suggestion_id) DO NOTHING`,
sg.SuggestionID, sg.StopID, sg.Type, sg.Title, sg.Text, sg.CreatedAt,
)
return err
}

View File

@@ -0,0 +1,163 @@
package db
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jacek/pamietnik/backend/internal/domain"
)
type TrackpointStore struct {
pool *pgxpool.Pool
}
func NewTrackpointStore(pool *pgxpool.Pool) *TrackpointStore {
return &TrackpointStore{pool: pool}
}
// UpsertBatch inserts trackpoints, ignoring duplicates (idempotency via device_id + event_id).
// Returns accepted event_ids and rejected items with reason.
func (s *TrackpointStore) UpsertBatch(ctx context.Context, userID string, points []domain.Trackpoint) (accepted []string, rejected []RejectedItem, err error) {
// First pass: validate all points
var valid []domain.Trackpoint
for _, p := range points {
if vErr := validateTrackpoint(p); vErr != nil {
rejected = append(rejected, RejectedItem{
EventID: p.EventID,
Code: "VALIDATION_ERROR",
Message: vErr.Error(),
})
continue
}
valid = append(valid, p)
}
if len(valid) == 0 {
return accepted, rejected, nil
}
// Ensure devices in a single batch (deduplicated)
if userID != "" {
seen := make(map[string]bool)
batch := &pgx.Batch{}
for _, p := range valid {
if !seen[p.DeviceID] {
seen[p.DeviceID] = true
batch.Queue(
`INSERT INTO devices (device_id, user_id) VALUES ($1, $2) ON CONFLICT (device_id) DO NOTHING`,
p.DeviceID, userID,
)
}
}
br := s.pool.SendBatch(ctx, batch)
if closeErr := br.Close(); closeErr != nil {
return accepted, rejected, fmt.Errorf("ensure devices: %w", closeErr)
}
}
// Insert trackpoints
for _, p := range valid {
_, err := s.pool.Exec(ctx, `
INSERT INTO trackpoints (
event_id, device_id, trip_id, ts,
lat, lon, source, note,
accuracy_m, speed_mps, bearing_deg, altitude_m
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT (device_id, event_id) DO NOTHING`,
p.EventID, p.DeviceID, p.TripID, p.Timestamp,
p.Lat, p.Lon, p.Source, p.Note,
p.AccuracyM, p.SpeedMps, p.BearingDeg, p.AltitudeM,
)
if err != nil {
rejected = append(rejected, RejectedItem{
EventID: p.EventID,
Code: "DB_ERROR",
Message: "database error",
})
continue
}
accepted = append(accepted, p.EventID)
}
return accepted, rejected, nil
}
type RejectedItem struct {
EventID string `json:"event_id"`
Code string `json:"code"`
Message string `json:"message"`
}
func validateTrackpoint(p domain.Trackpoint) error {
if p.EventID == "" {
return errors.New("event_id is required")
}
if p.DeviceID == "" {
return errors.New("device_id is required")
}
if p.Lat < -90 || p.Lat > 90 {
return errors.New("lat out of range")
}
if p.Lon < -180 || p.Lon > 180 {
return errors.New("lon out of range")
}
if p.Source != "" && p.Source != "gps" && p.Source != "manual" {
return errors.New("source must be 'gps' or 'manual'")
}
return nil
}
func (s *TrackpointStore) ListByDate(ctx context.Context, userID, date string) ([]domain.Trackpoint, error) {
rows, err := s.pool.Query(ctx, `
SELECT tp.event_id, tp.device_id, tp.trip_id, tp.ts,
tp.lat, tp.lon, tp.source, tp.note,
tp.accuracy_m, tp.speed_mps, tp.bearing_deg, tp.altitude_m
FROM trackpoints tp
JOIN devices d ON d.device_id = tp.device_id
WHERE d.user_id = $1
AND DATE(tp.ts AT TIME ZONE 'UTC') = $2::date
ORDER BY tp.ts`,
userID, date,
)
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.Trackpoint, error) {
var p domain.Trackpoint
err := row.Scan(
&p.EventID, &p.DeviceID, &p.TripID, &p.Timestamp,
&p.Lat, &p.Lon, &p.Source, &p.Note,
&p.AccuracyM, &p.SpeedMps, &p.BearingDeg, &p.AltitudeM,
)
return p, err
})
}
func (s *TrackpointStore) ListDays(ctx context.Context, userID, from, to string) ([]domain.DaySummary, error) {
rows, err := s.pool.Query(ctx, `
SELECT DATE(tp.ts AT TIME ZONE 'UTC')::text AS date,
COUNT(*) AS cnt,
MIN(tp.ts),
MAX(tp.ts)
FROM trackpoints tp
JOIN devices d ON d.device_id = tp.device_id
WHERE d.user_id = $1
AND DATE(tp.ts AT TIME ZONE 'UTC') BETWEEN $2::date AND $3::date
GROUP BY DATE(tp.ts AT TIME ZONE 'UTC')
ORDER BY date`,
userID, from, to,
)
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (domain.DaySummary, error) {
var d domain.DaySummary
err := row.Scan(&d.Date, &d.Count, &d.FirstTS, &d.LastTS)
return d, err
})
}

View File

@@ -0,0 +1,93 @@
package db
import (
"testing"
"github.com/jacek/pamietnik/backend/internal/domain"
)
func TestValidateTrackpoint_HappyPath(t *testing.T) {
p := domain.Trackpoint{
EventID: "evt-1",
DeviceID: "dev-1",
Lat: 52.5,
Lon: 13.4,
Source: "gps",
}
if err := validateTrackpoint(p); err != nil {
t.Fatalf("expected no error, got: %v", err)
}
}
func TestValidateTrackpoint_MissingEventID(t *testing.T) {
p := domain.Trackpoint{
DeviceID: "dev-1",
Lat: 52.5,
Lon: 13.4,
}
err := validateTrackpoint(p)
if err == nil {
t.Fatal("expected error for missing event_id, got nil")
}
}
func TestValidateTrackpoint_MissingDeviceID(t *testing.T) {
p := domain.Trackpoint{
EventID: "evt-1",
Lat: 52.5,
Lon: 13.4,
}
err := validateTrackpoint(p)
if err == nil {
t.Fatal("expected error for missing device_id, got nil")
}
}
func TestValidateTrackpoint_LatOutOfRange(t *testing.T) {
cases := []struct {
lat float64
lon float64
}{
{91, 0},
{-91, 0},
{0, 181},
{0, -181},
}
for _, c := range cases {
p := domain.Trackpoint{
EventID: "evt-1",
DeviceID: "dev-1",
Lat: c.lat,
Lon: c.lon,
}
if err := validateTrackpoint(p); err == nil {
t.Errorf("expected error for lat=%v lon=%v, got nil", c.lat, c.lon)
}
}
}
func TestValidateTrackpoint_InvalidSource(t *testing.T) {
p := domain.Trackpoint{
EventID: "evt-1",
DeviceID: "dev-1",
Lat: 10,
Lon: 10,
Source: "satellite",
}
if err := validateTrackpoint(p); err == nil {
t.Fatal("expected error for invalid source, got nil")
}
}
func TestValidateTrackpoint_EmptySourceIsAllowed(t *testing.T) {
p := domain.Trackpoint{
EventID: "evt-1",
DeviceID: "dev-1",
Lat: 10,
Lon: 10,
Source: "",
}
if err := validateTrackpoint(p); err != nil {
t.Fatalf("empty source should be allowed, got: %v", err)
}
}

View File

@@ -0,0 +1,43 @@
package db
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jacek/pamietnik/backend/internal/domain"
)
type UserStore struct {
pool *pgxpool.Pool
}
func NewUserStore(pool *pgxpool.Pool) *UserStore {
return &UserStore{pool: pool}
}
// ListUsers returns all users ordered by created_at.
func (s *UserStore) ListUsers(ctx context.Context) ([]domain.User, error) {
rows, err := s.pool.Query(ctx,
`SELECT user_id, username, is_admin, created_at FROM users ORDER BY created_at`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var users []domain.User
for rows.Next() {
var u domain.User
if err := rows.Scan(&u.UserID, &u.Username, &u.IsAdmin, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// DeleteUser removes a user by ID.
func (s *UserStore) DeleteUser(ctx context.Context, userID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM users WHERE user_id = $1`, userID)
return err
}

View File

@@ -0,0 +1,88 @@
package domain
import "time"
type Trackpoint struct {
EventID string `json:"event_id"`
DeviceID string `json:"device_id"`
TripID string `json:"trip_id"`
Timestamp time.Time `json:"timestamp"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Source string `json:"source"` // "gps" | "manual"
Note string `json:"note,omitempty"`
AccuracyM *float64 `json:"accuracy_m,omitempty"`
SpeedMps *float64 `json:"speed_mps,omitempty"`
BearingDeg *float64 `json:"bearing_deg,omitempty"`
AltitudeM *float64 `json:"altitude_m,omitempty"`
}
type Stop struct {
StopID string `json:"stop_id"`
DeviceID string `json:"device_id"`
TripID string `json:"trip_id"`
StartTS time.Time `json:"start_ts"`
EndTS time.Time `json:"end_ts"`
CenterLat float64 `json:"center_lat"`
CenterLon float64 `json:"center_lon"`
DurationS int `json:"duration_s"`
PlaceLabel string `json:"place_label,omitempty"`
PlaceDetails map[string]any `json:"place_details,omitempty"`
}
type Suggestion struct {
SuggestionID string `json:"suggestion_id"`
StopID string `json:"stop_id"`
Type string `json:"type"` // "highlight" | "name_place" | "add_note"
Title string `json:"title"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
}
type DaySummary struct {
Date string `json:"date"`
Count int `json:"count"`
FirstTS *time.Time `json:"first_ts,omitempty"`
LastTS *time.Time `json:"last_ts,omitempty"`
}
type JournalEntry struct {
EntryID string `json:"entry_id"`
UserID string `json:"user_id"`
EntryDate string `json:"entry_date"` // YYYY-MM-DD
EntryTime string `json:"entry_time"` // HH:MM
Title string `json:"title"`
Description string `json:"description"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Visibility string `json:"visibility"` // "public" | "private"
Hashtags []string `json:"hashtags"`
CreatedAt time.Time `json:"created_at"`
Images []JournalImage `json:"images,omitempty"`
}
type JournalImage struct {
ImageID string `json:"image_id"`
EntryID string `json:"entry_id"`
Filename string `json:"filename"`
OriginalName string `json:"original_name"`
MimeType string `json:"mime_type"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt time.Time `json:"created_at"`
}
type User struct {
UserID string `json:"user_id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
IsAdmin bool `json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
}
type Session struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}

8
backend/start.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -e
export DATABASE_URL="${DATABASE_URL:-postgres://ralph:ralph@localhost:5432/ralph?sslmode=disable}"
export LISTEN_ADDR="${LISTEN_ADDR:-:8081}"
echo "Starting RALPH backend on $LISTEN_ADDR ..."
exec go run ./cmd/server

12
backend/stop.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
PORT="${LISTEN_ADDR:-:8081}"
PORT="${PORT#:}"
PID=$(lsof -ti:$PORT)
if [ -z "$PID" ]; then
echo "Server is not running on port $PORT"
exit 0
fi
kill $PID
echo "Server stopped (port $PORT, pid $PID)"

728
doc/architecture.md Normal file
View File

@@ -0,0 +1,728 @@
# Pamietnik — Architekturdokumentation (arc42)
## 1. Einführung und Ziele
**Pamietnik** ist ein persönliches Journal bestehend aus drei Komponenten: einer Android-App, einer Web-App und einem Go-Backend-Server.
### Fachliches Zielbild
- Android-App loggt GPS-Standortdaten im Hintergrund, cached offline, lädt bevorzugt per HTTP zum Server hoch
- Manuelles Hinzufügen von Punkten (ohne GPS) mit optionaler Notiz und Hashtags
- Server erkennt aus Logdaten „Stops" (längere Aufenthalte) und leitet Vorschläge ab
- Standortinformationen (Reverse-Geocoding) über kostenlose API (Nominatim/OSM)
- Web-Ansicht nach Login: Tagesübersicht und Tagesdetail mit Karte, Stops, Vorschlägen
- Datei-Export aus der App (Android SAF)
- Nutzung erfordert Account und Login
### Qualitätsziele
| Priorität | Ziel | Maßnahme |
|-----------|------|----------|
| 1 | Datensicherheit | Argon2id Passwort-Hashing, Session-Auth, HTTPS |
| 2 | Offline-Fähigkeit | Room + WorkManager auf Android, kein RAM-only Queue |
| 3 | Idempotenz | `event_id` (UUID) als Unique Key pro Device |
| 4 | Erweiterbarkeit | Geocoding-Provider über Config austauschbar |
| 5 | Datenschutz | Background-Location Disclosure (Google Play) |
### Stakeholder
| Rolle | Erwartung |
|-------|-----------|
| Mobiler Nutzer | Automatisches Tracking, Offline-Betrieb, Export |
| Web-Nutzer | Login, Tagesübersicht mit Karte, Stops und Vorschläge |
---
## 2. Randbedingungen
| Art | Bedingung |
|-----|-----------|
| Technisch | Android: Kotlin + Jetpack Compose (DEC-CLIENT-01) |
| Technisch | Backend: Go, PostgreSQL (DEC-DB-01) |
| Technisch | Webapp: Vanilla TypeScript, keine Frameworks |
| Technisch | Server-Schnittstelle: HTTP/REST, kein MQTT (DEC-API-01) |
| Technisch | Karten: OpenStreetMap + MapLibre, konfigurierbare Tile-Quelle (DEC-MAP-01) |
| Technisch | API-Spec: OpenAPI 3.1 als `openapi.yaml` (DEC-OPENAPI-01) |
| Organisatorisch | Keine kommerziell lizenzierten Bibliotheken oder Schriften |
| Organisatorisch | Geocoding über kostenlose API, Policy-konform (DEC-GEO-01) |
| Organisatorisch | Alle Anforderungen in Markdown; Diagramme in Mermaid (DEC-DOC-01) |
---
## 3. Kontextsicht
### Fachlicher Kontext
| Kommunikationspartner | Interaktion |
|-----------------------|-------------|
| Benutzer mobil | Start/Stop Trip, Status, Export, manuelle Punkte, Hashtags |
| Benutzer web | Login, Tagesübersicht, Tagesdetail, Stops, Vorschläge, Tag-Filter |
| Android OS / Location Services | Liefert GPS-Standortupdates |
| Webserver | Nimmt Trackpoints entgegen, berechnet Stops, stellt Website bereit |
| Geocoding-Dienst (Nominatim) | Liefert Adress-/Place-Infos zu Koordinaten |
| Dateisystem via SAF | Export-Ziel für Logs/Trips |
### Technischer Kontext
```mermaid
flowchart LR
UserM[Nutzer mobil]
UserW[Nutzer Web]
App[Android App\nKotlin + Compose]
OS[Android Location Services]
Webapp[Webapp\nTS + nginx :9050]
API[Go Backend\nREST + Web UI]
DB[(PostgreSQL)]
GEO[Nominatim\nReverse-Geocoding]
SAF[Android SAF\nDatei-Export]
UserM -->|Start/Stop\nManuelle Punkte\nHashtags| App
OS -->|GPS Updates| App
App -->|HTTPS REST JSON\nPOST /v1/trackpoints:batch| API
UserW -->|HTTPS Browser| Webapp
Webapp -->|/v1/* proxy_pass| API
API --> DB
API -->|HTTPS cached| GEO
App -->|ACTION_CREATE_DOCUMENT| SAF
```
| Schnittstelle | Protokoll | Auth |
|---------------|-----------|------|
| Android → Backend | HTTPS REST JSON | Session Cookie (TBD: API-Key/JWT) |
| Browser → Backend | HTTPS | Session Cookie |
| Backend → Nominatim | HTTPS | — (Rate-Limit, gecached) |
| App → Dateisystem | Android SAF | OS-Permission |
---
## 4. Lösungsstrategie
| Entscheidung | ID | Begründung |
|---|---|---|
| Android: Kotlin + Jetpack Compose | DEC-CLIENT-01 | Android „Kotlin-first", moderne UI |
| Go Single Binary als Backend | — | Geringe Ressourcen, einfaches Deployment |
| Distroless Docker Image | — | Minimale Angriffsfläche |
| Schema via `go:embed` + `CREATE TABLE IF NOT EXISTS` | DEC-SCHEMA-01 | Keine Migrations-Infrastruktur, idempotenter Start |
| Room (SQLite) auf Android | DEC-DB-01 | Offline-First Persistenz |
| PostgreSQL im Backend | DEC-DB-01 | JSONB, UUID-Funktionen, zuverlässig |
| Session Cookie statt JWT (Web) | DEC-AUTH-01 | Serverseitige Invalidierung, einfacheres Threat Model |
| Argon2id für Passwort-Hashing | REQ-AUTH-01 | GPU-resistent, State-of-the-art |
| MapLibre + OpenStreetMap | DEC-MAP-01 | Open Source, keine Lizenzkosten, konfigurierbare Tiles |
| Nominatim als Geocoding-Provider | DEC-GEO-01 | Kostenlos, OSM-basiert, Provider austauschbar |
| Vanilla Web Components | — | Kein Framework-Overhead, langlebig, keine Lizenzkonflikte |
| OpenAPI 3.1 als API-Spec | DEC-OPENAPI-01 | Maschinenlesbar, Validierbar in CI |
---
## 5. Bausteinsicht
### Ebene 1 — Systeme
```mermaid
flowchart TB
subgraph Mobile[Android App — app/]
UI[Compose UI\nScreens + ViewModels]
Domain[Domain / Use-Cases]
LocalDB[(Room SQLite\nLocal DB + Queue)]
Worker[WorkManager\nUpload Worker]
Service[Foreground Service\nGPS Tracking]
Export[Export SAF]
end
subgraph Server[Go Backend — backend/]
Router[chi Router\n+ Middleware]
IngestAPI[Ingest API\nPOST /v1/trackpoints]
QueryAPI[Query API\nGET /v1/*]
WebUI[Web UI\nGo Templates SSR]
Auth[Auth Store\nArgon2id + Sessions]
Stores[DB Stores\nTrackpoints / Stops\nSuggestions / Journal]
StopEngine[Stop Detection\n+ Suggestions]
GeoAdapter[Geocoding Adapter\n+ Cache + RateLimit]
DB[(PostgreSQL)]
end
subgraph Web[Webapp — webapp/]
SPA[Vanilla TS SPA\nWeb Components]
Map[TrackMap\nMapLibre GL]
Nginx[nginx\nProxy + Static Files]
end
Mobile -->|HTTPS Batch| IngestAPI
SPA -->|HTTPS /v1/*| Nginx
Nginx -->|proxy_pass| Router
Router --> IngestAPI
Router --> QueryAPI
Router --> WebUI
Router --> Auth
IngestAPI --> Stores
QueryAPI --> Stores
WebUI --> Stores
Auth --> DB
Stores --> DB
StopEngine --> GeoAdapter
GeoAdapter -->|HTTPS| GEO[Nominatim]
```
### Ebene 2 — Backend-Pakete
```
backend/
├── cmd/
│ ├── server/ Einstiegspunkt: Pool → InitSchema → Router → HTTP Server
│ └── createuser/ CLI: User mit Argon2id-Hash anlegen
└── internal/
├── domain/ Shared Types: Trackpoint, Stop, Suggestion, Session, User
├── db/
│ ├── schema.sql Eingebettetes Schema (go:embed, IF NOT EXISTS)
│ ├── db.go NewPool + InitSchema
│ ├── trackpoints.go UpsertBatch, ListByDate, ListDays, EnsureDevice
│ ├── stops.go ListByDate
│ ├── suggestions.go ListByDate
│ ├── journal.go CRUD Journal Entries + Images; ListPublic, ListByUser
│ └── users.go ListUsers, DeleteUser
├── auth/
│ └── auth.go HashPassword, VerifyPassword, Login, Register, GetSession, Logout
└── api/
├── router.go chi Routing, Middleware-Gruppen
├── middleware.go RequireAuth, requireAdmin (Session Cookie → Context)
├── ingest.go HandleSingleTrackpoint, HandleBatchTrackpoints
├── query.go HandleListDays, HandleListTrackpoints, Stops, Suggestions
├── webui.go Web UI: Feed, Register, Days, Admin-Handlers
├── journal.go Journal Entry Endpoints (inkl. visibility + hashtags)
└── response.go writeJSON, writeError helpers
```
### Ebene 2 — Android-Pakete
```
app/app/src/main/kotlin/de/jacek/pamietnik/
├── domain/ Trackpoint Domain Model
├── data/ Room Entities, DAOs, lokale DB
├── service/ Background Location Foreground Service
├── worker/ WorkManager Upload Worker
└── ui/
├── home/ HomeScreen (Compose) + HomeViewModel
├── navigation/ NavGraph
└── theme/ Compose Theme
```
---
## 6. Laufzeitsicht
### R1 — Trip starten und Trackpoints senden
```mermaid
sequenceDiagram
participant App as Android App
participant Room as Room DB
participant Worker as WorkManager
participant API as Go Backend
participant DB as PostgreSQL
App->>Room: Trackpoint persistieren (source=gps, pending)
Worker->>Room: Pending Batch laden (NetworkConnected)
Worker->>API: POST /v1/trackpoints:batch + Cookie
API->>DB: EnsureDevice (INSERT ... ON CONFLICT DO NOTHING)
API->>DB: UpsertBatch (ON CONFLICT device+event_id DO NOTHING)
API-->>Worker: {accepted_ids, rejected: []}
Worker->>Room: Accepted als "sent" markieren
```
### R2 — Offline → Online Retry (REQ-SYNC-02, REQ-SYNC-04)
```mermaid
sequenceDiagram
participant Worker as WorkManager
participant Room as Room DB
participant API as Go Backend
Worker->>API: POST /v1/trackpoints:batch
API--xWorker: Netzwerkfehler / Timeout
Worker->>Room: Pending belassen, retryCount++
Note over Worker: Exponential Backoff\nConstraint: NetworkConnected
Worker->>API: POST /v1/trackpoints:batch (gleiche event_ids)
API-->>Worker: {accepted_ids} (Duplikate via ON CONFLICT ignoriert)
Worker->>Room: Accepted als "sent" markieren
```
### R3 — Web Login und Tagesansicht (REQ-WEB-01, REQ-WEB-02)
```mermaid
sequenceDiagram
participant B as Browser
participant Nginx as nginx :9050
participant API as Go Backend
participant DB as PostgreSQL
B->>Nginx: POST /login (username, password)
Nginx->>API: proxy_pass
API->>DB: User laden, Argon2id verify
API-->>B: Set-Cookie: session=..., 303 /days
B->>Nginx: GET /v1/days?from=&to= + Cookie
Nginx->>API: proxy_pass
API->>DB: trackpoints JOIN devices WHERE user_id = $1
API-->>B: [{date, count, first_ts, last_ts}]
B->>Nginx: GET /v1/trackpoints?date=YYYY-MM-DD + Cookie
API->>DB: trackpoints WHERE date = $2 AND user_id = $1
API-->>B: [Trackpoint...]
```
### R4 — Manuellen Punkt hinzufügen (REQ-MAN-01 bis REQ-MAN-05)
```mermaid
sequenceDiagram
participant U as Nutzer
participant VM as ViewModel
participant Room as Room DB
participant Worker as WorkManager
participant API as Go Backend
U->>VM: Eingabe lat/lon/timestamp/note/tags
VM-->>U: Validierung as-the-user-types
VM->>Room: Trackpoint (source=manual, tags=[...], pending)
Worker->>Room: Fetch pending
Worker->>API: POST /v1/trackpoints:batch
API-->>Worker: Ack
Worker->>Room: Mark sent
```
### R5 — Stop-Erkennung und Geocoding (REQ-SUG-01, REQ-GEO-01)
```mermaid
sequenceDiagram
participant API as Go Backend
participant SE as Stop Engine
participant GEO as Geocoding Adapter
participant NOM as Nominatim
participant DB as PostgreSQL
API->>SE: Trigger nach Batch-Ingest (async)
SE->>DB: Letzte Trackpoints lesen
SE->>SE: Stop erkennen (minDuration, radiusMeters)
SE->>GEO: Reverse-Geocode Stop-Koordinate
alt Cache Hit
GEO-->>SE: Place Info (gecached)
else Cache Miss
GEO->>NOM: HTTPS Reverse-Geocode
NOM-->>GEO: Place Info
GEO-->>SE: Place Info (jetzt gecached)
end
SE->>DB: Stop + Suggestion speichern
```
### R6 — Schema-Initialisierung beim Server-Start
```mermaid
sequenceDiagram
participant S as cmd/server
participant DB as PostgreSQL
S->>DB: pgxpool.New + Ping
S->>DB: schema.sql ausführen (go:embed)\nCREATE TABLE IF NOT EXISTS ...
Note over DB: Idempotent — bestehende\nTabellen bleiben erhalten
S->>S: Stores + Router aufbauen
S->>S: HTTP Server starten :8080
```
---
## 7. Verteilungssicht
```mermaid
flowchart LR
Phone[Android Device]
Browser[Web Browser]
subgraph Docker[Docker Compose]
Nginx[webapp\nnginx:alpine\n:9050]
API[api\ndistroless\n:8080 intern]
PG[postgres\npostgres:16-alpine\ninternal]
end
GEO[Nominatim\nexternal HTTPS]
Phone -->|HTTPS| Nginx
Browser -->|HTTPS| Nginx
Nginx -->|/v1/* /login /logout\nproxy_pass| API
Nginx -->|/ static SPA| Nginx
API --> PG
API -->|cached Rate-Limited| GEO
```
**Docker Compose Services:**
| Service | Image | Port extern | Funktion |
|---------|-------|-------------|----------|
| `postgres` | postgres:16-alpine | — (intern) | Datenhaltung |
| `api` | golang:1.25 → distroless | — (intern) | REST API + Web UI |
| `webapp` | node:22 → nginx:alpine | **9050** | SPA + API-Proxy |
**Start:**
```bash
docker-compose up --build
```
Schema wird beim API-Start automatisch initialisiert (keine separate Migration nötig).
---
## 7b. Datenbankschema
```mermaid
erDiagram
users {
TEXT user_id PK
TEXT username UK
TEXT password_hash
BOOLEAN is_admin
TIMESTAMPTZ created_at
}
sessions {
TEXT session_id PK
TEXT user_id FK
TIMESTAMPTZ created_at
TIMESTAMPTZ expires_at
}
devices {
TEXT device_id PK
TEXT user_id FK
TIMESTAMPTZ created_at
}
trackpoints {
BIGSERIAL id PK
TEXT event_id
TEXT device_id FK
TEXT trip_id
TIMESTAMPTZ ts
DOUBLE lat
DOUBLE lon
TEXT source
TEXT note
}
stops {
TEXT stop_id PK
TEXT device_id FK
TEXT trip_id
TIMESTAMPTZ start_ts
TIMESTAMPTZ end_ts
DOUBLE center_lat
DOUBLE center_lon
INT duration_s
TEXT place_label
}
suggestions {
TEXT suggestion_id PK
TEXT stop_id FK
TEXT type
TEXT title
TEXT text
TIMESTAMPTZ created_at
TIMESTAMPTZ dismissed_at
}
journal_entries {
TEXT entry_id PK
TEXT user_id FK
DATE entry_date
TIME entry_time
TEXT title
TEXT description
DOUBLE lat
DOUBLE lon
TEXT visibility
TEXT[] hashtags
TIMESTAMPTZ created_at
}
journal_images {
TEXT image_id PK
TEXT entry_id FK
TEXT filename
TEXT original_name
TEXT mime_type
BIGINT size_bytes
TIMESTAMPTZ created_at
}
users ||--o{ sessions : has
users ||--o{ devices : owns
users ||--o{ journal_entries : writes
devices ||--o{ trackpoints : records
stops ||--o{ suggestions : generates
journal_entries ||--o{ journal_images : contains
```
**Wichtige Felder:**
| Tabelle | Feld | Bedeutung |
|---------|------|-----------|
| `users` | `is_admin` | Admin-Flag für Zugang zum Admin-Bereich |
| `journal_entries` | `visibility` | `public` = im öffentlichen Feed sichtbar; `private` = nur für Autor |
| `journal_entries` | `hashtags` | Kommagetrennte Tags als `TEXT[]`-Array |
| `trackpoints` | `(device_id, event_id)` | UNIQUE-Constraint für Idempotenz |
---
## 8. Querschnittskonzepte
### Authentifizierung & Sessions (REQ-AUTH-01, REQ-AUTH-02, DEC-AUTH-01)
- Passwörter: Argon2id (`$argon2id$<saltHex>$<hashHex>`)
- Sessions in PostgreSQL (`sessions`-Tabelle): sofortige Invalidierung bei Logout
- Cookie: `HttpOnly`, `SameSite=Lax`, 24h Lebensdauer
- Android Upload-Auth: aktuell Session Cookie — **TBD: API-Key oder JWT** (DEC-AUTH-01)
### Idempotenz (REQ-SYNC-04)
- Jeder Trackpoint trägt eine client-generierte `event_id` (UUID empfohlen)
- Unique Constraint `(device_id, event_id)` in PostgreSQL
- `ON CONFLICT DO NOTHING` → doppelter Upload = 200 OK, kein Fehler
- Batch-Response enthält `accepted_ids` und `rejected[]` für Queue-Steuerung
### Offline-First (REQ-SYNC-01 bis REQ-SYNC-03)
- Jeder Punkt wird zuerst in Room (SQLite) persistiert
- Upload via WorkManager: `NetworkConnected`-Constraint, Exponential Backoff
- `source`-Feld unterscheidet `"gps"` und `"manual"`
### Device-Registrierung
- `EnsureDevice()` beim ersten Ingest: `INSERT ... ON CONFLICT DO NOTHING`
- Verknüpft `device_id` mit authentifiziertem `user_id`
- Query-Endpoints filtern über `devices.user_id`
### Hashtags (REQ-TAG-01 bis REQ-TAG-06)
- Feld `tags: string[]` im Trackpoint-Schema
- Eingabe-Vorschläge aus vorhandenen Tags des Nutzers
- Server: `GET /v1/tags` (Häufigkeit), `GET /v1/trackpoints?tag=...`
- Webapp: Tag-Filter in Tagesdetail, Tag-Übersicht
### Geocoding (REQ-GEO-01 bis REQ-GEO-03)
- Nur ereignisbasiert (pro Stop), niemals bulk/periodisch
- Cache vor jedem Provider-Call
- Provider über Config austauschbar (kein Hardcode)
- Nominatim: User-Agent korrekt setzen (Policy-Pflicht)
### Karten (DEC-MAP-01, REQ-MAP-01, REQ-MAP-02)
- Android: MapLibre SDK
- Webapp: MapLibre GL JS (open source, lizenzfrei)
- Tile-Quelle konfigurierbar (`tileUrl`-Property auf `<track-map>`)
---
## 9. Produktanforderungen (aus README)
### 3.1 Background-Logging
| ID | Anforderung |
|----|------------|
| REQ-LOC-01 | App MUSS Standort im Vordergrund und Hintergrund erfassen |
| REQ-LOC-02 | COARSE (optional FINE) im Manifest + Runtime; App funktioniert mit „approximate" |
| REQ-LOC-03 | Background-Location korrekt angefordert |
| REQ-LOC-04 | `foregroundServiceType="location"` wenn Foreground Service |
### 3.2 Offline-First
| ID | Anforderung |
|----|------------|
| REQ-SYNC-01 | Trackpoints zuerst lokal persistieren (kein RAM-Queue) |
| REQ-SYNC-02 | Upload bevorzugt; bei Fehler cachen und automatisch nachladen |
| REQ-SYNC-03 | WorkManager mit `NetworkConnected` + Retry/Backoff |
| REQ-SYNC-04 | Keine Duplikate bei Retries; Idempotenz via `event_id` |
### 3.3 Manuelle Punkte
| ID | Anforderung |
|----|------------|
| REQ-MAN-01 | Manueller Punkt: lat, lon, timestamp (Pflicht); Name/Notiz (optional) |
| REQ-MAN-02 | Lokal in Room speichern |
| REQ-MAN-03 | In Upload-Flow integriert (inkl. `event_id`) |
| REQ-MAN-04 | Validierung as-the-user-types; verständliche Fehlermeldungen |
| REQ-MAN-05 | Vorschlag „aktueller Zeitpunkt / aktuelle Position" (editierbar) |
### 3.4 Export
| ID | Anforderung |
|----|------------|
| REQ-EXP-01 | Export in Datei möglich |
| REQ-EXP-02 | Via SAF `ACTION_CREATE_DOCUMENT`; Nutzer wählt Speicherort |
### 3.5 Server Website
| ID | Anforderung |
|----|------------|
| REQ-WEB-01 | Website nur nach Login zugänglich |
| REQ-WEB-02 | Pro Tag Punkte anzeigen (Liste + Detailansicht) |
| REQ-WEB-03 | Daten nutzerspezifisch (ein Nutzer sieht nur eigene Daten) |
| REQ-WEB-04 | Kartenansicht (Punkte/Stops pro Tag) |
### 3.6 Security / Auth
| ID | Anforderung |
|----|------------|
| REQ-AUTH-01 | Passwörter NICHT im Klartext; Argon2id |
| REQ-AUTH-02 | Session Cookie; serverseitig verwaltbar (Logout/Expire) |
| REQ-AUTH-03 | Auth auch für Web-Query-Endpoints |
### 3.7 Stops / Vorschläge
| ID | Anforderung |
|----|------------|
| REQ-SUG-01 | Stop-Erkennung (Mindestdauer, Radius konfigurierbar) |
| REQ-SUG-02 | Vorschläge aus Stops ableiten |
| REQ-SUG-03 | Pro Nutzer/Device/Trip zuordenbar; in Website sichtbar |
### 3.8 Geocoding
| ID | Anforderung |
|----|------------|
| REQ-GEO-01 | Kostenloser Dienst (Nominatim); Caching + Rate-Limit |
| REQ-GEO-02 | Provider austauschbar via Config (ohne App-Update) |
| REQ-GEO-03 | Kein Bulk/periodisches Geocoding; ereignisbasiert (pro Stop) |
### 3.9 Karten
| ID | Anforderung |
|----|------------|
| REQ-MAP-01 | OpenStreetMap-Basis; MapLibre als Renderer |
| REQ-MAP-02 | Tile-Quelle konfigurierbar |
### 3.10 OpenAPI
| ID | Anforderung |
|----|------------|
| REQ-OPENAPI-01 | API als OpenAPI 3.1 dokumentiert |
| REQ-OPENAPI-02 | `openapi.yaml` im Repository versioniert |
| REQ-OPENAPI-03 | Alle Endpoints, Schemas, Fehler, Security-Schemes |
| REQ-OPENAPI-04 | CookieAuth via `type: apiKey, in: cookie` |
### 3.11 Google Play / Datenschutz
| ID | Anforderung |
|----|------------|
| REQ-PRIV-01 | Background-Location Disclosure im UI; korrekte Deklaration |
### 3.13 Hashtags
| ID | Anforderung |
|----|------------|
| REQ-TAG-01 | Trackpoints mit Hashtags versehen (`#restaurant`, ...) |
| REQ-TAG-02 | Tags lokal in Room + beim Upload übertragen (`tags: string[]`) |
| REQ-TAG-03 | Server persistiert Tags, abfragbar pro Trackpoint |
| REQ-TAG-04 | Webapp: Filter nach Hashtag |
| REQ-TAG-05 | Eingabe-Vorschläge aus vorhandenen Tags des Nutzers |
| REQ-TAG-06 | Webapp: Tag-Übersicht mit Häufigkeit |
---
## 10. Architekturentscheidungen
| ID | Entscheidung | Alternativen | Status |
|----|-------------|--------------|--------|
| DEC-CLIENT-01 | Android (Kotlin + Compose) | iOS, Flutter | Entschieden |
| DEC-LOC-01 | Background Location Logging | Nur Vordergrund | Entschieden |
| DEC-API-01 | HTTP/REST + JSON | MQTT, gRPC, Protobuf | Entschieden (Protobuf offen) |
| DEC-DB-01 | Room (Android) + PostgreSQL (Server) | SQLite Server, MySQL | Entschieden |
| DEC-WEB-01 | Website mit Login (SSR + SPA) | nur API | Entschieden |
| DEC-AUTH-01 | Session Cookie für Web; JWT optional | JWT only | Session entschieden |
| DEC-MAP-01 | MapLibre + OSM, konfigurierbare Tiles | Google Maps | Entschieden |
| DEC-GEO-01 | Nominatim, Provider austauschbar | Google Places API | Entschieden |
| DEC-OPENAPI-01 | OpenAPI 3.1 YAML | kein Spec | Entschieden |
| DEC-SCHEMA-01 | `go:embed` schema.sql, IF NOT EXISTS | golang-migrate | Entschieden |
---
## 11. Risiken und offene Punkte
### Implementierungsstatus
```mermaid
flowchart TD
subgraph Impl[Implementiert]
A[REST API Ingest\nPOST /v1/trackpoints]
B[Session Auth\nArgon2id + Cookie]
C[Tagesabfragen\nGET /v1/days etc.]
D[Web UI\nGo Templates SSR]
E[SPA Webapp\nMapLibre + WebComponents]
F[Schema Init\ngo:embed + IF NOT EXISTS]
G[Device-Registrierung\nEnsureDevice]
end
subgraph Backlog[Backlog / TBD]
H[Stop Detection\nT061]
I[Geocoding Adapter\nT063]
J[Suggestions Engine\nT062]
K[Android Auth\nAPI-Key / JWT T050]
L[OpenAPI Spec\nT070]
M[Hashtag-Support\nT073-T087]
end
subgraph Risiko[Sicherheit / Production]
N[CSRF-Schutz\nkritisch]
O[TLS / HTTPS\nhoch]
P[Rate-Limiting\nmittel]
Q[Secrets via .env\nkritisch]
R[Pagination\nhoch]
S[Security-Header\nnginx mittel]
end
style Impl fill:#d4edda,stroke:#28a745
style Backlog fill:#fff3cd,stroke:#ffc107
style Risiko fill:#f8d7da,stroke:#dc3545
```
### Security-Risiken (Architekt-Review)
```mermaid
quadrantChart
title Risiken — Schwere vs. Aufwand
x-axis Geringer Aufwand --> Hoher Aufwand
y-axis Geringes Risiko --> Hohes Risiko
quadrant-1 Sofort beheben
quadrant-2 Planen
quadrant-3 Beobachten
quadrant-4 Nice to have
CSRF-Schutz: [0.2, 0.9]
Secrets via .env: [0.1, 0.85]
TLS/HTTPS: [0.5, 0.8]
Rate-Limiting Login: [0.3, 0.75]
Pagination: [0.4, 0.6]
Android Auth: [0.7, 0.65]
Security-Header nginx: [0.15, 0.5]
Error-Disclosure: [0.2, 0.5]
EnsureDevice N+1: [0.35, 0.35]
DB Pool Config: [0.2, 0.4]
Stop Detection: [0.75, 0.5]
Geocoding: [0.8, 0.4]
OpenAPI Spec: [0.5, 0.2]
Hashtags: [0.65, 0.3]
```
### Produktionsreife
```mermaid
flowchart LR
DEV[Development\n✓ Lauffähig]
MVP[MVP\nFehlend: CSRF, TLS\nSecrets, Rate-Limit]
PROD[Production Ready\n+ Stop Detection\n+ Geocoding\n+ Monitoring\n+ Backup]
DEV -->|Kritische Security-Fixes| MVP
MVP -->|Business-Logik\n+ Observability| PROD
style DEV fill:#d4edda,stroke:#28a745
style MVP fill:#fff3cd,stroke:#ffc107
style PROD fill:#cce5ff,stroke:#004085
```
### Offene Entscheidungen
| Thema | Optionen | Auswirkung |
|-------|----------|-----------|
| `timestamp`-Format | epochMillis vs RFC3339 | Android + API Kompatibilität |
| Android Upload Auth | Session Cookie / API-Key / JWT | Security-Architektur |
| Payload | JSON vs Protobuf | Bandbreite auf Mobile |
| Batch-Limits | max Items / max Bytes | Denial-of-Service Schutz |
| Retention Policy | Löschen nach X Tagen | Storage-Kosten |
| Stop-Parameter | Mindestdauer, Radius | Qualität der Vorschläge |
| Geocoding Provider | Nominatim public / self-hosted | Datenschutz, Verfügbarkeit |

79
doc/deployment.md Normal file
View File

@@ -0,0 +1,79 @@
# Deployment
## Voraussetzungen
- Synology NAS mit laufendem shared PostgreSQL Stack (`/volume2/docker/shared/`)
- act_runner registriert und online
- Siehe Infra-Repo für Setup-Details
---
## Datenbank einrichten (einmalig)
```bash
sudo docker exec -it shared-postgres-1 psql -U postgres
```
```sql
CREATE DATABASE pamietnik;
CREATE USER pamietnik WITH PASSWORD '<passwort>';
GRANT ALL PRIVILEGES ON DATABASE pamietnik TO pamietnik;
GRANT ALL ON SCHEMA public TO pamietnik;
\q
```
---
## Gitea Secrets & Variables
**Repository → Einstellungen → Actions → Secrets:**
| Secret | Wert |
|--------|------|
| `DB_PASSWORD` | Passwort des `pamietnik` DB-Users |
**Repository → Einstellungen → Actions → Variables:**
| Variable | Wert |
|----------|------|
| `DEPLOY_DIR` | `/volume2/docker/pamietnik` |
| `DB_USER` | `pamietnik` |
| `DB_NAME` | `pamietnik` |
| `APP_PORT` | `9050` |
---
## Deploy
Push auf `main` triggert automatisch den Workflow (`.gitea/workflows/deploy.yml`):
1. Code nach `/volume2/docker/pamietnik/` klonen/pullen
2. `.env` mit DB-Credentials schreiben
3. `docker compose up --build -d`
4. Health check auf `/healthz`
App erreichbar unter: `http://192.168.1.4:9050`
---
## Ersten User anlegen
```bash
sudo docker exec -it pamietnik-api-1 /createuser
```
---
## Logs & Wartung
```bash
# Logs
sudo docker compose -f /volume2/docker/pamietnik/docker-compose.yml logs -f
# Neustart
sudo docker compose -f /volume2/docker/pamietnik/docker-compose.yml restart
# Backup
sudo docker exec shared-postgres-1 pg_dump -U postgres pamietnik \
> /volume2/docker/shared/backup_$(date +%Y%m%d)_pamietnik.sql
```

View File

@@ -1,47 +1,16 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ralph
POSTGRES_PASSWORD: ralph
POSTGRES_DB: ralph
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ralph"]
interval: 5s
timeout: 5s
retries: 5
migrate:
build: ./backend
command: ["/migrate"]
environment:
DATABASE_URL: postgres://ralph:ralph@postgres:5432/ralph?sslmode=disable
depends_on:
postgres:
condition: service_healthy
restart: on-failure
api:
build: ./backend
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
- "${APP_PORT:-9050}:8080"
extra_hosts:
- "host-gateway:host-gateway"
environment:
DATABASE_URL: postgres://ralph:ralph@postgres:5432/ralph?sslmode=disable
DATABASE_URL: postgres://${DB_USER:-pamietnik}:${DB_PASSWORD:?DB_PASSWORD is required}@host-gateway:5433/${DB_NAME:-pamietnik}?sslmode=disable
LISTEN_ADDR: :8080
depends_on:
migrate:
condition: service_completed_successfully
UPLOAD_DIR: /uploads
volumes:
- /volume2/docker/pamietnik/uploads:/uploads
restart: unless-stopped
webapp:
build: ./webapp
ports:
- "9050:80"
depends_on:
- api
restart: unless-stopped
volumes:
pgdata:

465
openapi.yaml Normal file
View File

@@ -0,0 +1,465 @@
openapi: 3.1.0
info:
title: Pamietnik API
version: 0.1.0
description: Life & travel journal — REST API for trackpoint ingest and data query.
servers:
- url: http://192.168.1.4:9050
description: NAS (local)
- url: http://localhost:9050
description: Local dev
security:
- cookieAuth: []
paths:
/healthz:
get:
summary: Health check
security: []
responses:
'200':
description: Server is up
content:
text/plain:
example: ok
/readyz:
get:
summary: Readiness check
security: []
responses:
'200':
description: Server is ready
# --- Ingest ---
/v1/trackpoints:
post:
summary: Ingest single trackpoint
tags: [Ingest]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TrackpointInput'
responses:
'200':
description: Accepted (or duplicate — idempotent)
content:
application/json:
schema:
$ref: '#/components/schemas/BatchResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/v1/trackpoints:batch:
post:
summary: Ingest batch of trackpoints (max 500)
tags: [Ingest]
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/TrackpointInput'
maxItems: 500
responses:
'200':
description: Processed — check accepted_ids and rejected for details
content:
application/json:
schema:
$ref: '#/components/schemas/BatchResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
# --- Query ---
/v1/days:
get:
summary: List days with trackpoint activity
tags: [Query]
parameters:
- name: from
in: query
required: true
schema:
type: string
format: date
example: '2026-01-01'
- name: to
in: query
required: true
schema:
type: string
format: date
example: '2026-12-31'
responses:
'200':
description: List of days
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/DaySummary'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/v1/trackpoints:
get:
summary: List trackpoints for a date
tags: [Query]
parameters:
- name: date
in: query
required: true
schema:
type: string
format: date
example: '2026-04-07'
responses:
'200':
description: List of trackpoints
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Trackpoint'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/v1/stops:
get:
summary: List stops for a date
tags: [Query]
parameters:
- name: date
in: query
required: true
schema:
type: string
format: date
example: '2026-04-07'
responses:
'200':
description: List of stops
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Stop'
'401':
$ref: '#/components/responses/Unauthorized'
/v1/suggestions:
get:
summary: List suggestions for a date
tags: [Query]
parameters:
- name: date
in: query
required: true
schema:
type: string
format: date
example: '2026-04-07'
responses:
'200':
description: List of suggestions
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Suggestion'
'401':
$ref: '#/components/responses/Unauthorized'
# --- Journal ---
/entries:
post:
summary: Create journal entry with optional images
tags: [Journal]
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [date, time]
properties:
date:
type: string
format: date
example: '2026-04-07'
time:
type: string
example: '14:30'
title:
type: string
description:
type: string
lat:
type: number
format: double
lon:
type: number
format: double
images:
type: array
items:
type: string
format: binary
description: JPEG, PNG, WebP or HEIC — max 10 MB each, 32 MB total
responses:
'201':
description: Entry created
content:
application/json:
schema:
$ref: '#/components/schemas/JournalEntry'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: session
responses:
BadRequest:
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Unauthorized:
description: Not authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
TrackpointInput:
type: object
required: [event_id, device_id, trip_id, timestamp, lat, lon]
properties:
event_id:
type: string
format: uuid
description: Client-generated UUID — used for idempotency
device_id:
type: string
trip_id:
type: string
timestamp:
type: string
format: date-time
example: '2026-04-07T12:00:00Z'
lat:
type: number
format: double
minimum: -90
maximum: 90
lon:
type: number
format: double
minimum: -180
maximum: 180
source:
type: string
enum: [gps, manual]
default: gps
note:
type: string
accuracy_m:
type: number
format: double
speed_mps:
type: number
format: double
bearing_deg:
type: number
format: double
altitude_m:
type: number
format: double
Trackpoint:
allOf:
- $ref: '#/components/schemas/TrackpointInput'
BatchResponse:
type: object
properties:
server_time:
type: string
format: date-time
accepted_ids:
type: array
items:
type: string
rejected:
type: array
items:
$ref: '#/components/schemas/RejectedItem'
RejectedItem:
type: object
properties:
event_id:
type: string
code:
type: string
enum: [VALIDATION_ERROR, DB_ERROR, INVALID_TIMESTAMP]
message:
type: string
DaySummary:
type: object
properties:
date:
type: string
format: date
count:
type: integer
first_ts:
type: string
format: date-time
last_ts:
type: string
format: date-time
Stop:
type: object
properties:
stop_id:
type: string
device_id:
type: string
trip_id:
type: string
start_ts:
type: string
format: date-time
end_ts:
type: string
format: date-time
center_lat:
type: number
format: double
center_lon:
type: number
format: double
duration_s:
type: integer
place_label:
type: string
place_details:
type: object
Suggestion:
type: object
properties:
suggestion_id:
type: string
stop_id:
type: string
type:
type: string
enum: [highlight, name_place, add_note]
title:
type: string
text:
type: string
created_at:
type: string
format: date-time
dismissed_at:
type: string
format: date-time
JournalEntry:
type: object
properties:
entry_id:
type: string
user_id:
type: string
entry_date:
type: string
format: date
entry_time:
type: string
example: '14:30'
title:
type: string
description:
type: string
lat:
type: number
format: double
lon:
type: number
format: double
created_at:
type: string
format: date-time
images:
type: array
items:
$ref: '#/components/schemas/JournalImage'
JournalImage:
type: object
properties:
image_id:
type: string
entry_id:
type: string
filename:
type: string
original_name:
type: string
mime_type:
type: string
size_bytes:
type: integer
format: int64
created_at:
type: string
format: date-time
Error:
type: object
properties:
code:
type: string
message:
type: string

7
webapp/.gitignore vendored
View File

@@ -1,2 +1,9 @@
node_modules/
dist/
.vite/
*.tsbuildinfo
# Environment
.env
.env.local
.env.*.local

View File

@@ -1,6 +1,6 @@
# Pamietnik Webapp
Eigenständige Single-Page-Application für das Pamietnik-Reisejournal. Kommuniziert über REST mit dem Go-Backend.
Eigenständige Single-Page-Application für das Pamietnik. Kommuniziert über REST mit dem Go-Backend.
## Technologie

View File

@@ -2,6 +2,16 @@ server {
listen 80;
# API und Auth-Endpunkte zum Backend proxieren
location /healthz {
proxy_pass http://api:8080;
proxy_set_header Host $host;
}
location /readyz {
proxy_pass http://api:8080;
proxy_set_header Host $host;
}
location /v1/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;

View File

@@ -50,9 +50,15 @@ async function get<T>(path: string): Promise<T> {
export const api = {
getDays(from?: string, to?: string): Promise<DaySummary[]> {
const params = new URLSearchParams()
if (from) params.set('from', from)
if (to) params.set('to', to)
const now = new Date()
const defaultTo = now.toISOString().slice(0, 10)
const past = new Date(now)
past.setDate(past.getDate() - 90)
const defaultFrom = past.toISOString().slice(0, 10)
const params = new URLSearchParams({
from: from ?? defaultFrom,
to: to ?? defaultTo,
})
return get<DaySummary[]>(`/v1/days?${params}`)
},
getTrackpoints(date: string): Promise<Trackpoint[]> {

View File

@@ -7,7 +7,7 @@ export class AppShell extends HTMLElement {
connectedCallback(): void {
this.innerHTML = `
<nav>
<a href="/days" id="nav-days">Tage</a>
<a href="/days" id="nav-days">Pamietnik</a>
<button id="nav-logout">Abmelden</button>
</nav>
<main id="outlet"></main>

View File

@@ -1,4 +1,4 @@
import { api, DaySummary } from '../api'
import { api } from '../api'
import { navigate } from '../router'
export class DaysPage extends HTMLElement {

View File

@@ -1,17 +1,210 @@
/* System fonts only — no external dependencies, no license issues */
:root {
--font-mono: 'Courier New', Courier, monospace;
--font-serif: Georgia, 'Times New Roman', serif;
--color-ink: #1a1a1a;
--color-muted: #666;
--color-rule: #ddd;
--color-bg: #fafaf8;
--column: 680px;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: system-ui, sans-serif;
font-size: 16px;
color: #1a1a1a;
background: #f5f5f5;
html {
font-size: 18px;
background: var(--color-bg);
color: var(--color-ink);
}
body {
font-family: var(--font-serif);
line-height: 1.7;
min-height: 100vh;
}
#app {
height: 100%;
min-height: 100vh;
}
/* --- Navigation --- */
nav {
border-bottom: 1px solid var(--color-rule);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
nav a {
color: var(--color-ink);
text-decoration: none;
}
nav a:hover {
text-decoration: underline;
}
nav button {
background: none;
border: none;
cursor: pointer;
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
padding: 0;
}
nav button:hover {
color: var(--color-ink);
}
/* --- Main content column --- */
main {
max-width: var(--column);
margin: 0 auto;
padding: 3rem 1.5rem;
}
/* --- Typography --- */
h1 {
font-family: var(--font-mono);
font-size: 1rem;
font-weight: normal;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-muted);
margin-bottom: 2rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-rule);
}
h2 {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: normal;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
margin: 2.5rem 0 1rem;
}
p {
margin-bottom: 1rem;
color: var(--color-muted);
font-family: var(--font-mono);
font-size: 0.85rem;
}
/* --- Lists --- */
ul {
list-style: none;
}
li {
border-bottom: 1px solid var(--color-rule);
padding: 0.75rem 0;
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--color-ink);
}
li:first-child {
border-top: 1px solid var(--color-rule);
}
li a {
color: var(--color-ink);
text-decoration: none;
display: block;
}
li a:hover {
text-decoration: underline;
}
/* --- Login form --- */
form {
max-width: 360px;
}
form h1 {
margin-bottom: 2.5rem;
}
label {
display: block;
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-muted);
margin-bottom: 1.5rem;
}
input[type="text"],
input[type="password"] {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.5rem 0;
border: none;
border-bottom: 1px solid var(--color-ink);
background: transparent;
font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--color-ink);
outline: none;
}
input:focus {
border-bottom-color: var(--color-ink);
}
button[type="submit"] {
margin-top: 2rem;
background: var(--color-ink);
color: var(--color-bg);
border: none;
padding: 0.6rem 1.5rem;
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
}
button[type="submit"]:hover {
opacity: 0.8;
}
#login-error {
color: #c00;
font-size: 0.8rem;
margin-top: -0.5rem;
margin-bottom: 1rem;
}
/* --- Map --- */
track-map {
display: block;
margin: 1.5rem 0;
border: 1px solid var(--color-rule);
}