package handlers import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "time" "github.com/go-chi/chi/v5" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/domain/signing" "github.com/forgeo/forgebucket/internal/models" ) type ArtifactHandler struct { db *xorm.Engine artifactRoot string keys *signing.KeyStore } func NewArtifactHandler(db *xorm.Engine, artifactRoot string, keys *signing.KeyStore) *ArtifactHandler { return &ArtifactHandler{db: db, artifactRoot: artifactRoot, keys: keys} } // List returns all artifacts for a pipeline run. func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) { repoID, runID, ok := h.resolveRunIDs(w, r) if !ok { return } var artifacts []models.Artifact if err := h.db.Where("run_id = ? AND repo_id = ?", runID, repoID).Find(&artifacts); err != nil { jsonError(w, "could not list artifacts", http.StatusInternalServerError) return } if artifacts == nil { artifacts = []models.Artifact{} } jsonOK(w, artifacts) } // Upload accepts a multipart file upload, stores it as an artifact, and // immediately signs it using the server's signing key. func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) { repoID, runID, ok := h.resolveRunIDs(w, r) if !ok { return } r.Body = http.MaxBytesReader(w, r.Body, 512<<20) // 512 MB max if err := r.ParseMultipartForm(32 << 20); err != nil { jsonError(w, "multipart parse failed", http.StatusBadRequest) return } file, header, err := r.FormFile("file") if err != nil { jsonError(w, "file field is required", http.StatusBadRequest) return } defer file.Close() name := r.FormValue("name") if name == "" { name = header.Filename } // Sanitize name: no path separators. for _, c := range []byte(name) { if c == '/' || c == '\\' || c == 0 { jsonError(w, "artifact name must not contain path separators", http.StatusBadRequest) return } } dir := filepath.Join(h.artifactRoot, fmt.Sprintf("%d", runID)) if err := os.MkdirAll(dir, 0755); err != nil { jsonError(w, "could not create storage directory", http.StatusInternalServerError) return } storagePath := filepath.Join(dir, name) dst, err := os.Create(storagePath) if err != nil { jsonError(w, "could not create file", http.StatusInternalServerError) return } defer dst.Close() // Read into memory so we can both write to disk and sign. content, err := io.ReadAll(file) if err != nil { jsonError(w, "could not read upload", http.StatusInternalServerError) return } if _, err := dst.Write(content); err != nil { jsonError(w, "could not write file", http.StatusInternalServerError) return } ct := header.Header.Get("Content-Type") if ct == "" { ct = "application/octet-stream" } // Store path relative to artifactRoot for portability. relPath := fmt.Sprintf("%d/%s", runID, name) artifact := &models.Artifact{ RunID: runID, RepoID: repoID, Name: name, StoragePath: relPath, Size: int64(len(content)), ContentType: ct, } if _, err := h.db.Insert(artifact); err != nil { jsonError(w, "could not record artifact", http.StatusInternalServerError) return } // Sign the artifact and persist the bundle. go h.signArtifact(artifact.ID, name, content) w.WriteHeader(http.StatusCreated) jsonOK(w, artifact) } // signArtifact is called in a goroutine after a successful upload. func (h *ArtifactHandler) signArtifact(artifactID int64, name string, content []byte) { bundle, err := h.keys.Sign(artifactID, name, content) if err != nil { fmt.Printf("signing: failed to sign artifact %d: %v\n", artifactID, err) return } bundleJSON, err := json.Marshal(bundle) if err != nil { fmt.Printf("signing: failed to marshal bundle for artifact %d: %v\n", artifactID, err) return } sig := &models.ArtifactSignature{ ArtifactID: artifactID, KeyID: bundle.KeyID, Algorithm: "ecdsa-p256-sha256", Digest: bundle.Payload.Digest, BundleJSON: string(bundleJSON), SignedAt: time.Now().UTC(), } if _, err := h.db.Insert(sig); err != nil { fmt.Printf("signing: failed to store signature for artifact %d: %v\n", artifactID, err) } } // Download streams the artifact file to the client. func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) { artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64) if err != nil { jsonError(w, "invalid artifact ID", http.StatusBadRequest) return } var artifact models.Artifact if found, _ := h.db.ID(artifactID).Get(&artifact); !found { jsonError(w, "artifact not found", http.StatusNotFound) return } fullPath := filepath.Join(h.artifactRoot, filepath.FromSlash(artifact.StoragePath)) if !isUnder(h.artifactRoot, fullPath) { jsonError(w, "forbidden", http.StatusForbidden) return } f, err := os.Open(fullPath) if err != nil { jsonError(w, "artifact file not found", http.StatusNotFound) return } defer f.Close() ct := artifact.ContentType if ct == "" { ct = "application/octet-stream" } w.Header().Set("Content-Type", ct) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, artifact.Name)) w.Header().Set("Content-Length", strconv.FormatInt(artifact.Size, 10)) io.Copy(w, f) //nolint:errcheck } // GetSignature returns the full signature bundle JSON for an artifact. // GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/signature func (h *ArtifactHandler) GetSignature(w http.ResponseWriter, r *http.Request) { artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64) if err != nil { jsonError(w, "invalid artifact ID", http.StatusBadRequest) return } var sig models.ArtifactSignature found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig) if err != nil { jsonError(w, "database error", http.StatusInternalServerError) return } if !found { jsonError(w, "signature not found — artifact may still be pending signing", http.StatusNotFound) return } // Return the raw bundle JSON so clients can verify independently. w.Header().Set("Content-Type", "application/vnd.forgebucket.signature.bundle+json") w.WriteHeader(http.StatusOK) w.Write([]byte(sig.BundleJSON)) //nolint:errcheck } // VerifySignature verifies the stored signature bundle for an artifact. // GET /api/v1/repos/{owner}/{repo}/artifacts/{artifactID}/verify func (h *ArtifactHandler) VerifySignature(w http.ResponseWriter, r *http.Request) { artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64) if err != nil { jsonError(w, "invalid artifact ID", http.StatusBadRequest) return } var sig models.ArtifactSignature found, err := h.db.Where("artifact_id = ?", artifactID).Get(&sig) if err != nil { jsonError(w, "database error", http.StatusInternalServerError) return } if !found { jsonError(w, "signature not found", http.StatusNotFound) return } result, err := h.keys.Verify([]byte(sig.BundleJSON)) if err != nil { jsonError(w, fmt.Sprintf("verification error: %v", err), http.StatusUnprocessableEntity) return } jsonOK(w, result) } // ─── helpers ───────────────────────────────────────────────────────────────── func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) { rID, ok := resolveRepoID(h.db, w, r) if !ok { return 0, 0, false } runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64) if err != nil { jsonError(w, "invalid run ID", http.StatusBadRequest) return 0, 0, false } return rID, runID, true } func isUnder(root, path string) bool { root = filepath.Clean(root) path = filepath.Clean(path) if len(path) <= len(root) { return false } return path[:len(root)] == root && path[len(root)] == filepath.Separator }