Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ WORKDIR /app
COPY --from=build /out/time-tracker /app/

EXPOSE 8080
ENV DB_PATH=/data/time-tracker.db
ENV TRACKER_DB_PATH=/data/time-tracker.db
VOLUME ["/data"]

ENTRYPOINT ["/app/time-tracker"]
Expand Down
32 changes: 21 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@ version tags, for `linux/amd64`, `linux/arm64`, `linux/arm/v7`, `linux/arm/v6`,

```sh
docker run -d -p 8080:8080 \
-e JWT_SECRET=change-me \
-e TRACKER_JWT_SECRET=change-me \
-e TRACKER_SECURE_COOKIES=false \
-v time-tracker-data:/data \
ghcr.io/bloomyindev/time-tracker:latest
```

The container runs `time-tracker serve` by default. The database is stored in
the `/data` volume (`DB_PATH=/data/time-tracker.db`). Run admin commands inside
the `/data` volume (`TRACKER_DB_PATH=/data/time-tracker.db`). Run admin commands inside
the container with `docker exec <container> /app/time-tracker <command>` (see
[CLI](#cli)).

Expand Down Expand Up @@ -82,7 +83,7 @@ archive contains the single `time-tracker` binary. Prebuilt targets:
```sh
tar -xzf time-tracker_*_linux_amd64.tar.gz
cd time-tracker_*_linux_amd64
JWT_SECRET=change-me ./time-tracker serve
TRACKER_JWT_SECRET=change-me TRACKER_SECURE_COOKIES=false ./time-tracker serve
```

### From source
Expand All @@ -93,7 +94,7 @@ Building from source requires Go 1.26 or later. See
## First run

1. **Start the server.** It listens on <http://localhost:8080>. In production,
always set `JWT_SECRET` (see [Configuration](#configuration)).
always set `TRACKER_JWT_SECRET` (see [Configuration](#configuration)).
2. **Create a user.** There is no public sign-up:
```sh
./time-tracker register --email you@example.com --password secret
Expand All @@ -106,13 +107,22 @@ Building from source requires Go 1.26 or later. See

The app is configured through environment variables:

| Variable | Default | Description |
|--------------|------------------------|---------------------------------------------------------------------------------------------|
| `DB_PATH` | `time-tracker.db` | Path to the SQLite database file. |
| `JWT_SECRET` | `dev-secret-change-me` | Secret for signing JWTs used by the (currently unused) bearer-token API flow. Set this in production. |
| Variable | Default | Description |
|---------------------------|------------------------|------------------------------------------------------------------------------------------------------|
| `TRACKER_PORT` | `8080` | TCP port the server listens on. |
| `TRACKER_DB_PATH` | `time-tracker.db` | Path to the SQLite database file. |
| `TRACKER_JWT_SECRET` | `dev-secret-change-me` | Secret for signing JWTs used by the (currently unused) bearer-token API flow. Set this in production. |
| `TRACKER_SECURE_COOKIES` | `true` | Mark cookies `Secure`, so browsers only send them over HTTPS. Set to `false` to serve plain HTTP. |
| `TRACKER_SQLITE_WAL` | `false` | Enable SQLite write-ahead logging. Adds `-wal` and `-shm` files next to the database. |

The server always listens on port `8080`. The database path can also be passed
with `--db-path`. The schema is created and migrated automatically on startup.
**Serving over plain HTTP?** Set `TRACKER_SECURE_COOKIES=false`. The default
assumes a TLS-terminating reverse proxy in front. Left on over plain HTTP, the
browser accepts the session cookie and then never sends it back, so logging in
appears to do nothing.

The database path can also be passed with `--db-path`, which takes precedence
over the environment. Migrations are embedded in the binary and applied on startup;
each one is recorded in a `schema_migrations` table so it runs exactly once.

## CLI

Expand All @@ -124,7 +134,7 @@ time-tracker register --email <email> --password <password> # create a user
time-tracker export-users # dump users as JSON (no password hashes)
```

Every command accepts `--db-path` (or the `DB_PATH` environment variable).
Every command accepts `--db-path` (or the `TRACKER_DB_PATH` environment variable).

## Contributing

Expand Down
3 changes: 1 addition & 2 deletions cmd/time-tracker/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"os"

"github.com/bloomyindev/time-tracker/internal/config"
"github.com/bloomyindev/time-tracker/internal/db"
"github.com/bloomyindev/time-tracker/internal/service/auth"
"github.com/urfave/cli/v3"
Expand All @@ -28,7 +27,7 @@ func registerCommand() *cli.Command {
}
defer conn.Close()

svc := auth.NewService(conn, config.Load().JWTSecret)
svc := auth.NewService(conn, cfg.JWTSecret)
email := cmd.String("email")
if err := svc.Register(email, cmd.String("password")); err != nil {
return fmt.Errorf("register: %w", err)
Expand Down
68 changes: 14 additions & 54 deletions cmd/time-tracker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ import (
"github.com/bloomyindev/time-tracker/internal/db"
"github.com/bloomyindev/time-tracker/internal/handlers"
"github.com/bloomyindev/time-tracker/internal/i18n"
"github.com/bloomyindev/time-tracker/internal/redirect"
"github.com/bloomyindev/time-tracker/internal/service/auth"
"github.com/urfave/cli/v3"
)

// cfg is read once for the whole process. Load reports malformed values as it
// falls back to a default, and every command builds its flags at startup, so
// reading it per command would print each of those warnings several times over.
var cfg = config.Load()

func main() {
cmd := &cli.Command{
Name: "time-tracker",
Expand All @@ -35,19 +39,19 @@ func main() {
}
}

// dbPathFlag overrides the database path (env: DB_PATH). A fresh flag is
// returned per command so each owns its own value.
// dbPathFlag overrides the database path (env: TRACKER_DB_PATH). A fresh flag
// is returned per command so each owns its own value.
func dbPathFlag() *cli.StringFlag {
return &cli.StringFlag{
Name: "db-path",
Usage: "path to the sqlite database file",
Value: config.Load().DBPath,
Sources: cli.EnvVars("DB_PATH"),
Value: cfg.DBPath,
Sources: cli.EnvVars("TRACKER_DB_PATH"),
}
}

func openDB(cmd *cli.Command) (*sql.DB, error) {
return db.Open(cmd.String("db-path"))
return db.Open(cmd.String("db-path"), db.Options{WAL: cfg.SQLiteWAL})
}

func serveCommand() *cli.Command {
Expand All @@ -56,8 +60,6 @@ func serveCommand() *cli.Command {
Usage: "run the web server",
Flags: []cli.Flag{dbPathFlag()},
Action: func(ctx context.Context, cmd *cli.Command) error {
cfg := config.Load()

if err := i18n.Load(); err != nil {
return fmt.Errorf("load locales: %w", err)
}
Expand All @@ -66,59 +68,17 @@ func serveCommand() *cli.Command {
if err != nil {
return fmt.Errorf("open db: %w", err)
}

authSvc := auth.NewService(conn, cfg.JWTSecret)

mux := http.NewServeMux()

mux.HandleFunc("GET /", handlers.Home)
mux.HandleFunc("GET /lang/{code}", handlers.SetLocale)
mux.HandleFunc("GET /login", handlers.Login)
mux.HandleFunc("POST /login", handlers.LoginSubmit(authSvc))
mux.HandleFunc("GET /logout", handlers.Logout(authSvc))

mux.Handle("GET /clients", authSvc.RequireAuth(handlers.ListClients(conn)))
mux.Handle("POST /clients", authSvc.RequireAuth(handlers.CreateClient(conn)))
mux.Handle("GET /clients/{id}", authSvc.RequireAuth(handlers.ClientDetail(conn)))
mux.Handle("GET /clients/{id}/report", authSvc.RequireAuth(handlers.ClientReport(conn)))
mux.Handle("GET /clients/{id}/edit", authSvc.RequireAuth(handlers.EditClientForm(conn)))
mux.Handle("POST /clients/{id}/edit", authSvc.RequireAuth(handlers.UpdateClient(conn)))
mux.Handle("POST /clients/{id}/delete", authSvc.RequireAuth(handlers.DeleteClient(conn)))

mux.Handle("GET /task-types", authSvc.RequireAuth(handlers.ListTaskTypes(conn)))
mux.Handle("POST /task-types", authSvc.RequireAuth(handlers.CreateTaskType(conn)))
mux.Handle("GET /task-types/{id}/edit", authSvc.RequireAuth(handlers.EditTaskTypeForm(conn)))
mux.Handle("POST /task-types/{id}/rename", authSvc.RequireAuth(handlers.RenameTaskType(conn)))
mux.Handle("POST /task-types/{id}/delete", authSvc.RequireAuth(handlers.DeleteTaskType(conn)))

mux.Handle("GET /periods", authSvc.RequireAuth(handlers.ListPeriods(conn)))
mux.Handle("POST /periods", authSvc.RequireAuth(handlers.CreatePeriod(conn)))
mux.Handle("POST /periods/{id}/default", authSvc.RequireAuth(handlers.SetDefaultPeriod(conn)))
mux.Handle("GET /periods/{id}/edit", authSvc.RequireAuth(handlers.EditPeriodForm(conn)))
mux.Handle("POST /periods/{id}/rename", authSvc.RequireAuth(handlers.RenamePeriod(conn)))
mux.Handle("POST /periods/{id}/delete", authSvc.RequireAuth(handlers.DeletePeriod(conn)))

mux.Handle("GET /tasks", authSvc.RequireAuth(handlers.ListTasks(conn)))
mux.Handle("POST /tasks", authSvc.RequireAuth(handlers.CreateTask(conn)))
mux.Handle("GET /tasks/{id}/edit", authSvc.RequireAuth(handlers.EditTaskForm(conn)))
mux.Handle("POST /tasks/{id}/update", authSvc.RequireAuth(handlers.UpdateTask(conn)))
mux.Handle("POST /tasks/{id}/delete", authSvc.RequireAuth(handlers.DeleteTask(conn)))

mux.Handle("GET /time", authSvc.RequireAuth(handlers.ListTimeEntries(conn)))
mux.Handle("GET /time/report", authSvc.RequireAuth(handlers.TimeReport(conn)))

mux.Handle("GET /account", authSvc.RequireAuth(handlers.Account(conn)))
mux.Handle("POST /account/hours", authSvc.RequireAuth(handlers.UpdateDailyHours(conn)))
mux.Handle("POST /account/password", authSvc.RequireAuth(handlers.ChangePassword(conn, authSvc)))
defer conn.Close()

staticFS, err := fs.Sub(assets.Static, "static")
if err != nil {
return fmt.Errorf("mount static assets: %w", err)
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFS)))

h := handlers.New(conn, auth.NewService(conn, cfg.JWTSecret), cfg)

log.Printf("listening on port %d", cfg.Port)
return http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), i18n.Middleware(redirect.Middleware(mux)))
return http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), h.Router(staticFS))
},
}
}
11 changes: 10 additions & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ services:
- "8080:8080"
environment:
# Set a strong secret in production.
JWT_SECRET: change-me
TRACKER_JWT_SECRET: change-me
# This file publishes port 8080 directly, so the app is reached over
# plain HTTP and the session cookie must not be marked Secure — the
# browser would accept it and then never send it back, and login would
# appear to do nothing. Drop this line the moment a TLS-terminating
# reverse proxy sits in front.
TRACKER_SECURE_COOKIES: "false"
# Write-ahead logging: readers and writers stop blocking each other, at
# the cost of two extra files next to the database.
# TRACKER_SQLITE_WAL: "true"
volumes:
- data:/data
restart: unless-stopped
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ tool (

require (
github.com/a-h/templ v0.3.1020
github.com/bryanvaz/go-templ-lucide-icons v0.480.0
github.com/go-chi/chi/v5 v5.3.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/invopop/ctxi18n v0.9.0
github.com/urfave/cli/v3 v3.10.1
Expand All @@ -23,7 +25,6 @@ require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bryanvaz/go-templ-lucide-icons v0.480.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
Expand Down
63 changes: 59 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
// Package config reads runtime settings from the environment. Every variable
// is namespaced with TRACKER_, so the app can't pick up a generic name another
// process on the same host happens to export.
package config

import "os"
import (
"log"
"os"
"strconv"
)

type Config struct {
Port int
DBPath string
JWTSecret string
// SQLiteWAL turns on write-ahead logging. It trades two extra files
// next to the database for readers and writers that no longer block
// each other.
SQLiteWAL bool
// SecureCookies marks cookies Secure, so a browser only ever sends
// them back over HTTPS. It defaults to on: the app is normally reached
// through a TLS-terminating reverse proxy, and a deployment that never
// thought about this should land on the safe setting. Turn it off to
// serve plain HTTP, or the session cookie gets set and never returned.
SecureCookies bool
}

func Load() Config {
return Config{
Port: 8080,
DBPath: getEnv("DB_PATH", "time-tracker.db"),
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
Port: getPort("TRACKER_PORT", 8080),
DBPath: getEnv("TRACKER_DB_PATH", "time-tracker.db"),
JWTSecret: getEnv("TRACKER_JWT_SECRET", "dev-secret-change-me"),
SQLiteWAL: getBool("TRACKER_SQLITE_WAL", false),
SecureCookies: getBool("TRACKER_SECURE_COOKIES", true),
}
}

Expand All @@ -22,3 +41,39 @@ func getEnv(key, fallback string) string {
}
return fallback
}

// getPort reads a TCP port. Zero is rejected along with the out-of-range
// values: asking the kernel to pick a free port is never what a server someone
// has to reach was meant to do.
func getPort(key string, fallback int) int {
raw := os.Getenv(key)
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil {
log.Printf("%s: %q isn't a number, using %d", key, raw, fallback)
return fallback
}
if parsed < 1 || parsed > 65535 {
log.Printf("%s: %d isn't a valid port, using %d", key, parsed, fallback)
return fallback
}
return parsed
}

// getBool reads a boolean in any form strconv accepts ("1", "true", "off").
// An unparseable value is a typo in the deployment, not a reason to run with a
// setting nobody chose, so it is reported and the default stands.
func getBool(key string, fallback bool) bool {
raw := os.Getenv(key)
if raw == "" {
return fallback
}
parsed, err := strconv.ParseBool(raw)
if err != nil {
log.Printf("%s: %q isn't a boolean, using %t", key, raw, fallback)
return fallback
}
return parsed
}
Loading
Loading