272 lines
7.8 KiB
Go
272 lines
7.8 KiB
Go
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
|
|
}
|