init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
513
internal/http/admin_api_keys.go
Normal file
513
internal/http/admin_api_keys.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue