diff --git a/README.md b/README.md index bd0f8ac..2d37428 100644 --- a/README.md +++ b/README.md @@ -158,10 +158,34 @@ $ curl localhost:7777/health | `--port` | `DREAMDICT_PORT` | `7777` | Listen port | | `--host` | `DREAMDICT_HOST` | `127.0.0.1` | Listen host | | `--db` | `DREAMDICT_DB` | `./dict.db` | Path to SQLite database | +| `--token` | `DREAMDICT_TOKEN` | _(none)_ | Bearer token for API auth | | `--log-level` | | `info` | `debug`, `info`, `warn`, `error` | Flags take precedence over environment variables. +### Authentication + +When `--token` or `DREAMDICT_TOKEN` is set, all endpoints except `/health` require a bearer token: + +```bash +# Start with auth enabled +DREAMDICT_TOKEN=mysecret go run ./cmd/server --db ./dict.db + +# Authenticated request +curl -H 'Authorization: Bearer mysecret' 'localhost:7777/define?word=casa&lang=pt-PT' + +# Health is always public (for monitoring) +curl localhost:7777/health +``` + +Requests without a valid token receive a `401` response: + +```json +{"error": "unauthorized", "message": "invalid or missing bearer token"} +``` + +When no token is configured, auth is disabled and all endpoints are open. + ## Import CLI ```bash @@ -265,6 +289,8 @@ chown -R dreamdict:dreamdict /opt/dreamdict cp deploy/dreamdict.service /etc/systemd/system/ echo 'DREAMDICT_PORT=7777' > /etc/dreamdict/dreamdict.env echo 'DREAMDICT_DB=/opt/dreamdict/dict.db' >> /etc/dreamdict/dreamdict.env +echo 'DREAMDICT_TOKEN=changeme' >> /etc/dreamdict/dreamdict.env +chmod 600 /etc/dreamdict/dreamdict.env systemctl daemon-reload systemctl enable --now dreamdict ``` diff --git a/cmd/server/main.go b/cmd/server/main.go index b695858..a302264 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/subtle" "encoding/json" "flag" "fmt" @@ -22,6 +23,7 @@ func main() { port := flag.Int("port", 0, "Listen port (default 7777)") host := flag.String("host", "", "Listen host (default 127.0.0.1)") dbPath := flag.String("db", "", "Path to dict.db") + token := flag.String("token", "", "Bearer token for API auth (or DREAMDICT_TOKEN env)") logLevel := flag.String("log-level", "info", "Log level: debug, info, warn, error") flag.Parse() @@ -51,6 +53,10 @@ func main() { } } + if *token == "" { + *token = os.Getenv("DREAMDICT_TOKEN") + } + var level slog.Level switch *logLevel { case "debug": @@ -102,9 +108,16 @@ func main() { serverStart = time.Now() addr := fmt.Sprintf("%s:%d", *host, *port) + + var handler http.Handler = mux + if *token != "" { + handler = authMiddleware(*token, mux) + slog.Info("bearer token auth enabled") + } + srv := &http.Server{ Addr: addr, - Handler: loggingMiddleware(mux), + Handler: loggingMiddleware(handler), ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, } @@ -172,6 +185,22 @@ func loggingMiddleware(next http.Handler) http.Handler { }) } +func authMiddleware(token string, next http.Handler) http.Handler { + expected := "Bearer " + token + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Health endpoint is exempt so monitoring works without credentials + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte(expected)) != 1 { + writeError(w, 401, "unauthorized", "invalid or missing bearer token") + return + } + next.ServeHTTP(w, r) + }) +} + func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/loader/cetempublico.go b/internal/loader/cetempublico.go index 82b85d6..167c8fb 100644 --- a/internal/loader/cetempublico.go +++ b/internal/loader/cetempublico.go @@ -40,8 +40,6 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error { slog.Warn("cetempublico: no data file found, skipping", "searched", candidates) return nil } - slog.Info("cetempublico: using file", "path", path) - tx, err := db.Begin() if err != nil { return fmt.Errorf("cetempublico: begin tx: %w", err) @@ -71,7 +69,6 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error { isLatin1 := !utf8.Valid(rawBytes) if isLatin1 { - slog.Info("cetempublico: detected ISO-8859-1 encoding, converting") rawBytes = []byte(latin1ToUTF8(rawBytes)) } @@ -89,7 +86,7 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error { } } - var count, skipped, inserted int + var count, inserted int processLine := func(line string) error { // Support TSV and space-separated, with either column order: // "word\tcount", "word count", or "count\tword", "count word" @@ -113,13 +110,11 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error { } if word == "" || containsDigit(word) || containsSpace(word) || containsNonLatin(word) { - skipped++ return nil } freqVal, err := strconv.ParseFloat(freqStr, 64) if err != nil || freqVal <= 0 { - skipped++ return nil } @@ -153,9 +148,6 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error { inserted++ count++ } - if count+skipped <= 5 { - slog.Info("cetempublico: sample", "word", word, "freq", freq, "total", count, "inserted", inserted) - } return nil } @@ -179,7 +171,7 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error { if err := tx.Commit(); err != nil { return fmt.Errorf("cetempublico: commit: %w", err) } - slog.Info("cetempublico loaded", "updated_words", count, "inserted_new", inserted, "skipped", skipped) + slog.Info("cetempublico loaded", "updated_words", count, "inserted_new", inserted) return nil }