mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
10daf3cb81
so you can use the host (domain) name of the mail server for serving other resources too. the default is is still that account is served on /, and so takes all incoming requests before giving webhandlers a chance. mox localserve now serves the account pages on /account/
356 lines
11 KiB
Go
356 lines
11 KiB
Go
package http
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "embed"
|
|
|
|
"github.com/mjl-/sherpa"
|
|
"github.com/mjl-/sherpaprom"
|
|
|
|
"github.com/mjl-/mox/config"
|
|
"github.com/mjl-/mox/dns"
|
|
"github.com/mjl-/mox/metrics"
|
|
"github.com/mjl-/mox/mlog"
|
|
"github.com/mjl-/mox/mox-"
|
|
"github.com/mjl-/mox/moxvar"
|
|
"github.com/mjl-/mox/store"
|
|
)
|
|
|
|
//go:embed accountapi.json
|
|
var accountapiJSON []byte
|
|
|
|
//go:embed account.html
|
|
var accountHTML []byte
|
|
|
|
var accountDoc = mustParseAPI(accountapiJSON)
|
|
|
|
var accountSherpaHandler http.Handler
|
|
|
|
func init() {
|
|
collector, err := sherpaprom.NewCollector("moxaccount", nil)
|
|
if err != nil {
|
|
xlog.Fatalx("creating sherpa prometheus collector", err)
|
|
}
|
|
|
|
accountSherpaHandler, err = sherpa.NewHandler("/api/", moxvar.Version, Account{}, &accountDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"})
|
|
if err != nil {
|
|
xlog.Fatalx("sherpa handler", err)
|
|
}
|
|
}
|
|
|
|
// Account exports web API functions for the account web interface. All its
|
|
// methods are exported under api/. Function calls require valid HTTP
|
|
// Authentication credentials of a user.
|
|
type Account struct{}
|
|
|
|
// check http basic auth, returns account name if valid, and writes http response
|
|
// and returns empty string otherwise.
|
|
func checkAccountAuth(ctx context.Context, log *mlog.Log, w http.ResponseWriter, r *http.Request) string {
|
|
authResult := "error"
|
|
start := time.Now()
|
|
var addr *net.TCPAddr
|
|
defer func() {
|
|
metrics.AuthenticationInc("httpaccount", "httpbasic", authResult)
|
|
if authResult == "ok" && addr != nil {
|
|
mox.LimiterFailedAuth.Reset(addr.IP, start)
|
|
}
|
|
}()
|
|
|
|
var err error
|
|
addr, err = net.ResolveTCPAddr("tcp", r.RemoteAddr)
|
|
if err != nil {
|
|
log.Errorx("parsing remote address", err, mlog.Field("addr", r.RemoteAddr))
|
|
}
|
|
if addr != nil && !mox.LimiterFailedAuth.Add(addr.IP, start, 1) {
|
|
metrics.AuthenticationRatelimitedInc("httpaccount")
|
|
http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
|
|
return ""
|
|
}
|
|
|
|
// store.OpenEmailAuth has an auth cache, so we don't bcrypt for every auth attempt.
|
|
if auth := r.Header.Get("Authorization"); auth == "" || !strings.HasPrefix(auth, "Basic ") {
|
|
} else if authBuf, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")); err != nil {
|
|
log.Debugx("parsing base64", err)
|
|
} else if t := strings.SplitN(string(authBuf), ":", 2); len(t) != 2 {
|
|
log.Debug("bad user:pass form")
|
|
} else if acc, err := store.OpenEmailAuth(t[0], t[1]); err != nil {
|
|
if errors.Is(err, store.ErrUnknownCredentials) {
|
|
authResult = "badcreds"
|
|
}
|
|
log.Errorx("open account", err)
|
|
} else {
|
|
authResult = "ok"
|
|
accName := acc.Name
|
|
err := acc.Close()
|
|
log.Check(err, "closing account")
|
|
return accName
|
|
}
|
|
// note: browsers don't display the realm to prevent users getting confused by malicious realm messages.
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with email address and password"`)
|
|
http.Error(w, "http 401 - unauthorized - mox account - login with email address and password", http.StatusUnauthorized)
|
|
return ""
|
|
}
|
|
|
|
func accountHandle(w http.ResponseWriter, r *http.Request) {
|
|
ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
|
|
log := xlog.WithContext(ctx).Fields(mlog.Field("userauth", ""))
|
|
|
|
// Without authentication. The token is unguessable.
|
|
if r.URL.Path == "/importprogress" {
|
|
if r.Method != "GET" {
|
|
http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
token := q.Get("token")
|
|
if token == "" {
|
|
http.Error(w, "400 - bad request - missing token", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
log.Error("internal error: ResponseWriter not a http.Flusher")
|
|
http.Error(w, "500 - internal error - cannot sync to http connection", 500)
|
|
return
|
|
}
|
|
|
|
l := importListener{token, make(chan importEvent, 100), make(chan bool, 1)}
|
|
importers.Register <- &l
|
|
ok = <-l.Register
|
|
if !ok {
|
|
http.Error(w, "400 - bad request - unknown token, import may have finished more than a minute ago", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer func() {
|
|
importers.Unregister <- &l
|
|
}()
|
|
|
|
h := w.Header()
|
|
h.Set("Content-Type", "text/event-stream")
|
|
h.Set("Cache-Control", "no-cache")
|
|
_, err := w.Write([]byte(": keepalive\n\n"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
|
|
cctx := r.Context()
|
|
for {
|
|
select {
|
|
case e := <-l.Events:
|
|
_, err := w.Write(e.SSEMsg)
|
|
flusher.Flush()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
case <-cctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
accName := checkAccountAuth(ctx, log, w, r)
|
|
if accName == "" {
|
|
// Response already sent.
|
|
return
|
|
}
|
|
|
|
switch r.URL.Path {
|
|
case "/":
|
|
if r.Method != "GET" {
|
|
http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache; max-age=0")
|
|
// We typically return the embedded admin.html, but during development it's handy
|
|
// to load from disk.
|
|
f, err := os.Open("http/account.html")
|
|
if err == nil {
|
|
defer f.Close()
|
|
_, _ = io.Copy(w, f)
|
|
} else {
|
|
_, _ = w.Write(accountHTML)
|
|
}
|
|
|
|
case "/mail-export-maildir.tgz", "/mail-export-maildir.zip", "/mail-export-mbox.tgz", "/mail-export-mbox.zip":
|
|
maildir := strings.Contains(r.URL.Path, "maildir")
|
|
tgz := strings.Contains(r.URL.Path, ".tgz")
|
|
|
|
acc, err := store.OpenAccount(accName)
|
|
if err != nil {
|
|
log.Errorx("open account for export", err)
|
|
http.Error(w, "500 - internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
err := acc.Close()
|
|
log.Check(err, "closing account")
|
|
}()
|
|
|
|
var archiver store.Archiver
|
|
if tgz {
|
|
// Don't tempt browsers to "helpfully" decompress.
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
|
|
gzw := gzip.NewWriter(w)
|
|
defer func() {
|
|
_ = gzw.Close()
|
|
}()
|
|
archiver = store.TarArchiver{Writer: tar.NewWriter(gzw)}
|
|
} else {
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
archiver = store.ZipArchiver{Writer: zip.NewWriter(w)}
|
|
}
|
|
defer func() {
|
|
err := archiver.Close()
|
|
log.Check(err, "exporting mail close")
|
|
}()
|
|
if err := store.ExportMessages(log, acc.DB, acc.Dir, archiver, maildir, ""); err != nil {
|
|
log.Errorx("exporting mail", err)
|
|
}
|
|
|
|
case "/import":
|
|
if r.Method != "POST" {
|
|
http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
f, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
if errors.Is(err, http.ErrMissingFile) {
|
|
http.Error(w, "400 - bad request - missing file", http.StatusBadRequest)
|
|
} else {
|
|
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
defer func() {
|
|
err := f.Close()
|
|
log.Check(err, "closing form file")
|
|
}()
|
|
skipMailboxPrefix := r.FormValue("skipMailboxPrefix")
|
|
tmpf, err := os.CreateTemp("", "mox-import")
|
|
if err != nil {
|
|
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() {
|
|
if tmpf != nil {
|
|
err := tmpf.Close()
|
|
log.Check(err, "closing uploaded file")
|
|
}
|
|
}()
|
|
if err := os.Remove(tmpf.Name()); err != nil {
|
|
log.Errorx("removing temporary file", err)
|
|
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := io.Copy(tmpf, f); err != nil {
|
|
log.Errorx("copying import to temporary file", err)
|
|
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
token, err := importStart(log, accName, tmpf, skipMailboxPrefix)
|
|
if err != nil {
|
|
log.Errorx("starting import", err)
|
|
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
tmpf = nil // importStart is now responsible for closing.
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"ImportToken": token})
|
|
|
|
default:
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
accountSherpaHandler.ServeHTTP(w, r.WithContext(context.WithValue(ctx, authCtxKey, accName)))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
type ctxKey string
|
|
|
|
var authCtxKey ctxKey = "account"
|
|
|
|
// SetPassword saves a new password for the account, invalidating the previous password.
|
|
// Sessions are not interrupted, and will keep working. New login attempts must use the new password.
|
|
// Password must be at least 8 characters.
|
|
func (Account) SetPassword(ctx context.Context, password string) {
|
|
if len(password) < 8 {
|
|
panic(&sherpa.Error{Code: "user:error", Message: "password must be at least 8 characters"})
|
|
}
|
|
accountName := ctx.Value(authCtxKey).(string)
|
|
acc, err := store.OpenAccount(accountName)
|
|
xcheckf(ctx, err, "open account")
|
|
defer func() {
|
|
err := acc.Close()
|
|
xlog.Check(err, "closing account")
|
|
}()
|
|
err = acc.SetPassword(password)
|
|
xcheckf(ctx, err, "setting password")
|
|
}
|
|
|
|
// Destinations returns the default domain, and the destinations (keys are email
|
|
// addresses, or localparts to the default domain).
|
|
// todo: replace with a function that returns the whole account, when sherpadoc understands unnamed struct fields.
|
|
func (Account) Destinations(ctx context.Context) (dns.Domain, map[string]config.Destination) {
|
|
accountName := ctx.Value(authCtxKey).(string)
|
|
accConf, ok := mox.Conf.Account(accountName)
|
|
if !ok {
|
|
xcheckf(ctx, errors.New("not found"), "looking up account")
|
|
}
|
|
return accConf.DNSDomain, accConf.Destinations
|
|
}
|
|
|
|
// DestinationSave updates a destination.
|
|
// OldDest is compared against the current destination. If it does not match, an
|
|
// error is returned. Otherwise newDest is saved and the configuration reloaded.
|
|
func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) {
|
|
accountName := ctx.Value(authCtxKey).(string)
|
|
accConf, ok := mox.Conf.Account(accountName)
|
|
if !ok {
|
|
xcheckf(ctx, errors.New("not found"), "looking up account")
|
|
}
|
|
curDest, ok := accConf.Destinations[destName]
|
|
if !ok {
|
|
xcheckf(ctx, errors.New("not found"), "looking up destination")
|
|
}
|
|
|
|
if !curDest.Equal(oldDest) {
|
|
xcheckf(ctx, errors.New("modified"), "checking stored destination")
|
|
}
|
|
|
|
// Keep fields we manage.
|
|
newDest.DMARCReports = curDest.DMARCReports
|
|
newDest.TLSReports = curDest.TLSReports
|
|
|
|
err := mox.DestinationSave(ctx, accountName, destName, newDest)
|
|
xcheckf(ctx, err, "saving destination")
|
|
}
|
|
|
|
// ImportAbort aborts an import that is in progress. If the import exists and isn't
|
|
// finished, no changes will have been made by the import.
|
|
func (Account) ImportAbort(ctx context.Context, importToken string) error {
|
|
req := importAbortRequest{importToken, make(chan error)}
|
|
importers.Abort <- req
|
|
return <-req.Response
|
|
}
|