Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/opportunities/document_handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/opportunities/document_handler.go |
| Original SHA-256 | 40b731cbdc1630292424cc4b51d9c4c4a3d72cbd4023d24dba5ecb855ddf7c2f |
| Git blob SHA | cdb6e2478ee29a877442e25327f187fbb80e9e36 |
| Reading-copy SHA-256 | 40b731cbdc1630292424cc4b51d9c4c4a3d72cbd4023d24dba5ecb855ddf7c2f |
Recovered source transcript
Download complete source text ↓127 lines · 4.6 KB
package opportunities
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/missionworkforce/missionmatch/internal/platform/security"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// The document intake HTTP surface, directive Section 6 screen 3.
//
// Gated on opportunity.manage rather than a read capability, because attaching a customer's solicitation
// package to an opportunity is a write to tenant data and should state that authority rather than inherit it.
//
// EVERY FAILURE MODE HERE RETURNS A REASON A HUMAN CAN ACT ON. "Upload failed" is the message that produces
// a support ticket; "this file is 81 MB and the cap is 64 MB" is the message that produces a smaller file.
type DocumentHandler struct{ svc *DocumentService }
func NewDocumentHandler(svc *DocumentService) *DocumentHandler { return &DocumentHandler{svc: svc} }
func (h *DocumentHandler) Routes(r chi.Router) {
write := security.RequireCapability(security.CapOpportunityManage)
read := security.RequireCapability(security.CapCandidateRead)
r.With(write).Post("/opportunities/{id}/documents", h.upload)
r.With(read).Get("/opportunities/{id}/documents", h.list)
}
// maxRequestBytes bounds what the server will READ, which is a different control from the size cap on the
// document itself. Without it a caller can stream forever and the cap is only reached after the bytes are
// already in memory, which is a denial of service with extra steps.
const maxRequestBytes = MaxDocumentBytes + (1 << 20)
func (h *DocumentHandler) upload(w http.ResponseWriter, r *http.Request) {
oppID := chi.URLParam(r, "id")
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeDocErr(w, http.StatusBadRequest, "this endpoint takes a multipart form with a file field: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeDocErr(w, http.StatusBadRequest, "no file field in the form")
return
}
defer file.Close()
// LimitReader with ONE byte of headroom, so exceeding the cap is DETECTED rather than silently truncated
// into a corrupt document that hashes differently every time.
body, err := io.ReadAll(io.LimitReader(file, maxRequestBytes+1))
if err != nil {
writeDocErr(w, http.StatusBadRequest, "could not read the uploaded file: "+err.Error())
return
}
if len(body) > maxRequestBytes {
writeDocErr(w, http.StatusRequestEntityTooLarge, "the upload exceeds the request cap")
return
}
p, _ := tenant.UserFromContext(r.Context())
uid, uerr := uuid.Parse(p.UserID)
if uerr != nil {
writeDocErr(w, http.StatusUnauthorized, "no validated user on this request")
return
}
doc, err := h.svc.Upload(r.Context(), uid, UploadInput{
OpportunityID: oppID,
DocumentType: r.FormValue("document_type"),
FileName: header.Filename,
MediaType: header.Header.Get("Content-Type"),
Bytes: body,
})
if err != nil {
writeDocErr(w, statusForDocumentError(err), err.Error())
return
}
writeDocJSON(w, http.StatusCreated, doc)
}
func (h *DocumentHandler) list(w http.ResponseWriter, r *http.Request) {
docs, err := h.svc.List(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeDocErr(w, statusForDocumentError(err), err.Error())
return
}
writeDocJSON(w, http.StatusOK, map[string]any{"documents": docs})
}
// statusForDocumentError maps a sentinel to a status. Mapping on typed values rather than on message text
// means renaming an error message never silently changes an HTTP contract.
func statusForDocumentError(err error) int {
switch {
case errors.Is(err, ErrDocumentTooLarge):
return http.StatusRequestEntityTooLarge
case errors.Is(err, ErrMediaTypeRefused):
return http.StatusUnsupportedMediaType
case errors.Is(err, ErrDuplicateDocument):
return http.StatusConflict
case errors.Is(err, ErrDocumentEmpty), errors.Is(err, ErrDocumentTypeInvalid):
return http.StatusBadRequest
case errors.Is(err, ErrOpportunityUnknown):
// 404 rather than 403: an opportunity belonging to another tenant must be indistinguishable from one
// that does not exist, or the error code itself becomes a cross tenant existence oracle.
return http.StatusNotFound
case errors.Is(err, ErrNoDatabase):
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
func writeDocJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeDocErr(w http.ResponseWriter, code int, msg string) {
writeDocJSON(w, code, map[string]string{"error": msg})
}