diff --git a/srv/.gitignore b/srv/.gitignore new file mode 100644 index 0000000..a43632f --- /dev/null +++ b/srv/.gitignore @@ -0,0 +1 @@ +mailinglist.sqlite3 diff --git a/srv/cmd/mediocre-blog/api.go b/srv/cmd/mediocre-blog/api.go deleted file mode 100644 index b4f90d6..0000000 --- a/srv/cmd/mediocre-blog/api.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "encoding/json" - "log" - "net/http" -) - -func internalServerError(rw http.ResponseWriter, r *http.Request, err error) { - http.Error(rw, "internal server error", 500) - log.Printf("%s %s: internal server error: %v", r.Method, r.URL, err) -} - -func jsonResult(rw http.ResponseWriter, r *http.Request, v interface{}) { - b, err := json.Marshal(v) - if err != nil { - internalServerError(rw, r, err) - return - } - b = append(b, '\n') - - rw.Header().Set("Content-Type", "application/json") - rw.Write(b) -} diff --git a/srv/cmd/mediocre-blog/mailinglist.go b/srv/cmd/mediocre-blog/mailinglist.go new file mode 100644 index 0000000..75e5b6d --- /dev/null +++ b/srv/cmd/mediocre-blog/mailinglist.go @@ -0,0 +1,67 @@ +package main + +import ( + "errors" + "net/http" + "strings" + + "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist" +) + +func mailingListSubscribeHandler(ml mailinglist.MailingList) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + email := r.PostFormValue("email") + if parts := strings.Split(email, "@"); len(parts) != 2 || + parts[0] == "" || + parts[1] == "" || + len(email) >= 512 { + badRequest(rw, r, errors.New("invalid email")) + } + + if err := ml.BeginSubscription(email); errors.Is(err, mailinglist.ErrAlreadyVerified) { + // just eat the error, make it look to the user like the + // verification email was sent. + } else if err != nil { + internalServerError(rw, r, err) + } + }) +} + +func mailingListFinalizeHandler(ml mailinglist.MailingList) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + subToken := r.PostFormValue("subToken") + if l := len(subToken); l == 0 || l > 128 { + badRequest(rw, r, errors.New("invalid subToken")) + return + } + + err := ml.FinalizeSubscription(subToken) + if errors.Is(err, mailinglist.ErrNotFound) || + errors.Is(err, mailinglist.ErrAlreadyVerified) { + badRequest(rw, r, err) + return + } else if err != nil { + internalServerError(rw, r, err) + return + } + }) +} + +func mailingListUnsubscribeHandler(ml mailinglist.MailingList) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + unsubToken := r.PostFormValue("unsubToken") + if l := len(unsubToken); l == 0 || l > 128 { + badRequest(rw, r, errors.New("invalid unsubToken")) + return + } + + err := ml.Unsubscribe(unsubToken) + if errors.Is(err, mailinglist.ErrNotFound) { + badRequest(rw, r, err) + return + } else if err != nil { + internalServerError(rw, r, err) + return + } + }) +} diff --git a/srv/cmd/mediocre-blog/main.go b/srv/cmd/mediocre-blog/main.go index 2952999..5a00a48 100644 --- a/srv/cmd/mediocre-blog/main.go +++ b/srv/cmd/mediocre-blog/main.go @@ -1,58 +1,140 @@ package main import ( + "context" "flag" - "log" + "fmt" "net/http" + "path" "strconv" + "strings" + "github.com/emersion/go-sasl" + "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist" "github.com/mediocregopher/blog.mediocregopher.com/srv/pow" + "github.com/mediocregopher/mediocre-go-lib/v2/mctx" + "github.com/mediocregopher/mediocre-go-lib/v2/mlog" "github.com/tilinna/clock" ) +func loggerFatalErr(ctx context.Context, logger *mlog.Logger, descr string, err error) { + logger.Fatal(ctx, fmt.Sprintf("%s: %v", descr, err)) +} + func main() { + + logger := mlog.NewLogger(nil) + + hostname := flag.String("hostname", "localhost:4000", "Hostname to advertise this server as") staticDir := flag.String("static-dir", "", "Directory from which static files are served") listenAddr := flag.String("listen-addr", ":4000", "Address to listen for HTTP requests on") + dataDir := flag.String("data-dir", ".", "Directory to use for long term storage") + powTargetStr := flag.String("pow-target", "0x000FFFF", "Proof-of-work target, lower is more difficult") powSecret := flag.String("pow-secret", "", "Secret used to sign proof-of-work challenge seeds") + smtpAddr := flag.String("ml-smtp-addr", "", "Address of SMTP server to use for sending emails for the mailing list") + smtpAuthStr := flag.String("ml-smtp-auth", "", "user:pass to use when authenticating with the mailing list SMTP server. The given user will also be used as the From address.") + // parse config flag.Parse() switch { case *staticDir == "": - log.Fatal("-static-dir is required") + logger.Fatal(context.Background(), "-static-dir is required") case *powSecret == "": - log.Fatal("-pow-secret is required") + logger.Fatal(context.Background(), "-pow-secret is required") + case *smtpAddr == "": + logger.Fatal(context.Background(), "-ml-smtp-addr is required") + case *smtpAuthStr == "": + logger.Fatal(context.Background(), "-ml-smtp-auth is required") } powTargetUint, err := strconv.ParseUint(*powTargetStr, 0, 32) if err != nil { - log.Fatalf("parsing -pow-target: %v", err) + loggerFatalErr(context.Background(), logger, "parsing -pow-target", err) } powTarget := uint32(powTargetUint) + smtpAuthParts := strings.SplitN(*smtpAuthStr, ":", 2) + if len(smtpAuthParts) < 2 { + logger.Fatal(context.Background(), "invalid -ml-smtp-auth") + } + smtpAuth := sasl.NewPlainClient("", smtpAuthParts[0], smtpAuthParts[1]) + smtpSendAs := smtpAuthParts[0] + // initialization + ctx := mctx.Annotate(context.Background(), + "hostname", *hostname, + "staticDir", *staticDir, + "listenAddr", *listenAddr, + "dataDir", *dataDir, + "powTarget", fmt.Sprintf("%x", powTarget), + "smtpAddr", *smtpAddr, + "smtpSendAs", smtpSendAs, + ) + clock := clock.Realtime() powStore := pow.NewMemoryStore(clock) defer powStore.Close() - mgr := pow.NewManager(pow.ManagerParams{ + powMgr := pow.NewManager(pow.ManagerParams{ Clock: clock, Store: powStore, Secret: []byte(*powSecret), Target: powTarget, }) + // sugar + requirePow := func(h http.Handler) http.Handler { return requirePowMiddleware(powMgr, h) } + + mailer := mailinglist.NewMailer(mailinglist.MailerParams{ + SMTPAddr: *smtpAddr, + SMTPAuth: smtpAuth, + SendAs: smtpSendAs, + }) + + mlStore, err := mailinglist.NewStore(path.Join(*dataDir, "mailinglist.sqlite3")) + if err != nil { + loggerFatalErr(ctx, logger, "initializing mailing list storage", err) + } + defer mlStore.Close() + + ml := mailinglist.New(mailinglist.Params{ + Store: mlStore, + Mailer: mailer, + Clock: clock, + FinalizeSubURL: *hostname + "/mailinglist/finalize.html", + UnsubURL: *hostname + "/mailinglist/unsubscribe.html", + }) + mux := http.NewServeMux() mux.Handle("/", http.FileServer(http.Dir(*staticDir))) - mux.Handle("/api/pow/challenge", newPowChallengeHandler(mgr)) + + apiMux := http.NewServeMux() + apiMux.Handle("/pow/challenge", newPowChallengeHandler(powMgr)) + apiMux.Handle("/pow/check", + requirePow( + http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {}), + ), + ) + + apiMux.Handle("/mailinglist/subscribe", requirePow(mailingListSubscribeHandler(ml))) + apiMux.Handle("/mailinglist/finalize", mailingListFinalizeHandler(ml)) + apiMux.Handle("/mailinglist/unsubscribe", mailingListUnsubscribeHandler(ml)) + + apiHandler := logMiddleware(logger.WithNamespace("api"), apiMux) + apiHandler = annotateMiddleware(apiHandler) + mux.Handle("/api/", http.StripPrefix("/api", apiHandler)) // run - log.Printf("listening on %q", *listenAddr) - log.Fatal(http.ListenAndServe(*listenAddr, mux)) + logger.Info(ctx, "listening") + + // TODO graceful shutdown + err = http.ListenAndServe(*listenAddr, mux) + loggerFatalErr(ctx, logger, "listening", err) } diff --git a/srv/cmd/mediocre-blog/middleware.go b/srv/cmd/mediocre-blog/middleware.go new file mode 100644 index 0000000..4ffba2c --- /dev/null +++ b/srv/cmd/mediocre-blog/middleware.go @@ -0,0 +1,69 @@ +package main + +import ( + "net" + "net/http" + "time" + + "github.com/mediocregopher/mediocre-go-lib/v2/mctx" + "github.com/mediocregopher/mediocre-go-lib/v2/mlog" +) + +func annotateMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + + type reqInfoKey string + + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + + ctx := r.Context() + ctx = mctx.Annotate(ctx, + reqInfoKey("remote_ip"), ip, + reqInfoKey("url"), r.URL, + reqInfoKey("method"), r.Method, + ) + + r = r.WithContext(ctx) + h.ServeHTTP(rw, r) + }) +} + +type logResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func newLogResponseWriter(rw http.ResponseWriter) *logResponseWriter { + return &logResponseWriter{ + ResponseWriter: rw, + statusCode: 200, + } +} + +func (lrw *logResponseWriter) WriteHeader(statusCode int) { + lrw.statusCode = statusCode + lrw.ResponseWriter.WriteHeader(statusCode) +} + +func logMiddleware(logger *mlog.Logger, h http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + + r = setRequestLogger(r, logger) + + lrw := newLogResponseWriter(rw) + + started := time.Now() + h.ServeHTTP(lrw, r) + took := time.Since(started) + + type logCtxKey string + + ctx := r.Context() + ctx = mctx.Annotate(ctx, + logCtxKey("took"), took.String(), + logCtxKey("response_code"), lrw.statusCode, + ) + + logger.Info(ctx, "handled HTTP request") + }) +} diff --git a/srv/cmd/mediocre-blog/pow.go b/srv/cmd/mediocre-blog/pow.go index 22b82f3..8e64739 100644 --- a/srv/cmd/mediocre-blog/pow.go +++ b/srv/cmd/mediocre-blog/pow.go @@ -2,6 +2,8 @@ package main import ( "encoding/hex" + "errors" + "fmt" "net/http" "github.com/mediocregopher/blog.mediocregopher.com/srv/pow" @@ -20,3 +22,28 @@ func newPowChallengeHandler(mgr pow.Manager) http.Handler { }) }) } + +func requirePowMiddleware(mgr pow.Manager, h http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + seedHex := r.PostFormValue("powSeed") + seed, err := hex.DecodeString(seedHex) + if err != nil || len(seed) == 0 { + badRequest(rw, r, errors.New("invalid powSeed")) + return + } + + solutionHex := r.PostFormValue("powSolution") + solution, err := hex.DecodeString(solutionHex) + if err != nil || len(seed) == 0 { + badRequest(rw, r, errors.New("invalid powSolution")) + return + } + + if err := mgr.CheckSolution(seed, solution); err != nil { + badRequest(rw, r, fmt.Errorf("checking proof-of-work solution: %w", err)) + return + } + + h.ServeHTTP(rw, r) + }) +} diff --git a/srv/cmd/mediocre-blog/utils.go b/srv/cmd/mediocre-blog/utils.go new file mode 100644 index 0000000..1c9408c --- /dev/null +++ b/srv/cmd/mediocre-blog/utils.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/mediocregopher/mediocre-go-lib/v2/mlog" +) + +type loggerCtxKey int + +func setRequestLogger(r *http.Request, logger *mlog.Logger) *http.Request { + ctx := r.Context() + ctx = context.WithValue(ctx, loggerCtxKey(0), logger) + return r.WithContext(ctx) +} + +func getRequestLogger(r *http.Request) *mlog.Logger { + ctx := r.Context() + logger, _ := ctx.Value(loggerCtxKey(0)).(*mlog.Logger) + if logger == nil { + logger = mlog.Null + } + return logger +} + +func jsonResult(rw http.ResponseWriter, r *http.Request, v interface{}) { + b, err := json.Marshal(v) + if err != nil { + internalServerError(rw, r, err) + return + } + b = append(b, '\n') + + rw.Header().Set("Content-Type", "application/json") + rw.Write(b) +} + +func badRequest(rw http.ResponseWriter, r *http.Request, err error) { + getRequestLogger(r).Warn(r.Context(), "bad request", err) + + rw.WriteHeader(400) + jsonResult(rw, r, struct { + Error string `json:"error"` + }{ + Error: err.Error(), + }) +} + +func internalServerError(rw http.ResponseWriter, r *http.Request, err error) { + getRequestLogger(r).Error(r.Context(), "internal server error", err) + + rw.WriteHeader(500) + jsonResult(rw, r, struct { + Error string `json:"error"` + }{ + Error: "internal server error", + }) +} diff --git a/srv/go.mod b/srv/go.mod index f14c154..f5a6114 100644 --- a/srv/go.mod +++ b/srv/go.mod @@ -7,6 +7,7 @@ require ( github.com/emersion/go-smtp v0.15.0 github.com/google/uuid v1.3.0 github.com/mattn/go-sqlite3 v1.14.8 + github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0 github.com/rubenv/sql-migrate v0.0.0-20210614095031-55d5740dbbcc github.com/stretchr/testify v1.7.0 github.com/tilinna/clock v1.1.0 diff --git a/srv/go.sum b/srv/go.sum index 0d9af8a..235a7c5 100644 --- a/srv/go.sum +++ b/srv/go.sum @@ -104,6 +104,8 @@ github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU= github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0 h1:i9FBkcCaWXxteJ8458AD8dBL2YqSxVlpsHOMWg5N9Dc= +github.com/mediocregopher/mediocre-go-lib/v2 v2.0.0-beta.0/go.mod h1:wOZVlnKYvIbkzyCJ3dxy1k40XkirvCd1pisX2O91qoQ= github.com/mitchellh/cli v1.1.2/go.mod h1:6iaV0fGdElS6dPBx0EApTxHrcWvmJphyh2n8YBLPPZ4= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= diff --git a/srv/mailinglist/mailinglist.go b/srv/mailinglist/mailinglist.go index 03067b2..2ebb952 100644 --- a/srv/mailinglist/mailinglist.go +++ b/srv/mailinglist/mailinglist.go @@ -51,6 +51,11 @@ type Params struct { UnsubURL string } +// New initializes and returns a MailingList instance using the given Params. +func New(params Params) MailingList { + return &mailingList{params: params} +} + type mailingList struct { params Params }