update_server/internal/http/admin_projects.go
2026-06-10 20:51:17 +03:00

485 lines
14 KiB
Go

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