package web import ( "encoding/json" "errors" "fmt" "net/http" "time" "codeberg.org/mix/selfpost/internal/backup" "codeberg.org/mix/selfpost/internal/domain" "codeberg.org/mix/selfpost/internal/store" ) // maxImportBytes caps a domain-import upload. A domain export is a small JSON // document (a DKIM key and a handful of credentials); this leaves generous head // room while refusing anything large enough to be an abuse attempt. const maxImportBytes = 1 << 20 // 1 MiB // handleBackupPage renders the backup/migration screen: the full-server backup // and the domain import are separate actions with different risk, so each gets // its own card here rather than sharing a block on the domain list. func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) { s.renderBackupPage(w, r, http.StatusOK, "") } // renderBackupPage draws the page; importErr surfaces a failed domain import // (spec 7.5.B) next to the form that produced it. func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) { s.render(w, status, "backup", map[string]any{ "Title": "SelfPost — backup", "User": currentUser(r), "Active": "backup", "ImportErr": importErr, }) } // handleBackup streams a full-server backup as a download (spec 7.5.A). It is an // authenticated admin action (this handler sits behind the auth middleware). The // archive carries DKIM private keys, the admin password hash and SASL // credentials, so it is served with no-store and as an attachment to discourage // caching of secret material. func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) { filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405")) w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) w.Header().Set("Cache-Control", "no-store") if err := backup.Create(w, backup.Params{ DataDir: s.cfg.DataDir, DBPath: s.cfg.DBPath, Version: s.cfg.Version, }); err != nil { // Headers (and possibly some bytes) may already be on the wire, so we // cannot switch to a clean error page; log it and let the truncated // download fail loudly on the client side. logf("panel: full backup failed: %v", err) return } } // handleExportDomain streams a single-domain export as a secret download (spec // 7.5.B). Like the full backup it is POST-only (state is not changed, but the // response contains the domain's DKIM private key and application passwords, so // it must not be prefetchable or cached). func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) { d, ok := s.lookupDomain(w, r) if !ok { return } exp, err := s.domains.Export(d.ID) if err != nil { logf("panel: export domain %d: %v", d.ID, err) http.Error(w, "export failed", http.StatusInternalServerError) return } body, err := json.MarshalIndent(exp, "", " ") if err != nil { logf("panel: export domain %d: encode: %v", d.ID, err) http.Error(w, "export failed", http.StatusInternalServerError) return } filename := fmt.Sprintf("selfpost-domain-%s.json", d.Name) w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(body) } // handleImportDomain accepts an uploaded domain-export file and re-creates the // domain on this instance (spec 7.5.B). The domain name is normalised and // validated here (spec 7.6.2); the domain service validates the selector, each // login and address, and the DKIM key before writing anything. On success it // redirects to the new domain's page; on failure it re-renders the backup page, // where the import form lives, with a friendly message. func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes) if err := r.ParseMultipartForm(maxImportBytes); err != nil { s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).") return } file, _, err := r.FormFile("file") if err != nil { s.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.") return } defer file.Close() var exp domain.DomainExport dec := json.NewDecoder(file) dec.DisallowUnknownFields() if err := dec.Decode(&exp); err != nil { s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.") return } // Normalise and validate the domain name before it reaches the service, the // same gate the add-domain form uses (spec 7.6.2). exp.Domain = normalizeDomain(exp.Domain) if err := validateDomain(exp.Domain); err != nil { s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error()) return } d, err := s.domains.Import(exp) if err != nil { logf("panel: import domain %q: %v", exp.Domain, err) status, msg := importErrorMessage(err) s.renderBackupPage(w, r, status, msg) return } http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther) } // importErrorMessage maps a domain-import failure (already logged by the caller) // to an HTTP status and a user-facing message. Duplicate domain/login are called // out specifically; other failures — validation errors describing what is wrong // with the file, or an internal write/reload problem — are surfaced verbatim to // this admin-only panel so the operator can act on them. func importErrorMessage(err error) (int, string) { switch { case errors.Is(err, store.ErrDomainExists): return http.StatusConflict, "A domain with that name already exists here. Delete it first, or import into a fresh instance." case errors.Is(err, store.ErrLoginExists): return http.StatusConflict, "One of the application logins in the file is already in use on this instance. Application logins must be unique across all domains." default: return http.StatusBadRequest, "Could not import the domain: " + err.Error() } }