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
|
||||
}
|
||||
}
|
||||
135
internal/http/admin_common.go
Normal file
135
internal/http/admin_common.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
authservice "update_server/internal/auth"
|
||||
"update_server/internal/db"
|
||||
"update_server/internal/slug"
|
||||
)
|
||||
|
||||
func (h *handler) basePageData(r *http.Request, title, eyebrow, heading, description string) PageData {
|
||||
return PageData{
|
||||
Title: title,
|
||||
Eyebrow: eyebrow,
|
||||
Heading: heading,
|
||||
Description: description,
|
||||
BaseURL: strings.TrimRight(h.config.BaseURL, "/"),
|
||||
CSRFToken: h.csrfToken(r),
|
||||
CurrentUser: h.currentUser(r),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) currentUser(r *http.Request) *db.User {
|
||||
sessionState, ok := authservice.FromContext(r.Context())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
user := sessionState.User
|
||||
return &user
|
||||
}
|
||||
|
||||
func (h *handler) currentUserID(r *http.Request) *int64 {
|
||||
user := h.currentUser(r)
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &user.ID
|
||||
}
|
||||
|
||||
func routeID(r *http.Request, key string) (int64, error) {
|
||||
raw := strings.TrimSpace(chi.URLParam(r, key))
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("%s is required", key)
|
||||
}
|
||||
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return 0, fmt.Errorf("invalid %s", key)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func projectFormInput(name, slugValue, description string) (db.CreateProjectParams, string) {
|
||||
name = strings.TrimSpace(name)
|
||||
slugValue = slug.Make(firstNonEmpty(slugValue, name))
|
||||
description = strings.TrimSpace(description)
|
||||
|
||||
switch {
|
||||
case name == "":
|
||||
return db.CreateProjectParams{}, "Project name is required."
|
||||
case slugValue == "":
|
||||
return db.CreateProjectParams{}, "Project slug is required."
|
||||
default:
|
||||
return db.CreateProjectParams{
|
||||
Name: name,
|
||||
Slug: slugValue,
|
||||
Description: description,
|
||||
}, ""
|
||||
}
|
||||
}
|
||||
|
||||
func tagFormInput(name, slugValue, description string) (db.CreateTagParams, string) {
|
||||
name = strings.TrimSpace(name)
|
||||
slugValue = slug.Make(firstNonEmpty(slugValue, name))
|
||||
description = strings.TrimSpace(description)
|
||||
|
||||
switch {
|
||||
case name == "":
|
||||
return db.CreateTagParams{}, "Tag name is required."
|
||||
case slugValue == "":
|
||||
return db.CreateTagParams{}, "Tag slug is required."
|
||||
default:
|
||||
return db.CreateTagParams{
|
||||
Name: name,
|
||||
Slug: slugValue,
|
||||
Description: description,
|
||||
}, ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func maxUploadMegabytes(bytes int64) int64 {
|
||||
if bytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
const mebibyte = 1024 * 1024
|
||||
value := bytes / mebibyte
|
||||
if bytes%mebibyte != 0 {
|
||||
value++
|
||||
}
|
||||
if value == 0 {
|
||||
value = 1
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func maxUploadRequestLimit(fileLimit int64) int64 {
|
||||
const multipartOverhead = 1 << 20
|
||||
return fileLimit + multipartOverhead
|
||||
}
|
||||
|
||||
func isMaxBytesError(err error) bool {
|
||||
var maxErr *http.MaxBytesError
|
||||
return errors.As(err, &maxErr)
|
||||
}
|
||||
485
internal/http/admin_projects.go
Normal file
485
internal/http/admin_projects.go
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"update_server/internal/db"
|
||||
"update_server/internal/releases"
|
||||
)
|
||||
|
||||
func (h *handler) adminProjects(w http.ResponseWriter, r *http.Request) {
|
||||
projects, err := h.store.Projects.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "project listing failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := ProjectsPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"Projects",
|
||||
"Protected Admin",
|
||||
"Projects",
|
||||
"Create projects, update their metadata, archive them when needed, and open each project to manage tags and release uploads.",
|
||||
),
|
||||
Projects: projects,
|
||||
}
|
||||
data.Flash = projectListFlash(r.URL.Query().Get("status"))
|
||||
|
||||
renderPage(w, h.renderer, "projects", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := ProjectFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Project",
|
||||
"Protected Admin",
|
||||
"Create Project",
|
||||
"Add a new update stream with a stable slug so future releases and API access rules can target it reliably.",
|
||||
),
|
||||
Form: ProjectFormData{
|
||||
Action: "/admin/projects",
|
||||
SubmitLabel: "Create project",
|
||||
},
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "project_form", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid project form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := ProjectFormData{
|
||||
Action: "/admin/projects",
|
||||
SubmitLabel: "Create project",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := projectFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderProjectFormPage(w, r, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
project, err := h.store.Projects.Create(r.Context(), params)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrConflict) {
|
||||
form.Error = "A project with that slug already exists."
|
||||
h.renderProjectFormPage(w, r, http.StatusConflict, form)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "project creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=project-created", project.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectDetail(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.projectDetailPageData(r, projectID, ProjectFormData{}, ReleaseUploadData{})
|
||||
if err != nil {
|
||||
h.renderProjectDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data.Flash = projectDetailFlash(r.URL.Query().Get("status"))
|
||||
renderPage(w, h.renderer, "project_detail", *data)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid project form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := ProjectFormData{
|
||||
Action: fmt.Sprintf("/admin/projects/%d", projectID),
|
||||
SubmitLabel: "Save project",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := projectFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, form, ReleaseUploadData{})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.store.Projects.Update(r.Context(), projectID, db.UpdateProjectParams(params))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
form.Error = "A project with that slug already exists."
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusConflict, form, ReleaseUploadData{})
|
||||
default:
|
||||
http.Error(w, "project update failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=project-updated", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectArchive(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid archive form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
state := r.FormValue("state")
|
||||
var (
|
||||
setActive bool
|
||||
statusCode string
|
||||
)
|
||||
|
||||
switch state {
|
||||
case "archive":
|
||||
setActive = false
|
||||
statusCode = "project-archived"
|
||||
case "restore":
|
||||
setActive = true
|
||||
statusCode = "project-restored"
|
||||
default:
|
||||
http.Error(w, "invalid archive action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Projects.SetActive(r.Context(), projectID, setActive); err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "project archive failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=%s", projectID, statusCode), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectAttachTag(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid tag assignment form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := strconv.ParseInt(r.FormValue("tag_id"), 10, 64)
|
||||
if err != nil || tagID <= 0 {
|
||||
h.renderProjectDetailPageWithFlash(w, r, projectID, http.StatusBadRequest, "error", "Choose a valid tag to attach.")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.Tags.GetByID(r.Context(), tagID); err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
h.renderProjectDetailPageWithFlash(w, r, projectID, http.StatusBadRequest, "error", "Selected tag no longer exists.")
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "tag lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Projects.AttachTag(r.Context(), projectID, tagID); err != nil {
|
||||
http.Error(w, "tag assignment failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=tag-attached", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectDetachTag(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := routeID(r, "tagID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Projects.DetachTag(r.Context(), projectID, tagID); err != nil {
|
||||
http.Error(w, "tag detach failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=tag-detached", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectUploadRelease(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestLimit(h.config.MaxUploadBytes))
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
upload := ReleaseUploadData{
|
||||
Version: r.FormValue("version"),
|
||||
Build: r.FormValue("build"),
|
||||
ReleaseNotes: r.FormValue("release_notes"),
|
||||
}
|
||||
|
||||
if isMaxBytesError(err) {
|
||||
upload.Error = fmt.Sprintf("Upload exceeds the configured %d MB limit.", maxUploadMegabytes(h.config.MaxUploadBytes))
|
||||
} else {
|
||||
upload.Error = "Upload form could not be read."
|
||||
}
|
||||
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r.MultipartForm != nil {
|
||||
_ = r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
|
||||
upload := ReleaseUploadData{
|
||||
Version: r.FormValue("version"),
|
||||
Build: r.FormValue("build"),
|
||||
ReleaseNotes: r.FormValue("release_notes"),
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("artifact")
|
||||
if err != nil {
|
||||
upload.Error = "Choose an artifact file to upload."
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := h.handleReleaseUpload(r, projectID, file, header, upload); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
upload.Error = "A release with that version and build already exists for this project."
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusConflict, ProjectFormData{}, upload)
|
||||
default:
|
||||
upload.Error = err.Error()
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=release-uploaded", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) handleReleaseUpload(r *http.Request, projectID int64, file multipart.File, header *multipart.FileHeader, upload ReleaseUploadData) error {
|
||||
_, err := h.releases.Upload(r.Context(), releases.UploadParams{
|
||||
ProjectID: projectID,
|
||||
Version: upload.Version,
|
||||
Build: upload.Build,
|
||||
ReleaseNotes: upload.ReleaseNotes,
|
||||
OriginalFilename: header.Filename,
|
||||
DeclaredType: header.Header.Get("Content-Type"),
|
||||
Reader: file,
|
||||
UploadedByUserID: h.currentUserID(r),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectFormPage(w http.ResponseWriter, r *http.Request, status int, form ProjectFormData) {
|
||||
data := ProjectFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Project",
|
||||
"Protected Admin",
|
||||
"Create Project",
|
||||
"Add a new update stream with a stable slug so future releases and API access rules can target it reliably.",
|
||||
),
|
||||
Form: form,
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "project_form", status, data)
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectDetailPage(w http.ResponseWriter, r *http.Request, projectID int64, status int, form ProjectFormData, upload ReleaseUploadData) {
|
||||
data, err := h.projectDetailPageData(r, projectID, form, upload)
|
||||
if err != nil {
|
||||
h.renderProjectDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "project_detail", status, *data)
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectDetailPageWithFlash(w http.ResponseWriter, r *http.Request, projectID int64, status int, kind, message string) {
|
||||
data, err := h.projectDetailPageData(r, projectID, ProjectFormData{}, ReleaseUploadData{})
|
||||
if err != nil {
|
||||
h.renderProjectDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data.Flash = &FlashMessage{Kind: kind, Message: message}
|
||||
renderPageStatus(w, h.renderer, "project_detail", status, *data)
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectDetailError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "project page failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *handler) projectDetailPageData(r *http.Request, projectID int64, form ProjectFormData, upload ReleaseUploadData) (*ProjectDetailPageData, error) {
|
||||
project, err := h.store.Projects.GetByID(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := h.store.Projects.ListTags(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availableTags, err := h.store.Tags.ListAvailableForProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
releasesList, err := h.store.Releases.ListByProjectID(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var latestRelease *db.Release
|
||||
if len(releasesList) > 0 {
|
||||
release := releasesList[0].Release
|
||||
latestRelease = &release
|
||||
}
|
||||
|
||||
if form.Action == "" {
|
||||
form = ProjectFormData{
|
||||
Action: fmt.Sprintf("/admin/projects/%d", projectID),
|
||||
SubmitLabel: "Save project",
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Description: project.Description,
|
||||
Error: form.Error,
|
||||
}
|
||||
}
|
||||
|
||||
if upload.Action == "" {
|
||||
upload.Action = fmt.Sprintf("/admin/projects/%d/releases", projectID)
|
||||
}
|
||||
if upload.MaxUploadMB == 0 {
|
||||
upload.MaxUploadMB = maxUploadMegabytes(h.config.MaxUploadBytes)
|
||||
}
|
||||
|
||||
archiveState := "archive"
|
||||
archiveLabel := "Archive project"
|
||||
if !project.IsActive {
|
||||
archiveState = "restore"
|
||||
archiveLabel = "Restore project"
|
||||
}
|
||||
|
||||
data := &ProjectDetailPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
project.Name,
|
||||
"Protected Admin",
|
||||
project.Name,
|
||||
"Edit project metadata, manage tag assignments, and upload releases with checksums stored in SQLite.",
|
||||
),
|
||||
Project: *project,
|
||||
Form: form,
|
||||
Tags: tags,
|
||||
AvailableTags: availableTags,
|
||||
Releases: releasesList,
|
||||
LatestRelease: latestRelease,
|
||||
AttachTagAction: fmt.Sprintf("/admin/projects/%d/tags", projectID),
|
||||
ArchiveAction: fmt.Sprintf("/admin/projects/%d/archive", projectID),
|
||||
ArchiveState: archiveState,
|
||||
ArchiveLabel: archiveLabel,
|
||||
Upload: upload,
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func projectListFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
case "":
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func projectDetailFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
case "project-created":
|
||||
return &FlashMessage{Kind: "success", Message: "Project created. You can now attach tags and upload releases."}
|
||||
case "project-updated":
|
||||
return &FlashMessage{Kind: "success", Message: "Project details updated."}
|
||||
case "project-archived":
|
||||
return &FlashMessage{Kind: "success", Message: "Project archived."}
|
||||
case "project-restored":
|
||||
return &FlashMessage{Kind: "success", Message: "Project restored and active again."}
|
||||
case "tag-attached":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag attached to the project."}
|
||||
case "tag-detached":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag detached from the project."}
|
||||
case "release-uploaded":
|
||||
return &FlashMessage{Kind: "success", Message: "Release uploaded. Metadata, checksum, and storage path were saved."}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
274
internal/http/admin_tags.go
Normal file
274
internal/http/admin_tags.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func (h *handler) adminTags(w http.ResponseWriter, r *http.Request) {
|
||||
tags, err := h.store.Tags.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "tag listing failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := TagsPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"Tags",
|
||||
"Protected Admin",
|
||||
"Tags",
|
||||
"Create reusable tags now so projects can be grouped for future access rules and release discovery endpoints.",
|
||||
),
|
||||
Tags: tags,
|
||||
}
|
||||
data.Flash = tagListFlash(r.URL.Query().Get("status"))
|
||||
|
||||
renderPage(w, h.renderer, "tags", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := TagFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Tag",
|
||||
"Protected Admin",
|
||||
"Create Tag",
|
||||
"Add a reusable label that can be attached to projects from day one and reused later in API key access rules.",
|
||||
),
|
||||
Form: TagFormData{
|
||||
Action: "/admin/tags",
|
||||
SubmitLabel: "Create tag",
|
||||
},
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "tag_form", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid tag form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := TagFormData{
|
||||
Action: "/admin/tags",
|
||||
SubmitLabel: "Create tag",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := tagFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderTagFormPage(w, r, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
tag, err := h.store.Tags.Create(r.Context(), params)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrConflict) {
|
||||
form.Error = "A tag with that slug already exists."
|
||||
h.renderTagFormPage(w, r, http.StatusConflict, form)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "tag creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/tags/%d?status=tag-created", tag.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagDetail(w http.ResponseWriter, r *http.Request) {
|
||||
tagID, err := routeID(r, "tagID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.tagDetailPageData(r, tagID, TagFormData{})
|
||||
if err != nil {
|
||||
h.renderTagDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data.Flash = tagDetailFlash(r.URL.Query().Get("status"), r.URL.Query().Get("error"))
|
||||
renderPage(w, h.renderer, "tag_form", *data)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
tagID, err := routeID(r, "tagID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid tag form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := TagFormData{
|
||||
Action: fmt.Sprintf("/admin/tags/%d", tagID),
|
||||
SubmitLabel: "Save tag",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := tagFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderTagDetailPage(w, r, tagID, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.store.Tags.Update(r.Context(), tagID, db.UpdateTagParams(params))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
form.Error = "A tag with that slug already exists."
|
||||
h.renderTagDetailPage(w, r, tagID, http.StatusConflict, form)
|
||||
default:
|
||||
http.Error(w, "tag update failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/tags/%d?status=tag-updated", tagID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminTagDelete(w http.ResponseWriter, r *http.Request) {
|
||||
tagID, err := routeID(r, "tagID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.Tags.Delete(r.Context(), tagID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/tags/%d?error=tag-in-use", tagID), http.StatusSeeOther)
|
||||
default:
|
||||
http.Error(w, "tag delete failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/tags?status=tag-deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) renderTagFormPage(w http.ResponseWriter, r *http.Request, status int, form TagFormData) {
|
||||
data := TagFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Tag",
|
||||
"Protected Admin",
|
||||
"Create Tag",
|
||||
"Add a reusable label that can be attached to projects from day one and reused later in API key access rules.",
|
||||
),
|
||||
Form: form,
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "tag_form", status, data)
|
||||
}
|
||||
|
||||
func (h *handler) renderTagDetailPage(w http.ResponseWriter, r *http.Request, tagID int64, status int, form TagFormData) {
|
||||
data, err := h.tagDetailPageData(r, tagID, form)
|
||||
if err != nil {
|
||||
h.renderTagDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "tag_form", status, *data)
|
||||
}
|
||||
|
||||
func (h *handler) renderTagDetailError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "tag page failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *handler) tagDetailPageData(r *http.Request, tagID int64, form TagFormData) (*TagFormPageData, error) {
|
||||
tag, err := h.store.Tags.GetByID(r.Context(), tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
projects, err := h.store.Tags.ListProjects(r.Context(), tagID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if form.Action == "" {
|
||||
form = TagFormData{
|
||||
Action: fmt.Sprintf("/admin/tags/%d", tagID),
|
||||
SubmitLabel: "Save tag",
|
||||
Name: tag.Name,
|
||||
Slug: tag.Slug,
|
||||
Description: tag.Description,
|
||||
Error: form.Error,
|
||||
}
|
||||
}
|
||||
form.DeleteAction = fmt.Sprintf("/admin/tags/%d/delete", tagID)
|
||||
form.CanDelete = len(projects) == 0
|
||||
|
||||
data := &TagFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
tag.Name,
|
||||
"Protected Admin",
|
||||
tag.Name,
|
||||
"Edit this tag and review which projects already use it before preparing future access rules.",
|
||||
),
|
||||
Form: form,
|
||||
Tag: tag,
|
||||
Projects: projects,
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func tagListFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
case "tag-deleted":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag deleted."}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tagDetailFlash(statusCode, errorCode string) *FlashMessage {
|
||||
switch statusCode {
|
||||
case "tag-created":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag created. You can now attach it to projects."}
|
||||
case "tag-updated":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag details updated."}
|
||||
}
|
||||
|
||||
switch errorCode {
|
||||
case "tag-in-use":
|
||||
return &FlashMessage{Kind: "error", Message: "This tag is still attached to one or more projects, so it cannot be deleted yet."}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
111
internal/http/api_key_middleware.go
Normal file
111
internal/http/api_key_middleware.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
)
|
||||
|
||||
func (h *handler) requireAPIKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if h.apiKeys == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "api key auth is unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
token, ok := bearerToken(r.Header.Get("Authorization"))
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "missing bearer api key")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := h.apiKeys.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
if errors.Is(err, apikeys.ErrUnauthenticated) {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "invalid api key")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "api key lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(apikeys.NewContext(r.Context(), state)))
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) requireAPIKeyPermission(permission apikeys.Permission) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if !apikeys.HasPermission(state.APIKey, permission) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"error": "api key permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) requireAPIKeyProjectAccess(routeParam string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
projectID, err := routeID(r, routeParam)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := h.apiKeys.CanAccessProject(r.Context(), state.APIKey, projectID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "project access lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{"error": "api key cannot access this project"})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(header string) (string, bool) {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
parts := strings.Fields(header)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(parts[1])
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return token, true
|
||||
}
|
||||
|
||||
func writeAPIKeyAuthError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="update-server"`)
|
||||
writeJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
162
internal/http/api_key_middleware_test.go
Normal file
162
internal/http/api_key_middleware_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestAPIKeyMiddlewareEnforcesAuthPermissionAndProjectScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, service, store := newAPIKeyMiddlewareTestHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
allowedProject, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: "Desktop App", Slug: "desktop-app"})
|
||||
if err != nil {
|
||||
t.Fatalf("create allowed project: %v", err)
|
||||
}
|
||||
|
||||
blockedProject, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: "Mobile App", Slug: "mobile-app"})
|
||||
if err != nil {
|
||||
t.Fatalf("create blocked project: %v", err)
|
||||
}
|
||||
|
||||
validKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Download Clients",
|
||||
ScopeMode: db.ScopeModeProjectAllowList,
|
||||
CanDownload: true,
|
||||
ProjectIDs: []int64{allowedProject.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create valid api key: %v", err)
|
||||
}
|
||||
|
||||
noPermissionKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "No Permission",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanUpload: true,
|
||||
ProjectIDs: nil,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create no-permission api key: %v", err)
|
||||
}
|
||||
|
||||
expiredAt := time.Now().UTC().Add(-time.Hour)
|
||||
expiredKey, err := service.Create(ctx, apikeys.CreateParams{
|
||||
Name: "Expired Key",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
ExpiresAt: &expiredAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create expired api key: %v", err)
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Route("/api", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.requireAPIKey)
|
||||
r.Use(h.requireAPIKeyPermission(apikeys.PermissionDownload))
|
||||
r.With(h.requireAPIKeyProjectAccess("projectID")).Get("/projects/{projectID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
recorder := performMiddlewareRequest(router, "/api/projects/1", "")
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected missing auth to return 401, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if header := recorder.Header().Get("WWW-Authenticate"); header == "" {
|
||||
t.Fatal("expected missing auth to include WWW-Authenticate header")
|
||||
}
|
||||
|
||||
recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+noPermissionKey.RawKey)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected missing permission to return 403, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, blockedProject.ID), "Bearer "+validKey.RawKey)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected blocked project to return 403, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+expiredKey.RawKey)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected expired key to return 401, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+validKey.RawKey)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected allowed project to return 200, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newAPIKeyMiddlewareTestHandler(t *testing.T) (*handler, *apikeys.Service, *db.Store) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
sqlitePath := filepath.Join(t.TempDir(), "http-api-keys.sqlite")
|
||||
database, err := db.Open(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx, database, middlewareProjectPath(t, "migrations")); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
|
||||
store := db.NewStore(database)
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
service := apikeys.NewService(store)
|
||||
return &handler{
|
||||
store: store,
|
||||
apiKeys: service,
|
||||
}, service, store
|
||||
}
|
||||
|
||||
func middlewareProjectPath(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve caller path")
|
||||
}
|
||||
|
||||
root := filepath.Join(filepath.Dir(filename), "..", "..")
|
||||
items := append([]string{root}, parts...)
|
||||
return filepath.Join(items...)
|
||||
}
|
||||
|
||||
func performMiddlewareRequest(router http.Handler, target, authorization string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func itoa(t *testing.T, value int64) string {
|
||||
t.Helper()
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
213
internal/http/api_keys_integration_test.go
Normal file
213
internal/http/api_keys_integration_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminAPIKeyFlowRevealsRawKeyOnceAndSupportsRevocation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, store := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
projectID := extractResourceID(t, projectLocation, "/admin/projects/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Mobile App"},
|
||||
"slug": {"mobile-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
tagLocation := submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{
|
||||
"name": {"Windows"},
|
||||
"slug": {"windows"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
tagID := extractResourceID(t, tagLocation, "/admin/tags/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/tags", url.Values{
|
||||
"tag_id": {tagID},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router, sessionCookie)
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/api-keys", url.Values{
|
||||
"name": {"Windows Clients"},
|
||||
"description": {"Download access for Windows builds."},
|
||||
"scope_mode": {"tag_allow_list"},
|
||||
"can_download": {"1"},
|
||||
"can_manage_projects": {"1"},
|
||||
"tag_id": {tagID},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, sessionCookie, csrfCookie)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected create api key page, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
assertHeaderContains(t, recorder, "Cache-Control", "no-store")
|
||||
assertHeaderEquals(t, recorder, "Pragma", "no-cache")
|
||||
assertHeaderEquals(t, recorder, "Expires", "0")
|
||||
|
||||
body := recorder.Body.String()
|
||||
rawKey := findRawAPIKey(t, body)
|
||||
if !strings.Contains(body, rawKey) {
|
||||
t.Fatal("expected raw api key to be shown on creation response")
|
||||
}
|
||||
|
||||
assertBodyContains(t, body, `value="Windows Clients"`)
|
||||
assertBodyContains(t, body, "Download access for Windows builds.")
|
||||
assertBodyContains(t, body, `option value="tag_allow_list" selected`)
|
||||
assertBodyContains(t, body, `name="can_download" value="1" checked`)
|
||||
assertBodyContains(t, body, `name="can_manage_projects" value="1" checked`)
|
||||
assertBodyContains(t, body, `name="tag_id" value="`+tagID+`" checked`)
|
||||
assertBodyContains(t, body, "/api/v1/projects")
|
||||
assertBodyContains(t, body, "/api/v1/projects/desktop-app/releases/latest")
|
||||
assertBodyContains(t, body, "Authorization: Bearer")
|
||||
|
||||
keys, err := store.APIKeys.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list api keys: %v", err)
|
||||
}
|
||||
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("expected one api key, got %d", len(keys))
|
||||
}
|
||||
|
||||
keyID := keys[0].APIKey.ID
|
||||
storedKey, err := store.APIKeys.GetByID(context.Background(), keyID)
|
||||
if err != nil {
|
||||
t.Fatalf("load stored api key: %v", err)
|
||||
}
|
||||
|
||||
if storedKey.KeyHash == rawKey {
|
||||
t.Fatal("expected stored api key hash to differ from the raw key")
|
||||
}
|
||||
|
||||
accessibleProjects, err := store.APIKeys.ListAccessibleProjects(context.Background(), keyID, storedKey.ScopeMode)
|
||||
if err != nil {
|
||||
t.Fatalf("list accessible projects: %v", err)
|
||||
}
|
||||
|
||||
if len(accessibleProjects) != 1 || accessibleProjects[0].Name != "Desktop App" {
|
||||
t.Fatalf("expected only Desktop App to be accessible, got %+v", accessibleProjects)
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin/api-keys/"+itoa64(keyID), nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected api key detail page to load, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if strings.Contains(recorder.Body.String(), rawKey) {
|
||||
t.Fatal("expected raw api key to disappear after the creation response")
|
||||
}
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/api-keys/"+itoa64(keyID)+"/activate", url.Values{
|
||||
"state": {"revoke"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
storedKey, err = store.APIKeys.GetByID(context.Background(), keyID)
|
||||
if err != nil {
|
||||
t.Fatalf("reload revoked api key: %v", err)
|
||||
}
|
||||
|
||||
if storedKey.IsActive {
|
||||
t.Fatal("expected api key to be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIKeyCreateResponseShowsPersistedProjectRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, _ := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
projectID := extractResourceID(t, projectLocation, "/admin/projects/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Mobile App"},
|
||||
"slug": {"mobile-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router, sessionCookie)
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/api-keys", url.Values{
|
||||
"name": {"Desktop Only"},
|
||||
"description": {"Project allow-list clients."},
|
||||
"scope_mode": {"project_allow_list"},
|
||||
"can_download": {"1"},
|
||||
"project_id": {projectID},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, sessionCookie, csrfCookie)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected create api key page, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
body := recorder.Body.String()
|
||||
assertBodyContains(t, body, `value="Desktop Only"`)
|
||||
assertBodyContains(t, body, "Project allow-list clients.")
|
||||
assertBodyContains(t, body, `option value="project_allow_list" selected`)
|
||||
assertBodyContains(t, body, `name="project_id" value="`+projectID+`" checked`)
|
||||
assertBodyContains(t, body, "/api/v1/projects")
|
||||
assertBodyContains(t, body, "/api/v1/projects/desktop-app/releases/latest")
|
||||
assertHeaderContains(t, recorder, "Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
func findRawAPIKey(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
re := regexp.MustCompile(`upsk_[A-Za-z0-9_-]+`)
|
||||
matches := re.FindAllString(body, -1)
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected raw api key in response body")
|
||||
}
|
||||
|
||||
longest := matches[0]
|
||||
for _, match := range matches[1:] {
|
||||
if len(match) > len(longest) {
|
||||
longest = match
|
||||
}
|
||||
}
|
||||
|
||||
return longest
|
||||
}
|
||||
|
||||
func itoa64(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
func assertBodyContains(t *testing.T, body, fragment string) {
|
||||
t.Helper()
|
||||
|
||||
if !strings.Contains(body, fragment) {
|
||||
t.Fatalf("expected response body to contain %q", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHeaderContains(t *testing.T, recorder *httptest.ResponseRecorder, key, fragment string) {
|
||||
t.Helper()
|
||||
|
||||
if value := recorder.Header().Get(key); !strings.Contains(value, fragment) {
|
||||
t.Fatalf("expected %s header to contain %q, got %q", key, fragment, value)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHeaderEquals(t *testing.T, recorder *httptest.ResponseRecorder, key, want string) {
|
||||
t.Helper()
|
||||
|
||||
if value := recorder.Header().Get(key); value != want {
|
||||
t.Fatalf("expected %s header %q, got %q", key, want, value)
|
||||
}
|
||||
}
|
||||
154
internal/http/auth_handlers.go
Normal file
154
internal/http/auth_handlers.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
authservice "update_server/internal/auth"
|
||||
)
|
||||
|
||||
const defaultAdminRedirect = "/admin"
|
||||
|
||||
func (h *handler) adminLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
if redirected, err := h.redirectAuthenticatedAdmin(w, r); err != nil {
|
||||
http.Error(w, "session lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
} else if redirected {
|
||||
return
|
||||
}
|
||||
|
||||
h.renderLoginPage(w, r, http.StatusOK, "", safeNextPath(r.URL.Query().Get("next")), "")
|
||||
}
|
||||
|
||||
func (h *handler) adminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid login form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
nextPath := safeNextPath(r.FormValue("next"))
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
token, sessionState, err := h.auth.Authenticate(r.Context(), email, password, clientIP(r), r.UserAgent())
|
||||
if err != nil {
|
||||
if errors.Is(err, authservice.ErrInvalidCredentials) {
|
||||
h.renderLoginPage(w, r, http.StatusUnauthorized, email, nextPath, "Invalid email or password.")
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "login failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.auth.SessionCookie(token, sessionState.Session.ExpiresAt))
|
||||
if _, err := h.issueCSRFCookie(w); err != nil {
|
||||
http.Error(w, "login failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, nextPath, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(h.auth.SessionCookieName()); err == nil {
|
||||
if err := h.auth.InvalidateSession(r.Context(), cookie.Value); err != nil {
|
||||
http.Error(w, "logout failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.auth.ClearSessionCookie())
|
||||
http.SetCookie(w, h.clearCSRFCookie())
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) renderLoginPage(w http.ResponseWriter, r *http.Request, status int, email, nextPath, errorMessage string) {
|
||||
hasActiveAdmin, err := h.store.Users.HasActiveAdmin(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "admin status lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
setupHint := ""
|
||||
if !hasActiveAdmin {
|
||||
setupHint = "No active admin user exists yet. Set ADMIN_EMAIL and ADMIN_PASSWORD, then restart the server once to bootstrap the first admin account."
|
||||
}
|
||||
|
||||
loginData := LoginPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"Admin Login",
|
||||
"Secure Sign-In",
|
||||
"Admin Login",
|
||||
"Use your admin credentials to open the protected server-rendered dashboard.",
|
||||
),
|
||||
Login: LoginFormData{
|
||||
Action: "/admin/login",
|
||||
Email: email,
|
||||
Next: nextPath,
|
||||
Error: errorMessage,
|
||||
SetupHint: setupHint,
|
||||
},
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "login", status, loginData)
|
||||
}
|
||||
|
||||
func (h *handler) redirectAuthenticatedAdmin(w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||
cookie, err := r.Cookie(h.auth.SessionCookieName())
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
sessionState, err := h.auth.LoadSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
if errors.Is(err, authservice.ErrUnauthenticated) {
|
||||
http.SetCookie(w, h.auth.ClearSessionCookie())
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
http.Redirect(w, r, safeNextPath(r.URL.Query().Get("next")), http.StatusSeeOther)
|
||||
_ = sessionState
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func safeNextPath(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultAdminRedirect
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||
return defaultAdminRedirect
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
func loginRedirectPath(nextPath string) string {
|
||||
values := url.Values{}
|
||||
if nextPath = safeNextPath(nextPath); nextPath != defaultAdminRedirect {
|
||||
values.Set("next", nextPath)
|
||||
}
|
||||
|
||||
if encoded := values.Encode(); encoded != "" {
|
||||
return "/admin/login?" + encoded
|
||||
}
|
||||
|
||||
return "/admin/login"
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
|
||||
return r.RemoteAddr
|
||||
}
|
||||
269
internal/http/auth_integration_test.go
Normal file
269
internal/http/auth_integration_test.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/auth"
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
httpserver "update_server/internal/http"
|
||||
"update_server/internal/releases"
|
||||
"update_server/internal/storage"
|
||||
)
|
||||
|
||||
const testCSRFCookieName = "update_server_csrf"
|
||||
|
||||
func TestAdminLoginLogoutFlowProtectsRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, cfg := newTestRouter(t)
|
||||
recorder := performRequest(t, router, http.MethodGet, "/admin", nil)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect for unauthenticated admin route, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if location := recorder.Header().Get("Location"); location != "/admin/login" {
|
||||
t.Fatalf("expected login redirect, got %q", location)
|
||||
}
|
||||
|
||||
loginForm := url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"correct horse battery staple"},
|
||||
"next": {"/admin"},
|
||||
}
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
loginForm.Set("csrf_token", csrfCookie.Value)
|
||||
recorder = performRequest(t, router, http.MethodPost, "/admin/login", loginForm, csrfCookie)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected login redirect, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if location := recorder.Header().Get("Location"); location != "/admin" {
|
||||
t.Fatalf("expected admin redirect after login, got %q", location)
|
||||
}
|
||||
|
||||
adminCookies := recorder.Result().Cookies()
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range adminCookies {
|
||||
if cookie.Name == cfg.SessionCookieName {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sessionCookie == nil || sessionCookie.Value == "" {
|
||||
t.Fatal("expected session cookie after successful login")
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected authenticated admin dashboard, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "Admin Dashboard") {
|
||||
t.Fatal("expected admin dashboard content in response body")
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "admin@example.com") {
|
||||
t.Fatal("expected authenticated admin email in dashboard response")
|
||||
}
|
||||
|
||||
logoutCSRFCookie := ensureCSRFCookie(t, router, sessionCookie)
|
||||
recorder = performRequest(t, router, http.MethodPost, "/admin/logout", url.Values{
|
||||
"csrf_token": {logoutCSRFCookie.Value},
|
||||
}, sessionCookie, logoutCSRFCookie)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected logout redirect, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if location := recorder.Header().Get("Location"); location != "/admin/login" {
|
||||
t.Fatalf("expected login redirect after logout, got %q", location)
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected invalidated session cookie to be rejected, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if location := recorder.Header().Get("Location"); location != "/admin/login" {
|
||||
t.Fatalf("expected invalidated session redirect, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginRejectsInvalidCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _ := newTestRouter(t)
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"definitely-wrong"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, csrfCookie)
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized login response, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "Invalid email or password.") {
|
||||
t.Fatal("expected invalid login message in response body")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T) (http.Handler, config.Config) {
|
||||
t.Helper()
|
||||
|
||||
router, cfg, _ := newTestRouterWithStore(t)
|
||||
return router, cfg
|
||||
}
|
||||
|
||||
func newTestRouterWithStore(t *testing.T) (http.Handler, config.Config, *db.Store) {
|
||||
return newTestRouterWithConfig(t, nil)
|
||||
}
|
||||
|
||||
func newTestRouterWithConfig(t *testing.T, mutate func(*config.Config)) (http.Handler, config.Config, *db.Store) {
|
||||
t.Helper()
|
||||
|
||||
store := newHTTPTestStore(t)
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
artifactsDir := filepath.Join(t.TempDir(), "artifacts")
|
||||
cfg := config.Config{
|
||||
AppName: "Update Server",
|
||||
BaseURL: "http://127.0.0.1:8080",
|
||||
ArtifactsDir: artifactsDir,
|
||||
TemplatesDir: httpProjectPath(t, "web", "templates"),
|
||||
StaticDir: httpProjectPath(t, "web", "static"),
|
||||
AdminEmail: "admin@example.com",
|
||||
AdminPassword: "correct horse battery staple",
|
||||
MaxUploadBytes: 8 << 20,
|
||||
SessionCookieName: "update_server_session",
|
||||
CSRFCookieName: testCSRFCookieName,
|
||||
SessionTTL: 24 * time.Hour,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
LoginRateLimitPerMinute: 10,
|
||||
LoginRateLimitBurst: 5,
|
||||
ClientRateLimitPerMinute: 120,
|
||||
ClientRateLimitBurst: 60,
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&cfg)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
renderer, err := httpserver.NewRenderer(cfg.TemplatesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create renderer: %v", err)
|
||||
}
|
||||
|
||||
authService := auth.NewService(cfg, logger, store)
|
||||
apiKeyService := apikeys.NewService(store)
|
||||
if err := authService.EnsureBootstrapAdmin(context.Background()); err != nil {
|
||||
t.Fatalf("bootstrap admin: %v", err)
|
||||
}
|
||||
|
||||
artifactStore, err := storage.NewLocal(cfg.ArtifactsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("create artifact storage: %v", err)
|
||||
}
|
||||
|
||||
releaseService := releases.NewService(store, artifactStore)
|
||||
return httpserver.NewRouter(cfg, logger, renderer, store, authService, apiKeyService, releaseService), cfg, store
|
||||
}
|
||||
|
||||
func newHTTPTestStore(t *testing.T) *db.Store {
|
||||
t.Helper()
|
||||
|
||||
sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite")
|
||||
database, err := db.Open(context.Background(), sqlitePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Migrate(context.Background(), database, httpProjectPath(t, "migrations")); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
|
||||
return db.NewStore(database)
|
||||
}
|
||||
|
||||
func httpProjectPath(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve caller path")
|
||||
}
|
||||
|
||||
root := filepath.Join(filepath.Dir(filename), "..", "..")
|
||||
items := append([]string{root}, parts...)
|
||||
return filepath.Join(items...)
|
||||
}
|
||||
|
||||
func performRequest(t *testing.T, handler http.Handler, method, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
var body io.Reader
|
||||
if form != nil {
|
||||
body = strings.NewReader(form.Encode())
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, target, body)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
if form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func ensureCSRFCookie(t *testing.T, handler http.Handler, cookies ...*http.Cookie) *http.Cookie {
|
||||
t.Helper()
|
||||
|
||||
for _, cookie := range cookies {
|
||||
if cookie != nil && cookie.Name == testCSRFCookieName && cookie.Value != "" {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
|
||||
recorder := performRequest(t, handler, http.MethodGet, "/admin/login", nil, cookies...)
|
||||
if recorder.Code != http.StatusOK && recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected csrf bootstrap request to succeed, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == testCSRFCookieName && cookie.Value != "" {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatal("expected csrf cookie from admin bootstrap request")
|
||||
return nil
|
||||
}
|
||||
52
internal/http/auth_middleware.go
Normal file
52
internal/http/auth_middleware.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
authservice "update_server/internal/auth"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func (h *handler) requireAuthenticatedSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(h.auth.SessionCookieName())
|
||||
if err != nil {
|
||||
http.Redirect(w, r, loginRedirectPath(r.URL.RequestURI()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
sessionState, err := h.auth.LoadSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
if errors.Is(err, authservice.ErrUnauthenticated) {
|
||||
http.SetCookie(w, h.auth.ClearSessionCookie())
|
||||
http.Redirect(w, r, loginRedirectPath(r.URL.RequestURI()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "session lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(authservice.NewContext(r.Context(), sessionState)))
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) requireRole(requiredRole db.UserRole) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sessionState, ok := authservice.FromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !authservice.RoleAllowed(sessionState.User.Role, requiredRole) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
247
internal/http/client_api.go
Normal file
247
internal/http/client_api.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
var errInvalidReleaseID = errors.New("invalid release id")
|
||||
|
||||
type apiProjectResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
LatestReleaseURL string `json:"latest_release_url"`
|
||||
}
|
||||
|
||||
type apiReleaseResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build"`
|
||||
Filename string `json:"filename"`
|
||||
ChecksumSHA256 string `json:"checksum_sha256"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ContentType string `json:"content_type"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
MetadataURL string `json:"metadata_url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
func (h *handler) apiAccessibleProjects(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := h.apiKeys.ListAccessibleProjects(r.Context(), state.APIKey)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "project listing failed"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]apiProjectResponse, 0, len(projects))
|
||||
for _, project := range projects {
|
||||
items = append(items, apiProjectPayload(project))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"projects": items})
|
||||
}
|
||||
|
||||
func (h *handler) apiLatestRelease(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
project, err := h.accessibleProjectBySlug(r, state.APIKey)
|
||||
if err != nil {
|
||||
h.writeClientResourceError(w, err, "project lookup failed")
|
||||
return
|
||||
}
|
||||
|
||||
release, err := h.store.Releases.GetLatestByProjectID(r.Context(), project.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "release not found"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "release lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"project": apiProjectPayload(*project),
|
||||
"release": apiReleasePayload(*release),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) apiReleaseMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
project, release, err := h.accessibleReleaseByID(r, state.APIKey)
|
||||
if err != nil {
|
||||
h.writeClientResourceError(w, err, "release lookup failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"project": apiProjectPayload(*project),
|
||||
"release": apiReleasePayload(*release),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) apiReleaseDownload(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := apikeys.FromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIKeyAuthError(w, http.StatusUnauthorized, "api key authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
_, release, err := h.accessibleReleaseByID(r, state.APIKey)
|
||||
if err != nil {
|
||||
h.writeClientResourceError(w, err, "release lookup failed")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.releases.Artifact(release.StoragePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "release not found"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("artifact open failed", "release_id", release.ID, "storage_path", release.StoragePath, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "artifact download failed"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": release.Filename})
|
||||
if disposition != "" {
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
w.Header().Set("Content-Type", release.ContentType)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
http.ServeContent(w, r, release.Filename, release.UpdatedAt, file)
|
||||
}
|
||||
|
||||
func (h *handler) accessibleProjectBySlug(r *http.Request, apiKey db.APIKey) (*db.Project, error) {
|
||||
projectSlug := strings.TrimSpace(chi.URLParam(r, "projectSlug"))
|
||||
if projectSlug == "" {
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
project, err := h.store.Projects.GetBySlug(r.Context(), projectSlug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !project.IsActive {
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
allowed, err := h.apiKeys.CanAccessProject(r.Context(), apiKey, project.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check project access: %w", err)
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (h *handler) accessibleReleaseByID(r *http.Request, apiKey db.APIKey) (*db.Project, *db.Release, error) {
|
||||
releaseID, err := routeID(r, "releaseID")
|
||||
if err != nil {
|
||||
return nil, nil, errInvalidReleaseID
|
||||
}
|
||||
|
||||
release, err := h.store.Releases.GetByID(r.Context(), releaseID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !release.IsActive {
|
||||
return nil, nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
project, err := h.store.Projects.GetByID(r.Context(), release.ProjectID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !project.IsActive {
|
||||
return nil, nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
allowed, err := h.apiKeys.CanAccessProject(r.Context(), apiKey, project.ID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("check release project access: %w", err)
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return nil, nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
return project, release, nil
|
||||
}
|
||||
|
||||
func (h *handler) writeClientResourceError(w http.ResponseWriter, err error, message string) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "resource not found"})
|
||||
case errors.Is(err, errInvalidReleaseID):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid release id"})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": message})
|
||||
}
|
||||
}
|
||||
|
||||
func apiProjectPayload(project db.Project) apiProjectResponse {
|
||||
return apiProjectResponse{
|
||||
ID: project.ID,
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Description: project.Description,
|
||||
LatestReleaseURL: fmt.Sprintf("/api/v1/projects/%s/releases/latest", project.Slug),
|
||||
}
|
||||
}
|
||||
|
||||
func apiReleasePayload(release db.Release) apiReleaseResponse {
|
||||
return apiReleaseResponse{
|
||||
ID: release.ID,
|
||||
Version: release.Version,
|
||||
Build: release.Build,
|
||||
Filename: release.Filename,
|
||||
ChecksumSHA256: release.ChecksumSHA256,
|
||||
SizeBytes: release.SizeBytes,
|
||||
ContentType: release.ContentType,
|
||||
ReleaseNotes: release.ReleaseNotes,
|
||||
CreatedAt: release.CreatedAt,
|
||||
MetadataURL: fmt.Sprintf("/api/v1/releases/%d", release.ID),
|
||||
DownloadURL: fmt.Sprintf("/api/v1/releases/%d/download", release.ID),
|
||||
}
|
||||
}
|
||||
290
internal/http/client_api_integration_test.go
Normal file
290
internal/http/client_api_integration_test.go
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestClientAPIListsAuthorizedProjectsReturnsLatestMetadataAndStreamsDownloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, store := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
desktopProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
"description": {"Primary desktop updater stream."},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
|
||||
|
||||
mobileProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Mobile App"},
|
||||
"slug": {"mobile-app"},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
|
||||
|
||||
legacyProjectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Legacy App"},
|
||||
"slug": {"legacy-app"},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
|
||||
|
||||
tagID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{
|
||||
"name": {"Windows"},
|
||||
"slug": {"windows"},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/tags/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+desktopProjectID+"/tags", url.Values{"tag_id": {tagID}}, http.StatusSeeOther, sessionCookie)
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+legacyProjectID+"/tags", url.Values{"tag_id": {tagID}}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
submitMultipartForm(t, router, "/admin/projects/"+desktopProjectID+"/releases", map[string]string{
|
||||
"version": "1.0.0",
|
||||
"build": "build-1",
|
||||
"release_notes": "Initial desktop rollout.",
|
||||
}, "artifact", "Desktop-App-1.0.0.zip", []byte("desktop-release-1.0.0"), sessionCookie)
|
||||
|
||||
submitMultipartForm(t, router, "/admin/projects/"+desktopProjectID+"/releases", map[string]string{
|
||||
"version": "1.1.0",
|
||||
"build": "build-2",
|
||||
"release_notes": "Improved desktop rollout.",
|
||||
}, "artifact", "Desktop-App-1.1.0.zip", []byte("desktop-release-1.1.0"), sessionCookie)
|
||||
|
||||
submitMultipartForm(t, router, "/admin/projects/"+mobileProjectID+"/releases", map[string]string{
|
||||
"version": "2.0.0",
|
||||
"build": "mobile-1",
|
||||
"release_notes": "Mobile rollout.",
|
||||
}, "artifact", "mobile-app-2.0.0.apk", []byte("mobile-release-2.0.0"), sessionCookie)
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+legacyProjectID+"/archive", url.Values{
|
||||
"state": {"archive"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Windows Clients",
|
||||
ScopeMode: db.ScopeModeTagAllowList,
|
||||
CanDownload: true,
|
||||
TagIDs: []int64{mustParseInt64(t, tagID)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create client api key: %v", err)
|
||||
}
|
||||
|
||||
projectsRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if projectsRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected project list to return 200, got %d with body %s", projectsRecorder.Code, projectsRecorder.Body.String())
|
||||
}
|
||||
|
||||
var projectsPayload struct {
|
||||
Projects []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
LatestReleaseURL string `json:"latest_release_url"`
|
||||
} `json:"projects"`
|
||||
}
|
||||
decodeJSONBody(t, projectsRecorder, &projectsPayload)
|
||||
|
||||
if len(projectsPayload.Projects) != 1 {
|
||||
t.Fatalf("expected exactly one accessible active project, got %+v", projectsPayload.Projects)
|
||||
}
|
||||
|
||||
if projectsPayload.Projects[0].Slug != "desktop-app" {
|
||||
t.Fatalf("expected desktop-app in accessible projects, got %+v", projectsPayload.Projects[0])
|
||||
}
|
||||
|
||||
if projectsPayload.Projects[0].LatestReleaseURL != "/api/v1/projects/desktop-app/releases/latest" {
|
||||
t.Fatalf("unexpected latest release url %q", projectsPayload.Projects[0].LatestReleaseURL)
|
||||
}
|
||||
|
||||
latestRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects/desktop-app/releases/latest", "Bearer "+keyResult.RawKey)
|
||||
if latestRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected latest release metadata to return 200, got %d with body %s", latestRecorder.Code, latestRecorder.Body.String())
|
||||
}
|
||||
|
||||
var latestPayload struct {
|
||||
Project struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"project"`
|
||||
Release struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build"`
|
||||
MetadataURL string `json:"metadata_url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
} `json:"release"`
|
||||
}
|
||||
decodeJSONBody(t, latestRecorder, &latestPayload)
|
||||
|
||||
if latestPayload.Project.Slug != "desktop-app" {
|
||||
t.Fatalf("expected desktop-app project payload, got %+v", latestPayload.Project)
|
||||
}
|
||||
|
||||
if latestPayload.Release.Version != "1.1.0" || latestPayload.Release.Build != "build-2" {
|
||||
t.Fatalf("expected latest release 1.1.0/build-2, got %+v", latestPayload.Release)
|
||||
}
|
||||
|
||||
if latestPayload.Release.MetadataURL != "/api/v1/releases/2" {
|
||||
t.Fatalf("unexpected metadata url %q", latestPayload.Release.MetadataURL)
|
||||
}
|
||||
|
||||
if latestPayload.Release.DownloadURL != "/api/v1/releases/2/download" {
|
||||
t.Fatalf("unexpected download url %q", latestPayload.Release.DownloadURL)
|
||||
}
|
||||
|
||||
metadataRecorder := performAPIRequest(t, router, http.MethodGet, latestPayload.Release.MetadataURL, "Bearer "+keyResult.RawKey)
|
||||
if metadataRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected release metadata to return 200, got %d with body %s", metadataRecorder.Code, metadataRecorder.Body.String())
|
||||
}
|
||||
|
||||
var metadataPayload struct {
|
||||
Project struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"project"`
|
||||
Release struct {
|
||||
ID int64 `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Filename string `json:"filename"`
|
||||
} `json:"release"`
|
||||
}
|
||||
decodeJSONBody(t, metadataRecorder, &metadataPayload)
|
||||
|
||||
if metadataPayload.Release.ID != latestPayload.Release.ID || metadataPayload.Release.Version != "1.1.0" {
|
||||
t.Fatalf("expected matching release metadata payload, got %+v", metadataPayload.Release)
|
||||
}
|
||||
|
||||
blockedLatestRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects/mobile-app/releases/latest", "Bearer "+keyResult.RawKey)
|
||||
if blockedLatestRecorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected blocked project latest lookup to return 404, got %d with body %s", blockedLatestRecorder.Code, blockedLatestRecorder.Body.String())
|
||||
}
|
||||
|
||||
blockedMetadataRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/releases/3", "Bearer "+keyResult.RawKey)
|
||||
if blockedMetadataRecorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected blocked release metadata to return 404, got %d with body %s", blockedMetadataRecorder.Code, blockedMetadataRecorder.Body.String())
|
||||
}
|
||||
|
||||
downloadRecorder := performAPIRequest(t, router, http.MethodGet, latestPayload.Release.DownloadURL, "Bearer "+keyResult.RawKey)
|
||||
if downloadRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected release download to return 200, got %d with body %s", downloadRecorder.Code, downloadRecorder.Body.String())
|
||||
}
|
||||
|
||||
if body := downloadRecorder.Body.String(); body != "desktop-release-1.1.0" {
|
||||
t.Fatalf("unexpected download body %q", body)
|
||||
}
|
||||
|
||||
if value := downloadRecorder.Header().Get("Content-Disposition"); !strings.Contains(value, "attachment") || !strings.Contains(value, "desktop-app-1.1.0.zip") {
|
||||
t.Fatalf("expected attachment content disposition, got %q", value)
|
||||
}
|
||||
|
||||
if value := downloadRecorder.Header().Get("Content-Type"); !strings.Contains(value, "text/plain") {
|
||||
t.Fatalf("expected sniffed text content type, got %q", value)
|
||||
}
|
||||
|
||||
blockedDownloadRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/releases/3/download", "Bearer "+keyResult.RawKey)
|
||||
if blockedDownloadRecorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected blocked release download to return 404, got %d with body %s", blockedDownloadRecorder.Code, blockedDownloadRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPIRejectsMissingPermissionDisabledAndExpiredKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, store := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
projectID := extractResourceID(t, submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, http.StatusSeeOther, sessionCookie), "/admin/projects/")
|
||||
|
||||
submitMultipartForm(t, router, "/admin/projects/"+projectID+"/releases", map[string]string{
|
||||
"version": "1.0.0",
|
||||
"build": "build-1",
|
||||
"release_notes": "Initial rollout.",
|
||||
}, "artifact", "desktop-app-1.0.0.zip", []byte("desktop-release-1.0.0"), sessionCookie)
|
||||
|
||||
service := apikeys.NewService(store)
|
||||
|
||||
noPermissionKey, err := service.Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "No Download",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanUpload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create no-permission key: %v", err)
|
||||
}
|
||||
|
||||
disabledKey, err := service.Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Disabled",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create disabled key: %v", err)
|
||||
}
|
||||
if err := service.SetActive(t.Context(), disabledKey.APIKey.ID, false); err != nil {
|
||||
t.Fatalf("disable api key: %v", err)
|
||||
}
|
||||
|
||||
expiredAt := time.Now().UTC().Add(-time.Hour)
|
||||
expiredKey, err := service.Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Expired",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
ExpiresAt: &expiredAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create expired key: %v", err)
|
||||
}
|
||||
|
||||
missingAuthRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "")
|
||||
if missingAuthRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected missing auth to return 401, got %d", missingAuthRecorder.Code)
|
||||
}
|
||||
assertHeaderContains(t, missingAuthRecorder, "WWW-Authenticate", "Bearer")
|
||||
|
||||
noPermissionRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+noPermissionKey.RawKey)
|
||||
if noPermissionRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected missing permission to return 403, got %d with body %s", noPermissionRecorder.Code, noPermissionRecorder.Body.String())
|
||||
}
|
||||
|
||||
disabledRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+disabledKey.RawKey)
|
||||
if disabledRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected disabled key to return 401, got %d with body %s", disabledRecorder.Code, disabledRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, disabledRecorder, "WWW-Authenticate", "Bearer")
|
||||
|
||||
expiredRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+expiredKey.RawKey)
|
||||
if expiredRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected expired key to return 401, got %d with body %s", expiredRecorder.Code, expiredRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, expiredRecorder, "WWW-Authenticate", "Bearer")
|
||||
}
|
||||
|
||||
func performAPIRequest(t *testing.T, handler http.Handler, method, target, authorization string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder, target any) {
|
||||
t.Helper()
|
||||
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), target); err != nil {
|
||||
t.Fatalf("decode json response: %v", err)
|
||||
}
|
||||
}
|
||||
156
internal/http/csrf.go
Normal file
156
internal/http/csrf.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
adminCookiePath = "/admin"
|
||||
csrfFormField = "csrf_token"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
type csrfTokenContextKey struct{}
|
||||
|
||||
func (h *handler) adminCSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, cookie, err := h.ensureCSRFCookie(r)
|
||||
if err != nil {
|
||||
http.Error(w, "csrf setup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if cookie != nil {
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), csrfTokenContextKey{}, token))
|
||||
|
||||
if requiresCSRFProtection(r.Method) {
|
||||
submittedToken, err := h.submittedCSRFToken(w, r)
|
||||
if err != nil || subtle.ConstantTimeCompare([]byte(token), []byte(submittedToken)) != 1 {
|
||||
http.Error(w, "csrf validation failed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) csrfToken(r *http.Request) string {
|
||||
if token, ok := r.Context().Value(csrfTokenContextKey{}).(string); ok {
|
||||
return token
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(h.config.CSRFCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !validCSRFCookieToken(cookie.Value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
func (h *handler) issueCSRFCookie(w http.ResponseWriter) (string, error) {
|
||||
token, err := generateCSRFToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.csrfCookie(token))
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (h *handler) clearCSRFCookie() *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: h.config.CSRFCookieName,
|
||||
Value: "",
|
||||
Path: adminCookiePath,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: h.config.SecureCookies,
|
||||
Expires: time.Unix(0, 0).UTC(),
|
||||
MaxAge: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) ensureCSRFCookie(r *http.Request) (string, *http.Cookie, error) {
|
||||
cookie, err := r.Cookie(h.config.CSRFCookieName)
|
||||
if err == nil && validCSRFCookieToken(cookie.Value) {
|
||||
return cookie.Value, nil, nil
|
||||
}
|
||||
|
||||
token, err := generateCSRFToken()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("generate csrf token: %w", err)
|
||||
}
|
||||
|
||||
return token, h.csrfCookie(token), nil
|
||||
}
|
||||
|
||||
func (h *handler) csrfCookie(token string) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: h.config.CSRFCookieName,
|
||||
Value: token,
|
||||
Path: adminCookiePath,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: h.config.SecureCookies,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) submittedCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
if token := strings.TrimSpace(r.Header.Get(csrfHeaderName)); token != "" {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
contentType := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestLimit(h.config.MaxUploadBytes))
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(r.FormValue(csrfFormField)), nil
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(r.FormValue(csrfFormField)), nil
|
||||
}
|
||||
|
||||
func generateCSRFToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func requiresCSRFProtection(method string) bool {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func validCSRFCookieToken(token string) bool {
|
||||
token = strings.TrimSpace(token)
|
||||
return len(token) >= 32
|
||||
}
|
||||
149
internal/http/handlers.go
Normal file
149
internal/http/handlers.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *handler) health(w http.ResponseWriter, r *http.Request) {
|
||||
payload := map[string]any{
|
||||
"status": "ok",
|
||||
"database": "ok",
|
||||
"service": h.config.AppName,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if h.store == nil {
|
||||
payload["status"] = "degraded"
|
||||
payload["database"] = "unavailable"
|
||||
payload["error"] = "database store is not initialized"
|
||||
writeJSON(w, http.StatusServiceUnavailable, payload)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.HealthCheck(r.Context()); err != nil {
|
||||
payload["status"] = "degraded"
|
||||
payload["database"] = "unavailable"
|
||||
payload["error"] = "database ping failed"
|
||||
writeJSON(w, http.StatusServiceUnavailable, payload)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (h *handler) home(w http.ResponseWriter, r *http.Request) {
|
||||
data := HomePageData{
|
||||
PageData: PageData{
|
||||
Title: "Client Update API Ready",
|
||||
Eyebrow: "Usable Product Flow",
|
||||
Heading: h.config.AppName,
|
||||
BaseURL: h.config.BaseURL,
|
||||
Description: "Protected admin flows now connect all the way through to bearer-authenticated client endpoints for project discovery, latest-release metadata, release metadata, and private artifact downloads.",
|
||||
},
|
||||
Links: []PageLink{
|
||||
{Label: "Health check", Href: "/healthz", Description: "JSON readiness endpoint covering both the HTTP server and SQLite connectivity."},
|
||||
{Label: "Admin login", Href: "/admin/login", Description: "Server-rendered login form for the protected admin workspace."},
|
||||
{Label: "Projects", Href: "/admin/projects", Description: "Create projects, attach tags, upload releases, and inspect the client-facing metadata paths from the protected admin UI."},
|
||||
{Label: "Tags", Href: "/admin/tags", Description: "Manage reusable tags that projects can use from day one."},
|
||||
{Label: "API keys", Href: "/admin/api-keys", Description: "Generate hashed API keys, reveal the raw key once, preview accessible projects, and copy the client API quick-start flow."},
|
||||
{Label: "Client API", Href: "/api/v1", Description: "Versioned JSON API entrypoint documenting the bearer-authenticated project, metadata, and download endpoints."},
|
||||
},
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "home", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminHome(w http.ResponseWriter, r *http.Request) {
|
||||
projects, err := h.store.Projects.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "project summary lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := h.store.Tags.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "tag summary lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
apiKeys, err := h.store.APIKeys.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "api key summary lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalReleases := 0
|
||||
for _, item := range projects {
|
||||
totalReleases += item.ReleaseCount
|
||||
}
|
||||
|
||||
data := DashboardPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"Admin Dashboard",
|
||||
"Protected Admin",
|
||||
"Admin Dashboard",
|
||||
"Projects, tags, releases, and API keys now feed the client update API so admins can complete the end-to-end product flow in one workspace.",
|
||||
),
|
||||
Metrics: []DashboardMetric{
|
||||
{Label: "Projects", Value: strconv.Itoa(len(projects)), Description: "Active and archived projects available for management."},
|
||||
{Label: "Tags", Value: strconv.Itoa(len(tags)), Description: "Reusable labels ready for project assignment and future access scopes."},
|
||||
{Label: "Releases", Value: strconv.Itoa(totalReleases), Description: "Stored release records with checksum and disk path metadata."},
|
||||
{Label: "API Keys", Value: strconv.Itoa(len(apiKeys)), Description: "Hashed client credentials with permission flags and scope rules."},
|
||||
},
|
||||
Links: []PageLink{
|
||||
{Label: "Manage projects", Href: "/admin/projects", Description: "Open project detail pages to edit metadata, attach tags, upload releases, and inspect client metadata or download paths."},
|
||||
{Label: "Manage tags", Href: "/admin/tags", Description: "Create tags once and reuse them across projects from the same protected UI."},
|
||||
{Label: "Manage API keys", Href: "/admin/api-keys", Description: "Generate, revoke, and scope client credentials, then copy the bearer-authenticated client API flow."},
|
||||
{Label: "Client API", Href: "/api/v1", Description: "Public JSON index for accessible projects, latest release metadata, release metadata, and authenticated downloads."},
|
||||
},
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "admin", data)
|
||||
}
|
||||
|
||||
func (h *handler) apiIndex(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"service": h.config.AppName,
|
||||
"version": "v1",
|
||||
"status": "client-api-ready",
|
||||
"auth": map[string]any{
|
||||
"type": "bearer",
|
||||
"header": "Authorization: Bearer <api_key>",
|
||||
},
|
||||
"routes": []map[string]string{
|
||||
{
|
||||
"method": http.MethodGet,
|
||||
"path": "/api/v1/projects",
|
||||
"description": "List active projects accessible to the API key.",
|
||||
},
|
||||
{
|
||||
"method": http.MethodGet,
|
||||
"path": "/api/v1/projects/{projectSlug}/releases/latest",
|
||||
"description": "Get the latest active release metadata for an accessible project.",
|
||||
},
|
||||
{
|
||||
"method": http.MethodGet,
|
||||
"path": "/api/v1/releases/{releaseID}",
|
||||
"description": "Get release metadata for an accessible release.",
|
||||
},
|
||||
{
|
||||
"method": http.MethodGet,
|
||||
"path": "/api/v1/releases/{releaseID}/download",
|
||||
"description": "Download the private artifact for an accessible release.",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func renderPage(w http.ResponseWriter, renderer *Renderer, name string, data any) {
|
||||
renderPageStatus(w, renderer, name, http.StatusOK, data)
|
||||
}
|
||||
|
||||
func renderPageStatus(w http.ResponseWriter, renderer *Renderer, name string, status int, data any) {
|
||||
if err := renderer.Render(w, name, status, data); err != nil {
|
||||
http.Error(w, "template rendering failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
29
internal/http/middleware.go
Normal file
29
internal/http/middleware.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
logger.Info("request completed",
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.Status(),
|
||||
"bytes", ww.BytesWritten(),
|
||||
"duration", time.Since(startedAt).String(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
323
internal/http/projects_integration_test.go
Normal file
323
internal/http/projects_integration_test.go
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminProjectTagAndReleaseFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, cfg, store := newTestRouterWithStore(t)
|
||||
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
projectLocation := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {""},
|
||||
"description": {"Primary desktop updater stream."},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
projectID := extractResourceID(t, projectLocation, "/admin/projects/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID, url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
"description": {"Primary desktop updater stream with release uploads."},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
tagLocation := submitForm(t, router, http.MethodPost, "/admin/tags", url.Values{
|
||||
"name": {"Windows Stable"},
|
||||
"slug": {""},
|
||||
"description": {"Windows production releases."},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
tagID := extractResourceID(t, tagLocation, "/admin/tags/")
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/tags", url.Values{
|
||||
"tag_id": {tagID},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
artifactBody := []byte("release-payload-1.0.0")
|
||||
uploadLocation := submitMultipartForm(t, router, "/admin/projects/"+projectID+"/releases", map[string]string{
|
||||
"version": "1.0.0",
|
||||
"build": "build-42",
|
||||
"release_notes": "Initial Windows stable rollout.",
|
||||
}, "artifact", "..\\Desktop App 1.0.0.zip", artifactBody, sessionCookie)
|
||||
|
||||
if !strings.Contains(uploadLocation, "status=release-uploaded") {
|
||||
t.Fatalf("expected release upload redirect status, got %q", uploadLocation)
|
||||
}
|
||||
|
||||
projectIDInt := mustParseInt64(t, projectID)
|
||||
project, err := store.Projects.GetByID(context.Background(), projectIDInt)
|
||||
if err != nil {
|
||||
t.Fatalf("load project: %v", err)
|
||||
}
|
||||
|
||||
if project.Slug != "desktop-app" {
|
||||
t.Fatalf("expected auto-generated project slug to persist, got %q", project.Slug)
|
||||
}
|
||||
|
||||
if !project.IsActive {
|
||||
t.Fatal("expected project to remain active until the archive action runs")
|
||||
}
|
||||
|
||||
projectTags, err := store.Projects.ListTags(context.Background(), project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load project tags: %v", err)
|
||||
}
|
||||
|
||||
if len(projectTags) != 1 || projectTags[0].Slug != "windows-stable" {
|
||||
t.Fatalf("expected one attached tag, got %+v", projectTags)
|
||||
}
|
||||
|
||||
releases, err := store.Releases.ListByProjectID(context.Background(), project.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load releases: %v", err)
|
||||
}
|
||||
|
||||
if len(releases) != 1 {
|
||||
t.Fatalf("expected one release, got %d", len(releases))
|
||||
}
|
||||
|
||||
release := releases[0].Release
|
||||
if release.Filename != "desktop-app-1.0.0.zip" {
|
||||
t.Fatalf("expected sanitized filename, got %q", release.Filename)
|
||||
}
|
||||
|
||||
expectedChecksum := sha256.Sum256(artifactBody)
|
||||
if release.ChecksumSHA256 != hex.EncodeToString(expectedChecksum[:]) {
|
||||
t.Fatalf("expected checksum %s, got %s", hex.EncodeToString(expectedChecksum[:]), release.ChecksumSHA256)
|
||||
}
|
||||
|
||||
if release.StoragePath != "desktop-app/1.0.0/build-42/desktop-app-1.0.0.zip" {
|
||||
t.Fatalf("unexpected storage path %q", release.StoragePath)
|
||||
}
|
||||
|
||||
artifactPath := filepath.Join(cfg.ArtifactsDir, filepath.FromSlash(release.StoragePath))
|
||||
if strings.HasPrefix(artifactPath, cfg.StaticDir) {
|
||||
t.Fatalf("artifact path %q should not be inside static dir %q", artifactPath, cfg.StaticDir)
|
||||
}
|
||||
|
||||
storedArtifact, err := os.ReadFile(artifactPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read artifact from disk: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(storedArtifact, artifactBody) {
|
||||
t.Fatal("stored artifact body did not match uploaded payload")
|
||||
}
|
||||
|
||||
recorder := performRequest(t, router, http.MethodGet, "/admin/projects/"+projectID, nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected project detail page to load, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
projectBody := recorder.Body.String()
|
||||
if !strings.Contains(projectBody, "/api/v1/projects/desktop-app/releases/latest") {
|
||||
t.Fatal("expected project detail page to show latest release metadata endpoint")
|
||||
}
|
||||
|
||||
if !strings.Contains(projectBody, "/api/v1/releases/1/download") {
|
||||
t.Fatal("expected project detail page to show release download endpoint")
|
||||
}
|
||||
|
||||
submitForm(t, router, http.MethodPost, "/admin/projects/"+projectID+"/archive", url.Values{
|
||||
"state": {"archive"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
|
||||
project, err = store.Projects.GetByID(context.Background(), projectIDInt)
|
||||
if err != nil {
|
||||
t.Fatalf("reload project after archive: %v", err)
|
||||
}
|
||||
|
||||
if project.IsActive {
|
||||
t.Fatal("expected archived project to be inactive")
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin/projects", nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected projects page to load, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "Desktop App") {
|
||||
t.Fatal("expected projects page to list the created project")
|
||||
}
|
||||
|
||||
recorder = performRequest(t, router, http.MethodGet, "/admin/tags", nil, sessionCookie)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected tags page to load, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "Windows Stable") {
|
||||
t.Fatal("expected tags page to list the created tag")
|
||||
}
|
||||
}
|
||||
|
||||
func loginAsAdmin(t *testing.T, router http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"correct horse battery staple"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, csrfCookie)
|
||||
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected login redirect, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == "update_server_session" {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatal("expected session cookie after login")
|
||||
return nil
|
||||
}
|
||||
|
||||
func submitForm(t *testing.T, router http.Handler, method, target string, form url.Values, expectedStatus int, cookies ...*http.Cookie) string {
|
||||
t.Helper()
|
||||
|
||||
if strings.HasPrefix(target, "/admin/") {
|
||||
csrfCookie := ensureCSRFCookie(t, router, cookies...)
|
||||
cookies = upsertCookie(cookies, csrfCookie)
|
||||
if form == nil {
|
||||
form = url.Values{}
|
||||
}
|
||||
if form.Get("csrf_token") == "" {
|
||||
form.Set("csrf_token", csrfCookie.Value)
|
||||
}
|
||||
}
|
||||
|
||||
recorder := performRequest(t, router, method, target, form, cookies...)
|
||||
if recorder.Code != expectedStatus {
|
||||
t.Fatalf("expected %d for %s %s, got %d with body %s", expectedStatus, method, target, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
return recorder.Header().Get("Location")
|
||||
}
|
||||
|
||||
func submitMultipartForm(t *testing.T, router http.Handler, target string, fields map[string]string, fileField, filename string, body []byte, cookies ...*http.Cookie) string {
|
||||
t.Helper()
|
||||
|
||||
if strings.HasPrefix(target, "/admin/") {
|
||||
csrfCookie := ensureCSRFCookie(t, router, cookies...)
|
||||
cookies = upsertCookie(cookies, csrfCookie)
|
||||
if fields == nil {
|
||||
fields = map[string]string{}
|
||||
}
|
||||
if fields["csrf_token"] == "" {
|
||||
fields["csrf_token"] = csrfCookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
var requestBody bytes.Buffer
|
||||
writer := multipart.NewWriter(&requestBody)
|
||||
for key, value := range fields {
|
||||
if err := writer.WriteField(key, value); err != nil {
|
||||
t.Fatalf("write multipart field %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
part, err := writer.CreateFormFile(fileField, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create multipart file part: %v", err)
|
||||
}
|
||||
|
||||
if _, err := part.Write(body); err != nil {
|
||||
t.Fatalf("write multipart file body: %v", err)
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, target, &requestBody)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected multipart upload redirect, got %d with body %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
return recorder.Header().Get("Location")
|
||||
}
|
||||
|
||||
func upsertCookie(cookies []*http.Cookie, cookie *http.Cookie) []*http.Cookie {
|
||||
if cookie == nil {
|
||||
return cookies
|
||||
}
|
||||
|
||||
updated := make([]*http.Cookie, 0, len(cookies)+1)
|
||||
replaced := false
|
||||
for _, existing := range cookies {
|
||||
if existing == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if existing.Name == cookie.Name {
|
||||
updated = append(updated, cookie)
|
||||
replaced = true
|
||||
continue
|
||||
}
|
||||
|
||||
updated = append(updated, existing)
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
updated = append(updated, cookie)
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
func extractResourceID(t *testing.T, location, prefix string) string {
|
||||
t.Helper()
|
||||
|
||||
if !strings.HasPrefix(location, prefix) {
|
||||
t.Fatalf("expected redirect location with prefix %q, got %q", prefix, location)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimPrefix(location, prefix)
|
||||
parts := strings.SplitN(trimmed, "?", 2)
|
||||
if parts[0] == "" {
|
||||
t.Fatalf("could not extract resource id from %q", location)
|
||||
}
|
||||
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func mustParseInt64(t *testing.T, raw string) int64 {
|
||||
t.Helper()
|
||||
|
||||
value, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unescape id %q: %v", raw, err)
|
||||
}
|
||||
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscan(value, &parsed); err != nil {
|
||||
t.Fatalf("parse id %q: %v", raw, err)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
139
internal/http/rate_limit.go
Normal file
139
internal/http/rate_limit.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rateLimitStore struct {
|
||||
mu sync.Mutex
|
||||
ratePerSec float64
|
||||
burst float64
|
||||
lastCleanup time.Time
|
||||
entries map[string]*rateLimitEntry
|
||||
}
|
||||
|
||||
type rateLimitEntry struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
func newRateLimitStore(perMinute, burst int) *rateLimitStore {
|
||||
return &rateLimitStore{
|
||||
ratePerSec: float64(perMinute) / 60,
|
||||
burst: float64(burst),
|
||||
entries: make(map[string]*rateLimitEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *rateLimitStore) Allow(key string, now time.Time) (bool, time.Duration) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
key = "global"
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if entry, ok := s.entries[key]; ok {
|
||||
elapsed := now.Sub(entry.lastSeen).Seconds()
|
||||
entry.tokens = math.Min(s.burst, entry.tokens+(elapsed*s.ratePerSec))
|
||||
entry.lastSeen = now
|
||||
if entry.tokens >= 1 {
|
||||
entry.tokens--
|
||||
s.cleanup(now)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
s.cleanup(now)
|
||||
return false, retryAfter(entry.tokens, s.ratePerSec)
|
||||
}
|
||||
|
||||
s.entries[key] = &rateLimitEntry{
|
||||
tokens: s.burst - 1,
|
||||
lastSeen: now,
|
||||
}
|
||||
s.cleanup(now)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (s *rateLimitStore) cleanup(now time.Time) {
|
||||
if !s.lastCleanup.IsZero() && now.Sub(s.lastCleanup) < 5*time.Minute {
|
||||
return
|
||||
}
|
||||
|
||||
for key, entry := range s.entries {
|
||||
if now.Sub(entry.lastSeen) > 15*time.Minute {
|
||||
delete(s.entries, key)
|
||||
}
|
||||
}
|
||||
|
||||
s.lastCleanup = now
|
||||
}
|
||||
|
||||
func retryAfter(tokens, ratePerSec float64) time.Duration {
|
||||
if ratePerSec <= 0 {
|
||||
return time.Minute
|
||||
}
|
||||
|
||||
if tokens < 0 {
|
||||
tokens = 0
|
||||
}
|
||||
|
||||
seconds := (1 - tokens) / ratePerSec
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return time.Duration(math.Ceil(seconds * float64(time.Second)))
|
||||
}
|
||||
|
||||
func retryAfterHeader(delay time.Duration) string {
|
||||
seconds := int(math.Ceil(delay.Seconds()))
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return strconv.Itoa(seconds)
|
||||
}
|
||||
|
||||
func (h *handler) loginRateLimit(next http.Handler) http.Handler {
|
||||
return h.rateLimit(h.loginRateLimiter, func(r *http.Request) string {
|
||||
return clientIP(r)
|
||||
}, func(w http.ResponseWriter, r *http.Request, delay time.Duration) {
|
||||
w.Header().Set("Retry-After", retryAfterHeader(delay))
|
||||
http.Error(w, "too many login attempts", http.StatusTooManyRequests)
|
||||
})(next)
|
||||
}
|
||||
|
||||
func (h *handler) clientAPIRateLimit(next http.Handler) http.Handler {
|
||||
return h.rateLimit(h.clientRateLimiter, func(r *http.Request) string {
|
||||
return clientIP(r)
|
||||
}, func(w http.ResponseWriter, r *http.Request, delay time.Duration) {
|
||||
w.Header().Set("Retry-After", retryAfterHeader(delay))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate limit exceeded"})
|
||||
})(next)
|
||||
}
|
||||
|
||||
func (h *handler) rateLimit(store *rateLimitStore, keyFn func(*http.Request) string, reject func(http.ResponseWriter, *http.Request, time.Duration)) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := clientIP(r)
|
||||
if keyFn != nil {
|
||||
key = keyFn(r)
|
||||
}
|
||||
|
||||
allowed, delay := store.Allow(key, time.Now().UTC())
|
||||
if !allowed {
|
||||
reject(w, r, delay)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
78
internal/http/render.go
Normal file
78
internal/http/render.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
func NewRenderer(templatesDir string) (*Renderer, error) {
|
||||
layouts, err := filepath.Glob(filepath.Join(templatesDir, "layouts", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find layouts: %w", err)
|
||||
}
|
||||
|
||||
if len(layouts) == 0 {
|
||||
return nil, fmt.Errorf("no layout templates found in %s", filepath.Join(templatesDir, "layouts"))
|
||||
}
|
||||
|
||||
partials, err := filepath.Glob(filepath.Join(templatesDir, "partials", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find partials: %w", err)
|
||||
}
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templatesDir, "pages", "*.gohtml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find pages: %w", err)
|
||||
}
|
||||
|
||||
if len(pages) == 0 {
|
||||
return nil, fmt.Errorf("no page templates found in %s", filepath.Join(templatesDir, "pages"))
|
||||
}
|
||||
|
||||
renderer := &Renderer{templates: make(map[string]*template.Template, len(pages))}
|
||||
|
||||
for _, page := range pages {
|
||||
files := append([]string{}, layouts...)
|
||||
files = append(files, partials...)
|
||||
files = append(files, page)
|
||||
|
||||
tmpl, err := template.ParseFiles(files...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template set for %s: %w", page, err)
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(filepath.Base(page), filepath.Ext(page))
|
||||
renderer.templates[name] = tmpl
|
||||
}
|
||||
|
||||
return renderer, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) Render(w http.ResponseWriter, name string, status int, data any) error {
|
||||
tmpl, ok := r.templates[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown template %q", name)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&output, "base", data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if status <= 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
|
||||
_, err := output.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
15
internal/http/response.go
Normal file
15
internal/http/response.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
http.Error(w, "json encoding failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
113
internal/http/router.go
Normal file
113
internal/http/router.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
authservice "update_server/internal/auth"
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
"update_server/internal/releases"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
config config.Config
|
||||
store *db.Store
|
||||
logger *slog.Logger
|
||||
renderer *Renderer
|
||||
auth *authservice.Service
|
||||
apiKeys *apikeys.Service
|
||||
releases *releases.Service
|
||||
loginRateLimiter *rateLimitStore
|
||||
clientRateLimiter *rateLimitStore
|
||||
}
|
||||
|
||||
func NewRouter(cfg config.Config, logger *slog.Logger, renderer *Renderer, store *db.Store, auth *authservice.Service, apiKeyService *apikeys.Service, releaseService *releases.Service) http.Handler {
|
||||
h := &handler{
|
||||
config: cfg,
|
||||
store: store,
|
||||
logger: logger,
|
||||
renderer: renderer,
|
||||
auth: auth,
|
||||
apiKeys: apiKeyService,
|
||||
releases: releaseService,
|
||||
loginRateLimiter: newRateLimitStore(cfg.LoginRateLimitPerMinute, cfg.LoginRateLimitBurst),
|
||||
clientRateLimiter: newRateLimitStore(cfg.ClientRateLimitPerMinute, cfg.ClientRateLimitBurst),
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RequestID)
|
||||
if cfg.TrustProxyHeaders {
|
||||
router.Use(middleware.RealIP)
|
||||
}
|
||||
router.Use(requestLogger(logger))
|
||||
router.Use(middleware.Recoverer)
|
||||
router.Use(middleware.StripSlashes)
|
||||
router.Use(securityHeaders(cfg))
|
||||
|
||||
router.Get("/healthz", h.health)
|
||||
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(cfg.StaticDir))))
|
||||
router.Get("/", h.home)
|
||||
|
||||
router.Route("/admin", func(r chi.Router) {
|
||||
r.Use(adminResponseHeaders)
|
||||
r.Use(h.adminCSRF)
|
||||
|
||||
r.Get("/login", h.adminLoginForm)
|
||||
r.With(h.loginRateLimit).Post("/login", h.adminLogin)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.requireAuthenticatedSession)
|
||||
r.Use(h.requireRole(db.UserRoleAdmin))
|
||||
|
||||
r.Get("/", h.adminHome)
|
||||
r.Post("/logout", h.adminLogout)
|
||||
r.Get("/projects", h.adminProjects)
|
||||
r.Get("/projects/new", h.adminProjectNew)
|
||||
r.Post("/projects", h.adminProjectCreate)
|
||||
r.Get("/projects/{projectID}", h.adminProjectDetail)
|
||||
r.Post("/projects/{projectID}", h.adminProjectUpdate)
|
||||
r.Post("/projects/{projectID}/archive", h.adminProjectArchive)
|
||||
r.Post("/projects/{projectID}/tags", h.adminProjectAttachTag)
|
||||
r.Post("/projects/{projectID}/tags/{tagID}/detach", h.adminProjectDetachTag)
|
||||
r.Post("/projects/{projectID}/releases", h.adminProjectUploadRelease)
|
||||
r.Get("/tags", h.adminTags)
|
||||
r.Get("/tags/new", h.adminTagNew)
|
||||
r.Post("/tags", h.adminTagCreate)
|
||||
r.Get("/tags/{tagID}", h.adminTagDetail)
|
||||
r.Post("/tags/{tagID}", h.adminTagUpdate)
|
||||
r.Post("/tags/{tagID}/delete", h.adminTagDelete)
|
||||
r.Get("/api-keys", h.adminAPIKeys)
|
||||
r.Get("/api-keys/new", h.adminAPIKeyNew)
|
||||
r.Post("/api-keys", h.adminAPIKeyCreate)
|
||||
r.Get("/api-keys/{apiKeyID}", h.adminAPIKeyDetail)
|
||||
r.Post("/api-keys/{apiKeyID}", h.adminAPIKeyUpdate)
|
||||
r.Post("/api-keys/{apiKeyID}/activate", h.adminAPIKeyToggleActive)
|
||||
})
|
||||
})
|
||||
|
||||
router.Route("/api", func(r chi.Router) {
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Use(apiResponseHeaders)
|
||||
r.Get("/", h.apiIndex)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(protectedAPIResponseHeaders)
|
||||
r.Use(h.clientAPIRateLimit)
|
||||
r.Use(h.requireAPIKey)
|
||||
r.Use(h.requireAPIKeyPermission(apikeys.PermissionDownload))
|
||||
|
||||
r.Get("/projects", h.apiAccessibleProjects)
|
||||
r.Get("/projects/{projectSlug}/releases/latest", h.apiLatestRelease)
|
||||
r.Get("/releases/{releaseID}", h.apiReleaseMetadata)
|
||||
r.Get("/releases/{releaseID}/download", h.apiReleaseDownload)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
80
internal/http/security.go
Normal file
80
internal/http/security.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"update_server/internal/config"
|
||||
)
|
||||
|
||||
const contentSecurityPolicy = "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
|
||||
|
||||
func securityHeaders(cfg config.Config) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
headers := w.Header()
|
||||
headers.Set("Content-Security-Policy", contentSecurityPolicy)
|
||||
headers.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
headers.Set("Cross-Origin-Resource-Policy", "same-origin")
|
||||
headers.Set("Permissions-Policy", "camera=(), geolocation=(), microphone=()")
|
||||
headers.Set("Referrer-Policy", "no-referrer")
|
||||
headers.Set("X-Content-Type-Options", "nosniff")
|
||||
headers.Set("X-Frame-Options", "DENY")
|
||||
|
||||
if cfg.SecureCookies {
|
||||
headers.Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func adminResponseHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
applyNoStoreHeaders(w)
|
||||
addVaryHeader(w, "Cookie")
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func apiResponseHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func protectedAPIResponseHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
applyNoStoreHeaders(w)
|
||||
addVaryHeader(w, "Authorization")
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func applyNoStoreHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "no-store, private, max-age=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
}
|
||||
|
||||
func addVaryHeader(w http.ResponseWriter, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
current := w.Header().Values("Vary")
|
||||
for _, entry := range current {
|
||||
for _, existing := range strings.Split(entry, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(existing), value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Vary", value)
|
||||
}
|
||||
194
internal/http/security_integration_test.go
Normal file
194
internal/http/security_integration_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestAdminPOSTRejectsMissingOrInvalidCSRFToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, _ := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
missingRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, sessionCookie)
|
||||
if missingRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected missing csrf token to return 403, got %d with body %s", missingRecorder.Code, missingRecorder.Body.String())
|
||||
}
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router, sessionCookie)
|
||||
invalidRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
"csrf_token": {"definitely-wrong"},
|
||||
}, sessionCookie, csrfCookie)
|
||||
if invalidRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected invalid csrf token to return 403, got %d with body %s", invalidRecorder.Code, invalidRecorder.Body.String())
|
||||
}
|
||||
|
||||
location := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
if location == "" {
|
||||
t.Fatal("expected valid csrf-protected form to redirect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersAndCookieDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, cfg, store := newTestRouterWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.BaseURL = "https://updates.example.com"
|
||||
cfg.SecureCookies = true
|
||||
})
|
||||
|
||||
loginPageRecorder := performRequest(t, router, http.MethodGet, "/admin/login", nil)
|
||||
if loginPageRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected login page to load, got %d", loginPageRecorder.Code)
|
||||
}
|
||||
|
||||
assertHeaderContains(t, loginPageRecorder, "Cache-Control", "no-store")
|
||||
assertHeaderEquals(t, loginPageRecorder, "X-Frame-Options", "DENY")
|
||||
assertHeaderEquals(t, loginPageRecorder, "X-Content-Type-Options", "nosniff")
|
||||
assertHeaderEquals(t, loginPageRecorder, "Referrer-Policy", "no-referrer")
|
||||
assertHeaderContains(t, loginPageRecorder, "Content-Security-Policy", "frame-ancestors 'none'")
|
||||
assertHeaderContains(t, loginPageRecorder, "Strict-Transport-Security", "max-age=31536000")
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
if csrfCookie.Path != "/admin" {
|
||||
t.Fatalf("expected csrf cookie path /admin, got %q", csrfCookie.Path)
|
||||
}
|
||||
if !csrfCookie.HttpOnly {
|
||||
t.Fatal("expected csrf cookie to be HttpOnly")
|
||||
}
|
||||
if !csrfCookie.Secure {
|
||||
t.Fatal("expected csrf cookie to be Secure when APP_BASE_URL is https")
|
||||
}
|
||||
if csrfCookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("expected csrf cookie SameSite=Strict, got %v", csrfCookie.SameSite)
|
||||
}
|
||||
|
||||
loginRecorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"correct horse battery staple"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, csrfCookie)
|
||||
if loginRecorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected login to redirect, got %d with body %s", loginRecorder.Code, loginRecorder.Body.String())
|
||||
}
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range loginRecorder.Result().Cookies() {
|
||||
if cookie.Name == cfg.SessionCookieName {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("expected session cookie after login")
|
||||
}
|
||||
if sessionCookie.Path != "/admin" {
|
||||
t.Fatalf("expected session cookie path /admin, got %q", sessionCookie.Path)
|
||||
}
|
||||
if !sessionCookie.HttpOnly {
|
||||
t.Fatal("expected session cookie to be HttpOnly")
|
||||
}
|
||||
if !sessionCookie.Secure {
|
||||
t.Fatal("expected session cookie to be Secure")
|
||||
}
|
||||
if sessionCookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("expected session cookie SameSite=Lax, got %v", sessionCookie.SameSite)
|
||||
}
|
||||
|
||||
keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
apiRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if apiRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected api project listing to succeed, got %d with body %s", apiRecorder.Code, apiRecorder.Body.String())
|
||||
}
|
||||
|
||||
assertHeaderContains(t, apiRecorder, "Cache-Control", "no-store")
|
||||
assertHeaderContains(t, apiRecorder, "Vary", "Authorization")
|
||||
assertHeaderEquals(t, apiRecorder, "X-Content-Type-Options", "nosniff")
|
||||
assertHeaderEquals(t, apiRecorder, "X-Frame-Options", "DENY")
|
||||
assertHeaderContains(t, apiRecorder, "X-Robots-Tag", "noindex")
|
||||
}
|
||||
|
||||
func TestLoginRateLimitReturnsTooManyRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, _ := newTestRouterWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.LoginRateLimitPerMinute = 60
|
||||
cfg.LoginRateLimitBurst = 2
|
||||
})
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
form := url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"wrong-password"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected attempt %d to return 401, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
limitedRecorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie)
|
||||
if limitedRecorder.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected limited login to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, limitedRecorder, "Retry-After", "1")
|
||||
}
|
||||
|
||||
func TestClientAPIRateLimitReturnsTooManyRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, store := newTestRouterWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.ClientRateLimitPerMinute = 60
|
||||
cfg.ClientRateLimitBurst = 2
|
||||
})
|
||||
|
||||
keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
recorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected api attempt %d to return 200, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
limitedRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if limitedRecorder.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected api rate limit to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, limitedRecorder, "Retry-After", "1")
|
||||
}
|
||||
165
internal/http/view_data.go
Normal file
165
internal/http/view_data.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package httpserver
|
||||
|
||||
import "update_server/internal/db"
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
Eyebrow string
|
||||
Heading string
|
||||
Description string
|
||||
BaseURL string
|
||||
CSRFToken string
|
||||
CurrentUser *db.User
|
||||
Flash *FlashMessage
|
||||
}
|
||||
|
||||
type FlashMessage struct {
|
||||
Kind string
|
||||
Message string
|
||||
}
|
||||
|
||||
type PageLink struct {
|
||||
Label string
|
||||
Href string
|
||||
Description string
|
||||
}
|
||||
|
||||
type HomePageData struct {
|
||||
PageData
|
||||
Links []PageLink
|
||||
}
|
||||
|
||||
type DashboardMetric struct {
|
||||
Label string
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
type DashboardPageData struct {
|
||||
PageData
|
||||
Links []PageLink
|
||||
Metrics []DashboardMetric
|
||||
}
|
||||
|
||||
type LoginFormData struct {
|
||||
Action string
|
||||
Email string
|
||||
Next string
|
||||
Error string
|
||||
SetupHint string
|
||||
}
|
||||
|
||||
type LoginPageData struct {
|
||||
PageData
|
||||
Login LoginFormData
|
||||
}
|
||||
|
||||
type ProjectsPageData struct {
|
||||
PageData
|
||||
Projects []db.ProjectListItem
|
||||
}
|
||||
|
||||
type ProjectFormData struct {
|
||||
Action string
|
||||
SubmitLabel string
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
Error string
|
||||
}
|
||||
|
||||
type ReleaseUploadData struct {
|
||||
Action string
|
||||
Version string
|
||||
Build string
|
||||
ReleaseNotes string
|
||||
Error string
|
||||
MaxUploadMB int64
|
||||
}
|
||||
|
||||
type ProjectFormPageData struct {
|
||||
PageData
|
||||
Form ProjectFormData
|
||||
}
|
||||
|
||||
type ProjectDetailPageData struct {
|
||||
PageData
|
||||
Project db.Project
|
||||
Form ProjectFormData
|
||||
Tags []db.Tag
|
||||
AvailableTags []db.Tag
|
||||
Releases []db.ReleaseListItem
|
||||
LatestRelease *db.Release
|
||||
AttachTagAction string
|
||||
ArchiveAction string
|
||||
ArchiveState string
|
||||
ArchiveLabel string
|
||||
Upload ReleaseUploadData
|
||||
}
|
||||
|
||||
type TagsPageData struct {
|
||||
PageData
|
||||
Tags []db.TagListItem
|
||||
}
|
||||
|
||||
type TagFormData struct {
|
||||
Action string
|
||||
SubmitLabel string
|
||||
Name string
|
||||
Slug string
|
||||
Description string
|
||||
Error string
|
||||
DeleteAction string
|
||||
CanDelete bool
|
||||
}
|
||||
|
||||
type TagFormPageData struct {
|
||||
PageData
|
||||
Form TagFormData
|
||||
Tag *db.Tag
|
||||
Projects []db.Project
|
||||
}
|
||||
|
||||
type APIKeyProjectChoice struct {
|
||||
Project db.Project
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type APIKeyTagChoice struct {
|
||||
Tag db.Tag
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type APIKeysPageData struct {
|
||||
PageData
|
||||
APIKeys []db.APIKeyListItem
|
||||
}
|
||||
|
||||
type APIKeyFormData struct {
|
||||
Action string
|
||||
SubmitLabel string
|
||||
Name string
|
||||
Description string
|
||||
ScopeMode db.ScopeMode
|
||||
ExpiresAt string
|
||||
CanDownload bool
|
||||
CanUpload bool
|
||||
CanDelete bool
|
||||
CanManageProjects bool
|
||||
SelectedProjectIDs []int64
|
||||
SelectedTagIDs []int64
|
||||
Error string
|
||||
RevealKey string
|
||||
}
|
||||
|
||||
type APIKeyPageData struct {
|
||||
PageData
|
||||
APIKey *db.APIKey
|
||||
Form APIKeyFormData
|
||||
ProjectChoices []APIKeyProjectChoice
|
||||
TagChoices []APIKeyTagChoice
|
||||
AccessibleProjects []db.Project
|
||||
ToggleAction string
|
||||
ToggleState string
|
||||
ToggleLabel string
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue