This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

View file

@ -0,0 +1,513 @@
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
}
}