mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
401 lines
11 KiB
Go
401 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/orgtransfer"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// Organization archives: export a whole workspace to a file, and import one
|
|
// back. Owner-only, and JWT-only at the route.
|
|
//
|
|
// Owner-only is not conservatism for its own sake. An export with credentials
|
|
// is the single most sensitive artifact this product can produce — every
|
|
// mailbox password and OAuth refresh token in the workspace, in one file — and
|
|
// an import rewrites the workspace wholesale. Both sit at the same level as
|
|
// deleting the organization, so both are gated the same way.
|
|
|
|
// orgOwnerContext resolves the caller's organization and confirms they own it,
|
|
// answering the request itself when they do not.
|
|
func (h *Handler) orgOwnerContext(c *gin.Context) (orgID, userID uuid.UUID, ok bool) {
|
|
uid, err := middleware.GetUserUUID(c)
|
|
if err != nil {
|
|
errx.JSON(c, errx.ErrUnauthorized)
|
|
return uuid.Nil, uuid.Nil, false
|
|
}
|
|
oid := middleware.GetOrganizationID(c)
|
|
if oid == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return uuid.Nil, uuid.Nil, false
|
|
}
|
|
if xerr := h.requireOrgOwner(c, *oid, uid); xerr != nil {
|
|
errx.JSON(c, errx.New(errx.Forbidden, "Only the workspace owner can export or import workspace data."))
|
|
return uuid.Nil, uuid.Nil, false
|
|
}
|
|
return *oid, uid, true
|
|
}
|
|
|
|
// GetOrgTransferGroups lists the data groups an archive can carry, so the
|
|
// settings page renders the toggles from the server's own catalog rather than
|
|
// a copy that drifts.
|
|
//
|
|
// GET /organization/current/transfer/groups
|
|
func (h *Handler) GetOrgTransferGroups(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"groups": models.OrgDataGroupCatalog,
|
|
"format_version": models.OrgTransferFormatVersion,
|
|
"min_passphrase": models.MinOrgExportPassphraseLength,
|
|
"retention_days": int(models.OrgExportRetention.Hours() / 24),
|
|
})
|
|
}
|
|
|
|
// ---------- export ----------
|
|
|
|
// CreateOrgExport starts an archive build.
|
|
//
|
|
// POST /organization/current/export
|
|
func (h *Handler) CreateOrgExport(c *gin.Context) {
|
|
orgID, userID, ok := h.orgOwnerContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.OrgTransferService == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "Workspace export is not available on this instance."))
|
|
return
|
|
}
|
|
|
|
var req models.CreateOrgExportRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.InvalidBody(err))
|
|
return
|
|
}
|
|
|
|
job, xerr := h.OrgTransferService.RequestExport(c.Request.Context(), orgID, &userID, &req)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionExport, models.AuditEntityOrgArchive, &job.ID, nil, map[string]string{
|
|
"include_secrets": fmt.Sprintf("%t", job.IncludeSecrets),
|
|
"groups": fmt.Sprintf("%d", len(job.Groups)),
|
|
})
|
|
c.JSON(http.StatusAccepted, job)
|
|
}
|
|
|
|
// ListOrgExports returns recent archive builds.
|
|
//
|
|
// GET /organization/current/export
|
|
func (h *Handler) ListOrgExports(c *gin.Context) {
|
|
orgID, _, ok := h.orgOwnerContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.OrgTransferService == nil {
|
|
c.JSON(http.StatusOK, gin.H{"data": []models.OrgExportJob{}})
|
|
return
|
|
}
|
|
|
|
jobs, xerr := h.OrgTransferService.ListExports(c.Request.Context(), orgID)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": jobs})
|
|
}
|
|
|
|
// GetOrgExport returns one archive build, for progress polling.
|
|
//
|
|
// GET /organization/current/export/:id
|
|
func (h *Handler) GetOrgExport(c *gin.Context) {
|
|
orgID, _, ok := h.orgOwnerContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := parseUUIDParam(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.OrgTransferService == nil {
|
|
errx.JSON(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
job, xerr := h.OrgTransferService.GetExport(c.Request.Context(), orgID, id)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// DownloadOrgExport streams a finished archive.
|
|
//
|
|
// GET /organization/current/export/:id/download
|
|
func (h *Handler) DownloadOrgExport(c *gin.Context) {
|
|
orgID, _, ok := h.orgOwnerContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := parseUUIDParam(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.OrgTransferService == nil {
|
|
errx.JSON(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
body, job, xerr := h.OrgTransferService.OpenExport(c.Request.Context(), orgID, id)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
defer body.Close()
|
|
|
|
name := "workspace-" + job.ID.String()[:8] + ".warmbly.zip"
|
|
if org, err := h.OrgRepo.GetByID(c.Request.Context(), orgID); err == nil && org != nil {
|
|
name = orgtransfer.ArchiveFilename(org.Name, job.ID)
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionExport, models.AuditEntityOrgArchive, &job.ID, nil, map[string]string{
|
|
"downloaded": "true",
|
|
})
|
|
|
|
c.Header("Content-Type", "application/zip")
|
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
if job.ArchiveBytes != nil {
|
|
c.Header("Content-Length", fmt.Sprintf("%d", *job.ArchiveBytes))
|
|
}
|
|
if job.ArchiveSHA256 != nil {
|
|
c.Header("X-Archive-SHA256", *job.ArchiveSHA256)
|
|
}
|
|
|
|
if _, err := io.Copy(c.Writer, body); err != nil {
|
|
// The client hung up mid-download. Nothing useful left to say: the
|
|
// status line is already sent.
|
|
return
|
|
}
|
|
}
|
|
|
|
// DeleteOrgExport removes an archive and its stored object.
|
|
//
|
|
// DELETE /organization/current/export/:id
|
|
func (h *Handler) DeleteOrgExport(c *gin.Context) {
|
|
orgID, _, ok := h.orgOwnerContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := parseUUIDParam(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.OrgTransferService == nil {
|
|
errx.JSON(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
if xerr := h.OrgTransferService.DeleteExport(c.Request.Context(), orgID, id); xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityOrgArchive, &id, nil, nil)
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// ---------- import ----------
|
|
|
|
// PreflightOrgImport reports what an uploaded archive would do, writing
|
|
// nothing.
|
|
//
|
|
// POST /organization/current/import/preflight
|
|
func (h *Handler) PreflightOrgImport(c *gin.Context) {
|
|
orgID, _, ok := h.orgOwnerContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.OrgTransferService == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "Workspace import is not available on this instance."))
|
|
return
|
|
}
|
|
|
|
spooled, xerr := spoolArchiveUpload(c)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
defer spooled.Close()
|
|
|
|
report, xerr := h.OrgTransferService.Preflight(
|
|
c.Request.Context(), orgID, spooled, c.Request.FormValue("passphrase"))
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, report)
|
|
}
|
|
|
|
// CreateOrgImport applies an uploaded archive to the current workspace.
|
|
//
|
|
// POST /organization/current/import
|
|
func (h *Handler) CreateOrgImport(c *gin.Context) {
|
|
orgID, userID, ok := h.orgOwnerContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.OrgTransferService == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "Workspace import is not available on this instance."))
|
|
return
|
|
}
|
|
|
|
// The import keeps reading this file after the request returns, so
|
|
// ownership passes to the service on success and it closes it when the
|
|
// job ends. Every failure path before that hands it back here.
|
|
spooled, xerr := spoolArchiveUpload(c)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
|
|
var req models.CreateOrgImportRequest
|
|
if opts := c.Request.FormValue("options"); opts != "" {
|
|
if err := jsonUnmarshalString(opts, &req); err != nil {
|
|
_ = spooled.Close()
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid 'options' JSON: "+err.Error()))
|
|
return
|
|
}
|
|
}
|
|
req.Passphrase = c.Request.FormValue("passphrase")
|
|
|
|
job, xerr := h.OrgTransferService.RequestImport(c.Request.Context(), orgID, &userID, spooled, &req)
|
|
if xerr != nil {
|
|
_ = spooled.Close()
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionImport, models.AuditEntityOrgArchive, &job.ID, nil, map[string]string{
|
|
"conflict_strategy": string(job.ConflictStrategy),
|
|
})
|
|
c.JSON(http.StatusAccepted, job)
|
|
}
|
|
|
|
// ListOrgImports returns recent imports.
|
|
//
|
|
// GET /organization/current/import
|
|
func (h *Handler) ListOrgImports(c *gin.Context) {
|
|
orgID, _, ok := h.orgOwnerContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.OrgTransferService == nil {
|
|
c.JSON(http.StatusOK, gin.H{"data": []models.OrgImportJob{}})
|
|
return
|
|
}
|
|
|
|
jobs, xerr := h.OrgTransferService.ListImports(c.Request.Context(), orgID)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": jobs})
|
|
}
|
|
|
|
// GetOrgImport returns one import, for progress polling.
|
|
//
|
|
// GET /organization/current/import/:id
|
|
func (h *Handler) GetOrgImport(c *gin.Context) {
|
|
orgID, _, ok := h.orgOwnerContext(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, ok := parseUUIDParam(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.OrgTransferService == nil {
|
|
errx.JSON(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
job, xerr := h.OrgTransferService.GetImport(c.Request.Context(), orgID, id)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// ---------- upload spooling ----------
|
|
|
|
// spooledArchive is an uploaded archive on local disk. A zip needs random
|
|
// access and a length, and a workspace archive is far too large to hold in
|
|
// memory, so the upload lands in a temporary file first.
|
|
//
|
|
// It is an io.Closer so ownership can be handed to a background import: the
|
|
// service closes it when the job ends, which is the only point at which the
|
|
// bytes are certainly no longer needed.
|
|
type spooledArchive struct {
|
|
f *os.File
|
|
size int64
|
|
}
|
|
|
|
func (s *spooledArchive) ReadAt(p []byte, off int64) (int, error) { return s.f.ReadAt(p, off) }
|
|
func (s *spooledArchive) Size() int64 { return s.size }
|
|
|
|
func (s *spooledArchive) Close() error {
|
|
err := s.f.Close()
|
|
_ = os.Remove(s.f.Name())
|
|
return err
|
|
}
|
|
|
|
// spoolArchiveUpload writes the multipart upload to a temporary file.
|
|
func spoolArchiveUpload(c *gin.Context) (*spooledArchive, *errx.Error) {
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, models.MaxOrgArchiveUploadBytes)
|
|
|
|
file, _, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
return nil, errx.New(errx.BadRequest, "missing 'file' form field")
|
|
}
|
|
defer file.Close()
|
|
|
|
tmp, err := os.CreateTemp("", "warmbly-import-*.zip")
|
|
if err != nil {
|
|
return nil, errx.InternalError()
|
|
}
|
|
spooled := &spooledArchive{f: tmp}
|
|
|
|
size, err := io.Copy(tmp, file)
|
|
if err != nil {
|
|
_ = spooled.Close()
|
|
return nil, errx.New(errx.BadRequest, "the upload could not be read")
|
|
}
|
|
if size == 0 {
|
|
_ = spooled.Close()
|
|
return nil, errx.New(errx.BadRequest, "the uploaded file is empty")
|
|
}
|
|
spooled.size = size
|
|
|
|
return spooled, nil
|
|
}
|
|
|
|
// parseUUIDParam reads a UUID path parameter, answering 400 on a malformed one.
|
|
func parseUUIDParam(c *gin.Context, name string) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(c.Param(name))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid "+name))
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|