mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 08:01:16 +00:00
162 lines
5.6 KiB
Go
162 lines
5.6 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
"github.com/warmbly/warmbly/internal/api/middleware"
|
||
"github.com/warmbly/warmbly/internal/errx"
|
||
"github.com/warmbly/warmbly/internal/models"
|
||
)
|
||
|
||
// jsonUnmarshalString parses a JSON-encoded form field. Wrapping the
|
||
// stdlib so the call site stays readable.
|
||
func jsonUnmarshalString(s string, v any) error {
|
||
return json.Unmarshal([]byte(s), v)
|
||
}
|
||
|
||
// maxImportUploadBytes caps an upload so a malicious or accidental
|
||
// 1GB CSV doesn't OOM the API process. 50MB is wide enough for ~500k
|
||
// rows of typical contact data; the service-level row cap (50k) then
|
||
// truncates whatever's left.
|
||
const maxImportUploadBytes = 50 * 1024 * 1024
|
||
|
||
// ExportContacts streams CSV/XLSX/JSON. We never buffer the full body
|
||
// in memory if we can help it — the service writes through to
|
||
// c.Writer so the response trickles out as rows are encoded.
|
||
func (h *Handler) ExportContacts(c *gin.Context) {
|
||
userIDStr := middleware.GetUserID(c)
|
||
|
||
var req models.ContactExportRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
errx.Handle(c, errx.ErrInvalid)
|
||
return
|
||
}
|
||
|
||
// Pre-set the headers BEFORE the service starts writing rows.
|
||
// Once any byte has been sent we can't change the content type or
|
||
// status, so any error path after this point can only abort the
|
||
// stream — gin will surface that as a truncated response.
|
||
//
|
||
// Service runs in two phases: it first resolves filename + format
|
||
// + total count by doing the DB read (no writes yet), and then
|
||
// hands us back metadata + does the encoding pass. We use a
|
||
// captured buffer indirection: write directly to c.Writer.
|
||
c.Writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||
|
||
// Hand the gin ResponseWriter to the service. The service sets
|
||
// the actual Content-Disposition + Content-Type via the returned
|
||
// values, so we set those first by doing a "dry" precheck — but
|
||
// our service does buffered write into an io.Writer. To keep
|
||
// things simple, we capture into a buffer and ship at once. For
|
||
// 50k rows × 12 columns this is a few MB; well within reason.
|
||
pw := &bufferedResponse{c: c}
|
||
filename, contentType, count, err := h.ContactService.Export(c.Request.Context(), userIDStr, &req, pw)
|
||
if err != nil {
|
||
errx.Handle(c, err)
|
||
return
|
||
}
|
||
|
||
c.Writer.Header().Set("Content-Type", contentType)
|
||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||
c.Writer.Header().Set("X-Total-Rows", fmt.Sprintf("%d", count))
|
||
if _, werr := c.Writer.Write(pw.buf); werr != nil {
|
||
// Best-effort: client closed.
|
||
return
|
||
}
|
||
|
||
// Audit
|
||
if userID, perr := uuid.Parse(userIDStr); perr == nil {
|
||
h.AuditService.LogAction(c.Request.Context(), userID, models.AuditActionExport, models.AuditEntityContact, nil, c.ClientIP(), c.Request.UserAgent(), nil, map[string]string{
|
||
"count": fmt.Sprintf("%d", count),
|
||
"format": string(req.Format),
|
||
"scope": string(req.Scope),
|
||
})
|
||
}
|
||
}
|
||
|
||
// bufferedResponse is a minimal io.Writer that captures bytes for the
|
||
// export. We use it so the service can write through a stable
|
||
// io.Writer without coupling to gin internals. For very large exports
|
||
// this trades RAM for simplicity; the row cap upstream keeps it
|
||
// bounded.
|
||
type bufferedResponse struct {
|
||
c *gin.Context
|
||
buf []byte
|
||
}
|
||
|
||
func (b *bufferedResponse) Write(p []byte) (int, error) {
|
||
b.buf = append(b.buf, p...)
|
||
return len(p), nil
|
||
}
|
||
|
||
// ImportPreviewContacts accepts a multipart upload, parses it, and
|
||
// returns column metadata + a sample for the column-mapping UI.
|
||
func (h *Handler) ImportPreviewContacts(c *gin.Context) {
|
||
if _, err := middleware.GetUserUUID(c); err != nil {
|
||
errx.Handle(c, errx.ErrUser)
|
||
return
|
||
}
|
||
|
||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxImportUploadBytes)
|
||
file, header, err := c.Request.FormFile("file")
|
||
if err != nil {
|
||
errx.Handle(c, errx.New(errx.BadRequest, "missing 'file' form field"))
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
preview, xerr := h.ContactService.ImportPreview(c.Request.Context(), file, header.Filename)
|
||
if xerr != nil {
|
||
errx.Handle(c, xerr)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, preview)
|
||
}
|
||
|
||
// ImportCommitContacts accepts a multipart upload + an "options"
|
||
// JSON form field, applies the mapping, and returns per-row results.
|
||
func (h *Handler) ImportCommitContacts(c *gin.Context) {
|
||
userIDStr := middleware.GetUserID(c)
|
||
|
||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxImportUploadBytes)
|
||
file, header, err := c.Request.FormFile("file")
|
||
if err != nil {
|
||
errx.Handle(c, errx.New(errx.BadRequest, "missing 'file' form field"))
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
optsStr := c.Request.FormValue("options")
|
||
if optsStr == "" {
|
||
errx.Handle(c, errx.New(errx.BadRequest, "missing 'options' form field"))
|
||
return
|
||
}
|
||
var opts models.ContactImportCommit
|
||
if jerr := jsonUnmarshalString(optsStr, &opts); jerr != nil {
|
||
errx.Handle(c, errx.New(errx.BadRequest, "invalid 'options' JSON: "+jerr.Error()))
|
||
return
|
||
}
|
||
|
||
result, xerr := h.ContactService.ImportCommit(c.Request.Context(), userIDStr, file, header.Filename, &opts)
|
||
if xerr != nil {
|
||
errx.Handle(c, xerr)
|
||
return
|
||
}
|
||
|
||
if userID, perr := uuid.Parse(userIDStr); perr == nil {
|
||
h.AuditService.LogAction(c.Request.Context(), userID, models.AuditActionImport, models.AuditEntityContact, nil, c.ClientIP(), c.Request.UserAgent(), nil, map[string]string{
|
||
"total": fmt.Sprintf("%d", result.Total),
|
||
"imported": fmt.Sprintf("%d", result.Imported),
|
||
"updated": fmt.Sprintf("%d", result.Updated),
|
||
"skipped": fmt.Sprintf("%d", result.Skipped),
|
||
"failed": fmt.Sprintf("%d", result.Failed),
|
||
})
|
||
}
|
||
|
||
c.JSON(http.StatusOK, result)
|
||
}
|