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