From a9829ce48f9d55c2bd5ef06e3f22d592f41aecce Mon Sep 17 00:00:00 2001 From: user Date: Mon, 16 Feb 2026 00:20:41 -0800 Subject: [PATCH] feat: add JSON API with token auth (closes #69) - Add API token model with SHA-256 hashed tokens - Add migration 006_add_api_tokens.sql - Add Bearer token auth middleware - Add API endpoints under /api/v1/: - GET /whoami - POST /tokens (create new API token) - GET /apps (list all apps) - POST /apps (create app) - GET /apps/{id} (get app) - DELETE /apps/{id} (delete app) - POST /apps/{id}/deploy (trigger deployment) - GET /apps/{id}/deployments (list deployments) - Add comprehensive tests for all API endpoints - All tests pass, zero lint issues --- .../migrations/006_add_api_tokens.sql | 11 + internal/handlers/api.go | 372 ++++++++++++++++++ internal/handlers/api_test.go | 254 ++++++++++++ internal/handlers/handlers_test.go | 28 +- internal/middleware/middleware.go | 83 +++- internal/models/api_token.go | 187 +++++++++ internal/server/routes.go | 15 + 7 files changed, 938 insertions(+), 12 deletions(-) create mode 100644 internal/database/migrations/006_add_api_tokens.sql create mode 100644 internal/handlers/api.go create mode 100644 internal/handlers/api_test.go create mode 100644 internal/models/api_token.go diff --git a/internal/database/migrations/006_add_api_tokens.sql b/internal/database/migrations/006_add_api_tokens.sql new file mode 100644 index 0000000..fe36c4e --- /dev/null +++ b/internal/database/migrations/006_add_api_tokens.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + token_hash TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); diff --git a/internal/handlers/api.go b/internal/handlers/api.go new file mode 100644 index 0000000..72b6db5 --- /dev/null +++ b/internal/handlers/api.go @@ -0,0 +1,372 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "git.eeqj.de/sneak/upaas/internal/middleware" + "git.eeqj.de/sneak/upaas/internal/models" + "git.eeqj.de/sneak/upaas/internal/service/app" +) + +// apiAppResponse is the JSON representation of an app. +type apiAppResponse struct { + ID string `json:"id"` + Name string `json:"name"` + RepoURL string `json:"repoUrl"` + Branch string `json:"branch"` + DockerfilePath string `json:"dockerfilePath"` + Status string `json:"status"` + WebhookSecret string `json:"webhookSecret"` + SSHPublicKey string `json:"sshPublicKey"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// apiDeploymentResponse is the JSON representation of a deployment. +type apiDeploymentResponse struct { + ID int64 `json:"id"` + AppID string `json:"appId"` + CommitSHA string `json:"commitSha,omitempty"` + Status string `json:"status"` + Duration string `json:"duration,omitempty"` + StartedAt string `json:"startedAt"` + FinishedAt string `json:"finishedAt,omitempty"` +} + +func appToAPI(a *models.App) apiAppResponse { + return apiAppResponse{ + ID: a.ID, + Name: a.Name, + RepoURL: a.RepoURL, + Branch: a.Branch, + DockerfilePath: a.DockerfilePath, + Status: string(a.Status), + WebhookSecret: a.WebhookSecret, + SSHPublicKey: a.SSHPublicKey, + CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +func deploymentToAPI(d *models.Deployment) apiDeploymentResponse { + resp := apiDeploymentResponse{ + ID: d.ID, + AppID: d.AppID, + Status: string(d.Status), + Duration: d.Duration(), + StartedAt: d.StartedAt.Format("2006-01-02T15:04:05Z"), + } + + if d.CommitSHA.Valid { + resp.CommitSHA = d.CommitSHA.String + } + + if d.FinishedAt.Valid { + resp.FinishedAt = d.FinishedAt.Time.Format("2006-01-02T15:04:05Z") + } + + return resp +} + +// HandleAPIListApps returns a handler that lists all apps as JSON. +func (h *Handlers) HandleAPIListApps() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + apps, err := h.appService.ListApps(request.Context()) + if err != nil { + h.respondJSON(writer, request, + map[string]string{"error": "failed to list apps"}, + http.StatusInternalServerError) + + return + } + + result := make([]apiAppResponse, 0, len(apps)) + for _, a := range apps { + result = append(result, appToAPI(a)) + } + + h.respondJSON(writer, request, result, http.StatusOK) + } +} + +// HandleAPIGetApp returns a handler that gets a single app by ID. +func (h *Handlers) HandleAPIGetApp() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + + application, err := h.appService.GetApp(request.Context(), appID) + if err != nil { + h.respondJSON(writer, request, + map[string]string{"error": "internal server error"}, + http.StatusInternalServerError) + + return + } + + if application == nil { + h.respondJSON(writer, request, + map[string]string{"error": "app not found"}, + http.StatusNotFound) + + return + } + + h.respondJSON(writer, request, appToAPI(application), http.StatusOK) + } +} + +// HandleAPICreateApp returns a handler that creates a new app. +func (h *Handlers) HandleAPICreateApp() http.HandlerFunc { + type createRequest struct { + Name string `json:"name"` + RepoURL string `json:"repoUrl"` + Branch string `json:"branch"` + DockerfilePath string `json:"dockerfilePath"` + DockerNetwork string `json:"dockerNetwork"` + NtfyTopic string `json:"ntfyTopic"` + SlackWebhook string `json:"slackWebhook"` + } + + return func(writer http.ResponseWriter, request *http.Request) { + var req createRequest + + decodeErr := json.NewDecoder(request.Body).Decode(&req) + if decodeErr != nil { + h.respondJSON(writer, request, + map[string]string{"error": "invalid JSON body"}, + http.StatusBadRequest) + + return + } + + if req.Name == "" || req.RepoURL == "" { + h.respondJSON(writer, request, + map[string]string{"error": "name and repo_url are required"}, + http.StatusBadRequest) + + return + } + + nameErr := validateAppName(req.Name) + if nameErr != nil { + h.respondJSON(writer, request, + map[string]string{"error": "invalid app name: " + nameErr.Error()}, + http.StatusBadRequest) + + return + } + + createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{ + Name: req.Name, + RepoURL: req.RepoURL, + Branch: req.Branch, + DockerfilePath: req.DockerfilePath, + DockerNetwork: req.DockerNetwork, + NtfyTopic: req.NtfyTopic, + SlackWebhook: req.SlackWebhook, + }) + if createErr != nil { + h.log.Error("api: failed to create app", "error", createErr) + h.respondJSON(writer, request, + map[string]string{"error": "failed to create app"}, + http.StatusInternalServerError) + + return + } + + h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated) + } +} + +// HandleAPIDeleteApp returns a handler that deletes an app. +func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + + application, err := h.appService.GetApp(request.Context(), appID) + if err != nil { + h.respondJSON(writer, request, + map[string]string{"error": "internal server error"}, + http.StatusInternalServerError) + + return + } + + if application == nil { + h.respondJSON(writer, request, + map[string]string{"error": "app not found"}, + http.StatusNotFound) + + return + } + + deleteErr := h.appService.DeleteApp(request.Context(), application) + if deleteErr != nil { + h.log.Error("api: failed to delete app", "error", deleteErr) + h.respondJSON(writer, request, + map[string]string{"error": "failed to delete app"}, + http.StatusInternalServerError) + + return + } + + h.respondJSON(writer, request, + map[string]string{"status": "deleted"}, http.StatusOK) + } +} + +// deploymentsPageLimit is the default number of deployments per page. +const deploymentsPageLimit = 20 + +// HandleAPIListDeployments returns a handler that lists deployments for an app. +func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + + application, err := h.appService.GetApp(request.Context(), appID) + if err != nil || application == nil { + h.respondJSON(writer, request, + map[string]string{"error": "app not found"}, + http.StatusNotFound) + + return + } + + limit := deploymentsPageLimit + + if l := request.URL.Query().Get("limit"); l != "" { + parsed, parseErr := strconv.Atoi(l) + if parseErr == nil && parsed > 0 { + limit = parsed + } + } + + deployments, deployErr := application.GetDeployments( + request.Context(), limit, + ) + if deployErr != nil { + h.respondJSON(writer, request, + map[string]string{"error": "failed to list deployments"}, + http.StatusInternalServerError) + + return + } + + result := make([]apiDeploymentResponse, 0, len(deployments)) + for _, d := range deployments { + result = append(result, deploymentToAPI(d)) + } + + h.respondJSON(writer, request, result, http.StatusOK) + } +} + +// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app. +func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + + application, err := h.appService.GetApp(request.Context(), appID) + if err != nil || application == nil { + h.respondJSON(writer, request, + map[string]string{"error": "app not found"}, + http.StatusNotFound) + + return + } + + deployErr := h.deploy.Deploy(request.Context(), application, nil, true) + if deployErr != nil { + h.log.Error("api: failed to trigger deploy", "error", deployErr) + h.respondJSON(writer, request, + map[string]string{"error": deployErr.Error()}, + http.StatusConflict) + + return + } + + h.respondJSON(writer, request, + map[string]string{"status": "deploying"}, http.StatusAccepted) + } +} + +// HandleAPICreateToken returns a handler that creates an API token. +func (h *Handlers) HandleAPICreateToken() http.HandlerFunc { + type createTokenRequest struct { + Name string `json:"name"` + } + + type createTokenResponse struct { + Token string `json:"token"` + Name string `json:"name"` + ID int64 `json:"id"` + } + + return func(writer http.ResponseWriter, request *http.Request) { + user := middleware.APIUserFromContext(request.Context()) + if user == nil { + h.respondJSON(writer, request, + map[string]string{"error": "unauthorized"}, + http.StatusUnauthorized) + + return + } + + var req createTokenRequest + + decodeErr := json.NewDecoder(request.Body).Decode(&req) + if decodeErr != nil { + req.Name = "default" + } + + if req.Name == "" { + req.Name = "default" + } + + rawToken, token, err := models.GenerateAPIToken( + request.Context(), h.db, user.ID, req.Name, + ) + if err != nil { + h.log.Error("api: failed to create token", "error", err) + h.respondJSON(writer, request, + map[string]string{"error": "failed to create token"}, + http.StatusInternalServerError) + + return + } + + h.respondJSON(writer, request, createTokenResponse{ + Token: rawToken, + Name: token.Name, + ID: token.ID, + }, http.StatusCreated) + } +} + +// HandleAPIWhoAmI returns a handler that shows the current authenticated user. +func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc { + type whoAmIResponse struct { + UserID int64 `json:"userId"` + Username string `json:"username"` + } + + return func(writer http.ResponseWriter, request *http.Request) { + user := middleware.APIUserFromContext(request.Context()) + if user == nil { + h.respondJSON(writer, request, + map[string]string{"error": "unauthorized"}, + http.StatusUnauthorized) + + return + } + + h.respondJSON(writer, request, whoAmIResponse{ + UserID: user.ID, + Username: user.Username, + }, http.StatusOK) + } +} diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go new file mode 100644 index 0000000..9ff9719 --- /dev/null +++ b/internal/handlers/api_test.go @@ -0,0 +1,254 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.eeqj.de/sneak/upaas/internal/models" +) + +func setupAPITest(t *testing.T) (*testContext, string) { + t.Helper() + + tc := setupTestHandlers(t) + + // Create a user first. + _, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123") + require.NoError(t, err) + + user, err := models.FindUserByUsername(t.Context(), tc.database, "admin") + require.NoError(t, err) + require.NotNil(t, user) + + // Generate an API token. + rawToken, _, err := models.GenerateAPIToken(t.Context(), tc.database, user.ID, "test") + require.NoError(t, err) + + return tc, rawToken +} + +func apiRequest( + t *testing.T, + tc *testContext, + token, method, path string, + body string, +) *httptest.ResponseRecorder { + t.Helper() + + var req *http.Request + if body != "" { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + + req.Header.Set("Authorization", "Bearer "+token) + + rr := httptest.NewRecorder() + + // Build a chi router with API routes. + r := chi.NewRouter() + mw := tc.middleware + + r.Route("/api/v1", func(apiR chi.Router) { + apiR.Use(mw.APITokenAuth()) + apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI()) + apiR.Post("/tokens", tc.handlers.HandleAPICreateToken()) + apiR.Get("/apps", tc.handlers.HandleAPIListApps()) + apiR.Post("/apps", tc.handlers.HandleAPICreateApp()) + apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp()) + apiR.Delete("/apps/{id}", tc.handlers.HandleAPIDeleteApp()) + apiR.Post("/apps/{id}/deploy", tc.handlers.HandleAPITriggerDeploy()) + apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments()) + }) + + r.ServeHTTP(rr, req) + + return rr +} + +func TestAPIAuthRejectsNoToken(t *testing.T) { + t.Parallel() + + tc := setupTestHandlers(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil) + rr := httptest.NewRecorder() + + r := chi.NewRouter() + r.Route("/api/v1", func(apiR chi.Router) { + apiR.Use(tc.middleware.APITokenAuth()) + apiR.Get("/apps", tc.handlers.HandleAPIListApps()) + }) + + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAPIAuthRejectsInvalidToken(t *testing.T) { + t.Parallel() + + tc := setupTestHandlers(t) + + rr := apiRequest(t, tc, "invalid-token", http.MethodGet, "/api/v1/apps", "") + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAPIWhoAmI(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/whoami", "") + assert.Equal(t, http.StatusOK, rr.Code) + + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.Equal(t, "admin", resp["username"]) +} + +func TestAPIListAppsEmpty(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps", "") + assert.Equal(t, http.StatusOK, rr.Code) + + var apps []any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &apps)) + assert.Empty(t, apps) +} + +func TestAPICreateApp(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + assert.Equal(t, http.StatusCreated, rr.Code) + + var app map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app)) + assert.Equal(t, "test-app", app["name"]) + assert.Equal(t, "pending", app["status"]) +} + +func TestAPICreateAppValidation(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + // Missing required fields. + body := `{"name":"","repoUrl":""}` + rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestAPIGetApp(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + // Create an app first. + body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + require.Equal(t, http.StatusCreated, rr.Code) + + var created map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created)) + + appID, ok := created["id"].(string) + require.True(t, ok) + + // Get the app. + rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID, "") + assert.Equal(t, http.StatusOK, rr.Code) + + var app map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app)) + assert.Equal(t, "my-app", app["name"]) +} + +func TestAPIGetAppNotFound(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + rr := apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/nonexistent", "") + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestAPIDeleteApp(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + // Create an app. + body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + require.Equal(t, http.StatusCreated, rr.Code) + + var created map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created)) + + appID, ok := created["id"].(string) + require.True(t, ok) + + // Delete it. + rr = apiRequest(t, tc, token, http.MethodDelete, "/api/v1/apps/"+appID, "") + assert.Equal(t, http.StatusOK, rr.Code) + + // Verify it's gone. + rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID, "") + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestAPIListDeployments(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + // Create an app. + body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/apps", body) + require.Equal(t, http.StatusCreated, rr.Code) + + var created map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created)) + + appID, ok := created["id"].(string) + require.True(t, ok) + + // List deployments (should be empty). + rr = apiRequest(t, tc, token, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "") + assert.Equal(t, http.StatusOK, rr.Code) + + var deployments []any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &deployments)) + assert.Empty(t, deployments) +} + +func TestAPICreateToken(t *testing.T) { + t.Parallel() + + tc, token := setupAPITest(t) + + body := `{"name":"new-token"}` + rr := apiRequest(t, tc, token, http.MethodPost, "/api/v1/tokens", body) + assert.Equal(t, http.StatusCreated, rr.Code) + + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.Equal(t, "new-token", resp["name"]) + assert.NotEmpty(t, resp["token"]) +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index efb996f..967bb7e 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -24,6 +24,7 @@ import ( "git.eeqj.de/sneak/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/logger" + "git.eeqj.de/sneak/upaas/internal/middleware" "git.eeqj.de/sneak/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/deploy" @@ -32,10 +33,11 @@ import ( ) type testContext struct { - handlers *handlers.Handlers - database *database.Database - authSvc *auth.Service - appSvc *app.Service + handlers *handlers.Handlers + database *database.Database + authSvc *auth.Service + appSvc *app.Service + middleware *middleware.Middleware } func createTestConfig(t *testing.T) *config.Config { @@ -166,11 +168,21 @@ func setupTestHandlers(t *testing.T) *testContext { ) require.NoError(t, handlerErr) + mw, mwErr := middleware.New(fx.Lifecycle(nil), middleware.Params{ + Logger: logInstance, + Globals: globalInstance, + Config: cfg, + Auth: authSvc, + Database: dbInstance, + }) + require.NoError(t, mwErr) + return &testContext{ - handlers: handlersInstance, - database: dbInstance, - authSvc: authSvc, - appSvc: appSvc, + handlers: handlersInstance, + database: dbInstance, + authSvc: authSvc, + appSvc: appSvc, + middleware: mw, } } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 69fa447..0ca3caf 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -2,6 +2,7 @@ package middleware import ( + "context" "log/slog" "math" "net" @@ -19,22 +20,28 @@ import ( "golang.org/x/time/rate" "git.eeqj.de/sneak/upaas/internal/config" + "git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/logger" + "git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/service/auth" ) // corsMaxAge is the maximum age for CORS preflight responses in seconds. const corsMaxAge = 300 +// apiUserContextKey is the context key for the authenticated API user. +type apiUserContextKey struct{} + // Params contains dependencies for Middleware. type Params struct { fx.In - Logger *logger.Logger - Globals *globals.Globals - Config *config.Config - Auth *auth.Service + Logger *logger.Logger + Globals *globals.Globals + Config *config.Config + Auth *auth.Service + Database *database.Database } // Middleware provides HTTP middleware. @@ -339,6 +346,74 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler { } } +// APITokenAuth returns middleware that authenticates requests via Bearer token. +// It looks up the token hash in the database and stores the user in context. +func (m *Middleware) APITokenAuth() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + authHeader := request.Header.Get("Authorization") + if authHeader == "" { + http.Error(writer, `{"error":"missing Authorization header"}`, http.StatusUnauthorized) + + return + } + + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + http.Error(writer, `{"error":"invalid Authorization header"}`, http.StatusUnauthorized) + + return + } + + rawToken := strings.TrimPrefix(authHeader, bearerPrefix) + if rawToken == "" { + http.Error(writer, `{"error":"empty token"}`, http.StatusUnauthorized) + + return + } + + hash := models.HashAPIToken(rawToken) + + apiToken, err := models.FindAPITokenByHash(request.Context(), m.params.Database, hash) + if err != nil { + m.log.Error("api token lookup error", "error", err) + http.Error(writer, `{"error":"internal server error"}`, http.StatusInternalServerError) + + return + } + + if apiToken == nil { + http.Error(writer, `{"error":"invalid token"}`, http.StatusUnauthorized) + + return + } + + // Touch last used (best-effort, don't block on error) + _ = apiToken.TouchLastUsed(request.Context()) + + user, userErr := models.FindUser(request.Context(), m.params.Database, apiToken.UserID) + if userErr != nil || user == nil { + http.Error(writer, `{"error":"token user not found"}`, http.StatusUnauthorized) + + return + } + + ctx := context.WithValue(request.Context(), apiUserContextKey{}, user) + next.ServeHTTP(writer, request.WithContext(ctx)) + }) + } +} + +// APIUserFromContext extracts the authenticated API user from the context. +func APIUserFromContext(ctx context.Context) *models.User { + user, _ := ctx.Value(apiUserContextKey{}).(*models.User) + + return user +} + // SetupRequired returns middleware that redirects to setup if no user exists. func (m *Middleware) SetupRequired() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/internal/models/api_token.go b/internal/models/api_token.go new file mode 100644 index 0000000..ed88b32 --- /dev/null +++ b/internal/models/api_token.go @@ -0,0 +1,187 @@ +package models + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" + + "git.eeqj.de/sneak/upaas/internal/database" +) + +// tokenBytes is the number of random bytes for a raw API token. +const tokenBytes = 32 + +// APIToken represents an API authentication token. +type APIToken struct { + db *database.Database + + ID int64 + UserID int64 + Name string + TokenHash string + CreatedAt time.Time + LastUsedAt sql.NullTime +} + +// NewAPIToken creates a new APIToken with a database reference. +func NewAPIToken(db *database.Database) *APIToken { + return &APIToken{db: db} +} + +// GenerateAPIToken creates a new API token for a user, returning the raw token +// string (shown once) and the persisted APIToken record. +func GenerateAPIToken( + ctx context.Context, + db *database.Database, + userID int64, + name string, +) (string, *APIToken, error) { + raw := make([]byte, tokenBytes) + + _, err := rand.Read(raw) + if err != nil { + return "", nil, fmt.Errorf("generating token bytes: %w", err) + } + + rawHex := hex.EncodeToString(raw) + hash := HashAPIToken(rawHex) + + token := NewAPIToken(db) + token.UserID = userID + token.Name = name + token.TokenHash = hash + + query := `INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)` + + result, execErr := db.Exec(ctx, query, userID, name, hash) + if execErr != nil { + return "", nil, fmt.Errorf("inserting api token: %w", execErr) + } + + id, idErr := result.LastInsertId() + if idErr != nil { + return "", nil, fmt.Errorf("getting token id: %w", idErr) + } + + token.ID = id + + reloadErr := token.Reload(ctx) + if reloadErr != nil { + return "", nil, fmt.Errorf("reloading token: %w", reloadErr) + } + + return rawHex, token, nil +} + +// HashAPIToken returns the SHA-256 hex digest of a raw token string. +func HashAPIToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + + return hex.EncodeToString(sum[:]) +} + +// Reload refreshes the token from the database. +func (t *APIToken) Reload(ctx context.Context) error { + row := t.db.QueryRow(ctx, + `SELECT id, user_id, name, token_hash, created_at, last_used_at + FROM api_tokens WHERE id = ?`, t.ID, + ) + + return t.scan(row) +} + +// Delete removes the token from the database. +func (t *APIToken) Delete(ctx context.Context) error { + _, err := t.db.Exec(ctx, "DELETE FROM api_tokens WHERE id = ?", t.ID) + + return err +} + +// TouchLastUsed updates the last_used_at timestamp. +func (t *APIToken) TouchLastUsed(ctx context.Context) error { + _, err := t.db.Exec(ctx, + "UPDATE api_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", + t.ID, + ) + + return err +} + +func (t *APIToken) scan(row *sql.Row) error { + return row.Scan( + &t.ID, &t.UserID, &t.Name, &t.TokenHash, + &t.CreatedAt, &t.LastUsedAt, + ) +} + +// FindAPITokenByHash looks up a token by its SHA-256 hash. +// +//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record +func FindAPITokenByHash( + ctx context.Context, + db *database.Database, + hash string, +) (*APIToken, error) { + token := NewAPIToken(db) + + row := db.QueryRow(ctx, + `SELECT id, user_id, name, token_hash, created_at, last_used_at + FROM api_tokens WHERE token_hash = ?`, hash, + ) + + err := token.scan(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + + return nil, fmt.Errorf("scanning api token: %w", err) + } + + return token, nil +} + +// FindAPITokensByUserID returns all tokens for a user. +func FindAPITokensByUserID( + ctx context.Context, + db *database.Database, + userID int64, +) ([]*APIToken, error) { + rows, err := db.Query(ctx, + `SELECT id, user_id, name, token_hash, created_at, last_used_at + FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID, + ) + if err != nil { + return nil, fmt.Errorf("querying api tokens: %w", err) + } + + defer func() { _ = rows.Close() }() + + var tokens []*APIToken + + for rows.Next() { + tok := NewAPIToken(db) + + scanErr := rows.Scan( + &tok.ID, &tok.UserID, &tok.Name, &tok.TokenHash, + &tok.CreatedAt, &tok.LastUsedAt, + ) + if scanErr != nil { + return nil, fmt.Errorf("scanning api token row: %w", scanErr) + } + + tokens = append(tokens, tok) + } + + rowsErr := rows.Err() + if rowsErr != nil { + return nil, fmt.Errorf("iterating api token rows: %w", rowsErr) + } + + return tokens, nil +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 2fb632f..ec42264 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -97,6 +97,21 @@ func (s *Server) SetupRoutes() { }) }) + // API v1 routes (Bearer token auth, no CSRF) + s.router.Route("/api/v1", func(r chi.Router) { + r.Use(s.mw.APITokenAuth()) + + r.Get("/whoami", s.handlers.HandleAPIWhoAmI()) + r.Post("/tokens", s.handlers.HandleAPICreateToken()) + + r.Get("/apps", s.handlers.HandleAPIListApps()) + r.Post("/apps", s.handlers.HandleAPICreateApp()) + r.Get("/apps/{id}", s.handlers.HandleAPIGetApp()) + r.Delete("/apps/{id}", s.handlers.HandleAPIDeleteApp()) + r.Post("/apps/{id}/deploy", s.handlers.HandleAPITriggerDeploy()) + r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments()) + }) + // Metrics endpoint (optional, with basic auth) if s.params.Config.MetricsUsername != "" { s.router.Group(func(r chi.Router) {