package httpserver import ( "errors" "fmt" "mime" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "update_server/internal/apikeys" "update_server/internal/db" "update_server/internal/releases" ) 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) apiUploadRelease(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 } r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestLimit(h.config.MaxUploadBytes)) if err := r.ParseMultipartForm(16 << 20); err != nil { if isMaxBytesError(err) { writeJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": fmt.Sprintf("upload exceeds the configured %d MB limit", maxUploadMegabytes(h.config.MaxUploadBytes))}) return } writeJSON(w, http.StatusBadRequest, map[string]any{"error": "upload form could not be read"}) return } defer func() { if r.MultipartForm != nil { _ = r.MultipartForm.RemoveAll() } }() file, header, err := r.FormFile("artifact") if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"error": "artifact file is required"}) return } defer file.Close() result, err := h.releases.Upload(r.Context(), releases.UploadParams{ ProjectID: project.ID, Version: r.FormValue("version"), Build: r.FormValue("build"), ReleaseNotes: r.FormValue("release_notes"), OriginalFilename: header.Filename, DeclaredType: header.Header.Get("Content-Type"), Reader: file, }) if err != nil { switch { case errors.Is(err, db.ErrNotFound): writeJSON(w, http.StatusNotFound, map[string]any{"error": "resource not found"}) case errors.Is(err, db.ErrConflict): writeJSON(w, http.StatusConflict, map[string]any{"error": "release version and build already exist for this project"}) default: writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) } return } releasePayload := apiReleasePayload(*result.Release) w.Header().Set("Location", releasePayload.MetadataURL) writeJSON(w, http.StatusCreated, map[string]any{ "project": apiProjectPayload(*result.Project), "release": releasePayload, }) } 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), } }