init
Some checks failed
release-tag / release-image (push) Has been cancelled

This commit is contained in:
2026-08-14 17:42:54 +02:00
parent 18a48d2285
commit 222b5d2413
22 changed files with 3013 additions and 1 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.gitignore
Dockerfile*
docker-compose*.yml
README.md
*.zip
/data
pocketwatch.db*

View File

@@ -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 }}

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/data/
*.db
*.db-shm
*.db-wal
pocketwatch
.DS_Store

18
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

16
Makefile Normal file
View File

@@ -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

205
README.md
View File

@@ -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 160 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.

21
docker-compose.yml Normal file
View File

@@ -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:

17
go.mod Normal file
View File

@@ -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
)

50
go.sum Normal file
View File

@@ -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=

204
internal/app/auth.go Normal file
View File

@@ -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")
}

16
internal/app/auth_test.go Normal file
View File

@@ -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])
}
}
}

531
internal/app/db.go Normal file
View File

@@ -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<?`
args = append(args, f.ToMS)
}
if q := strings.TrimSpace(f.Query); q != "" {
where += ` AND (client LIKE ? ESCAPE '\' COLLATE NOCASE OR activity LIKE ? ESCAPE '\' COLLATE NOCASE)`
q = "%" + escapeLike(q) + "%"
args = append(args, q, q)
}
return where, args
}
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func (s *store) recentClients(ctx context.Context, userID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT client FROM entries WHERE user_id=? AND TRIM(client)<>'' 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
}

253
internal/app/export.go Normal file
View File

@@ -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]) + "…"
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}
}

757
internal/app/server.go Normal file
View File

@@ -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: 364 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) }

200
internal/app/web/app.css Normal file
View File

@@ -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; }
}

299
internal/app/web/app.js Normal file
View File

@@ -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 => `<option value="${escapeText(x)}"></option>`).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 `<section class="day-group"><div class="day-heading"><strong>${escapeText(dayTitle(g.start))}</strong><span>${duration(dayTotal)}</span></div>${g.items.map(entryHTML).join('')}</section>`;
}).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 `<button class="entry-row" data-id="${e.id}"><span class="entry-client">${escapeText(e.client||'Kein Kunde')}</span><span class="entry-activity">${escapeText(e.activity||'—')}</span><span class="entry-time">${clock(e.start_ms)}${clock(e.end_ms)}</span><span class="entry-duration">${duration(rounded(e.end_ms-e.start_ms))}</span><span class="entry-chevron"></span></button>`}
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<start){showError('#entry-error',new Error('Bitte einen gültigen Start- und Endzeitpunkt wählen.'));return}const body={client:$('#edit-client').value,activity:$('#edit-activity').value,start_ms:start,end_ms:end};try{if(id)await api(`/api/entries/${id}`,{method:'PUT',body:JSON.stringify(body)});else await api('/api/entries',{method:'POST',body:JSON.stringify(body)});$('#entry-dialog').close();if(state.running?.id===id){state.running=null;renderRunning()}await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]);toast('Eintrag gespeichert.')}catch(e){showError('#entry-error',e)}}
async function deleteEntry(){const id=$('#entry-id').value;if(!id||!confirm('Diesen Eintrag wirklich löschen?'))return;try{await api(`/api/entries/${id}`,{method:'DELETE'});if(state.running?.id===id){state.running=null;renderRunning()}$('#entry-dialog').close();await Promise.all([refreshEntries(true),refreshTotals()]);toast('Eintrag gelöscht.')}catch(e){showError('#entry-error',e)}}
function fillSettings(){const s=state.settings;$('#setting-time-format').value=s.time_format;$('#setting-rounding').value=s.rounding_minutes;$('#setting-round-up').checked=s.round_up;$('#setting-week').checked=s.show_week_total;$('#setting-sticky').checked=s.sticky_days;$('#setting-reminder').checked=s.long_run_reminder;$('#setting-export-name').value=s.export_name||'';$('#setting-timezone').value=s.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||'UTC';$('#setting-export-date').checked=s.export_date;$('#admin-section').hidden=state.me.role!=='admin'}
async function openSettings(){fillSettings();hideError('#settings-error');$('#settings-dialog').showModal();if(state.me.role==='admin')await loadUsers()}
async function saveSettings(ev){ev.preventDefault();hideError('#settings-error');const s={language:state.settings.language||'de',time_format:$('#setting-time-format').value,rounding_minutes:Number($('#setting-rounding').value),round_up:$('#setting-round-up').checked,show_week_total:$('#setting-week').checked,sticky_days:$('#setting-sticky').checked,long_run_reminder:$('#setting-reminder').checked,export_name:$('#setting-export-name').value,timezone:$('#setting-timezone').value,export_date:$('#setting-export-date').checked};try{state.settings=await api('/api/settings',{method:'PUT',body:JSON.stringify(s)});$('#settings-dialog').close();renderEntries();await refreshTotals();toast('Einstellungen gespeichert.')}catch(e){showError('#settings-error',e)}}
async function changeOwnPassword(ev){
ev.preventDefault(); hideError('#account-password-error');
try {
await api('/api/account/password',{method:'POST',body:JSON.stringify({current_password:$('#current-password').value,new_password:$('#own-new-password').value})});
location.assign('/login');
} catch(e){ showError('#account-password-error',e); }
}
async function loadUsers(){try{const x=await api('/api/admin/users');$('#user-list').innerHTML=x.users.map(u=>`<div class="user-row" data-user-id="${u.id}"><div class="user-meta"><strong>${escapeText(u.display_name||u.username)}</strong><span>${escapeText(u.username)}</span></div><span class="role-badge">${u.role==='admin'?'Admin':'Benutzer'}</span><button type="button" class="btn subtle user-password" ${u.active?'':'disabled'}>Passwort</button><button type="button" class="btn ${u.active?'danger':'subtle'} user-toggle" ${u.id===state.me.id?'disabled':''}>${u.active?'Deaktivieren':'Aktivieren'}</button></div>`).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||to<from) { $('#export-preview').textContent='Bitte einen gültigen Zeitraum wählen.'; return; }
try {
const p=exportParams(false); p.set('limit','1'); p.set('offset','0');
const result=await api('/api/entries?'+p.toString());
if(seq===exportPreviewSeq) $('#export-preview').textContent=`${result.total_count} Einträge · ${duration(result.total_duration_ms)}`;
} catch(e) { if(seq===exportPreviewSeq) $('#export-preview').textContent=e.message; }
}
function openExport(){
const now=new Date(), first=new Date(now.getFullYear(),now.getMonth(),1);
if(!$('#export-from').value) $('#export-from').value=dateInputValue(first);
if(!$('#export-to').value) $('#export-to').value=dateInputValue(now);
updateExportPreview(); $('#export-dialog').showModal();
}
function downloadExport(type){
const p=exportParams(true), a=document.createElement('a');
a.href=`/api/export.${type}${p.toString()?'?'+p.toString():''}`; a.click();
}
function wireEvents(){
$('#timer-button').addEventListener('click',timerToggle); $('#activity-input').addEventListener('blur',()=>saveRunningActivity().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.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.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();

174
internal/app/web/index.html Normal file
View File

@@ -0,0 +1,174 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0f11">
<title>Pocketwatch</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand"><span class="brand-dot"></span><span>pocketwatch</span></div>
<div class="topbar-actions">
<div id="week-badge" class="metric-inline"><strong id="header-week">0:00</strong><span>Diese Woche</span></div>
<button class="icon-btn" id="export-open" title="Exportieren" aria-label="Exportieren"></button>
<button class="icon-btn" id="settings-open" title="Einstellungen" aria-label="Einstellungen"></button>
<button class="avatar-btn" id="account-menu" title="Abmelden"><span id="avatar-text">U</span></button>
</div>
</header>
<main class="main-grid">
<aside class="tracker-pane">
<div class="mobile-pane-head"><h1>Erfassen</h1><button class="link-btn" id="mobile-history">Verlauf →</button></div>
<section class="tracker-card">
<div class="tracker-date"><span id="today-label">Heute</span><span id="running-hint" class="status-pill" hidden>läuft</span></div>
<label class="field-label">Kunde
<input id="client-input" list="client-list" maxlength="200" placeholder="Kunde / Projekt">
<datalist id="client-list"></datalist>
</label>
<label class="field-label">Tätigkeit
<textarea id="activity-input" rows="4" maxlength="4000" placeholder="Woran arbeitest du?"></textarea>
</label>
<div class="timer-display" id="timer-display">00:00:00</div>
<button id="timer-button" class="btn primary timer-btn">▶ Start</button>
<button id="edit-running" class="link-btn centered" hidden>Laufenden Eintrag bearbeiten</button>
</section>
<div class="metric-grid">
<article class="metric-card"><span>Heute</span><strong id="today-total">0:00</strong></article>
<article class="metric-card"><span>Diese Woche</span><strong id="week-total">0:00</strong></article>
</div>
<div id="long-running-warning" class="warning-box" hidden>Der Timer läuft seit mehr als 8 Stunden. Vielleicht vergessen zu stoppen?</div>
<footer class="sidebar-footer"><span id="user-label"></span><span>SQLite · Go</span></footer>
</aside>
<section class="history-pane" id="history-pane">
<div class="history-head">
<div class="history-title"><button class="link-btn mobile-only" id="mobile-track">← Erfassen</button><p class="eyebrow">ZEITEN</p><h1>Verlauf</h1></div>
<div class="history-actions">
<button class="btn subtle" id="add-entry">+ Nachtragen</button>
<button class="btn subtle" id="history-export">Export</button>
</div>
</div>
<div class="filter-bar">
<input id="search-input" type="search" placeholder="Kunde oder Tätigkeit suchen…" aria-label="Verlauf durchsuchen">
<div class="period-tabs" role="group" aria-label="Zeitraum">
<button data-period="day">Tag</button><button data-period="week">Woche</button><button data-period="month">Monat</button><button data-period="year">Jahr</button><button data-period="all" class="active">Alle</button>
</div>
<div class="period-step"><button id="period-prev" aria-label="Vorheriger Zeitraum"></button><span id="period-label">Alle Zeiten</span><button id="period-next" aria-label="Nächster Zeitraum"></button></div>
</div>
<div class="history-scroll">
<div id="empty-state" class="empty-state" hidden><div class="empty-icon"></div><h2>Noch keine Einträge</h2><p>Starte links einen Timer oder trage eine Zeit nach.</p></div>
<div id="entries" class="entries"></div>
<button id="load-more" class="btn subtle load-more" hidden>Weitere laden</button>
</div>
<footer class="history-footer"><span><strong id="result-count">0</strong> Einträge</span><span>Gesamt <strong id="result-total">0:00</strong></span></footer>
</section>
</main>
</div>
<dialog id="entry-dialog" class="modal">
<form id="entry-form" method="dialog" class="modal-card">
<div class="modal-head"><div><p class="eyebrow">ZEITEINTRAG</p><h2 id="entry-dialog-title">Eintrag bearbeiten</h2></div><button type="button" class="icon-btn" data-close="entry-dialog">×</button></div>
<input type="hidden" id="entry-id">
<label>Kunde<input id="edit-client" maxlength="200" list="client-list"></label>
<label>Tätigkeit<textarea id="edit-activity" rows="4" maxlength="4000"></textarea></label>
<div class="two-col"><label>Start<input id="edit-start" type="datetime-local" required></label><label>Ende<input id="edit-end" type="datetime-local"></label></div>
<p class="muted small">Dauer: <strong id="edit-duration"></strong></p>
<p id="entry-error" class="error-box" hidden></p>
<div class="modal-actions split"><button type="button" id="delete-entry" class="btn danger" hidden>Löschen</button><div class="spacer"></div><button type="button" class="btn subtle" data-close="entry-dialog">Abbrechen</button><button type="submit" class="btn primary">Speichern</button></div>
</form>
</dialog>
<dialog id="settings-dialog" class="modal modal-wide">
<form id="settings-form" method="dialog" class="modal-card">
<div class="modal-head"><div><p class="eyebrow">POCKETWATCH</p><h2>Einstellungen</h2></div><button type="button" class="icon-btn" data-close="settings-dialog">×</button></div>
<div class="settings-grid">
<section>
<h3>Anzeige & Auswertung</h3>
<label>Zeitformat<select id="setting-time-format"><option value="24">24 Stunden</option><option value="12">12 Stunden</option></select></label>
<label>Rundung in Minuten<input id="setting-rounding" type="number" min="1" max="60"></label>
<label class="check-row"><input id="setting-round-up" type="checkbox"><span>Immer aufrunden</span></label>
<label class="check-row"><input id="setting-week" type="checkbox"><span>Wochensumme anzeigen</span></label>
<label class="check-row"><input id="setting-sticky" type="checkbox"><span>Tagesüberschriften fixieren</span></label>
<label class="check-row"><input id="setting-reminder" type="checkbox"><span>Warnung bei langem Timer</span></label>
</section>
<section>
<h3>Export</h3>
<label>Name im PDF<input id="setting-export-name" maxlength="120"></label>
<label>Zeitzone für Exporte<input id="setting-timezone" maxlength="80" placeholder="Europe/Berlin"></label>
<label class="check-row"><input id="setting-export-date" type="checkbox"><span>Exportdatum im PDF</span></label>
<p class="muted small">Tipp: Die Zeitzone wird beim ersten Öffnen automatisch aus dem Browser übernommen.</p>
<button type="button" class="btn subtle" id="change-own-password">Eigenes Passwort ändern</button>
</section>
</div>
<section id="admin-section" class="admin-section" hidden>
<div class="section-title"><div><p class="eyebrow">ADMIN</p><h3>Benutzer</h3></div><button type="button" id="add-user" class="btn subtle">+ Benutzer</button></div>
<div id="user-list" class="user-list"></div>
</section>
<p id="settings-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="settings-dialog">Abbrechen</button><button type="submit" class="btn primary">Speichern</button></div>
</form>
</dialog>
<dialog id="user-dialog" class="modal">
<form id="user-form" method="dialog" class="modal-card compact">
<div class="modal-head"><h2>Benutzer anlegen</h2><button type="button" class="icon-btn" data-close="user-dialog">×</button></div>
<label>Benutzername<input id="new-username" minlength="3" maxlength="64" required></label>
<label>Anzeigename<input id="new-display-name" maxlength="120"></label>
<label>Passwort<input id="new-password" type="password" minlength="10" required></label>
<label>Rolle<select id="new-role"><option value="user">Benutzer</option><option value="admin">Administrator</option></select></label>
<p id="user-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="user-dialog">Abbrechen</button><button type="submit" class="btn primary">Anlegen</button></div>
</form>
</dialog>
<dialog id="account-password-dialog" class="modal">
<form id="account-password-form" method="dialog" class="modal-card compact">
<div class="modal-head"><h2>Eigenes Passwort ändern</h2><button type="button" class="icon-btn" data-close="account-password-dialog">×</button></div>
<label>Aktuelles Passwort<input id="current-password" type="password" autocomplete="current-password" required></label>
<label>Neues Passwort<input id="own-new-password" type="password" minlength="10" autocomplete="new-password" required></label>
<p id="account-password-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="account-password-dialog">Abbrechen</button><button type="submit" class="btn primary">Ändern</button></div>
</form>
</dialog>
<dialog id="password-dialog" class="modal">
<form id="password-form" method="dialog" class="modal-card compact">
<div class="modal-head"><h2>Passwort zurücksetzen</h2><button type="button" class="icon-btn" data-close="password-dialog">×</button></div>
<input type="hidden" id="password-user-id">
<label>Neues Passwort<input id="reset-password" type="password" minlength="10" required></label>
<p id="password-error" class="error-box" hidden></p>
<div class="modal-actions"><button type="button" class="btn subtle" data-close="password-dialog">Abbrechen</button><button type="submit" class="btn primary">Zurücksetzen</button></div>
</form>
</dialog>
<dialog id="export-dialog" class="modal">
<div class="modal-card compact">
<div class="modal-head"><div><p class="eyebrow">EXPORT</p><h2>Zeiten exportieren</h2></div><button type="button" class="icon-btn" data-close="export-dialog">×</button></div>
<p class="muted">Rundung und Zeitzone werden aus deinen Einstellungen übernommen.</p>
<div class="export-scope" role="radiogroup" aria-label="Exportumfang">
<label class="check-row"><input type="radio" name="export-scope" value="view" checked><span>Aktuelle Ansicht</span></label>
<label class="check-row"><input type="radio" name="export-scope" value="custom"><span>Eigener Zeitraum</span></label>
</div>
<div id="export-custom" class="two-col" hidden>
<label>Von<input id="export-from" type="date"></label>
<label>Bis<input id="export-to" type="date"></label>
</div>
<div class="two-col">
<label>Sortierung<select id="export-sort"><option value="desc">Neueste zuerst</option><option value="asc">Älteste zuerst</option></select></label>
<label class="check-row export-compact"><input id="export-compact" type="checkbox"><span>Nur Zeiten (ohne Tätigkeit)</span></label>
</div>
<p id="export-preview" class="export-preview">0 Einträge · 0:00</p>
<div class="export-buttons"><button id="export-pdf" class="btn primary">PDF herunterladen</button><button id="export-csv" class="btn subtle">CSV herunterladen</button></div>
</div>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="/static/app.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0e0f11">
<title>Pocketwatch</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="auth-body">
<main class="auth-shell">
<section class="auth-card" aria-labelledby="auth-title">
<div class="brand brand-large"><span class="brand-dot"></span><span>pocketwatch</span></div>
<div id="login-panel">
<p class="eyebrow">SELF-HOSTED TIME TRACKING</p>
<h1 id="auth-title">Anmelden</h1>
<p class="muted">Deine Zeiten bleiben auf deinem Server.</p>
<form id="login-form" class="form-stack">
<label>Benutzername<input name="username" autocomplete="username" required autofocus></label>
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
<button class="btn primary wide" type="submit">Anmelden</button>
</form>
</div>
<div id="setup-panel" hidden>
<p class="eyebrow">ERSTEINRICHTUNG</p>
<h1>Admin anlegen</h1>
<p class="muted">Der erste Benutzer wird Administrator. Weitere Konten legst du später in den Einstellungen an.</p>
<form id="setup-form" class="form-stack">
<label>Benutzername<input name="username" minlength="3" maxlength="64" autocomplete="username" required autofocus></label>
<label>Anzeigename<input name="displayName" maxlength="120" autocomplete="name"></label>
<label>Passwort<input name="password" type="password" minlength="10" autocomplete="new-password" required></label>
<button class="btn primary wide" type="submit">Instanz einrichten</button>
</form>
</div>
<p id="auth-error" class="error-box" role="alert" hidden></p>
</section>
</main>
<script src="/static/login.js" defer></script>
</body>
</html>

40
internal/app/web/login.js Normal file
View File

@@ -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); }
});