206 lines
6.0 KiB
Go
206 lines
6.0 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"cmbuyer/admin/internal/deviceauth"
|
|
"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, err := options.DeviceAuthenticator.Authenticate(context.Request)
|
|
if errors.Is(err, deviceauth.ErrUnauthenticated) {
|
|
context.Header("WWW-Authenticate", "Bearer")
|
|
context.Status(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if err != nil {
|
|
context.Status(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if !deviceauth.ValidDeviceID(principal.ID) {
|
|
// A custom authenticator is still an untrusted boundary. Do not defer principal
|
|
// validation until Commit because multipart bytes would already have been read.
|
|
context.Status(http.StatusServiceUnavailable)
|
|
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)
|
|
}
|
|
}
|