485 lines
14 KiB
Go
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
|
|
}
|
|
}
|