diff --git a/internal/handlers/api.go b/internal/handlers/api.go new file mode 100644 index 0000000..d97be9b --- /dev/null +++ b/internal/handlers/api.go @@ -0,0 +1,377 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "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 +} + +// HandleAPILoginPOST returns a handler that authenticates via JSON credentials +// and sets a session cookie. +func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc { + type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + } + + type loginResponse struct { + UserID int64 `json:"userId"` + Username string `json:"username"` + } + + return func(writer http.ResponseWriter, request *http.Request) { + var req loginRequest + + 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.Username == "" || req.Password == "" { + h.respondJSON(writer, request, + map[string]string{"error": "username and password are required"}, + http.StatusBadRequest) + + return + } + + user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password) + if authErr != nil { + h.respondJSON(writer, request, + map[string]string{"error": "invalid credentials"}, + http.StatusUnauthorized) + + return + } + + sessionErr := h.auth.CreateSession(writer, request, user) + if sessionErr != nil { + h.log.Error("api: failed to create session", "error", sessionErr) + h.respondJSON(writer, request, + map[string]string{"error": "failed to create session"}, + http.StatusInternalServerError) + + return + } + + h.respondJSON(writer, request, loginResponse{ + UserID: user.ID, + Username: user.Username, + }, http.StatusOK) + } +} + +// 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) + } +} + +// 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, err := h.auth.GetCurrentUser(request.Context(), request) + if err != nil || 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..6d7d899 --- /dev/null +++ b/internal/handlers/api_test.go @@ -0,0 +1,299 @@ +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" +) + +// apiRouter builds a chi router with the API routes using session auth middleware. +func apiRouter(tc *testContext) http.Handler { + r := chi.NewRouter() + + r.Route("/api/v1", func(apiR chi.Router) { + apiR.Post("/login", tc.handlers.HandleAPILoginPOST()) + + apiR.Group(func(apiR chi.Router) { + apiR.Use(tc.middleware.APISessionAuth()) + apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI()) + 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()) + }) + }) + + return r +} + +// setupAPITest creates a test context with a user and returns session cookies. +func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) { + t.Helper() + + tc := setupTestHandlers(t) + + // Create a user. + _, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123") + require.NoError(t, err) + + // Login via the API to get session cookies. + r := apiRouter(tc) + + loginBody := `{"username":"admin","password":"password123"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + + cookies := rr.Result().Cookies() + require.NotEmpty(t, cookies, "login should return session cookies") + + return tc, cookies +} + +// apiRequest makes an authenticated API request using session cookies. +func apiRequest( + t *testing.T, + tc *testContext, + cookies []*http.Cookie, + 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) + } + + for _, c := range cookies { + req.AddCookie(c) + } + + rr := httptest.NewRecorder() + + r := apiRouter(tc) + r.ServeHTTP(rr, req) + + return rr +} + +func TestAPILoginSuccess(t *testing.T) { + t.Parallel() + + tc := setupTestHandlers(t) + + _, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123") + require.NoError(t, err) + + r := apiRouter(tc) + + body := `{"username":"admin","password":"password123"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + 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"]) + + // Should have a Set-Cookie header. + assert.NotEmpty(t, rr.Result().Cookies()) +} + +func TestAPILoginInvalidCredentials(t *testing.T) { + t.Parallel() + + tc := setupTestHandlers(t) + + _, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123") + require.NoError(t, err) + + r := apiRouter(tc) + + body := `{"username":"admin","password":"wrong"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAPILoginMissingFields(t *testing.T) { + t.Parallel() + + tc := setupTestHandlers(t) + + r := apiRouter(tc) + + body := `{"username":"","password":""}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestAPIRejectsUnauthenticated(t *testing.T) { + t.Parallel() + + tc := setupTestHandlers(t) + + r := apiRouter(tc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestAPIWhoAmI(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + rr := apiRequest(t, tc, cookies, 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, cookies := setupAPITest(t) + + rr := apiRequest(t, tc, cookies, 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, cookies := setupAPITest(t) + + body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, cookies, 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, cookies := setupAPITest(t) + + body := `{"name":"","repoUrl":""}` + rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestAPIGetApp(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, cookies, 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) + + rr = apiRequest(t, tc, cookies, 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, cookies := setupAPITest(t) + + rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "") + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestAPIDeleteApp(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, cookies, 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) + + rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "") + assert.Equal(t, http.StatusOK, rr.Code) + + rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "") + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestAPIListDeployments(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}` + rr := apiRequest(t, tc, cookies, 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) + + rr = apiRequest(t, tc, cookies, 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) +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index df6964d..ae04f3c 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,20 @@ 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, + }) + 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..0b8179b 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -339,6 +339,27 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler { } } +// APISessionAuth returns middleware that requires session authentication for API routes. +// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login. +func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func( + writer http.ResponseWriter, + request *http.Request, + ) { + user, err := m.params.Auth.GetCurrentUser(request.Context(), request) + if err != nil || user == nil { + writer.Header().Set("Content-Type", "application/json") + http.Error(writer, `{"error":"unauthorized"}`, http.StatusUnauthorized) + + return + } + + next.ServeHTTP(writer, request) + }) + } +} + // 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/server/routes.go b/internal/server/routes.go index f89b6c9..21e4d3d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -102,6 +102,26 @@ func (s *Server) SetupRoutes() { }) }) + // API v1 routes (cookie-based session auth, no CSRF) + s.router.Route("/api/v1", func(r chi.Router) { + // Login endpoint is public (returns session cookie) + r.With(s.mw.LoginRateLimit()).Post("/login", s.handlers.HandleAPILoginPOST()) + + // All other API routes require session auth + r.Group(func(r chi.Router) { + r.Use(s.mw.APISessionAuth()) + + r.Get("/whoami", s.handlers.HandleAPIWhoAmI()) + + 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) {