package server import ( "errors" "io" "mime" "mime/multipart" "net/http" "strconv" "strings" "time" "unicode/utf8" "cmbuyer/admin/internal/evidence" "github.com/gin-gonic/gin" ) const ( maxEvidenceRequestBytes = evidence.MaxFileBytes + 64<<10 maxEvidenceFieldBytes = 4 << 10 ) var evidenceFieldNames = map[string]struct{}{ "upload_key": {}, "attempt_id": {}, "kind": {}, "privacy_tier": {}, "sha256": {}, "captured_at": {}, } func uploadEvidence(options Options) gin.HandlerFunc { return func(context *gin.Context) { // Authentication deliberately precedes content-type parsing and every body read. A rejected // device must not make the service spool or inspect a potentially sensitive upload. principal, authenticated := options.DeviceAuthenticator.Authenticate(context.Request) if !authenticated { context.Status(http.StatusUnauthorized) return } boundary, ok := multipartBoundary(context.GetHeader("Content-Type")) if !ok { context.Status(http.StatusUnsupportedMediaType) return } context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxEvidenceRequestBytes) reader := multipart.NewReader(context.Request.Body, boundary) fields := make(map[string]string, len(evidenceFieldNames)) var staged evidence.StagedFile hasFile := false discard := func() { if hasFile { options.Evidence.Discard(staged) } } for { part, err := reader.NextPart() if errors.Is(err, io.EOF) { break } if err != nil { discard() writeMultipartError(context, err) return } name := part.FormName() if name == "file" { if hasFile || part.FileName() == "" || !exactPNGContentType(part.Header.Get("Content-Type")) { _ = part.Close() discard() context.Status(http.StatusUnsupportedMediaType) return } staged, err = options.Evidence.Stage(part, evidence.PNGContentType) _ = part.Close() if err != nil { writeEvidenceStoreError(context, err) return } hasFile = true continue } if _, allowed := evidenceFieldNames[name]; !allowed || part.FileName() != "" { _ = part.Close() discard() context.Status(http.StatusBadRequest) return } if _, duplicate := fields[name]; duplicate { _ = part.Close() discard() context.Status(http.StatusBadRequest) return } value, err := io.ReadAll(io.LimitReader(part, maxEvidenceFieldBytes+1)) _ = part.Close() if err != nil || len(value) == 0 || len(value) > maxEvidenceFieldBytes || !utf8.Valid(value) { discard() context.Status(http.StatusBadRequest) return } fields[name] = string(value) } if !hasFile || len(fields) != len(evidenceFieldNames) { discard() context.Status(http.StatusBadRequest) return } captured, err := time.Parse(time.RFC3339Nano, fields["captured_at"]) if err != nil || !strings.HasSuffix(fields["captured_at"], "Z") { discard() context.Status(http.StatusBadRequest) return } asset, replayed, err := options.Evidence.Commit(context.Request.Context(), principal, evidence.UploadMetadata{ UploadKey: fields["upload_key"], TaskID: context.Param("id"), AttemptID: fields["attempt_id"], Kind: fields["kind"], PrivacyTier: fields["privacy_tier"], SHA256: fields["sha256"], CapturedAt: captured.UTC(), }, staged) if err != nil { writeEvidenceStoreError(context, err) return } status := http.StatusCreated if replayed { status = http.StatusOK } context.JSON(status, asset) } } func readEvidence(options Options) gin.HandlerFunc { return func(context *gin.Context) { if !options.Sessions.IsAuthenticated(context.Request) { context.Status(http.StatusUnauthorized) return } asset, file, err := options.Evidence.Open(context.Request.Context(), context.Param("asset_id")) if errors.Is(err, evidence.ErrNotFound) { context.Status(http.StatusNotFound) return } if err != nil { context.Status(http.StatusInternalServerError) return } defer file.Close() context.Header("Content-Type", evidence.PNGContentType) context.Header("Content-Length", strconv.FormatInt(asset.ByteSize, 10)) context.Header("Content-Disposition", `inline; filename="evidence.png"`) context.Header("Cache-Control", "no-store") context.Header("X-Content-Type-Options", "nosniff") context.Status(http.StatusOK) if _, err := io.Copy(context.Writer, file); err != nil { _ = context.Error(err) } } } func multipartBoundary(value string) (string, bool) { mediaType, parameters, err := mime.ParseMediaType(value) if err != nil || mediaType != "multipart/form-data" || len(parameters) != 1 || parameters["boundary"] == "" { return "", false } return parameters["boundary"], true } func exactPNGContentType(value string) bool { mediaType, parameters, err := mime.ParseMediaType(value) return err == nil && mediaType == evidence.PNGContentType && len(parameters) == 0 } func writeMultipartError(context *gin.Context, err error) { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { context.Status(http.StatusRequestEntityTooLarge) return } context.Status(http.StatusBadRequest) } func writeEvidenceStoreError(context *gin.Context, err error) { var tooLarge *http.MaxBytesError switch { case errors.As(err, &tooLarge): context.Status(http.StatusRequestEntityTooLarge) case errors.Is(err, evidence.ErrTooLarge): context.Status(http.StatusRequestEntityTooLarge) case errors.Is(err, evidence.ErrInvalid): context.Status(http.StatusBadRequest) case errors.Is(err, evidence.ErrConflict): context.Status(http.StatusConflict) default: context.Status(http.StatusInternalServerError) } }