feat(admin): add routed task evidence details
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user