diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..049527d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +Dockerfile* +docker-compose*.yml +README.md +*.zip +/data +pocketwatch.db* diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..5528dc7 --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,87 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: sendnrw + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."git.send.nrw"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: git.send.nrw # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile.server + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:server_${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:server_${{ env.DOCKER_LATEST }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile.customer-service + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:customer_${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:customer_${{ env.DOCKER_LATEST }} + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile.worker + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:worker_${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:worker_${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17b72cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/data/ +*.db +*.db-shm +*.db-wal +pocketwatch +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eab28c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1.7 +FROM golang:1.26.6-alpine AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/pocketwatch ./cmd/pocketwatch + +FROM alpine:3.24 +RUN addgroup -S -g 10001 pocketwatch && adduser -S -D -H -u 10001 -G pocketwatch pocketwatch \ + && mkdir -p /data && chown -R pocketwatch:pocketwatch /data +COPY --from=build /out/pocketwatch /usr/local/bin/pocketwatch +USER pocketwatch +VOLUME ["/data"] +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD wget -q -O - http://127.0.0.1:8080/healthz | grep -q '"status":"ok"' || exit 1 +ENV APP_ADDR=:8080 DATA_DIR=/data COOKIE_SECURE=false +ENTRYPOINT ["/usr/local/bin/pocketwatch"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..316e5d1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pocketwatch Go contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7a60972 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: run test fmt build docker + +run: + DATA_DIR=./data APP_ADDR=:8080 go run ./cmd/pocketwatch + +build: + CGO_ENABLED=0 go build -trimpath -o pocketwatch ./cmd/pocketwatch + +test: + go test ./... + +fmt: + gofmt -w cmd internal + +docker: + docker compose up -d --build diff --git a/README.md b/README.md index e1b2bee..2e92e94 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,205 @@ -# flancer +# Pocketwatch Go +Ein schlanker, self-hosted Zeiterfasser in Go – funktional an `winnicodes/pocketwatch` angelehnt, aber ohne React, Node.js, PHP oder Nginx im Anwendungs-Stack. + +Die Anwendung besteht aus einem Go-Binary, eingebettetem HTML/CSS/Vanilla-JavaScript und SQLite über `modernc.org/sqlite` (pure Go, kein CGO notwendig). + +## Enthalten + +- Start-/Stop-Timer mit Live-Anzeige +- Laufender Timer überlebt Reloads und Gerätewechsel +- Kunde + Tätigkeit, Kunden-Autocomplete nach letzter Nutzung +- Tages- und Wochensumme +- Verlauf gruppiert nach Tagen +- Suche über Kunde und Tätigkeit +- Zeitraumfilter: Tag, Woche, Monat, Jahr, alle +- Zeitraum vor/zurück schalten +- Einträge nachtragen, bearbeiten und löschen +- Rundung 1–60 Minuten, normal oder immer aufwärts +- CSV-Export (Semikolon + UTF-8 BOM für Excel) +- PDF-Export ohne PDF-Framework +- Export wahlweise für aktuelle Ansicht oder freien Datumsbereich, auf-/absteigend und kompakt +- Responsive Desktop-/Mobile-Oberfläche +- Mehrbenutzerbetrieb mit strikt getrennten Daten +- Admin-/Benutzerrollen +- Benutzer anlegen, deaktivieren und Passwörter zurücksetzen +- Eigenes Passwort ändern +- Keine öffentliche Registrierung nach der Ersteinrichtung +- Sessions in SQLite, HttpOnly-Cookie, CSRF-Token +- Login-Rate-Limit +- CSP und weitere Security-Header +- SQLite WAL, Foreign Keys, Busy Timeout +- Healthcheck unter `/healthz` +- Docker/Compose, unprivilegierter Runtime-Benutzer, alle Linux-Capabilities entfernt + +## Abhängigkeiten + +Zur Laufzeit gibt es nur eine externe Go-Abhängigkeit: + +```text +modernc.org/sqlite +``` + +Alles andere verwendet die Go-Standardbibliothek. Das Frontend hat **keine** npm-/Node-Abhängigkeiten und keinen separaten Build-Schritt. + +## Schnellstart mit Docker Compose + +```bash +docker compose up -d --build +``` + +Danach: + +```text +http://localhost:8080 +``` + +Beim ersten Aufruf erscheint die Ersteinrichtung. Der erste Account wird Administrator. + +Die Daten liegen im Docker-Volume `pocketwatch-data` in `/data/pocketwatch.db`. Das Image enthält außerdem einen Docker-Healthcheck gegen `/healthz`. + +Bei einem Host-Bind-Mount statt eines Named Volumes muss das Zielverzeichnis für UID/GID `10001` schreibbar sein. + +## Lokal ohne Docker + +Voraussetzungen: + +- Go 1.26+ + +Dann: + +```bash +go mod tidy +DATA_DIR=./data APP_ADDR=:8080 go run ./cmd/pocketwatch +``` + +Oder: + +```bash +make run +``` + +## Konfiguration + +| Variable | Default | Bedeutung | +|---|---:|---| +| `APP_ADDR` | `:8080` | Listen-Adresse des HTTP-Servers | +| `DATA_DIR` | `/data` | Verzeichnis für `pocketwatch.db` | +| `COOKIE_SECURE` | `false` | Auf `true` setzen, wenn die App ausschließlich über HTTPS erreichbar ist | + +### Hinter Reverse Proxy / HTTPS + +Wenn z. B. Caddy, Traefik oder nginx TLS terminiert: + +```yaml +environment: + COOKIE_SECURE: "true" +``` + +Die App setzt selbst keine CORS-Header. API und UI sind als Same-Origin-Anwendung gedacht. + +## Datenmodell + +SQLite enthält vier Kernbereiche: + +- `users` – Accounts, Rollen, Aktivstatus +- `sessions` – gehashte Session-Tokens + CSRF-Token +- `entries` – Zeiteinträge, immer mit `user_id` +- `user_settings` – persönliche Rundungs-, Export- und Anzeigeeinstellungen + +Ein partieller Unique-Index stellt sicher, dass pro Benutzer höchstens ein laufender Timer existiert. + +Zeitpunkte werden als Unix-Millisekunden gespeichert. Die Web-Oberfläche verwendet die lokale Browser-Zeitzone; Exporte verwenden die persönliche IANA-Zeitzone, z. B. `Europe/Berlin`. + +## Sicherheit + +### Passwörter + +Passwörter werden mit PBKDF2-HMAC-SHA256, zufälligem Salt und 310.000 Iterationen gespeichert. Die Implementierung nutzt `crypto/pbkdf2`, `crypto/sha256` und `crypto/rand` aus der Go-Standardbibliothek von Go 1.26. + +### Sessions + +- 256-Bit zufällige Session-Tokens +- nur SHA-256-Digest des Session-Tokens in SQLite +- HttpOnly-Cookie +- SameSite=Lax +- optional `Secure` +- serverseitiges Ablaufdatum +- Sessions werden beim Deaktivieren eines Benutzers oder Passwort-Reset invalidiert + +### CSRF + +Schreibende API-Aufrufe benötigen zusätzlich ein zufälliges, sitzungsgebundenes `X-CSRF-Token`. + +### Mandantentrennung + +Jede SQL-Operation auf Zeiten enthält die `user_id` aus der authentifizierten Session. IDs aus einem anderen Benutzerkonto reichen daher nicht aus, um fremde Einträge zu lesen oder zu verändern. + +## Backup + +Wegen WAL sollte die Datenbank nicht blind während Schreibzugriffen als einzelne Datei kopiert werden. Der einfachste konsistente Weg bei Docker Compose: + +```bash +docker compose stop pocketwatch + +docker run --rm \ + -v pocketwatch-go_pocketwatch-data:/data:ro \ + -v "$PWD:/backup" \ + alpine:3.22 \ + tar czf /backup/pocketwatch-backup.tgz -C /data . + +docker compose start pocketwatch +``` + +Der konkrete Volume-Name kann je nach Compose-Projektname abweichen (`docker volume ls`). + +## Projektstruktur + +```text +pocketwatch-go/ +├── cmd/pocketwatch/main.go +├── internal/app/ +│ ├── auth.go +│ ├── db.go +│ ├── export.go +│ ├── server.go +│ ├── *_test.go +│ └── web/ +│ ├── index.html +│ ├── login.html +│ ├── app.css +│ ├── app.js +│ └── login.js +├── Dockerfile +├── docker-compose.yml +├── Makefile +└── go.mod +``` + +## Tests + +```bash +go test ./... +``` + +Enthalten sind u. a. Tests für PBKDF2, Rundungslogik und den minimalen PDF-Writer. + +## Bewusste Unterschiede zum ursprünglichen Pocketwatch + +Diese Implementierung übernimmt das Produktkonzept und die wichtigsten Bedienabläufe, ist aber technisch ein Neuaufbau: + +- SQLite statt JSON-Dateien +- Go statt PHP +- Vanilla JS statt React/Vite/Tailwind +- kein Node.js im Build oder Betrieb +- Login und Mehrbenutzerbetrieb +- Adminverwaltung +- serverseitige Datenisolation +- CSRF- und Session-Schutz +- Healthcheck und Security-Header + +Die Oberfläche orientiert sich am dunklen, kompakten Amber-Design des Originals, ist aber kein 1:1 kopierter Frontend-Quellcode. Sie ist derzeit bewusst deutschsprachig; das Datenmodell hält die Spracheinstellung bereits für eine spätere vollständige Lokalisierung vor. + +## Inspiration + +Inspiriert von [`winnicodes/pocketwatch`](https://github.com/winnicodes/pocketwatch), das als minimalistischer self-hosted Zeiterfasser unter MIT veröffentlicht ist. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f49f2e6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + pocketwatch: + build: . + image: pocketwatch-go:local + restart: unless-stopped + ports: + - "8080:8080" + environment: + APP_ADDR: ":8080" + DATA_DIR: "/data" + # Hinter HTTPS-Reverse-Proxy auf true setzen. + COOKIE_SECURE: "false" + volumes: + - pocketwatch-data:/data + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + pocketwatch-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..51588bc --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module pocketwatch-go + +go 1.26 + +require modernc.org/sqlite v1.56.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1932692 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/app/auth.go b/internal/app/auth.go new file mode 100644 index 0000000..5b99575 --- /dev/null +++ b/internal/app/auth.go @@ -0,0 +1,204 @@ +package app + +import ( + "context" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + passwordIterations = 310_000 + passwordSaltBytes = 16 + passwordKeyBytes = 32 + sessionCookie = "pocketwatch_session" +) + +// This is deliberately public and never authenticates an account. It only keeps +// failed-login password work roughly constant when a username does not exist. +const dummyPasswordHash = "pbkdf2-sha256$310000$cG9ja2V0d2F0Y2gtZHVtbXk$y2kfBbJVOW/xpojEt9AAGQfhF+Ul6iGKDKH09PSY7IY" + +type session struct { + User User + CSRF string +} + +type contextKey int + +const sessionKey contextKey = 1 + +func hashPassword(password string) (string, error) { + if len(password) < 10 { + return "", fmt.Errorf("password must have at least 10 characters") + } + salt := make([]byte, passwordSaltBytes) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := pbkdf2SHA256([]byte(password), salt, passwordIterations, passwordKeyBytes) + return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil +} + +func verifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { + return false + } + iter, err := strconv.Atoi(parts[1]) + if err != nil || iter < 100_000 || iter > 2_000_000 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil || len(want) == 0 { + return false + } + got := pbkdf2SHA256([]byte(password), salt, iter, len(want)) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// pbkdf2SHA256 wraps Go 1.26's standard-library PBKDF2 implementation. +func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte { + key, err := pbkdf2.Key(sha256.New, string(password), salt, iterations, keyLen) + if err != nil { + panic(err) // callers use fixed, validated parameters + } + return key +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func tokenDigest(token string) []byte { + s := sha256.Sum256([]byte(token)) + return s[:] +} + +func newID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + h := hex.EncodeToString(b) + return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:] +} + +func (a *App) createSession(ctx context.Context, userID string) (token, csrf string, expires time.Time, err error) { + token, err = randomToken(32) + if err != nil { + return + } + csrf, err = randomToken(24) + if err != nil { + return + } + now := time.Now() + expires = now.Add(a.cfg.SessionTTL) + _, err = a.store.db.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,csrf_token,created_at_ms,expires_at_ms) VALUES(?,?,?,?,?)`, tokenDigest(token), userID, csrf, now.UnixMilli(), expires.UnixMilli()) + return +} + +func (a *App) sessionFromRequest(r *http.Request) (*session, error) { + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + return nil, sql.ErrNoRows + } + var userID, csrf string + var expires int64 + err = a.store.db.QueryRowContext(r.Context(), `SELECT user_id,csrf_token,expires_at_ms FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)).Scan(&userID, &csrf, &expires) + if err != nil { + return nil, err + } + if expires <= time.Now().UnixMilli() { + _, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)) + return nil, sql.ErrNoRows + } + u, err := a.store.userByID(r.Context(), userID) + if err != nil || !u.Active { + return nil, sql.ErrNoRows + } + return &session{User: u, CSRF: csrf}, nil +} + +func (a *App) withSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s, err := a.sessionFromRequest(r) + if err != nil { + if strings.HasPrefix(r.URL.Path, "/api/") { + jsonError(w, http.StatusUnauthorized, "not_authenticated", "Bitte anmelden.") + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s))) + }) +} + +func sessionOf(r *http.Request) *session { + x, _ := r.Context().Value(sessionKey).(*session) + return x +} + +func requireCSRF(w http.ResponseWriter, r *http.Request) bool { + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { + return true + } + s := sessionOf(r) + if s == nil || subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(s.CSRF)) != 1 { + jsonError(w, http.StatusForbidden, "csrf", "Ungültiges CSRF-Token.") + return false + } + return true +} + +func requireAdmin(w http.ResponseWriter, r *http.Request) bool { + s := sessionOf(r) + if s == nil || s.User.Role != "admin" { + jsonError(w, http.StatusForbidden, "forbidden", "Admin-Rechte erforderlich.") + return false + } + return true +} + +func (a *App) deleteCurrentSession(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + _, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)) + } + a.clearSessionCookie(w) +} + +func (a *App) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())}) +} + +func (a *App) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)}) +} + +func isUniqueConstraint(err error) bool { + if err == nil { + return false + } + s := strings.ToLower(err.Error()) + return strings.Contains(s, "unique constraint") || strings.Contains(s, "constraint failed") +} diff --git a/internal/app/auth_test.go b/internal/app/auth_test.go new file mode 100644 index 0000000..6ee2a58 --- /dev/null +++ b/internal/app/auth_test.go @@ -0,0 +1,16 @@ +package app + +import "testing" + +func TestPBKDF2Deterministic(t *testing.T) { + got := pbkdf2SHA256([]byte("password"), []byte("salt"), 2, 32) + want := []byte{0xae, 0x4d, 0x0c, 0x95, 0xaf, 0x6b, 0x46, 0xd3, 0x2d, 0x0a, 0xdf, 0xf9, 0x28, 0xf0, 0x6d, 0xd0, 0x2a, 0x30, 0x3f, 0x8e, 0xf3, 0xc2, 0x51, 0xdf, 0xd6, 0xe2, 0xd8, 0x5a, 0x95, 0x47, 0x4c, 0x43} + if len(got) != len(want) { + t.Fatalf("length %d", len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("byte %d: got %x want %x", i, got[i], want[i]) + } + } +} diff --git a/internal/app/db.go b/internal/app/db.go new file mode 100644 index 0000000..f100d28 --- /dev/null +++ b/internal/app/db.go @@ -0,0 +1,531 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Active bool `json:"active"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +type Settings struct { + Language string `json:"language"` + TimeFormat string `json:"time_format"` + RoundingMinutes int `json:"rounding_minutes"` + RoundUp bool `json:"round_up"` + ShowWeekTotal bool `json:"show_week_total"` + StickyDays bool `json:"sticky_days"` + LongRunReminder bool `json:"long_run_reminder"` + ExportName string `json:"export_name"` + Timezone string `json:"timezone"` + ExportDate bool `json:"export_date"` +} + +type Entry struct { + ID string `json:"id"` + Client string `json:"client"` + Activity string `json:"activity"` + StartMS int64 `json:"start_ms"` + EndMS *int64 `json:"end_ms"` + Created int64 `json:"created_at_ms"` + Updated int64 `json:"updated_at_ms"` +} + +type store struct{ db *sql.DB } + +func openStore(path string) (*store, error) { + // modernc.org/sqlite supports validated DSN shorthands for common PRAGMAs. + u := &url.URL{Scheme: "file", Path: path} + q := u.Query() + q.Set("_fk", "1") + q.Set("_journal", "WAL") + q.Set("_timeout", "5000") + q.Set("_sync", "NORMAL") + q.Set("_dqs", "false") + u.RawQuery = q.Encode() + dsn := u.String() + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + // SQLite has a single writer. A small pool avoids accidental writer stampedes while + // still allowing concurrent reads in WAL mode. + db.SetMaxOpenConns(8) + db.SetMaxIdleConns(4) + db.SetConnMaxLifetime(0) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, err + } + + s := &store{db: db} + if err := s.migrate(ctx); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *store) migrate(ctx context.Context) error { + const schema = ` +CREATE TABLE IF NOT EXISTS app_state ( + id INTEGER PRIMARY KEY CHECK(id = 1), + setup_complete INTEGER NOT NULL DEFAULT 0 CHECK(setup_complete IN (0,1)) +); +INSERT OR IGNORE INTO app_state(id, setup_complete) VALUES(1, 0); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at_ms INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token_hash BLOB PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expires_at_ms); + +CREATE TABLE IF NOT EXISTS entries ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + client TEXT NOT NULL DEFAULT '', + activity TEXT NOT NULL DEFAULT '', + start_ms INTEGER NOT NULL, + end_ms INTEGER, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + CHECK(end_ms IS NULL OR end_ms >= start_ms) +); +CREATE INDEX IF NOT EXISTS idx_entries_user_start ON entries(user_id, start_ms DESC); +CREATE INDEX IF NOT EXISTS idx_entries_user_client ON entries(user_id, client COLLATE NOCASE); +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_running_entry_per_user ON entries(user_id) WHERE end_ms IS NULL; + +UPDATE app_state SET setup_complete=1 WHERE id=1 AND EXISTS(SELECT 1 FROM users); + +CREATE TABLE IF NOT EXISTS user_settings ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + language TEXT NOT NULL DEFAULT 'de' CHECK(language IN ('de','en')), + time_format TEXT NOT NULL DEFAULT '24' CHECK(time_format IN ('24','12')), + rounding_minutes INTEGER NOT NULL DEFAULT 1 CHECK(rounding_minutes BETWEEN 1 AND 60), + round_up INTEGER NOT NULL DEFAULT 0 CHECK(round_up IN (0,1)), + show_week_total INTEGER NOT NULL DEFAULT 1 CHECK(show_week_total IN (0,1)), + sticky_days INTEGER NOT NULL DEFAULT 1 CHECK(sticky_days IN (0,1)), + long_run_reminder INTEGER NOT NULL DEFAULT 1 CHECK(long_run_reminder IN (0,1)), + export_name TEXT NOT NULL DEFAULT '', + timezone TEXT NOT NULL DEFAULT 'UTC', + export_date INTEGER NOT NULL DEFAULT 1 CHECK(export_date IN (0,1)) +); +` + _, err := s.db.ExecContext(ctx, schema) + return err +} + +var errAlreadySetup = errors.New("instance already set up") + +func (s *store) needsSetup(ctx context.Context) (bool, error) { + var complete int + err := s.db.QueryRowContext(ctx, `SELECT setup_complete FROM app_state WHERE id=1`).Scan(&complete) + return complete == 0, err +} + +func (s *store) bootstrapAdmin(ctx context.Context, username, display, passwordHash string) (User, error) { + username = strings.TrimSpace(username) + display = strings.TrimSpace(display) + if display == "" { + display = username + } + now := time.Now().UnixMilli() + u := User{ID: newID(), Username: username, DisplayName: display, Role: "admin", Active: true, CreatedAtMS: now} + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return User{}, err + } + defer tx.Rollback() + res, err := tx.ExecContext(ctx, `UPDATE app_state SET setup_complete=1 WHERE id=1 AND setup_complete=0`) + if err != nil { + return User{}, err + } + n, err := res.RowsAffected() + if err != nil { + return User{}, err + } + if n != 1 { + return User{}, errAlreadySetup + } + if _, err := tx.ExecContext(ctx, `INSERT INTO users(id,username,display_name,password_hash,role,active,created_at_ms) VALUES(?,?,?,?,'admin',1,?)`, u.ID, u.Username, u.DisplayName, passwordHash, now); err != nil { + return User{}, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO user_settings(user_id,export_name) VALUES(?,?)`, u.ID, u.DisplayName); err != nil { + return User{}, err + } + if err := tx.Commit(); err != nil { + return User{}, err + } + return u, nil +} + +func (s *store) createUser(ctx context.Context, username, display, passwordHash, role string) (User, error) { + username = strings.TrimSpace(username) + display = strings.TrimSpace(display) + if display == "" { + display = username + } + if role != "admin" { + role = "user" + } + now := time.Now().UnixMilli() + u := User{ID: newID(), Username: username, DisplayName: display, Role: role, Active: true, CreatedAtMS: now} + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return User{}, err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `INSERT INTO users(id,username,display_name,password_hash,role,active,created_at_ms) VALUES(?,?,?,?,?,1,?)`, u.ID, u.Username, u.DisplayName, passwordHash, u.Role, now); err != nil { + return User{}, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO user_settings(user_id,export_name) VALUES(?,?)`, u.ID, u.DisplayName); err != nil { + return User{}, err + } + if err := tx.Commit(); err != nil { + return User{}, err + } + return u, nil +} + +func (s *store) userForLogin(ctx context.Context, username string) (User, string, error) { + var u User + var hash string + var active int + err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,role,active,created_at_ms FROM users WHERE username=?`, strings.TrimSpace(username)).Scan(&u.ID, &u.Username, &u.DisplayName, &hash, &u.Role, &active, &u.CreatedAtMS) + u.Active = active == 1 + return u, hash, err +} + +func (s *store) userByID(ctx context.Context, id string) (User, error) { + var u User + var active int + err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,role,active,created_at_ms FROM users WHERE id=?`, id).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &active, &u.CreatedAtMS) + u.Active = active == 1 + return u, err +} + +func (s *store) listUsers(ctx context.Context) ([]User, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,username,display_name,role,active,created_at_ms FROM users ORDER BY username COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + var active int + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &active, &u.CreatedAtMS); err != nil { + return nil, err + } + u.Active = active == 1 + out = append(out, u) + } + return out, rows.Err() +} + +func (s *store) setUserActive(ctx context.Context, id string, active bool) error { + v := 0 + if active { + v = 1 + } + res, err := s.db.ExecContext(ctx, `UPDATE users SET active=? WHERE id=?`, v, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + if !active { + _, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id=?`, id) + } + return nil +} + +func (s *store) resetPassword(ctx context.Context, id, hash string) error { + res, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash=? WHERE id=?`, hash, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + _, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id=?`, id) + return nil +} + +func (s *store) settings(ctx context.Context, userID string) (Settings, error) { + var x Settings + var up, week, sticky, reminder, exportDate int + err := s.db.QueryRowContext(ctx, `SELECT language,time_format,rounding_minutes,round_up,show_week_total,sticky_days,long_run_reminder,export_name,timezone,export_date FROM user_settings WHERE user_id=?`, userID).Scan( + &x.Language, &x.TimeFormat, &x.RoundingMinutes, &up, &week, &sticky, &reminder, &x.ExportName, &x.Timezone, &exportDate, + ) + x.RoundUp = up == 1 + x.ShowWeekTotal = week == 1 + x.StickyDays = sticky == 1 + x.LongRunReminder = reminder == 1 + x.ExportDate = exportDate == 1 + return x, err +} + +func (s *store) updateSettings(ctx context.Context, userID string, x Settings) error { + boolInt := func(v bool) int { + if v { + return 1 + } + return 0 + } + _, err := s.db.ExecContext(ctx, `UPDATE user_settings SET language=?,time_format=?,rounding_minutes=?,round_up=?,show_week_total=?,sticky_days=?,long_run_reminder=?,export_name=?,timezone=?,export_date=? WHERE user_id=?`, + x.Language, x.TimeFormat, x.RoundingMinutes, boolInt(x.RoundUp), boolInt(x.ShowWeekTotal), boolInt(x.StickyDays), boolInt(x.LongRunReminder), strings.TrimSpace(x.ExportName), x.Timezone, boolInt(x.ExportDate), userID) + return err +} + +func (s *store) runningEntry(ctx context.Context, userID string) (*Entry, error) { + var e Entry + err := s.db.QueryRowContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries WHERE user_id=? AND end_ms IS NULL LIMIT 1`, userID).Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &e, err +} + +func (s *store) startEntry(ctx context.Context, userID, client, activity string, startMS int64) (Entry, error) { + now := time.Now().UnixMilli() + if startMS == 0 { + startMS = now + } + e := Entry{ID: newID(), Client: strings.TrimSpace(client), Activity: strings.TrimSpace(activity), StartMS: startMS, Created: now, Updated: now} + _, err := s.db.ExecContext(ctx, `INSERT INTO entries(id,user_id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms) VALUES(?,?,?,?,?,NULL,?,?)`, e.ID, userID, e.Client, e.Activity, e.StartMS, now, now) + return e, err +} + +func (s *store) stopEntry(ctx context.Context, userID, id string, endMS int64) (Entry, error) { + if endMS == 0 { + endMS = time.Now().UnixMilli() + } + now := time.Now().UnixMilli() + res, err := s.db.ExecContext(ctx, `UPDATE entries SET end_ms=?,updated_at_ms=? WHERE id=? AND user_id=? AND end_ms IS NULL AND start_ms<=?`, endMS, now, id, userID, endMS) + if err != nil { + return Entry{}, err + } + n, _ := res.RowsAffected() + if n == 0 { + return Entry{}, sql.ErrNoRows + } + return s.entryByID(ctx, userID, id) +} + +func (s *store) entryByID(ctx context.Context, userID, id string) (Entry, error) { + var e Entry + err := s.db.QueryRowContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries WHERE id=? AND user_id=?`, id, userID).Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated) + return e, err +} + +func (s *store) updateEntry(ctx context.Context, userID, id, client, activity string, startMS int64, endMS *int64) (Entry, error) { + if startMS <= 0 || (endMS != nil && *endMS < startMS) { + return Entry{}, fmt.Errorf("invalid time range") + } + now := time.Now().UnixMilli() + res, err := s.db.ExecContext(ctx, `UPDATE entries SET client=?,activity=?,start_ms=?,end_ms=?,updated_at_ms=? WHERE id=? AND user_id=?`, strings.TrimSpace(client), strings.TrimSpace(activity), startMS, endMS, now, id, userID) + if err != nil { + return Entry{}, err + } + n, _ := res.RowsAffected() + if n == 0 { + return Entry{}, sql.ErrNoRows + } + return s.entryByID(ctx, userID, id) +} + +func (s *store) deleteEntry(ctx context.Context, userID, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM entries WHERE id=? AND user_id=?`, id, userID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +type entryFilter struct { + Query string + FromMS int64 + ToMS int64 + Limit int + Offset int + SortAsc bool + Compact bool +} + +type entryPage struct { + Entries []Entry `json:"entries"` + TotalCount int `json:"total_count"` + TotalDurationMS int64 `json:"total_duration_ms"` +} + +func (s *store) listEntries(ctx context.Context, userID string, f entryFilter, cfg Settings) (entryPage, error) { + where, args := buildEntryWhere(userID, f) + var total int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM entries `+where+` AND end_ms IS NOT NULL`, args...).Scan(&total); err != nil { + return entryPage{}, err + } + + // Totals are calculated row-wise to keep the rounding semantics identical to exports. + rowsDur, err := s.db.QueryContext(ctx, `SELECT start_ms,end_ms FROM entries `+where+` AND end_ms IS NOT NULL`, args...) + if err != nil { + return entryPage{}, err + } + var totalDur int64 + for rowsDur.Next() { + var start, end int64 + if err := rowsDur.Scan(&start, &end); err != nil { + rowsDur.Close() + return entryPage{}, err + } + totalDur += roundedDuration(end-start, cfg.RoundingMinutes, cfg.RoundUp) + } + rowsDur.Close() + if err := rowsDur.Err(); err != nil { + return entryPage{}, err + } + + qargs := append(append([]any{}, args...), f.Limit, f.Offset) + rows, err := s.db.QueryContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries `+where+` AND end_ms IS NOT NULL ORDER BY start_ms DESC,id DESC LIMIT ? OFFSET ?`, qargs...) + if err != nil { + return entryPage{}, err + } + defer rows.Close() + out := entryPage{TotalCount: total, TotalDurationMS: totalDur, Entries: []Entry{}} + for rows.Next() { + var e Entry + if err := rows.Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated); err != nil { + return entryPage{}, err + } + out.Entries = append(out.Entries, e) + } + return out, rows.Err() +} + +func (s *store) allEntries(ctx context.Context, userID string, f entryFilter) ([]Entry, error) { + where, args := buildEntryWhere(userID, f) + order := "DESC" + if f.SortAsc { + order = "ASC" + } + rows, err := s.db.QueryContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries `+where+` AND end_ms IS NOT NULL ORDER BY start_ms `+order+`,id `+order, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Entry + for rows.Next() { + var e Entry + if err := rows.Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func buildEntryWhere(userID string, f entryFilter) (string, []any) { + where := `WHERE user_id=?` + args := []any{userID} + if f.FromMS > 0 { + where += ` AND start_ms>=?` + args = append(args, f.FromMS) + } + if f.ToMS > 0 { + where += ` AND start_ms'' GROUP BY client COLLATE NOCASE ORDER BY MAX(start_ms) DESC LIMIT 50`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var x string + if err := rows.Scan(&x); err != nil { + return nil, err + } + out = append(out, x) + } + return out, rows.Err() +} + +func roundedDuration(ms int64, minutes int, up bool) int64 { + if ms <= 0 { + return 0 + } + if minutes < 1 { + minutes = 1 + } + interval := int64(minutes) * 60_000 + if interval <= 60_000 { + return ms + } + if up { + return ((ms + interval - 1) / interval) * interval + } + return ((ms + interval/2) / interval) * interval +} + +func (s *store) createFinishedEntry(ctx context.Context, userID, client, activity string, startMS, endMS int64) (Entry, error) { + if startMS <= 0 || endMS < startMS { + return Entry{}, fmt.Errorf("invalid time range") + } + now := time.Now().UnixMilli() + e := Entry{ID: newID(), Client: strings.TrimSpace(client), Activity: strings.TrimSpace(activity), StartMS: startMS, EndMS: &endMS, Created: now, Updated: now} + _, err := s.db.ExecContext(ctx, `INSERT INTO entries(id,user_id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms) VALUES(?,?,?,?,?,?,?,?)`, e.ID, userID, e.Client, e.Activity, startMS, endMS, now, now) + return e, err +} diff --git a/internal/app/export.go b/internal/app/export.go new file mode 100644 index 0000000..db3625e --- /dev/null +++ b/internal/app/export.go @@ -0,0 +1,253 @@ +package app + +import ( + "bytes" + "encoding/csv" + "fmt" + "strconv" + "strings" + "time" +) + +func makeCSV(entries []Entry, cfg Settings, compact bool) ([]byte, error) { + loc := location(cfg.Timezone) + var b bytes.Buffer + b.Write([]byte{0xEF, 0xBB, 0xBF}) // Excel-friendly UTF-8 BOM. + w := csv.NewWriter(&b) + w.Comma = ';' + if compact { + _ = w.Write([]string{"Datum", "Kunde", "Start", "Ende", "Dauer"}) + } else { + _ = w.Write([]string{"Datum", "Kunde", "Tätigkeit", "Start", "Ende", "Dauer"}) + } + for _, e := range entries { + if e.EndMS == nil { + continue + } + start := time.UnixMilli(e.StartMS).In(loc) + end := time.UnixMilli(*e.EndMS).In(loc) + dur := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp) + if compact { + _ = w.Write([]string{start.Format("02.01.2006"), e.Client, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)}) + } else { + _ = w.Write([]string{start.Format("02.01.2006"), e.Client, e.Activity, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)}) + } + } + w.Flush() + return b.Bytes(), w.Error() +} + +type pdfPageLine struct { + x, y, size float64 + bold bool + text string +} + +func makePDF(entries []Entry, cfg Settings, owner User, compact bool) []byte { + loc := location(cfg.Timezone) + name := strings.TrimSpace(cfg.ExportName) + if name == "" { + name = owner.DisplayName + } + + const pageW, pageH = 595.0, 842.0 // A4 points. + var pages [][]pdfPageLine + var page []pdfPageLine + y := 795.0 + + newPage := func() { + if len(page) > 0 { + pages = append(pages, page) + } + page = []pdfPageLine{} + y = 795 + page = append(page, pdfPageLine{50, y, 19, true, "Zeiterfassung"}) + y -= 24 + page = append(page, pdfPageLine{50, y, 10, false, name}) + y -= 22 + if compact { + page = append(page, + pdfPageLine{50, y, 9, true, "Datum"}, + pdfPageLine{112, y, 9, true, "Kunde"}, + pdfPageLine{430, y, 9, true, "Zeit"}, + pdfPageLine{515, y, 9, true, "Dauer"}, + ) + } else { + page = append(page, + pdfPageLine{50, y, 9, true, "Datum"}, + pdfPageLine{112, y, 9, true, "Kunde"}, + pdfPageLine{255, y, 9, true, "Tätigkeit"}, + pdfPageLine{430, y, 9, true, "Zeit"}, + pdfPageLine{515, y, 9, true, "Dauer"}, + ) + } + y -= 16 + } + newPage() + + var total int64 + for _, e := range entries { + if e.EndMS == nil { + continue + } + if y < 65 { + newPage() + } + start := time.UnixMilli(e.StartMS).In(loc) + end := time.UnixMilli(*e.EndMS).In(loc) + d := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp) + total += d + page = append(page, + pdfPageLine{50, y, 8.5, false, start.Format("02.01.06")}, + pdfPageLine{112, y, 8.5, false, clipRunes(e.Client, func() int { + if compact { + return 50 + } + return 25 + }())}, + ) + if !compact { + page = append(page, pdfPageLine{255, y, 8.5, false, clipRunes(e.Activity, 29)}) + } + page = append(page, + pdfPageLine{430, y, 8.5, false, formatClock(start, cfg.TimeFormat) + "-" + formatClock(end, cfg.TimeFormat)}, + pdfPageLine{515, y, 8.5, false, formatDuration(d)}, + ) + y -= 15 + } + if y < 60 { + newPage() + } + page = append(page, pdfPageLine{430, y - 4, 10, true, "Gesamt"}, pdfPageLine{515, y - 4, 10, true, formatDuration(total)}) + pages = append(pages, page) + + return buildSimplePDF(pageW, pageH, pages, cfg.ExportDate, loc) +} + +// buildSimplePDF writes a small, standards-compliant PDF using only built-in Type 1 +// fonts. This avoids pulling a PDF framework into the server binary. +func buildSimplePDF(pageW, pageH float64, pages [][]pdfPageLine, exportDate bool, loc *time.Location) []byte { + objs := make([][]byte, 0) + add := func(s string) int { + objs = append(objs, []byte(s)) + return len(objs) + } + catalogID := add("") + pagesID := add("") + fontID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`) + boldID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>`) + + pageIDs := make([]int, 0, len(pages)) + for i, lines := range pages { + var c strings.Builder + for _, ln := range lines { + font := "F1" + if ln.bold { + font = "F2" + } + fmt.Fprintf(&c, "BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n", font, ln.size, ln.x, ln.y, pdfEscape(ln.text)) + } + footer := "Seite " + strconv.Itoa(i+1) + "/" + strconv.Itoa(len(pages)) + if exportDate { + footer = "Export: " + time.Now().In(loc).Format("02.01.2006") + " - " + footer + } + fmt.Fprintf(&c, "BT /F1 7.5 Tf 50 28 Td (%s) Tj ET\n", pdfEscape(footer)) + + content := c.String() + contentID := add(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content)) + pageID := add(fmt.Sprintf( + "<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %.0f %.0f] /Resources << /Font << /F1 %d 0 R /F2 %d 0 R >> >> /Contents %d 0 R >>", + pagesID, pageW, pageH, fontID, boldID, contentID, + )) + pageIDs = append(pageIDs, pageID) + } + kids := make([]string, len(pageIDs)) + for i, id := range pageIDs { + kids[i] = fmt.Sprintf("%d 0 R", id) + } + objs[catalogID-1] = []byte(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pagesID)) + objs[pagesID-1] = []byte(fmt.Sprintf("<< /Type /Pages /Count %d /Kids [%s] >>", len(pageIDs), strings.Join(kids, " "))) + + var out bytes.Buffer + out.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n") + offsets := make([]int, len(objs)+1) + for i, obj := range objs { + offsets[i+1] = out.Len() + fmt.Fprintf(&out, "%d 0 obj\n", i+1) + out.Write(obj) + out.WriteString("\nendobj\n") + } + xref := out.Len() + fmt.Fprintf(&out, "xref\n0 %d\n", len(objs)+1) + out.WriteString("0000000000 65535 f \n") + for i := 1; i <= len(objs); i++ { + fmt.Fprintf(&out, "%010d 00000 n \n", offsets[i]) + } + fmt.Fprintf(&out, "trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, catalogID, xref) + return out.Bytes() +} + +func pdfEscape(s string) string { + var b strings.Builder + for _, r := range s { + var c byte + switch r { + case '–', '—': + c = '-' + case '€': + c = 0x80 + case '“', '”': + c = '"' + case '’': + c = '\'' + default: + if r >= 32 && r <= 255 { + c = byte(r) + } else if r == '\n' || r == '\r' { + c = ' ' + } else { + c = '?' + } + } + if c == '(' || c == ')' || c == '\\' { + b.WriteByte('\\') + } + b.WriteByte(c) + } + return b.String() +} + +func formatClock(t time.Time, f string) string { + if f == "12" { + return t.Format("03:04 PM") + } + return t.Format("15:04") +} + +func formatDuration(ms int64) string { + if ms < 0 { + ms = 0 + } + mins := (ms + 30_000) / 60_000 + return fmt.Sprintf("%d:%02d", mins/60, mins%60) +} + +func location(name string) *time.Location { + if name != "" { + if x, err := time.LoadLocation(name); err == nil { + return x + } + } + return time.UTC +} + +func clipRunes(s string, max int) string { + r := []rune(strings.TrimSpace(s)) + if len(r) <= max { + return string(r) + } + if max < 2 { + return string(r[:max]) + } + return string(r[:max-1]) + "…" +} diff --git a/internal/app/export_test.go b/internal/app/export_test.go new file mode 100644 index 0000000..54f749e --- /dev/null +++ b/internal/app/export_test.go @@ -0,0 +1,28 @@ +package app + +import ( + "bytes" + "testing" +) + +func TestPDFHeader(t *testing.T) { + end := int64(3_600_000) + pdf := makePDF([]Entry{{ID: "x", Client: "ACME", Activity: "Arbeit", StartMS: 0, EndMS: &end}}, Settings{RoundingMinutes: 1, TimeFormat: "24", Timezone: "UTC"}, User{DisplayName: "Test"}, false) + if !bytes.HasPrefix(pdf, []byte("%PDF-1.4")) { + t.Fatal("missing PDF header") + } + if !bytes.Contains(pdf, []byte("xref")) { + t.Fatal("missing xref") + } +} + +func TestCSVCompactOmitsActivity(t *testing.T) { + end := int64(3_600_000) + b, err := makeCSV([]Entry{{ID: "x", Client: "ACME", Activity: "Geheim", StartMS: 0, EndMS: &end}}, Settings{RoundingMinutes: 1, TimeFormat: "24", Timezone: "UTC"}, true) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(b, []byte("Geheim")) { + t.Fatal("compact CSV contains activity") + } +} diff --git a/internal/app/rounding_test.go b/internal/app/rounding_test.go new file mode 100644 index 0000000..57f7b60 --- /dev/null +++ b/internal/app/rounding_test.go @@ -0,0 +1,22 @@ +package app + +import "testing" + +func TestRoundedDuration(t *testing.T) { + tests := []struct { + ms int64 + min int + up bool + want int64 + }{ + {37 * 60_000, 15, false, 30 * 60_000}, + {38 * 60_000, 15, false, 45 * 60_000}, + {37 * 60_000, 15, true, 45 * 60_000}, + {37 * 60_000, 1, false, 37 * 60_000}, + } + for _, tt := range tests { + if got := roundedDuration(tt.ms, tt.min, tt.up); got != tt.want { + t.Errorf("roundedDuration(%d,%d,%v)=%d want %d", tt.ms, tt.min, tt.up, got, tt.want) + } + } +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..bf21d08 --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,757 @@ +package app + +import ( + "context" + "database/sql" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "mime" + "net" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "sync" + "time" +) + +//go:embed web/* +var webFS embed.FS + +type Config struct { + DBPath string + CookieSecure bool + SessionTTL time.Duration +} + +type App struct { + cfg Config + store *store + log *slog.Logger + mux *http.ServeMux + limiter *loginLimiter + static fs.FS +} + +func New(cfg Config, logger *slog.Logger) (*App, error) { + if cfg.SessionTTL <= 0 { + cfg.SessionTTL = 30 * 24 * time.Hour + } + st, err := openStore(cfg.DBPath) + if err != nil { + return nil, err + } + static, err := fs.Sub(webFS, "web") + if err != nil { + st.db.Close() + return nil, err + } + a := &App{cfg: cfg, store: st, log: logger, mux: http.NewServeMux(), limiter: newLoginLimiter(), static: static} + a.routes() + return a, nil +} + +func (a *App) Close() error { return a.store.db.Close() } + +func (a *App) Handler() http.Handler { return a.securityHeaders(a.recoverer(a.accessLog(a.mux))) } + +func (a *App) routes() { + // Public. + a.mux.HandleFunc("GET /healthz", a.health) + a.mux.HandleFunc("GET /login", a.loginPage) + a.mux.HandleFunc("GET /api/bootstrap", a.bootstrap) + a.mux.HandleFunc("POST /api/setup", a.setup) + a.mux.HandleFunc("POST /api/login", a.login) + + fileServer := http.FileServer(http.FS(a.static)) + a.mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) + + // Authenticated HTML. + a.mux.Handle("GET /{$}", a.withSession(http.HandlerFunc(a.indexPage))) + + // Authenticated API. + api := http.NewServeMux() + api.HandleFunc("GET /api/me", a.me) + api.HandleFunc("POST /api/logout", a.logout) + api.HandleFunc("POST /api/account/password", a.changeOwnPassword) + api.HandleFunc("GET /api/settings", a.getSettings) + api.HandleFunc("PUT /api/settings", a.putSettings) + api.HandleFunc("GET /api/running", a.getRunning) + api.HandleFunc("GET /api/clients", a.getClients) + api.HandleFunc("GET /api/entries", a.getEntries) + api.HandleFunc("POST /api/entries", a.createEntry) + api.HandleFunc("POST /api/entries/start", a.startEntry) + api.HandleFunc("POST /api/entries/{id}/stop", a.stopEntry) + api.HandleFunc("PUT /api/entries/{id}", a.updateEntry) + api.HandleFunc("DELETE /api/entries/{id}", a.deleteEntry) + api.HandleFunc("GET /api/export.csv", a.exportCSV) + api.HandleFunc("GET /api/export.pdf", a.exportPDF) + api.HandleFunc("GET /api/admin/users", a.adminUsers) + api.HandleFunc("POST /api/admin/users", a.adminCreateUser) + api.HandleFunc("PATCH /api/admin/users/{id}", a.adminPatchUser) + api.HandleFunc("POST /api/admin/users/{id}/password", a.adminResetPassword) + a.mux.Handle("/api/", a.withSession(api)) +} + +func (a *App) health(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := a.store.db.PingContext(ctx); err != nil { + jsonError(w, 503, "db_unavailable", "database unavailable") + return + } + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +func (a *App) loginPage(w http.ResponseWriter, r *http.Request) { + if _, err := a.sessionFromRequest(r); err == nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + a.serveAsset(w, r, "login.html") +} +func (a *App) indexPage(w http.ResponseWriter, r *http.Request) { a.serveAsset(w, r, "index.html") } + +func (a *App) serveAsset(w http.ResponseWriter, r *http.Request, name string) { + b, err := fs.ReadFile(a.static, name) + if err != nil { + http.NotFound(w, r) + return + } + if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { + w.Header().Set("Content-Type", ct) + } + if strings.HasSuffix(name, ".html") { + w.Header().Set("Cache-Control", "no-store") + } + _, _ = w.Write(b) +} + +func (a *App) bootstrap(w http.ResponseWriter, r *http.Request) { + needs, err := a.store.needsSetup(r.Context()) + if err != nil { + jsonError(w, 500, "db", "Datenbankfehler.") + return + } + writeJSON(w, 200, map[string]bool{"needs_setup": needs}) +} + +func (a *App) setup(w http.ResponseWriter, r *http.Request) { + needs, err := a.store.needsSetup(r.Context()) + if err != nil { + jsonError(w, 500, "db", "Datenbankfehler.") + return + } + if !needs { + jsonError(w, 409, "already_setup", "Die Instanz ist bereits eingerichtet.") + return + } + var in struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + } + if !decodeJSON(w, r, &in) { + return + } + if !validUsername(in.Username) { + jsonError(w, 400, "username", "Benutzername: 3–64 Zeichen, Buchstaben/Zahlen/._-.") + return + } + hash, err := hashPassword(in.Password) + if err != nil { + jsonError(w, 400, "password", err.Error()) + return + } + u, err := a.store.bootstrapAdmin(r.Context(), in.Username, in.DisplayName, hash) + if errors.Is(err, errAlreadySetup) { + jsonError(w, 409, "already_setup", "Die Instanz ist bereits eingerichtet.") + return + } + if err != nil { + jsonError(w, 409, "create_user", "Benutzer konnte nicht angelegt werden.") + return + } + token, _, exp, err := a.createSession(r.Context(), u.ID) + if err != nil { + jsonError(w, 500, "session", "Session konnte nicht erstellt werden.") + return + } + a.setSessionCookie(w, token, exp) + writeJSON(w, 201, map[string]any{"user": u}) +} + +func (a *App) login(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if !a.limiter.allow(ip) { + jsonError(w, 429, "rate_limited", "Zu viele Anmeldeversuche. Bitte später erneut versuchen.") + return + } + var in struct { + Username string `json:"username"` + Password string `json:"password"` + } + if !decodeJSON(w, r, &in) { + return + } + u, hash, err := a.store.userForLogin(r.Context(), in.Username) + hashToCheck := hash + if err != nil { + hashToCheck = dummyPasswordHash + } + passwordOK := verifyPassword(hashToCheck, in.Password) + if err != nil || !u.Active || !passwordOK { + a.limiter.fail(ip) + time.Sleep(150 * time.Millisecond) + jsonError(w, 401, "bad_credentials", "Benutzername oder Passwort ist falsch.") + return + } + a.limiter.success(ip) + _, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE expires_at_ms<=?`, time.Now().UnixMilli()) + token, _, exp, err := a.createSession(r.Context(), u.ID) + if err != nil { + jsonError(w, 500, "session", "Session konnte nicht erstellt werden.") + return + } + a.setSessionCookie(w, token, exp) + writeJSON(w, 200, map[string]any{"user": u}) +} + +func (a *App) me(w http.ResponseWriter, r *http.Request) { + s := sessionOf(r) + writeJSON(w, 200, map[string]any{"user": s.User, "csrf_token": s.CSRF}) +} +func (a *App) logout(w http.ResponseWriter, r *http.Request) { + if !requireCSRF(w, r) { + return + } + a.deleteCurrentSession(w, r) + w.WriteHeader(204) +} + +func (a *App) changeOwnPassword(w http.ResponseWriter, r *http.Request) { + if !requireCSRF(w, r) { + return + } + var in struct { + Current string `json:"current_password"` + New string `json:"new_password"` + } + if !decodeJSON(w, r, &in) { + return + } + s := sessionOf(r) + _, currentHash, err := a.store.userForLogin(r.Context(), s.User.Username) + if err != nil || !verifyPassword(currentHash, in.Current) { + jsonError(w, http.StatusUnauthorized, "bad_password", "Das aktuelle Passwort ist falsch.") + return + } + hash, err := hashPassword(in.New) + if err != nil { + jsonError(w, 400, "password", err.Error()) + return + } + if err := a.store.resetPassword(r.Context(), s.User.ID, hash); err != nil { + jsonError(w, 500, "db", "Passwort konnte nicht geändert werden.") + return + } + a.clearSessionCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +func (a *App) getSettings(w http.ResponseWriter, r *http.Request) { + x, err := a.store.settings(r.Context(), sessionOf(r).User.ID) + if err != nil { + jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.") + return + } + writeJSON(w, 200, x) +} +func (a *App) putSettings(w http.ResponseWriter, r *http.Request) { + if !requireCSRF(w, r) { + return + } + var x Settings + if !decodeJSON(w, r, &x) { + return + } + if x.Language != "de" && x.Language != "en" { + x.Language = "de" + } + if x.TimeFormat != "12" { + x.TimeFormat = "24" + } + if x.RoundingMinutes < 1 || x.RoundingMinutes > 60 { + jsonError(w, 400, "rounding", "Rundung muss zwischen 1 und 60 Minuten liegen.") + return + } + if len(x.ExportName) > 120 { + jsonError(w, 400, "export_name", "Name ist zu lang.") + return + } + if len(x.Timezone) > 80 { + jsonError(w, 400, "timezone", "Zeitzone ist ungültig.") + return + } + if _, err := time.LoadLocation(x.Timezone); err != nil { + x.Timezone = "UTC" + } + if err := a.store.updateSettings(r.Context(), sessionOf(r).User.ID, x); err != nil { + jsonError(w, 500, "db", "Einstellungen konnten nicht gespeichert werden.") + return + } + writeJSON(w, 200, x) +} + +func (a *App) getRunning(w http.ResponseWriter, r *http.Request) { + e, err := a.store.runningEntry(r.Context(), sessionOf(r).User.ID) + if err != nil { + jsonError(w, 500, "db", "Timer konnte nicht geladen werden.") + return + } + writeJSON(w, 200, map[string]any{"entry": e}) +} +func (a *App) getClients(w http.ResponseWriter, r *http.Request) { + x, err := a.store.recentClients(r.Context(), sessionOf(r).User.ID) + if err != nil { + jsonError(w, 500, "db", "Kunden konnten nicht geladen werden.") + return + } + writeJSON(w, 200, map[string]any{"clients": x}) +} + +func (a *App) getEntries(w http.ResponseWriter, r *http.Request) { + f, ok := parseFilter(w, r) + if !ok { + return + } + cfg, err := a.store.settings(r.Context(), sessionOf(r).User.ID) + if err != nil { + jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.") + return + } + p, err := a.store.listEntries(r.Context(), sessionOf(r).User.ID, f, cfg) + if err != nil { + jsonError(w, 500, "db", "Einträge konnten nicht geladen werden.") + return + } + writeJSON(w, 200, p) +} +func (a *App) startEntry(w http.ResponseWriter, r *http.Request) { + if !requireCSRF(w, r) { + return + } + var in struct { + Client string `json:"client"` + Activity string `json:"activity"` + StartMS int64 `json:"start_ms"` + } + if !decodeJSON(w, r, &in) { + return + } + if len(in.Client) > 200 || len(in.Activity) > 4000 { + jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.") + return + } + if in.StartMS < 0 { + jsonError(w, 400, "start_ms", "Ungültiger Startzeitpunkt.") + return + } + e, err := a.store.startEntry(r.Context(), sessionOf(r).User.ID, in.Client, in.Activity, in.StartMS) + if err != nil { + if isUniqueConstraint(err) { + jsonError(w, 409, "timer_running", "Es läuft bereits ein Timer.") + return + } + jsonError(w, 500, "db", "Timer konnte nicht gestartet werden.") + return + } + writeJSON(w, 201, e) +} +func (a *App) createEntry(w http.ResponseWriter, r *http.Request) { + if !requireCSRF(w, r) { + return + } + var in struct { + Client string `json:"client"` + Activity string `json:"activity"` + StartMS int64 `json:"start_ms"` + EndMS int64 `json:"end_ms"` + } + if !decodeJSON(w, r, &in) { + return + } + if len(in.Client) > 200 || len(in.Activity) > 4000 { + jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.") + return + } + e, err := a.store.createFinishedEntry(r.Context(), sessionOf(r).User.ID, in.Client, in.Activity, in.StartMS, in.EndMS) + if err != nil { + jsonError(w, 400, "invalid_entry", "Start und Ende prüfen.") + return + } + writeJSON(w, 201, e) +} +func (a *App) stopEntry(w http.ResponseWriter, r *http.Request) { + if !requireCSRF(w, r) { + return + } + var in struct { + EndMS int64 `json:"end_ms"` + } + if !decodeJSONAllowEmpty(w, r, &in) { + return + } + e, err := a.store.stopEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id"), in.EndMS) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + jsonError(w, 404, "not_found", "Laufender Eintrag nicht gefunden.") + return + } + jsonError(w, 500, "db", "Timer konnte nicht gestoppt werden.") + return + } + writeJSON(w, 200, e) +} +func (a *App) updateEntry(w http.ResponseWriter, r *http.Request) { + if !requireCSRF(w, r) { + return + } + var in struct { + Client string `json:"client"` + Activity string `json:"activity"` + StartMS int64 `json:"start_ms"` + EndMS *int64 `json:"end_ms"` + } + if !decodeJSON(w, r, &in) { + return + } + if len(in.Client) > 200 || len(in.Activity) > 4000 { + jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.") + return + } + e, err := a.store.updateEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id"), in.Client, in.Activity, in.StartMS, in.EndMS) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + jsonError(w, 404, "not_found", "Eintrag nicht gefunden.") + return + } + if isUniqueConstraint(err) { + jsonError(w, 409, "timer_running", "Es kann nur einen laufenden Timer geben.") + return + } + jsonError(w, 400, "invalid_entry", "Start und Ende prüfen.") + return + } + writeJSON(w, 200, e) +} +func (a *App) deleteEntry(w http.ResponseWriter, r *http.Request) { + if !requireCSRF(w, r) { + return + } + if err := a.store.deleteEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id")); err != nil { + if errors.Is(err, sql.ErrNoRows) { + jsonError(w, 404, "not_found", "Eintrag nicht gefunden.") + return + } + jsonError(w, 500, "db", "Eintrag konnte nicht gelöscht werden.") + return + } + w.WriteHeader(204) +} + +func (a *App) exportCSV(w http.ResponseWriter, r *http.Request) { + f, ok := parseFilter(w, r) + if !ok { + return + } + entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, f) + if err != nil { + jsonError(w, 500, "db", "Export konnte nicht erstellt werden.") + return + } + cfg, _ := a.store.settings(r.Context(), sessionOf(r).User.ID) + b, err := makeCSV(entries, cfg, f.Compact) + if err != nil { + jsonError(w, 500, "export", "CSV konnte nicht erstellt werden.") + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="pocketwatch.csv"`) + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(b) +} +func (a *App) exportPDF(w http.ResponseWriter, r *http.Request) { + f, ok := parseFilter(w, r) + if !ok { + return + } + entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, f) + if err != nil { + jsonError(w, 500, "db", "Export konnte nicht erstellt werden.") + return + } + cfg, _ := a.store.settings(r.Context(), sessionOf(r).User.ID) + b := makePDF(entries, cfg, sessionOf(r).User, f.Compact) + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="pocketwatch.pdf"`) + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(b) +} + +func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } + users, err := a.store.listUsers(r.Context()) + if err != nil { + jsonError(w, 500, "db", "Benutzer konnten nicht geladen werden.") + return + } + writeJSON(w, 200, map[string]any{"users": users}) +} +func (a *App) adminCreateUser(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) || !requireCSRF(w, r) { + return + } + var in struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + Role string `json:"role"` + } + if !decodeJSON(w, r, &in) { + return + } + if !validUsername(in.Username) { + jsonError(w, 400, "username", "Ungültiger Benutzername.") + return + } + hash, err := hashPassword(in.Password) + if err != nil { + jsonError(w, 400, "password", err.Error()) + return + } + u, err := a.store.createUser(r.Context(), in.Username, in.DisplayName, hash, in.Role) + if err != nil { + jsonError(w, 409, "username_exists", "Benutzername ist bereits vergeben.") + return + } + writeJSON(w, 201, u) +} +func (a *App) adminPatchUser(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) || !requireCSRF(w, r) { + return + } + id := r.PathValue("id") + if id == sessionOf(r).User.ID { + jsonError(w, 400, "self", "Den eigenen Account hier nicht deaktivieren.") + return + } + var in struct { + Active *bool `json:"active"` + } + if !decodeJSON(w, r, &in) { + return + } + if in.Active == nil { + jsonError(w, 400, "active", "active fehlt.") + return + } + if err := a.store.setUserActive(r.Context(), id, *in.Active); err != nil { + jsonError(w, 404, "not_found", "Benutzer nicht gefunden.") + return + } + w.WriteHeader(204) +} +func (a *App) adminResetPassword(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) || !requireCSRF(w, r) { + return + } + var in struct { + Password string `json:"password"` + } + if !decodeJSON(w, r, &in) { + return + } + hash, err := hashPassword(in.Password) + if err != nil { + jsonError(w, 400, "password", err.Error()) + return + } + if err := a.store.resetPassword(r.Context(), r.PathValue("id"), hash); err != nil { + jsonError(w, 404, "not_found", "Benutzer nicht gefunden.") + return + } + w.WriteHeader(204) +} + +func parseFilter(w http.ResponseWriter, r *http.Request) (entryFilter, bool) { + q := r.URL.Query() + from, ok := parseInt64Param(w, q, "from") + if !ok { + return entryFilter{}, false + } + to, ok := parseInt64Param(w, q, "to") + if !ok { + return entryFilter{}, false + } + limit := 200 + if x := q.Get("limit"); x != "" { + n, err := strconv.Atoi(x) + if err != nil || n < 1 { + jsonError(w, 400, "limit", "Ungültiges Limit.") + return entryFilter{}, false + } + if n > 500 { + n = 500 + } + limit = n + } + offset := 0 + if x := q.Get("offset"); x != "" { + n, err := strconv.Atoi(x) + if err != nil || n < 0 { + jsonError(w, 400, "offset", "Ungültiger Offset.") + return entryFilter{}, false + } + offset = n + } + return entryFilter{Query: q.Get("q"), FromMS: from, ToMS: to, Limit: limit, Offset: offset, SortAsc: q.Get("sort") == "asc", Compact: q.Get("compact") == "1"}, true +} +func parseInt64Param(w http.ResponseWriter, q url.Values, key string) (int64, bool) { + x := q.Get(key) + if x == "" { + return 0, true + } + n, err := strconv.ParseInt(x, 10, 64) + if err != nil || n < 0 { + jsonError(w, 400, key, "Ungültiger Zeitraum.") + return 0, false + } + return n, true +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") { + jsonError(w, 415, "content_type", "Content-Type application/json erforderlich.") + return false + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + jsonError(w, 400, "json", "Ungültige JSON-Daten.") + return false + } + if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + jsonError(w, 400, "json", "Nach dem JSON-Objekt sind weitere Daten enthalten.") + return false + } + return true +} +func decodeJSONAllowEmpty(w http.ResponseWriter, r *http.Request, dst any) bool { + if r.ContentLength == 0 { + return true + } + return decodeJSON(w, r, dst) +} +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} +func jsonError(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": msg}}) +} + +func validUsername(s string) bool { + s = strings.TrimSpace(s) + if len(s) < 3 || len(s) > 64 { + return false + } + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { + continue + } + return false + } + return true +} +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func (a *App) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + next.ServeHTTP(w, r) + }) +} +func (a *App) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if v := recover(); v != nil { + a.log.Error("panic", "value", fmt.Sprint(v), "path", r.URL.Path) + jsonError(w, 500, "internal", "Interner Serverfehler.") + } + }() + next.ServeHTTP(w, r) + }) +} +func (a *App) accessLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + if r.URL.Path != "/healthz" { + a.log.Info("http", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(start).Milliseconds()) + } + }) +} + +type loginAttempt struct { + fails int + first, blockedUntil time.Time +} +type loginLimiter struct { + mu sync.Mutex + m map[string]loginAttempt +} + +func newLoginLimiter() *loginLimiter { return &loginLimiter{m: map[string]loginAttempt{}} } +func (l *loginLimiter) allow(k string) bool { + l.mu.Lock() + defer l.mu.Unlock() + x := l.m[k] + return x.blockedUntil.IsZero() || time.Now().After(x.blockedUntil) +} +func (l *loginLimiter) fail(k string) { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + x := l.m[k] + if x.first.IsZero() || now.Sub(x.first) > 10*time.Minute { + x = loginAttempt{first: now} + } + x.fails++ + if x.fails >= 5 { + x.blockedUntil = now.Add(10 * time.Minute) + } + l.m[k] = x +} +func (l *loginLimiter) success(k string) { l.mu.Lock(); defer l.mu.Unlock(); delete(l.m, k) } diff --git a/internal/app/web/app.css b/internal/app/web/app.css new file mode 100644 index 0000000..50f0682 --- /dev/null +++ b/internal/app/web/app.css @@ -0,0 +1,200 @@ +:root { + color-scheme: dark; + --bg: #0e0f11; + --panel: #111316; + --card: #16181b; + --raised: #1c1f23; + --active: #2b3037; + --border: #282c31; + --divider: #202328; + --text: #eceef1; + --muted: #969ca4; + --dim: #6c727a; + --amber: #f5c065; + --amber-hover: #ffd98a; + --on-amber: #16191c; + --danger: #e85b61; + --shadow: 0 24px 70px rgba(0,0,0,.42); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); } +body { min-height: 100dvh; } +button, input, textarea, select { font: inherit; } +button { color: inherit; } +input, textarea, select { + width: 100%; border: 1px solid var(--border); background: var(--card); color: var(--text); + border-radius: 12px; padding: 11px 12px; outline: none; transition: border-color .15s, background .15s; +} +input:focus, textarea:focus, select:focus { border-color: var(--amber); background: #191c20; } +textarea { resize: vertical; min-height: 90px; } +label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; font-weight: 600; } +button { border: 0; cursor: pointer; } +[hidden] { display: none !important; } + +.app-shell { height: 100dvh; display: flex; flex-direction: column; overflow: hidden; } +.topbar { height: 68px; flex: 0 0 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--divider); } +.brand { display: flex; align-items: center; gap: 10px; font-weight: 720; letter-spacing: -.025em; font-size: 18px; } +.brand-large { font-size: 22px; margin-bottom: 32px; } +.brand-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 4px rgba(245,192,101,.08); } +.topbar-actions { display: flex; align-items: center; gap: 9px; } +.metric-inline { display: grid; text-align: right; margin-right: 12px; } +.metric-inline strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 18px; } +.metric-inline span { font-size: 10px; color: var(--muted); } +.icon-btn, .avatar-btn { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: transparent; border: 1px solid transparent; } +.icon-btn:hover, .avatar-btn:hover { background: var(--raised); border-color: var(--border); } +.avatar-btn { background: var(--amber); color: var(--on-amber); font-weight: 800; border-radius: 50%; width: 34px; height: 34px; margin-left: 4px; } + +.main-grid { min-height: 0; flex: 1; display: grid; grid-template-columns: minmax(320px, 400px) 1fr; } +.tracker-pane { min-height: 0; overflow-y: auto; background: var(--panel); padding: 26px; display: flex; flex-direction: column; gap: 18px; } +.tracker-card { padding: 22px; border: 1px solid var(--border); border-radius: 22px; background: var(--card); display: grid; gap: 16px; box-shadow: inset 0 1px rgba(255,255,255,.02); } +.tracker-date { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--muted); } +.status-pill { color: var(--amber); background: rgba(245,192,101,.08); border: 1px solid rgba(245,192,101,.25); border-radius: 999px; padding: 4px 8px; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; } +.timer-display { padding: 7px 0 0; text-align: center; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(34px, 5vw, 48px); font-variant-numeric: tabular-nums; font-weight: 700; letter-spacing: -.05em; } +.timer-display.running { color: var(--amber); } +.btn { min-height: 40px; padding: 9px 14px; border-radius: 12px; font-weight: 700; border: 1px solid transparent; } +.btn.primary { background: var(--amber); color: var(--on-amber); } +.btn.primary:hover { background: var(--amber-hover); } +.btn.subtle { background: var(--raised); border-color: var(--border); color: var(--text); } +.btn.subtle:hover { background: var(--active); } +.btn.danger { background: rgba(232,91,97,.11); color: #ff9296; border-color: rgba(232,91,97,.28); } +.btn.wide { width: 100%; } +.timer-btn { min-height: 52px; font-size: 16px; } +.link-btn { background: transparent; color: var(--muted); padding: 3px; font-size: 13px; } +.link-btn:hover { color: var(--text); } +.centered { justify-self: center; } +.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.metric-card { background: var(--card); border: 1px solid var(--border); border-radius: 17px; padding: 15px 16px; display: grid; gap: 3px; } +.metric-card span { font-size: 11px; color: var(--muted); } +.metric-card strong { color: var(--amber); font: 700 21px ui-monospace, SFMono-Regular, Menlo, monospace; } +.warning-box, .error-box { border-radius: 12px; padding: 11px 12px; font-size: 13px; } +.warning-box { background: rgba(245,192,101,.08); color: #f6cf8c; border: 1px solid rgba(245,192,101,.22); } +.error-box { background: rgba(232,91,97,.09); color: #ff9aa0; border: 1px solid rgba(232,91,97,.22); margin: 0; } +.sidebar-footer { margin-top: auto; padding-top: 10px; display: flex; justify-content: space-between; color: var(--dim); font-size: 11px; } +.mobile-pane-head { display: none; } + +.history-pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-left: 1px solid var(--divider); } +.history-head { padding: 25px 28px 15px; display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; } +.history-head h1, .mobile-pane-head h1 { margin: 0; font-size: 31px; line-height: 1; letter-spacing: -.035em; } +.eyebrow { margin: 0 0 7px; color: var(--dim); letter-spacing: .13em; font-size: 10px; font-weight: 800; } +.history-actions { display: flex; gap: 8px; } +.filter-bar { padding: 0 28px 15px; display: grid; grid-template-columns: minmax(180px, 1fr) auto auto; gap: 10px; align-items: center; border-bottom: 1px solid var(--divider); } +.filter-bar input { min-width: 0; } +.period-tabs { display: flex; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 3px; } +.period-tabs button, .period-step button { background: transparent; color: var(--muted); padding: 7px 9px; border-radius: 8px; font-size: 12px; font-weight: 700; } +.period-tabs button.active { background: var(--active); color: var(--text); } +.period-step { display: flex; align-items: center; justify-content: center; gap: 2px; color: var(--muted); white-space: nowrap; } +.period-step span { min-width: 112px; text-align: center; font-size: 12px; } +.period-step button:hover { color: var(--text); background: var(--raised); } +.history-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 28px 20px; } +.entries { display: grid; } +.day-group { min-width: 0; } +.day-heading { position: sticky; top: 0; z-index: 2; padding: 15px 0 8px; display: flex; justify-content: space-between; align-items: baseline; background: linear-gradient(var(--bg) 80%, transparent); } +.day-heading strong { font-size: 13px; } +.day-heading span { color: var(--dim); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } +.entry-row { width: 100%; display: grid; grid-template-columns: minmax(120px, .8fr) minmax(180px, 1.3fr) 120px 72px 28px; gap: 14px; align-items: center; padding: 12px 14px; margin-bottom: 6px; background: var(--card); border: 1px solid transparent; border-radius: 13px; text-align: left; } +.entry-row:hover { border-color: var(--border); background: var(--raised); } +.entry-client { min-width: 0; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.entry-activity { min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.entry-time { color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } +.entry-duration { text-align: right; color: var(--amber); font: 700 13px ui-monospace, SFMono-Regular, Menlo, monospace; } +.entry-chevron { color: var(--dim); font-size: 19px; } +.history-footer { flex: 0 0 50px; border-top: 1px solid var(--divider); display: flex; align-items: center; justify-content: space-between; padding: 0 28px; color: var(--muted); font-size: 12px; } +.history-footer strong { color: var(--text); } +.history-footer span:last-child strong { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 15px; margin-left: 8px; } +.empty-state { min-height: 55vh; display: grid; place-items: center; align-content: center; text-align: center; color: var(--muted); } +.empty-state h2 { color: var(--text); margin: 10px 0 4px; font-size: 18px; } +.empty-state p { margin: 0; font-size: 13px; } +.empty-icon { font-size: 40px; color: var(--dim); } +.load-more { display: block; margin: 18px auto 0; } + +.modal { width: min(620px, calc(100vw - 28px)); max-height: calc(100dvh - 28px); padding: 0; border: 1px solid var(--border); border-radius: 20px; background: var(--card); color: var(--text); box-shadow: var(--shadow); } +.modal::backdrop { background: rgba(0,0,0,.68); backdrop-filter: blur(5px); } +.modal-wide { width: min(860px, calc(100vw - 28px)); } +.modal-card { padding: 22px; display: grid; gap: 16px; max-height: calc(100dvh - 30px); overflow-y: auto; } +.modal-card.compact { max-width: 520px; } +.modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; } +.modal h2, .modal h3 { margin: 0; letter-spacing: -.025em; } +.modal h2 { font-size: 24px; } +.modal h3 { font-size: 15px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 5px; } +.modal-actions.split { justify-content: initial; } +.spacer { flex: 1; } +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; } +.settings-grid section { display: grid; gap: 13px; align-content: start; } +.settings-grid h3 { padding-bottom: 4px; } +.check-row { display: flex; align-items: center; gap: 10px; color: var(--text); font-weight: 500; } +.check-row input { width: 17px; height: 17px; accent-color: var(--amber); } +.admin-section { border-top: 1px solid var(--divider); padding-top: 18px; display: grid; gap: 10px; } +.section-title { display: flex; justify-content: space-between; align-items: center; } +.user-list { display: grid; gap: 7px; } +.user-row { display: grid; grid-template-columns: minmax(140px,1fr) auto auto auto; gap: 9px; align-items: center; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; } +.user-row .user-meta { min-width: 0; display: grid; } +.user-row .user-meta strong, .user-row .user-meta span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.user-row .user-meta span { color: var(--muted); font-size: 11px; } +.role-badge { color: var(--muted); font-size: 11px; background: var(--raised); padding: 4px 7px; border-radius: 999px; } +.export-scope { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 11px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; } +.export-compact { align-self: end; min-height: 42px; padding-bottom: 9px; } +.export-preview { margin: -2px 0 0; padding: 10px 12px; border-radius: 11px; background: var(--bg); border: 1px solid var(--border); color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } +.export-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.muted { color: var(--muted); } +.small { font-size: 12px; } +.toast { position: fixed; z-index: 30; bottom: 24px; left: 50%; transform: translateX(-50%); background: #25292e; border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; box-shadow: var(--shadow); font-size: 13px; } +.mobile-only { display: none; } + +.auth-body { display: grid; min-height: 100dvh; place-items: center; background: radial-gradient(circle at 50% 0, #1c1a16 0, var(--bg) 38%); padding: 20px; } +.auth-shell { width: min(440px, 100%); } +.auth-card { background: rgba(22,24,27,.96); border: 1px solid var(--border); border-radius: 24px; padding: 30px; box-shadow: var(--shadow); } +.auth-card h1 { margin: 0 0 7px; font-size: 30px; letter-spacing: -.035em; } +.auth-card .muted { margin: 0 0 22px; line-height: 1.5; } +.form-stack { display: grid; gap: 15px; } +.form-stack .btn { margin-top: 5px; } +.auth-card .error-box { margin-top: 15px; } + +@media (max-width: 1050px) { + .filter-bar { grid-template-columns: 1fr auto; } + .period-step { grid-column: 1 / -1; justify-self: end; } + .entry-row { grid-template-columns: minmax(110px,.8fr) minmax(140px,1.2fr) 110px 68px 20px; gap: 9px; } +} +@media (max-width: 820px) { + .app-shell { overflow: hidden; } + .topbar { height: 58px; flex-basis: 58px; padding: 0 15px; } + .metric-inline, #export-open { display: none; } + .main-grid { display: block; overflow: hidden; } + .tracker-pane, .history-pane { height: calc(100dvh - 58px); border-left: 0; } + .tracker-pane { padding: 15px; } + .history-pane { display: none; } + body.mobile-history .tracker-pane { display: none; } + body.mobile-history .history-pane { display: flex; } + .mobile-pane-head { display: flex; align-items: center; justify-content: space-between; } + .mobile-only { display: inline-block; } + .history-head { padding: 16px 15px 12px; align-items: center; } + .history-title .eyebrow { display: none; } + .history-title h1 { font-size: 25px; margin-top: 4px; } + .history-actions #history-export { display: none; } + .filter-bar { padding: 0 15px 12px; display: flex; flex-wrap: wrap; } + .filter-bar > input { flex: 1 1 100%; } + .period-tabs { flex: 1 1 auto; overflow-x: auto; } + .period-step { flex: 1 1 100%; justify-content: space-between; } + .history-scroll { padding: 0 15px 16px; } + .entry-row { grid-template-columns: 1fr auto 18px; grid-template-areas: "client dur chevron" "activity time chevron"; gap: 4px 10px; padding: 12px; } + .entry-client { grid-area: client; } + .entry-activity { grid-area: activity; } + .entry-time { grid-area: time; } + .entry-duration { grid-area: dur; } + .entry-chevron { grid-area: chevron; align-self: center; } + .history-footer { padding: 0 15px; } + .settings-grid { grid-template-columns: 1fr; gap: 20px; } + .user-row { grid-template-columns: 1fr auto; } + .user-row .role-badge { justify-self: end; } + .two-col, .export-scope, .export-buttons { grid-template-columns: 1fr; } + .modal { width: calc(100vw - 12px); max-height: calc(100dvh - 12px); border-radius: 17px; } + .modal-card { max-height: calc(100dvh - 14px); padding: 18px; } +} +@media (max-width: 430px) { + .tracker-card { padding: 17px; } + .metric-grid { grid-template-columns: 1fr 1fr; } + .history-actions .btn { padding-inline: 10px; } + .auth-card { padding: 23px; } +} diff --git a/internal/app/web/app.js b/internal/app/web/app.js new file mode 100644 index 0000000..632607d --- /dev/null +++ b/internal/app/web/app.js @@ -0,0 +1,299 @@ +const state = { + me: null, + csrf: '', + settings: null, + running: null, + entries: [], + clients: [], + totalCount: 0, + totalDuration: 0, + filter: { q: '', period: 'all', anchor: new Date(), offset: 0, limit: 120 }, + timerTick: null, +}; + +const $ = (s) => document.querySelector(s); +const $$ = (s) => [...document.querySelectorAll(s)]; + +async function api(url, options = {}) { + const headers = { ...(options.headers || {}) }; + if (options.body !== undefined && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'; + if (state.csrf && options.method && !['GET','HEAD'].includes(options.method.toUpperCase())) headers['X-CSRF-Token'] = state.csrf; + const res = await fetch(url, { ...options, headers }); + if (res.status === 401) { location.assign('/login'); throw new Error('Nicht angemeldet.'); } + const ct = res.headers.get('content-type') || ''; + const body = res.status === 204 ? null : (ct.includes('application/json') ? await res.json().catch(() => ({})) : await res.text()); + if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`); + return body; +} + +function toast(msg) { + const el = $('#toast'); el.textContent = msg; el.hidden = false; + clearTimeout(toast._t); toast._t = setTimeout(() => { el.hidden = true; }, 2600); +} +function showError(sel, err) { const el = $(sel); el.textContent = err?.message || String(err); el.hidden = false; } +function hideError(sel) { $(sel).hidden = true; } +function pad(n) { return String(n).padStart(2, '0'); } +function duration(ms, withSeconds = false) { + ms = Math.max(0, ms || 0); + const sec = Math.floor(ms / 1000), h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60; + return withSeconds ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${h}:${pad(m)}`; +} +function rounded(ms) { + const mins = Math.max(1, Number(state.settings?.rounding_minutes || 1)); + if (mins <= 1) return Math.max(0, ms); + const step = mins * 60000; + return state.settings?.round_up ? Math.ceil(ms / step) * step : Math.round(ms / step) * step; +} +function clock(ms) { + const d = new Date(ms); + return new Intl.DateTimeFormat(state.settings?.language === 'en' ? 'en' : 'de-DE', { hour: '2-digit', minute: '2-digit', hour12: state.settings?.time_format === '12' }).format(d); +} +function localDateTimeValue(ms) { + const d = new Date(ms); const off = d.getTimezoneOffset(); + return new Date(d.getTime() - off * 60000).toISOString().slice(0,16); +} +function msFromLocalValue(v) { return v ? new Date(v).getTime() : null; } +function dayKey(ms) { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; } +function dayTitle(ms) { + const d = new Date(ms), now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const target = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + if (target === today) return 'Heute'; + const yd = new Date(now.getFullYear(), now.getMonth(), now.getDate()); yd.setDate(yd.getDate()-1); + if (target === yd.getTime()) return 'Gestern'; + return new Intl.DateTimeFormat('de-DE', { weekday:'long', day:'2-digit', month:'long', year:'numeric' }).format(d); +} +function initials(s) { return (s || '?').trim().split(/\s+/).slice(0,2).map(x=>x[0]?.toUpperCase()||'').join('') || '?'; } +function escapeText(s) { const x=document.createElement('span'); x.textContent=s??''; return x.innerHTML; } + +function rangeForFilter() { + const { period, anchor } = state.filter; + if (period === 'all') return { from:0, to:0, label:'Alle Zeiten' }; + let from, to; + const d = new Date(anchor); d.setHours(0,0,0,0); + if (period === 'day') { from = d; to = new Date(d); to.setDate(to.getDate()+1); } + if (period === 'week') { const wd = (d.getDay()+6)%7; d.setDate(d.getDate()-wd); from = new Date(d); to = new Date(d); to.setDate(to.getDate()+7); } + if (period === 'month') { from = new Date(d.getFullYear(),d.getMonth(),1); to = new Date(d.getFullYear(),d.getMonth()+1,1); } + if (period === 'year') { from = new Date(d.getFullYear(),0,1); to = new Date(d.getFullYear()+1,0,1); } + let label=''; + if (period === 'day') label = new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(from); + if (period === 'week') label = `${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit'}).format(from)} – ${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(new Date(to.getTime()-1))}`; + if (period === 'month') label = new Intl.DateTimeFormat('de-DE',{month:'long',year:'numeric'}).format(from); + if (period === 'year') label = String(from.getFullYear()); + return { from: from.getTime(), to: to.getTime(), label }; +} +function stepPeriod(delta) { + const d = new Date(state.filter.anchor), p = state.filter.period; + if (p === 'day') d.setDate(d.getDate()+delta); + if (p === 'week') d.setDate(d.getDate()+7*delta); + if (p === 'month') d.setMonth(d.getMonth()+delta); + if (p === 'year') d.setFullYear(d.getFullYear()+delta); + state.filter.anchor = d; state.filter.offset = 0; refreshEntries(); +} +function queryString(includePaging = true) { + const r = rangeForFilter(); const p = new URLSearchParams(); + if (state.filter.q) p.set('q', state.filter.q); + if (r.from) p.set('from', r.from); if (r.to) p.set('to', r.to); + if (includePaging) { p.set('limit', state.filter.limit); p.set('offset', state.filter.offset); } + return p.toString(); +} + +async function init() { + try { + const me = await api('/api/me'); state.me = me.user; state.csrf = me.csrf_token; + const [settings, running, clients] = await Promise.all([api('/api/settings'), api('/api/running'), api('/api/clients')]); + state.settings = settings; state.running = running.entry; state.clients = clients.clients || []; + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if ((!state.settings.timezone || state.settings.timezone === 'UTC') && tz && tz !== 'UTC') { + state.settings.timezone = tz; + try { state.settings = await api('/api/settings', { method:'PUT', body:JSON.stringify(state.settings) }); } catch (_) {} + } + renderIdentity(); renderClients(); renderRunning(); fillSettings(); + await Promise.all([refreshEntries(), refreshTotals()]); + startTicker(); + } catch (e) { toast(e.message); } +} + +function renderIdentity() { + $('#user-label').textContent = state.me.display_name || state.me.username; + $('#avatar-text').textContent = initials(state.me.display_name || state.me.username); +} +function renderClients() { $('#client-list').innerHTML = state.clients.map(x => ``).join(''); } +function renderRunning() { + const e = state.running; + $('#running-hint').hidden = !e; $('#edit-running').hidden = !e; + $('#timer-button').textContent = e ? '■ Stop' : '▶ Start'; + $('#timer-display').classList.toggle('running', !!e); + $('#client-input').disabled = !!e; + if (e) { $('#client-input').value=e.client||''; $('#activity-input').value=e.activity||''; } + updateTicker(); +} +function startTicker() { clearInterval(state.timerTick); state.timerTick = setInterval(updateTicker, 1000); updateTicker(); } +function updateTicker() { + const e=state.running, elapsed=e ? Date.now()-e.start_ms : 0; + $('#timer-display').textContent=duration(elapsed,true); + const warn=!!(e && state.settings?.long_run_reminder && elapsed>8*3600000); $('#long-running-warning').hidden=!warn; +} + +async function timerToggle() { + try { + if (!state.running) { + const x = await api('/api/entries/start', { method:'POST', body:JSON.stringify({ client:$('#client-input').value, activity:$('#activity-input').value, start_ms:Date.now() }) }); + state.running=x; renderRunning(); toast('Timer gestartet.'); + } else { + await saveRunningActivity(); + await api(`/api/entries/${state.running.id}/stop`, { method:'POST', body:JSON.stringify({ end_ms:Date.now() }) }); + state.running=null; $('#client-input').disabled=false; $('#client-input').value=''; $('#activity-input').value=''; renderRunning(); toast('Timer gestoppt.'); + await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]); + } + } catch(e) { toast(e.message); } +} +async function saveRunningActivity() { + if (!state.running) return; + const activity=$('#activity-input').value; + if (activity===state.running.activity) return; + const x=await api(`/api/entries/${state.running.id}`,{method:'PUT',body:JSON.stringify({client:state.running.client,activity,start_ms:state.running.start_ms,end_ms:null})}); + state.running=x; +} +async function refreshClients(){const x=await api('/api/clients');state.clients=x.clients||[];renderClients();} + +async function refreshTotals() { + const now=new Date(); const today=new Date(now.getFullYear(),now.getMonth(),now.getDate()); + const week=new Date(today); week.setDate(week.getDate()-((week.getDay()+6)%7)); + try { + const [t,w]=await Promise.all([ + api(`/api/entries?from=${today.getTime()}&to=${new Date(today.getFullYear(),today.getMonth(),today.getDate()+1).getTime()}&limit=1&offset=0`), + api(`/api/entries?from=${week.getTime()}&limit=1&offset=0`) + ]); + $('#today-total').textContent=duration(t.total_duration_ms); + $('#week-total').textContent=duration(w.total_duration_ms); + $('#header-week').textContent=duration(w.total_duration_ms); + $('#week-badge').hidden=!state.settings.show_week_total; + } catch(e){ console.warn(e); } +} + +async function refreshEntries(reset=true) { + if(reset){state.filter.offset=0;state.entries=[];} + const range=rangeForFilter(); $('#period-label').textContent=range.label; + try { + const p=await api('/api/entries?'+queryString(true)); + state.totalCount=p.total_count;state.totalDuration=p.total_duration_ms; + state.entries=reset?p.entries:[...state.entries,...p.entries]; + renderEntries(); + } catch(e){toast(e.message);} +} +function renderEntries() { + $('#result-count').textContent=state.totalCount; $('#result-total').textContent=duration(state.totalDuration); + $('#empty-state').hidden=state.totalCount!==0; + $('#load-more').hidden=state.entries.length>=state.totalCount; + const groups=[]; let current=null; + for(const e of state.entries){const k=dayKey(e.start_ms);if(!current||current.key!==k){current={key:k,start:e.start_ms,items:[]};groups.push(current)}current.items.push(e)} + $('#entries').innerHTML=groups.map(g=>{ + const dayTotal=g.items.reduce((n,e)=>n+rounded((e.end_ms||e.start_ms)-e.start_ms),0); + return `
${escapeText(dayTitle(g.start))}${duration(dayTotal)}
${g.items.map(entryHTML).join('')}
`; + }).join(''); + $$('.entry-row').forEach(el=>el.addEventListener('click',()=>openEntry(el.dataset.id))); + $$('.day-heading').forEach(el=>el.style.position=state.settings.sticky_days?'sticky':'static'); +} +function entryHTML(e){return ``} + +function openEntry(id=null, running=false) { + hideError('#entry-error'); + let e = null; + if (running) e=state.running; else if(id) e=state.entries.find(x=>x.id===id); + $('#entry-id').value=e?.id||''; $('#edit-client').value=e?.client||''; $('#edit-activity').value=e?.activity||''; + const now=Date.now(); $('#edit-start').value=localDateTimeValue(e?.start_ms||now); $('#edit-end').value=e?.end_ms?localDateTimeValue(e.end_ms):(running?'':localDateTimeValue(now+3600000)); + $('#entry-dialog-title').textContent=e?'Eintrag bearbeiten':'Zeit nachtragen'; $('#delete-entry').hidden=!e; + updateEditDuration(); $('#entry-dialog').showModal(); +} +function updateEditDuration(){const s=msFromLocalValue($('#edit-start').value),e=msFromLocalValue($('#edit-end').value);$('#edit-duration').textContent=s&&e&&e>=s?duration(rounded(e-s)):'–'} +async function saveEntry(ev){ev.preventDefault();hideError('#entry-error');const id=$('#entry-id').value;const start=msFromLocalValue($('#edit-start').value),end=msFromLocalValue($('#edit-end').value);if(!start||!end||end`
${escapeText(u.display_name||u.username)}${escapeText(u.username)}
${u.role==='admin'?'Admin':'Benutzer'}
`).join('');$$('.user-password').forEach(b=>b.addEventListener('click',()=>openPassword(b.closest('.user-row').dataset.userId)));$$('.user-toggle').forEach(b=>b.addEventListener('click',()=>toggleUser(b.closest('.user-row').dataset.userId,b.textContent==='Aktivieren')))}catch(e){showError('#settings-error',e)}} +function openPassword(id){$('#password-user-id').value=id;$('#reset-password').value='';hideError('#password-error');$('#password-dialog').showModal()} +async function toggleUser(id,active){try{await api(`/api/admin/users/${id}`,{method:'PATCH',body:JSON.stringify({active})});await loadUsers();toast(active?'Benutzer aktiviert.':'Benutzer deaktiviert.')}catch(e){showError('#settings-error',e)}} +async function createUser(ev){ev.preventDefault();hideError('#user-error');const body={username:$('#new-username').value,displayName:$('#new-display-name').value,password:$('#new-password').value,role:$('#new-role').value};try{await api('/api/admin/users',{method:'POST',body:JSON.stringify(body)});$('#user-dialog').close();ev.currentTarget.reset();await loadUsers();toast('Benutzer angelegt.')}catch(e){showError('#user-error',e)}} +async function resetPassword(ev){ev.preventDefault();hideError('#password-error');try{await api(`/api/admin/users/${$('#password-user-id').value}/password`,{method:'POST',body:JSON.stringify({password:$('#reset-password').value})});$('#password-dialog').close();toast('Passwort zurückgesetzt.')}catch(e){showError('#password-error',e)}} + +function dateInputValue(d) { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; } +function localDayStartMS(value) { + if (!value) return 0; + const [y,m,d]=value.split('-').map(Number); + return new Date(y,m-1,d,0,0,0,0).getTime(); +} +function exportParams(includePresentation=true) { + const p=new URLSearchParams(); + const scope=$('input[name="export-scope"]:checked')?.value||'view'; + if(scope==='view') { + const r=rangeForFilter(); + if(state.filter.q) p.set('q',state.filter.q); + if(r.from) p.set('from',r.from); + if(r.to) p.set('to',r.to); + } else { + const from=localDayStartMS($('#export-from').value); + const toStart=localDayStartMS($('#export-to').value); + if(from) p.set('from',from); + if(toStart) { const end=new Date(toStart); end.setDate(end.getDate()+1); p.set('to',end.getTime()); } + } + if(includePresentation) { + if($('#export-sort').value==='asc') p.set('sort','asc'); + if($('#export-compact').checked) p.set('compact','1'); + } + return p; +} +let exportPreviewSeq=0; +async function updateExportPreview() { + const seq=++exportPreviewSeq; + const scope=$('input[name="export-scope"]:checked')?.value||'view'; + $('#export-custom').hidden=scope!=='custom'; + if(scope==='view') { $('#export-preview').textContent=`${state.totalCount} Einträge · ${duration(state.totalDuration)}`; return; } + const from=localDayStartMS($('#export-from').value), to=localDayStartMS($('#export-to').value); + if(!from||!to||tosaveRunningActivity().catch(e=>toast(e.message))); + $('#edit-running').addEventListener('click',()=>openEntry(null,true)); $('#add-entry').addEventListener('click',()=>openEntry()); + $('#entry-form').addEventListener('submit',saveEntry); $('#delete-entry').addEventListener('click',deleteEntry); $('#edit-start').addEventListener('input',updateEditDuration); $('#edit-end').addEventListener('input',updateEditDuration); + $('#settings-open').addEventListener('click',openSettings); $('#settings-form').addEventListener('submit',saveSettings); $('#change-own-password').addEventListener('click',()=>{hideError('#account-password-error');$('#account-password-form').reset();$('#account-password-dialog').showModal()}); $('#account-password-form').addEventListener('submit',changeOwnPassword); $('#add-user').addEventListener('click',()=>{hideError('#user-error');$('#user-dialog').showModal()}); $('#user-form').addEventListener('submit',createUser); $('#password-form').addEventListener('submit',resetPassword); + $('#export-open').addEventListener('click',openExport); $('#history-export').addEventListener('click',openExport); $('#export-pdf').addEventListener('click',()=>downloadExport('pdf')); $('#export-csv').addEventListener('click',()=>downloadExport('csv')); $$('input[name="export-scope"]').forEach(x=>x.addEventListener('change',updateExportPreview)); $('#export-from').addEventListener('change',updateExportPreview); $('#export-to').addEventListener('change',updateExportPreview); + $('#account-menu').addEventListener('click',async()=>{if(!confirm('Abmelden?'))return;try{await api('/api/logout',{method:'POST',body:'{}'});}finally{location.assign('/login')}}); + let searchTimer; $('#search-input').addEventListener('input',e=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{state.filter.q=e.target.value.trim();state.filter.offset=0;refreshEntries(true)},220)}); + $$('.period-tabs button').forEach(b=>b.addEventListener('click',()=>{$$('.period-tabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.filter.period=b.dataset.period;state.filter.anchor=new Date();state.filter.offset=0;refreshEntries(true)})); + $('#period-prev').addEventListener('click',()=>stepPeriod(-1)); $('#period-next').addEventListener('click',()=>stepPeriod(1)); + $('#load-more').addEventListener('click',()=>{state.filter.offset=state.entries.length;refreshEntries(false)}); + $$('[data-close]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.close).close())); + $$('dialog').forEach(d=>d.addEventListener('click',e=>{const r=d.getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)d.close()})); + $('#mobile-history').addEventListener('click',()=>document.body.classList.add('mobile-history')); $('#mobile-track').addEventListener('click',()=>document.body.classList.remove('mobile-history')); +} + +wireEvents(); init(); diff --git a/internal/app/web/index.html b/internal/app/web/index.html new file mode 100644 index 0000000..5e60fb5 --- /dev/null +++ b/internal/app/web/index.html @@ -0,0 +1,174 @@ + + + + + + + + Pocketwatch + + + +
+
+
pocketwatch
+
+
0:00Diese Woche
+ + + +
+
+ +
+ + +
+
+

ZEITEN

Verlauf

+
+ + +
+
+
+ +
+ +
+
Alle Zeiten
+
+ +
+ +
+ +
+
0 EinträgeGesamt 0:00
+
+
+
+ + +
+ + + + +
+

Dauer:

+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/app/web/login.html b/internal/app/web/login.html new file mode 100644 index 0000000..4f5e4a3 --- /dev/null +++ b/internal/app/web/login.html @@ -0,0 +1,41 @@ + + + + + + + + Pocketwatch + + + +
+
+
pocketwatch
+
+

SELF-HOSTED TIME TRACKING

+

Anmelden

+

Deine Zeiten bleiben auf deinem Server.

+
+ + + +
+
+ + +
+
+ + + diff --git a/internal/app/web/login.js b/internal/app/web/login.js new file mode 100644 index 0000000..e8dba9f --- /dev/null +++ b/internal/app/web/login.js @@ -0,0 +1,40 @@ +const errorBox = document.querySelector('#auth-error'); +const loginPanel = document.querySelector('#login-panel'); +const setupPanel = document.querySelector('#setup-panel'); + +function showError(message) { + errorBox.textContent = message; + errorBox.hidden = !message; +} +async function request(url, options = {}) { + const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } }); + const body = res.status === 204 ? null : await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`); + return body; +} + +(async () => { + try { + const x = await request('/api/bootstrap'); + loginPanel.hidden = x.needs_setup; + setupPanel.hidden = !x.needs_setup; + } catch (e) { showError(e.message); } +})(); + +document.querySelector('#login-form').addEventListener('submit', async (ev) => { + ev.preventDefault(); showError(''); + const fd = new FormData(ev.currentTarget); + try { + await request('/api/login', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), password: fd.get('password') }) }); + location.assign('/'); + } catch (e) { showError(e.message); } +}); + +document.querySelector('#setup-form').addEventListener('submit', async (ev) => { + ev.preventDefault(); showError(''); + const fd = new FormData(ev.currentTarget); + try { + await request('/api/setup', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), displayName: fd.get('displayName'), password: fd.get('password') }) }); + location.assign('/'); + } catch (e) { showError(e.message); } +});