package handler import ( "encoding/json" "fmt" "io" "net/http" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/mattnite/cairn/internal/blob" "github.com/mattnite/cairn/internal/fingerprint" "github.com/mattnite/cairn/internal/forgejo" "github.com/mattnite/cairn/internal/models" ) type IngestHandler struct { Pool *pgxpool.Pool Store blob.Store ForgejoSync *forgejo.Sync } type IngestRequest struct { Repository string `json:"repository"` Owner string `json:"owner"` CommitSHA string `json:"commit_sha"` Type string `json:"type"` CrashMessage string `json:"crash_message,omitempty"` StackTrace string `json:"stack_trace,omitempty"` Tags json.RawMessage `json:"tags,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` } func (h *IngestHandler) Create(c *gin.Context) { metaJSON := c.PostForm("meta") if metaJSON == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'meta' form field"}) return } var req IngestRequest if err := json.Unmarshal([]byte(metaJSON), &req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid meta JSON: " + err.Error()}) return } if req.Repository == "" || req.Owner == "" || req.CommitSHA == "" || req.Type == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "repository, owner, commit_sha, and type are required"}) return } file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'file' form field: " + err.Error()}) return } defer file.Close() ctx := c.Request.Context() repo, err := models.GetOrCreateRepository(ctx, h.Pool, req.Owner, req.Repository) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } commit, err := models.GetOrCreateCommit(ctx, h.Pool, repo.ID, req.CommitSHA) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } blobKey := fmt.Sprintf("%s/%s/%s/%s", repo.Name, commit.SHA[:8], req.Type, header.Filename) if err := h.Store.Put(ctx, blobKey, file, header.Size); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "storing blob: " + err.Error()}) return } var crashMsg, stackTrace *string if req.CrashMessage != "" { crashMsg = &req.CrashMessage } if req.StackTrace != "" { stackTrace = &req.StackTrace } artifact, err := models.CreateArtifact(ctx, h.Pool, models.CreateArtifactParams{ RepositoryID: repo.ID, CommitID: commit.ID, Type: req.Type, BlobKey: blobKey, BlobSize: header.Size, CrashMessage: crashMsg, StackTrace: stackTrace, Tags: req.Tags, Metadata: req.Metadata, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Run fingerprinting pipeline if we have a stack trace. if req.StackTrace != "" { if result := fingerprint.Compute(req.StackTrace); result != nil { sig, isNew, err := models.GetOrCreateSignature(ctx, h.Pool, repo.ID, result.Fingerprint, stackTrace) if err == nil { models.UpdateArtifactSignature(ctx, h.Pool, artifact.ID, sig.ID, result.Fingerprint) if isNew { title := req.Type + " crash in " + req.Repository if len(result.Frames) > 0 { title = req.Type + ": " + result.Frames[0].Function } group, groupErr := models.CreateCrashGroup(ctx, h.Pool, sig.ID, repo.ID, title) if groupErr == nil && h.ForgejoSync != nil { h.ForgejoSync.CreateIssueForCrashGroup(ctx, group, req.StackTrace) } } } } } c.JSON(http.StatusCreated, artifact) } type DownloadHandler struct { Pool *pgxpool.Pool Store blob.Store } func (h *DownloadHandler) Download(c *gin.Context) { id := c.Param("id") artifact, err := models.GetArtifact(c.Request.Context(), h.Pool, id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "artifact not found"}) return } reader, err := h.Store.Get(c.Request.Context(), artifact.BlobKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "reading blob: " + err.Error()}) return } defer reader.Close() c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", artifact.BlobKey)) c.Header("Content-Type", "application/octet-stream") io.Copy(c.Writer, reader) }