130 lines
3.7 KiB
Go
130 lines
3.7 KiB
Go
package httpapi
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
const brandLogoMaxUpload = 3 << 20 // parse budget slightly above 2 MiB file cap
|
||
|
|
|
||
|
|
func (s *Server) handleUploadBrandLogo(w http.ResponseWriter, r *http.Request) {
|
||
|
|
cid, _ := CompanyIDFromContext(r.Context())
|
||
|
|
role, _ := RoleFromContext(r.Context())
|
||
|
|
if role != "admin" {
|
||
|
|
Error(w, http.StatusForbidden, "admin required")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := r.ParseMultipartForm(brandLogoMaxUpload); err != nil {
|
||
|
|
Error(w, http.StatusBadRequest, "invalid multipart form")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
file, header, err := r.FormFile("file")
|
||
|
|
if err != nil {
|
||
|
|
file, header, err = r.FormFile("logo")
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusBadRequest, "file field required")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
|
||
|
|
logoURL, _, _, _, err := company.SaveBrandLogo(
|
||
|
|
s.Config.UploadDir,
|
||
|
|
cid,
|
||
|
|
header.Filename,
|
||
|
|
header.Header.Get("Content-Type"),
|
||
|
|
file,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
ClientOrLog(w, http.StatusBadRequest, "could not upload logo", err, company.ClientError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
current, err := company.LoadBrand(r.Context(), s.Pool, cid)
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusInternalServerError, "load brand failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
current.LogoURL = logoURL
|
||
|
|
saved, err := company.UpsertBrand(r.Context(), s.Pool, cid, current)
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusInternalServerError, "save brand failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.brandResponse(w, r, saved)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleGetBrandLogoFile(w http.ResponseWriter, r *http.Request) {
|
||
|
|
cid, _ := CompanyIDFromContext(r.Context())
|
||
|
|
name := chi.URLParam(r, "filename")
|
||
|
|
s.serveBrandLogo(w, r, cid, name)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handlePublicBrandLogo(w http.ResponseWriter, r *http.Request) {
|
||
|
|
companyRaw := chi.URLParam(r, "companyID")
|
||
|
|
cid, err := uuid.Parse(companyRaw)
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusBadRequest, "invalid company id")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
name := chi.URLParam(r, "filename")
|
||
|
|
expRaw := strings.TrimSpace(r.URL.Query().Get("exp"))
|
||
|
|
sig := strings.TrimSpace(r.URL.Query().Get("sig"))
|
||
|
|
exp, err := strconv.ParseInt(expRaw, 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusForbidden, "invalid or expired signature")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
secret := strings.TrimSpace(s.Config.TokenSigningSecret)
|
||
|
|
if secret == "" {
|
||
|
|
Error(w, http.StatusServiceUnavailable, "signed logos unavailable")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := company.VerifyPublicBrandLogoSig(secret, cid, name, exp, sig); err != nil {
|
||
|
|
Error(w, http.StatusForbidden, "invalid or expired signature")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.serveBrandLogo(w, r, cid, name)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) serveBrandLogo(w http.ResponseWriter, r *http.Request, companyID uuid.UUID, name string) {
|
||
|
|
f, contentType, err := company.OpenBrandLogo(s.Config.UploadDir, companyID, name)
|
||
|
|
if err != nil {
|
||
|
|
switch {
|
||
|
|
case errors.Is(err, company.ErrLogoInvalidName), errors.Is(err, company.ErrLogoForbidden):
|
||
|
|
Error(w, http.StatusBadRequest, "invalid logo path")
|
||
|
|
case errors.Is(err, company.ErrLogoNotFound):
|
||
|
|
Error(w, http.StatusNotFound, "logo not found")
|
||
|
|
default:
|
||
|
|
Error(w, http.StatusInternalServerError, "could not open logo")
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
st, err := f.Stat()
|
||
|
|
if err != nil {
|
||
|
|
Error(w, http.StatusInternalServerError, "could not stat logo")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
w.Header().Set("Content-Type", contentType)
|
||
|
|
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
|
|
http.ServeContent(w, r, name, st.ModTime(), f)
|
||
|
|
}
|
||
|
|
|
||
|
|
func isBrandLogoURLError(err error) bool {
|
||
|
|
return errors.Is(err, security.ErrInvalidURL) ||
|
||
|
|
errors.Is(err, security.ErrBlockedURL) ||
|
||
|
|
errors.Is(err, security.ErrBlockedHost) ||
|
||
|
|
errors.Is(err, company.ErrLogoInvalidName)
|
||
|
|
}
|