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

View file

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

View 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)
}

View 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
View 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
}
}

View 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})
}

View 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)
}

View 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)
}
}

View 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
}

View 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
}

View 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
View 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),
}
}

View 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
View 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
View 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)
}
}

View 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(),
)
})
}
}

View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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
View 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
}