513 lines
13 KiB
Go
513 lines
13 KiB
Go
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
|
|
}
|
|
}
|