mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-13 23:30:37 +01:00
463 lines
14 KiB
Go
463 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ProtonMail/go-crypto/openpgp"
|
|
|
|
"github.com/ghostersk/gowebmail/internal/db"
|
|
"github.com/ghostersk/gowebmail/internal/middleware"
|
|
"github.com/ghostersk/gowebmail/internal/models"
|
|
"github.com/ghostersk/gowebmail/internal/pgp"
|
|
"github.com/ghostersk/gowebmail/internal/smime"
|
|
)
|
|
|
|
// dbSigner builds a signed/encrypted outgoing message from whatever S/MIME identity and
|
|
// PGP contact keys the sending account/user actually has on file. Implements
|
|
// internal/email.Signer. Sign first (if an S/MIME identity exists for the account), then
|
|
// encrypt (if every recipient has a PGP contact key on file) — matches the reference
|
|
// design: "S/MIME certificates sign... PGP keys encrypt...".
|
|
type dbSigner struct {
|
|
db *db.DB
|
|
userID int64
|
|
}
|
|
|
|
func (s *dbSigner) SignAndEncrypt(account *models.EmailAccount, recipients []string, raw []byte) ([]byte, error) {
|
|
out := raw
|
|
|
|
identities, err := s.db.ListSMIMEIdentities(account.ID)
|
|
if err == nil && len(identities) > 0 {
|
|
id := identities[0]
|
|
signed, err := smime.SignMIME([]byte(id.CertPEM), []byte(id.KeyPEM), out)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("smime sign: %w", err)
|
|
}
|
|
out = signed
|
|
}
|
|
|
|
if len(recipients) > 0 {
|
|
var pgpEntities []*openpgp.Entity
|
|
allHaveKeys := true
|
|
for _, addr := range recipients {
|
|
contact, err := s.db.GetPGPContactByEmail(s.userID, addr)
|
|
if err != nil || contact == nil {
|
|
allHaveKeys = false
|
|
break
|
|
}
|
|
entity, err := pgp.ParsePublicKey([]byte(contact.PublicKeyArmor))
|
|
if err != nil {
|
|
allHaveKeys = false
|
|
break
|
|
}
|
|
pgpEntities = append(pgpEntities, entity)
|
|
}
|
|
if allHaveKeys && len(pgpEntities) > 0 {
|
|
encrypted, err := pgp.EncryptMIME(out, pgpEntities)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pgp encrypt: %w", err)
|
|
}
|
|
out = encrypted
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// newSigner builds a Signer for outgoing mail on this account/user, or nil if no S/MIME
|
|
// identity and no PGP recipient keys apply — SendMessageFull treats nil as a no-op.
|
|
func (h *APIHandler) newSigner(userID int64) *dbSigner {
|
|
return &dbSigner{db: h.db, userID: userID}
|
|
}
|
|
|
|
// ---- S/MIME handlers ----
|
|
|
|
func (h *APIHandler) SMIMEIdentity(w http.ResponseWriter, r *http.Request) {
|
|
accountID := queryInt64(r, "account_id", 0)
|
|
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
|
if accountID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "account_id required")
|
|
}
|
|
return
|
|
}
|
|
identities, err := h.db.ListSMIMEIdentities(accountID)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
|
|
return
|
|
}
|
|
h.writeJSON(w, identities)
|
|
}
|
|
|
|
func (h *APIHandler) SMIMEGenerate(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
AccountID int64 `json:"account_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "account_id required")
|
|
return
|
|
}
|
|
if !h.ownAccount(w, r, req.AccountID) {
|
|
return
|
|
}
|
|
account, _ := h.db.GetAccount(req.AccountID)
|
|
certPEM, keyPEM, err := smime.GenerateSelfSigned(account.EmailAddress, smime.DefaultValidity)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to generate certificate")
|
|
return
|
|
}
|
|
cert, _ := smime.ParseCertPEM(certPEM)
|
|
id, err := h.db.CreateSMIMEIdentity(req.AccountID, string(certPEM), string(keyPEM), cert.NotAfter)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
|
}
|
|
|
|
func (h *APIHandler) SMIMEImport(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(5 << 20); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid form")
|
|
return
|
|
}
|
|
accountID := queryInt64(r, "account_id", 0)
|
|
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
|
|
accountID = a
|
|
}
|
|
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
|
if accountID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "account_id required")
|
|
}
|
|
return
|
|
}
|
|
file, _, err := r.FormFile("p12_file")
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "p12_file required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
|
return
|
|
}
|
|
password := r.FormValue("p12_password")
|
|
certPEM, keyPEM, err := smime.ImportPKCS12(data, password)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
|
|
return
|
|
}
|
|
cert, _ := smime.ParseCertPEM(certPEM)
|
|
notAfter := time.Now().Add(smime.DefaultValidity)
|
|
if cert != nil {
|
|
notAfter = cert.NotAfter
|
|
}
|
|
id, err := h.db.CreateSMIMEIdentity(accountID, string(certPEM), string(keyPEM), notAfter)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
|
}
|
|
|
|
func (h *APIHandler) SMIMERemoveIdentity(w http.ResponseWriter, r *http.Request) {
|
|
id := pathInt64(r, "id")
|
|
accountID := queryInt64(r, "account_id", 0)
|
|
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
|
if accountID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "account_id required")
|
|
}
|
|
return
|
|
}
|
|
if err := h.db.DeleteSMIMEIdentity(accountID, id); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (h *APIHandler) SMIMEContacts(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r)
|
|
contacts, err := h.db.ListSMIMEContacts(userID)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
|
|
return
|
|
}
|
|
h.writeJSON(w, contacts)
|
|
}
|
|
|
|
func (h *APIHandler) SMIMEAddContact(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r)
|
|
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid form")
|
|
return
|
|
}
|
|
email := strings.TrimSpace(r.FormValue("email"))
|
|
if email == "" {
|
|
h.writeError(w, http.StatusBadRequest, "email required")
|
|
return
|
|
}
|
|
file, _, err := r.FormFile("cert_file")
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "cert_file required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
|
return
|
|
}
|
|
if _, err := smime.ParseCertPEM(data); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid certificate: "+err.Error())
|
|
return
|
|
}
|
|
if err := h.db.UpsertSMIMEContact(userID, email, string(data)); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (h *APIHandler) SMIMERemoveContact(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r)
|
|
id := pathInt64(r, "id")
|
|
if err := h.db.DeleteSMIMEContact(userID, id); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
// ---- PGP handlers ----
|
|
|
|
func (h *APIHandler) PGPIdentity(w http.ResponseWriter, r *http.Request) {
|
|
accountID := queryInt64(r, "account_id", 0)
|
|
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
|
if accountID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "account_id required")
|
|
}
|
|
return
|
|
}
|
|
identities, err := h.db.ListPGPIdentities(accountID)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
|
|
return
|
|
}
|
|
h.writeJSON(w, identities)
|
|
}
|
|
|
|
func (h *APIHandler) PGPGenerate(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
AccountID int64 `json:"account_id"`
|
|
Label string `json:"label"`
|
|
Passphrase string `json:"passphrase"`
|
|
Confirm string `json:"passphrase_confirm"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "account_id required")
|
|
return
|
|
}
|
|
if !h.ownAccount(w, r, req.AccountID) {
|
|
return
|
|
}
|
|
if len(req.Passphrase) < 8 {
|
|
h.writeError(w, http.StatusBadRequest, "passphrase must be at least 8 characters")
|
|
return
|
|
}
|
|
if req.Passphrase != req.Confirm {
|
|
h.writeError(w, http.StatusBadRequest, "passphrases do not match")
|
|
return
|
|
}
|
|
account, _ := h.db.GetAccount(req.AccountID)
|
|
pubArmor, privArmor, err := pgp.GenerateKeyPair(account.EmailAddress, req.Passphrase)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to generate key")
|
|
return
|
|
}
|
|
entity, _ := pgp.ParsePublicKey(pubArmor)
|
|
fingerprint := ""
|
|
if entity != nil {
|
|
fingerprint = pgp.Fingerprint(entity)
|
|
}
|
|
id, err := h.db.CreatePGPIdentity(req.AccountID, req.Label, account.EmailAddress, fingerprint, string(pubArmor), string(privArmor))
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
|
}
|
|
|
|
func (h *APIHandler) PGPImport(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(5 << 20); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid form")
|
|
return
|
|
}
|
|
accountID := queryInt64(r, "account_id", 0)
|
|
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
|
|
accountID = a
|
|
}
|
|
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
|
if accountID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "account_id required")
|
|
}
|
|
return
|
|
}
|
|
passphrase := r.FormValue("passphrase")
|
|
label := r.FormValue("label")
|
|
file, _, err := r.FormFile("key_file")
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "key_file required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
|
return
|
|
}
|
|
pubArmor, privArmor, err := pgp.ImportPrivateKey(data, passphrase)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
|
|
return
|
|
}
|
|
entity, _ := pgp.ParsePublicKey(pubArmor)
|
|
email, fingerprint := "", ""
|
|
if entity != nil {
|
|
fingerprint = pgp.Fingerprint(entity)
|
|
for name := range entity.Identities {
|
|
if id := entity.Identities[name]; id.UserId != nil && id.UserId.Email != "" {
|
|
email = id.UserId.Email
|
|
break
|
|
}
|
|
}
|
|
}
|
|
account, _ := h.db.GetAccount(accountID)
|
|
if email == "" && account != nil {
|
|
email = account.EmailAddress
|
|
}
|
|
id, err := h.db.CreatePGPIdentity(accountID, label, email, fingerprint, string(pubArmor), string(privArmor))
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
|
|
}
|
|
|
|
func (h *APIHandler) PGPRemoveIdentity(w http.ResponseWriter, r *http.Request) {
|
|
id := pathInt64(r, "id")
|
|
accountID := queryInt64(r, "account_id", 0)
|
|
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
|
if accountID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "account_id required")
|
|
}
|
|
return
|
|
}
|
|
if err := h.db.DeletePGPIdentity(accountID, id); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
// PGPUnlock verifies a passphrase decrypts the identity's private key, then caches the
|
|
// unlocked entity for this session (see internal/pgp.Cache) so a future decrypt-on-read
|
|
// of incoming PGP mail — not yet implemented — won't need to re-prompt for it. Cleared on
|
|
// logout (AuthHandler.Logout).
|
|
func (h *APIHandler) PGPUnlock(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
IdentityID int64 `json:"identity_id"`
|
|
Passphrase string `json:"passphrase"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.IdentityID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "identity_id required")
|
|
return
|
|
}
|
|
accountID := queryInt64(r, "account_id", 0)
|
|
if accountID == 0 || !h.ownAccount(w, r, accountID) {
|
|
if accountID == 0 {
|
|
h.writeError(w, http.StatusBadRequest, "account_id required")
|
|
}
|
|
return
|
|
}
|
|
identity, err := h.db.GetPGPIdentity(accountID, req.IdentityID)
|
|
if err != nil || identity == nil {
|
|
h.writeError(w, http.StatusNotFound, "identity not found")
|
|
return
|
|
}
|
|
entity, err := pgp.ParsePrivateKey([]byte(identity.PrivateKeyArmor))
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to parse key")
|
|
return
|
|
}
|
|
if err := pgp.UnlockPrivateKey(entity, req.Passphrase); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "incorrect passphrase")
|
|
return
|
|
}
|
|
if h.pgpCache != nil {
|
|
if cookie, err := r.Cookie("gomail_session"); err == nil {
|
|
h.pgpCache.Put(cookie.Value, req.IdentityID, entity)
|
|
}
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (h *APIHandler) PGPContacts(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r)
|
|
contacts, err := h.db.ListPGPContacts(userID)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
|
|
return
|
|
}
|
|
h.writeJSON(w, contacts)
|
|
}
|
|
|
|
func (h *APIHandler) PGPAddContact(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r)
|
|
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid form")
|
|
return
|
|
}
|
|
email := strings.TrimSpace(r.FormValue("email"))
|
|
if email == "" {
|
|
h.writeError(w, http.StatusBadRequest, "email required")
|
|
return
|
|
}
|
|
label := r.FormValue("label")
|
|
file, _, err := r.FormFile("key_file")
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "key_file required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
|
return
|
|
}
|
|
entity, err := pgp.ParsePublicKey(data)
|
|
if err != nil {
|
|
h.writeError(w, http.StatusBadRequest, "invalid public key: "+err.Error())
|
|
return
|
|
}
|
|
if err := h.db.UpsertPGPContact(userID, email, label, pgp.Fingerprint(entity), string(data)); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"ok": true})
|
|
}
|
|
|
|
func (h *APIHandler) PGPRemoveContact(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.GetUserID(r)
|
|
id := pathInt64(r, "id")
|
|
if err := h.db.DeletePGPContact(userID, id); err != nil {
|
|
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
|
|
return
|
|
}
|
|
h.writeJSON(w, map[string]interface{}{"ok": true})
|
|
}
|