package httpserver import ( "errors" "fmt" "net/http" "strconv" "strings" "time" "update_server/internal/apikeys" "update_server/internal/db" ) func (h *handler) adminAPIKeys(w http.ResponseWriter, r *http.Request) { keys, err := h.apiKeys.List(r.Context()) if err != nil { http.Error(w, "api key listing failed", http.StatusInternalServerError) return } data := APIKeysPageData{ PageData: h.basePageData( r, "API Keys", "Protected Admin", "API Keys", "Generate client credentials, assign action permissions, and control project visibility through project or tag allow and deny rules.", ), APIKeys: keys, } data.Flash = apiKeyListFlash(r.URL.Query().Get("status")) renderPage(w, h.renderer, "api_keys", data) } func (h *handler) adminAPIKeyNew(w http.ResponseWriter, r *http.Request) { data := APIKeyPageData{ PageData: h.basePageData( r, "New API Key", "Protected Admin", "Create API Key", "Generate a secure key, choose the minimum permissions it needs, and define access with one scope mode at a time.", ), Form: APIKeyFormData{ Action: "/admin/api-keys", SubmitLabel: "Create API key", ScopeMode: db.ScopeModeProjectAllowList, CanDownload: true, }, } if err := h.populateAPIKeyChoices(r, &data); err != nil { http.Error(w, "api key form failed", http.StatusInternalServerError) return } renderPage(w, h.renderer, "api_key_form", data) } func (h *handler) adminAPIKeyCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "invalid api key form", http.StatusBadRequest) return } form, inputErr := apiKeyFormDataFromRequest(r, "/admin/api-keys", "Create API key") if inputErr != "" { form.Error = inputErr h.renderAPIKeyNewPage(w, r, http.StatusBadRequest, form) return } expiresAt, err := parseAPIKeyExpiration(form.ExpiresAt) if err != nil { form.Error = "Expiration must be empty, YYYY-MM-DD, or RFC3339 UTC." h.renderAPIKeyNewPage(w, r, http.StatusBadRequest, form) return } result, err := h.apiKeys.Create(r.Context(), apikeys.CreateParams{ Name: form.Name, Description: form.Description, ScopeMode: form.ScopeMode, CanDownload: form.CanDownload, CanUpload: form.CanUpload, CanDelete: form.CanDelete, CanManageProjects: form.CanManageProjects, ExpiresAt: expiresAt, ProjectIDs: form.SelectedProjectIDs, TagIDs: form.SelectedTagIDs, CreatedByUserID: h.currentUserID(r), }) if err != nil { if userError := apiKeyUserError(err); userError != "" { form.Error = userError h.renderAPIKeyNewPage(w, r, http.StatusBadRequest, form) return } http.Error(w, "api key creation failed", http.StatusInternalServerError) return } detailForm := APIKeyFormData{ RevealKey: result.RawKey, } h.renderAPIKeyDetailPage(w, r, result.APIKey.ID, http.StatusOK, detailForm) } func (h *handler) adminAPIKeyDetail(w http.ResponseWriter, r *http.Request) { apiKeyID, err := routeID(r, "apiKeyID") if err != nil { http.NotFound(w, r) return } data, err := h.apiKeyPageData(r, apiKeyID, APIKeyFormData{}) if err != nil { h.renderAPIKeyDetailError(w, r, err) return } data.Flash = apiKeyDetailFlash(r.URL.Query().Get("status")) renderPage(w, h.renderer, "api_key_form", *data) } func (h *handler) adminAPIKeyUpdate(w http.ResponseWriter, r *http.Request) { apiKeyID, err := routeID(r, "apiKeyID") if err != nil { http.NotFound(w, r) return } if err := r.ParseForm(); err != nil { http.Error(w, "invalid api key form", http.StatusBadRequest) return } form, inputErr := apiKeyFormDataFromRequest(r, fmt.Sprintf("/admin/api-keys/%d", apiKeyID), "Save API key") if inputErr != "" { form.Error = inputErr h.renderAPIKeyDetailPage(w, r, apiKeyID, http.StatusBadRequest, form) return } expiresAt, err := parseAPIKeyExpiration(form.ExpiresAt) if err != nil { form.Error = "Expiration must be empty, YYYY-MM-DD, or RFC3339 UTC." h.renderAPIKeyDetailPage(w, r, apiKeyID, http.StatusBadRequest, form) return } _, err = h.apiKeys.Update(r.Context(), apiKeyID, apikeys.UpdateParams{ Name: form.Name, Description: form.Description, ScopeMode: form.ScopeMode, CanDownload: form.CanDownload, CanUpload: form.CanUpload, CanDelete: form.CanDelete, CanManageProjects: form.CanManageProjects, ExpiresAt: expiresAt, ProjectIDs: form.SelectedProjectIDs, TagIDs: form.SelectedTagIDs, }) if err != nil { switch { case errors.Is(err, db.ErrNotFound): http.NotFound(w, r) case apiKeyUserError(err) != "": form.Error = apiKeyUserError(err) h.renderAPIKeyDetailPage(w, r, apiKeyID, http.StatusBadRequest, form) default: http.Error(w, "api key update failed", http.StatusInternalServerError) } return } http.Redirect(w, r, fmt.Sprintf("/admin/api-keys/%d?status=api-key-updated", apiKeyID), http.StatusSeeOther) } func (h *handler) adminAPIKeyToggleActive(w http.ResponseWriter, r *http.Request) { apiKeyID, err := routeID(r, "apiKeyID") if err != nil { http.NotFound(w, r) return } if err := r.ParseForm(); err != nil { http.Error(w, "invalid api key lifecycle form", http.StatusBadRequest) return } var ( isActive bool statusCode string ) switch strings.TrimSpace(r.FormValue("state")) { case "activate": isActive = true statusCode = "api-key-activated" case "revoke": isActive = false statusCode = "api-key-revoked" default: http.Error(w, "invalid lifecycle action", http.StatusBadRequest) return } if err := h.apiKeys.SetActive(r.Context(), apiKeyID, isActive); err != nil { if errors.Is(err, db.ErrNotFound) { http.NotFound(w, r) return } http.Error(w, "api key lifecycle update failed", http.StatusInternalServerError) return } http.Redirect(w, r, fmt.Sprintf("/admin/api-keys/%d?status=%s", apiKeyID, statusCode), http.StatusSeeOther) } func (h *handler) renderAPIKeyNewPage(w http.ResponseWriter, r *http.Request, status int, form APIKeyFormData) { data := APIKeyPageData{ PageData: h.basePageData( r, "New API Key", "Protected Admin", "Create API Key", "Generate a secure key, choose the minimum permissions it needs, and define access with one scope mode at a time.", ), Form: form, } if err := h.populateAPIKeyChoices(r, &data); err != nil { http.Error(w, "api key form failed", http.StatusInternalServerError) return } renderPageStatus(w, h.renderer, "api_key_form", status, data) } func (h *handler) renderAPIKeyDetailPage(w http.ResponseWriter, r *http.Request, apiKeyID int64, status int, form APIKeyFormData) { if form.RevealKey != "" { applyNoStoreHeaders(w) } data, err := h.apiKeyPageData(r, apiKeyID, form) if err != nil { h.renderAPIKeyDetailError(w, r, err) return } renderPageStatus(w, h.renderer, "api_key_form", status, *data) } func (h *handler) renderAPIKeyDetailError(w http.ResponseWriter, r *http.Request, err error) { if errors.Is(err, db.ErrNotFound) { http.NotFound(w, r) return } http.Error(w, "api key page failed", http.StatusInternalServerError) } func (h *handler) apiKeyPageData(r *http.Request, apiKeyID int64, form APIKeyFormData) (*APIKeyPageData, error) { apiKey, err := h.apiKeys.GetByID(r.Context(), apiKeyID) if err != nil { return nil, err } projectAccess, err := h.apiKeys.ListProjectAccess(r.Context(), apiKeyID) if err != nil { return nil, err } tagAccess, err := h.apiKeys.ListTagAccess(r.Context(), apiKeyID) if err != nil { return nil, err } accessibleProjects, err := h.apiKeys.ListAccessibleProjects(r.Context(), *apiKey) if err != nil { return nil, err } if form.Action == "" { form = APIKeyFormData{ Action: fmt.Sprintf("/admin/api-keys/%d", apiKeyID), SubmitLabel: "Save API key", Name: apiKey.Name, Description: apiKey.Description, ScopeMode: apiKey.ScopeMode, ExpiresAt: formatOptionalTimestamp(apiKey.ExpiresAt), CanDownload: apiKey.CanDownload, CanUpload: apiKey.CanUpload, CanDelete: apiKey.CanDelete, CanManageProjects: apiKey.CanManageProjects, SelectedProjectIDs: projectIDs(projectAccess), SelectedTagIDs: tagIDs(tagAccess), Error: form.Error, RevealKey: form.RevealKey, } } data := &APIKeyPageData{ PageData: h.basePageData( r, apiKey.Name, "Protected Admin", apiKey.Name, "Rotate permissions, change scope mode, and preview which active projects this key can currently reach.", ), APIKey: apiKey, Form: form, AccessibleProjects: accessibleProjects, ToggleAction: fmt.Sprintf("/admin/api-keys/%d/activate", apiKeyID), } if apiKey.IsActive { data.ToggleState = "revoke" data.ToggleLabel = "Revoke key" } else { data.ToggleState = "activate" data.ToggleLabel = "Activate key" } if err := h.populateAPIKeyChoices(r, data); err != nil { return nil, err } return data, nil } func (h *handler) populateAPIKeyChoices(r *http.Request, data *APIKeyPageData) error { projectItems, err := h.store.Projects.List(r.Context()) if err != nil { return err } tagItems, err := h.store.Tags.List(r.Context()) if err != nil { return err } selectedProjects := make(map[int64]struct{}, len(data.Form.SelectedProjectIDs)) for _, id := range data.Form.SelectedProjectIDs { selectedProjects[id] = struct{}{} } selectedTags := make(map[int64]struct{}, len(data.Form.SelectedTagIDs)) for _, id := range data.Form.SelectedTagIDs { selectedTags[id] = struct{}{} } data.ProjectChoices = make([]APIKeyProjectChoice, 0, len(projectItems)) for _, item := range projectItems { _, selected := selectedProjects[item.Project.ID] data.ProjectChoices = append(data.ProjectChoices, APIKeyProjectChoice{ Project: item.Project, Selected: selected, }) } data.TagChoices = make([]APIKeyTagChoice, 0, len(tagItems)) for _, item := range tagItems { _, selected := selectedTags[item.Tag.ID] data.TagChoices = append(data.TagChoices, APIKeyTagChoice{ Tag: item.Tag, Selected: selected, }) } return nil } func apiKeyFormDataFromRequest(r *http.Request, action, submitLabel string) (APIKeyFormData, string) { form := APIKeyFormData{ Action: action, SubmitLabel: submitLabel, Name: strings.TrimSpace(r.FormValue("name")), Description: strings.TrimSpace(r.FormValue("description")), ScopeMode: db.ScopeMode(strings.TrimSpace(r.FormValue("scope_mode"))), ExpiresAt: strings.TrimSpace(r.FormValue("expires_at")), CanDownload: r.FormValue("can_download") != "", CanUpload: r.FormValue("can_upload") != "", CanDelete: r.FormValue("can_delete") != "", CanManageProjects: r.FormValue("can_manage_projects") != "", } projectIDs, err := parseMultiIDList(r.Form["project_id"]) if err != nil { return form, "Choose only valid projects." } form.SelectedProjectIDs = projectIDs tagIDs, err := parseMultiIDList(r.Form["tag_id"]) if err != nil { return form, "Choose only valid tags." } form.SelectedTagIDs = tagIDs if form.Name == "" { return form, "API key name is required." } return form, "" } func parseMultiIDList(values []string) ([]int64, error) { result := make([]int64, 0, len(values)) for _, raw := range values { raw = strings.TrimSpace(raw) if raw == "" { continue } value, err := strconv.ParseInt(raw, 10, 64) if err != nil || value <= 0 { return nil, fmt.Errorf("invalid id") } result = append(result, value) } return result, nil } func parseAPIKeyExpiration(raw string) (*time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } if parsed, err := time.Parse(time.RFC3339, raw); err == nil { utc := parsed.UTC() return &utc, nil } if parsed, err := time.Parse("2006-01-02", raw); err == nil { utc := parsed.UTC() return &utc, nil } return nil, fmt.Errorf("invalid expiration") } func formatOptionalTimestamp(value *time.Time) string { if value == nil { return "" } return value.UTC().Format(time.RFC3339) } func apiKeyUserError(err error) string { message := strings.TrimSpace(err.Error()) lower := strings.ToLower(message) switch { case strings.Contains(lower, "api key name is required"): return "API key name is required." case strings.Contains(lower, "scope mode is required"): return "Choose one scope mode." case strings.Contains(lower, "select at least one permission"): return "Select at least one permission." case strings.Contains(lower, "foreign key"): return "One of the selected projects or tags no longer exists." default: return "" } } func projectIDs(projects []db.Project) []int64 { ids := make([]int64, 0, len(projects)) for _, project := range projects { ids = append(ids, project.ID) } return ids } func tagIDs(tags []db.Tag) []int64 { ids := make([]int64, 0, len(tags)) for _, tag := range tags { ids = append(ids, tag.ID) } return ids } func apiKeyListFlash(code string) *FlashMessage { switch code { default: return nil } } func apiKeyDetailFlash(code string) *FlashMessage { switch code { case "api-key-updated": return &FlashMessage{Kind: "success", Message: "API key updated."} case "api-key-activated": return &FlashMessage{Kind: "success", Message: "API key activated."} case "api-key-revoked": return &FlashMessage{Kind: "success", Message: "API key revoked."} default: return nil } }