init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
485
internal/http/admin_projects.go
Normal file
485
internal/http/admin_projects.go
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
package httpserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"update_server/internal/db"
|
||||
"update_server/internal/releases"
|
||||
)
|
||||
|
||||
func (h *handler) adminProjects(w http.ResponseWriter, r *http.Request) {
|
||||
projects, err := h.store.Projects.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "project listing failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := ProjectsPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"Projects",
|
||||
"Protected Admin",
|
||||
"Projects",
|
||||
"Create projects, update their metadata, archive them when needed, and open each project to manage tags and release uploads.",
|
||||
),
|
||||
Projects: projects,
|
||||
}
|
||||
data.Flash = projectListFlash(r.URL.Query().Get("status"))
|
||||
|
||||
renderPage(w, h.renderer, "projects", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectNew(w http.ResponseWriter, r *http.Request) {
|
||||
data := ProjectFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Project",
|
||||
"Protected Admin",
|
||||
"Create Project",
|
||||
"Add a new update stream with a stable slug so future releases and API access rules can target it reliably.",
|
||||
),
|
||||
Form: ProjectFormData{
|
||||
Action: "/admin/projects",
|
||||
SubmitLabel: "Create project",
|
||||
},
|
||||
}
|
||||
|
||||
renderPage(w, h.renderer, "project_form", data)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid project form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := ProjectFormData{
|
||||
Action: "/admin/projects",
|
||||
SubmitLabel: "Create project",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := projectFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderProjectFormPage(w, r, http.StatusBadRequest, form)
|
||||
return
|
||||
}
|
||||
|
||||
project, err := h.store.Projects.Create(r.Context(), params)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrConflict) {
|
||||
form.Error = "A project with that slug already exists."
|
||||
h.renderProjectFormPage(w, r, http.StatusConflict, form)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "project creation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=project-created", project.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectDetail(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.projectDetailPageData(r, projectID, ProjectFormData{}, ReleaseUploadData{})
|
||||
if err != nil {
|
||||
h.renderProjectDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data.Flash = projectDetailFlash(r.URL.Query().Get("status"))
|
||||
renderPage(w, h.renderer, "project_detail", *data)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid project form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := ProjectFormData{
|
||||
Action: fmt.Sprintf("/admin/projects/%d", projectID),
|
||||
SubmitLabel: "Save project",
|
||||
Name: r.FormValue("name"),
|
||||
Slug: r.FormValue("slug"),
|
||||
Description: r.FormValue("description"),
|
||||
}
|
||||
|
||||
params, validationError := projectFormInput(form.Name, form.Slug, form.Description)
|
||||
form.Name = params.Name
|
||||
form.Slug = params.Slug
|
||||
form.Description = params.Description
|
||||
if validationError != "" {
|
||||
form.Error = validationError
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, form, ReleaseUploadData{})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.store.Projects.Update(r.Context(), projectID, db.UpdateProjectParams(params))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
form.Error = "A project with that slug already exists."
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusConflict, form, ReleaseUploadData{})
|
||||
default:
|
||||
http.Error(w, "project update failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=project-updated", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectArchive(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid archive form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
state := r.FormValue("state")
|
||||
var (
|
||||
setActive bool
|
||||
statusCode string
|
||||
)
|
||||
|
||||
switch state {
|
||||
case "archive":
|
||||
setActive = false
|
||||
statusCode = "project-archived"
|
||||
case "restore":
|
||||
setActive = true
|
||||
statusCode = "project-restored"
|
||||
default:
|
||||
http.Error(w, "invalid archive action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Projects.SetActive(r.Context(), projectID, setActive); err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "project archive failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=%s", projectID, statusCode), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectAttachTag(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid tag assignment form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := strconv.ParseInt(r.FormValue("tag_id"), 10, 64)
|
||||
if err != nil || tagID <= 0 {
|
||||
h.renderProjectDetailPageWithFlash(w, r, projectID, http.StatusBadRequest, "error", "Choose a valid tag to attach.")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store.Tags.GetByID(r.Context(), tagID); err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
h.renderProjectDetailPageWithFlash(w, r, projectID, http.StatusBadRequest, "error", "Selected tag no longer exists.")
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "tag lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Projects.AttachTag(r.Context(), projectID, tagID); err != nil {
|
||||
http.Error(w, "tag assignment failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=tag-attached", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectDetachTag(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
tagID, err := routeID(r, "tagID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Projects.DetachTag(r.Context(), projectID, tagID); err != nil {
|
||||
http.Error(w, "tag detach failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=tag-detached", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) adminProjectUploadRelease(w http.ResponseWriter, r *http.Request) {
|
||||
projectID, err := routeID(r, "projectID")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestLimit(h.config.MaxUploadBytes))
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
upload := ReleaseUploadData{
|
||||
Version: r.FormValue("version"),
|
||||
Build: r.FormValue("build"),
|
||||
ReleaseNotes: r.FormValue("release_notes"),
|
||||
}
|
||||
|
||||
if isMaxBytesError(err) {
|
||||
upload.Error = fmt.Sprintf("Upload exceeds the configured %d MB limit.", maxUploadMegabytes(h.config.MaxUploadBytes))
|
||||
} else {
|
||||
upload.Error = "Upload form could not be read."
|
||||
}
|
||||
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r.MultipartForm != nil {
|
||||
_ = r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
|
||||
upload := ReleaseUploadData{
|
||||
Version: r.FormValue("version"),
|
||||
Build: r.FormValue("build"),
|
||||
ReleaseNotes: r.FormValue("release_notes"),
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("artifact")
|
||||
if err != nil {
|
||||
upload.Error = "Choose an artifact file to upload."
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := h.handleReleaseUpload(r, projectID, file, header, upload); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrNotFound):
|
||||
http.NotFound(w, r)
|
||||
case errors.Is(err, db.ErrConflict):
|
||||
upload.Error = "A release with that version and build already exists for this project."
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusConflict, ProjectFormData{}, upload)
|
||||
default:
|
||||
upload.Error = err.Error()
|
||||
h.renderProjectDetailPage(w, r, projectID, http.StatusBadRequest, ProjectFormData{}, upload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/projects/%d?status=release-uploaded", projectID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *handler) handleReleaseUpload(r *http.Request, projectID int64, file multipart.File, header *multipart.FileHeader, upload ReleaseUploadData) error {
|
||||
_, err := h.releases.Upload(r.Context(), releases.UploadParams{
|
||||
ProjectID: projectID,
|
||||
Version: upload.Version,
|
||||
Build: upload.Build,
|
||||
ReleaseNotes: upload.ReleaseNotes,
|
||||
OriginalFilename: header.Filename,
|
||||
DeclaredType: header.Header.Get("Content-Type"),
|
||||
Reader: file,
|
||||
UploadedByUserID: h.currentUserID(r),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectFormPage(w http.ResponseWriter, r *http.Request, status int, form ProjectFormData) {
|
||||
data := ProjectFormPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
"New Project",
|
||||
"Protected Admin",
|
||||
"Create Project",
|
||||
"Add a new update stream with a stable slug so future releases and API access rules can target it reliably.",
|
||||
),
|
||||
Form: form,
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "project_form", status, data)
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectDetailPage(w http.ResponseWriter, r *http.Request, projectID int64, status int, form ProjectFormData, upload ReleaseUploadData) {
|
||||
data, err := h.projectDetailPageData(r, projectID, form, upload)
|
||||
if err != nil {
|
||||
h.renderProjectDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
renderPageStatus(w, h.renderer, "project_detail", status, *data)
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectDetailPageWithFlash(w http.ResponseWriter, r *http.Request, projectID int64, status int, kind, message string) {
|
||||
data, err := h.projectDetailPageData(r, projectID, ProjectFormData{}, ReleaseUploadData{})
|
||||
if err != nil {
|
||||
h.renderProjectDetailError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
data.Flash = &FlashMessage{Kind: kind, Message: message}
|
||||
renderPageStatus(w, h.renderer, "project_detail", status, *data)
|
||||
}
|
||||
|
||||
func (h *handler) renderProjectDetailError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "project page failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *handler) projectDetailPageData(r *http.Request, projectID int64, form ProjectFormData, upload ReleaseUploadData) (*ProjectDetailPageData, error) {
|
||||
project, err := h.store.Projects.GetByID(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := h.store.Projects.ListTags(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availableTags, err := h.store.Tags.ListAvailableForProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
releasesList, err := h.store.Releases.ListByProjectID(r.Context(), projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var latestRelease *db.Release
|
||||
if len(releasesList) > 0 {
|
||||
release := releasesList[0].Release
|
||||
latestRelease = &release
|
||||
}
|
||||
|
||||
if form.Action == "" {
|
||||
form = ProjectFormData{
|
||||
Action: fmt.Sprintf("/admin/projects/%d", projectID),
|
||||
SubmitLabel: "Save project",
|
||||
Name: project.Name,
|
||||
Slug: project.Slug,
|
||||
Description: project.Description,
|
||||
Error: form.Error,
|
||||
}
|
||||
}
|
||||
|
||||
if upload.Action == "" {
|
||||
upload.Action = fmt.Sprintf("/admin/projects/%d/releases", projectID)
|
||||
}
|
||||
if upload.MaxUploadMB == 0 {
|
||||
upload.MaxUploadMB = maxUploadMegabytes(h.config.MaxUploadBytes)
|
||||
}
|
||||
|
||||
archiveState := "archive"
|
||||
archiveLabel := "Archive project"
|
||||
if !project.IsActive {
|
||||
archiveState = "restore"
|
||||
archiveLabel = "Restore project"
|
||||
}
|
||||
|
||||
data := &ProjectDetailPageData{
|
||||
PageData: h.basePageData(
|
||||
r,
|
||||
project.Name,
|
||||
"Protected Admin",
|
||||
project.Name,
|
||||
"Edit project metadata, manage tag assignments, and upload releases with checksums stored in SQLite.",
|
||||
),
|
||||
Project: *project,
|
||||
Form: form,
|
||||
Tags: tags,
|
||||
AvailableTags: availableTags,
|
||||
Releases: releasesList,
|
||||
LatestRelease: latestRelease,
|
||||
AttachTagAction: fmt.Sprintf("/admin/projects/%d/tags", projectID),
|
||||
ArchiveAction: fmt.Sprintf("/admin/projects/%d/archive", projectID),
|
||||
ArchiveState: archiveState,
|
||||
ArchiveLabel: archiveLabel,
|
||||
Upload: upload,
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func projectListFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
case "":
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func projectDetailFlash(code string) *FlashMessage {
|
||||
switch code {
|
||||
case "project-created":
|
||||
return &FlashMessage{Kind: "success", Message: "Project created. You can now attach tags and upload releases."}
|
||||
case "project-updated":
|
||||
return &FlashMessage{Kind: "success", Message: "Project details updated."}
|
||||
case "project-archived":
|
||||
return &FlashMessage{Kind: "success", Message: "Project archived."}
|
||||
case "project-restored":
|
||||
return &FlashMessage{Kind: "success", Message: "Project restored and active again."}
|
||||
case "tag-attached":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag attached to the project."}
|
||||
case "tag-detached":
|
||||
return &FlashMessage{Kind: "success", Message: "Tag detached from the project."}
|
||||
case "release-uploaded":
|
||||
return &FlashMessage{Kind: "success", Message: "Release uploaded. Metadata, checksum, and storage path were saved."}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue