From 0197d9cd493b5785bca05f476856540ec64da64a Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Sat, 7 Aug 2021 20:38:37 -0600 Subject: split configuration parsing out into separate packages, split api out as well --- srv/api/api.go | 174 +++++++++++++++++++++++++++++++ srv/api/mailinglist.go | 87 ++++++++++++++++ srv/api/middleware.go | 78 ++++++++++++++ srv/api/pow.go | 51 ++++++++++ srv/api/utils.go | 60 +++++++++++ srv/cfg/cfg.go | 52 ++++++++++ srv/cmd/mediocre-blog/mailinglist.go | 85 ---------------- srv/cmd/mediocre-blog/main.go | 191 ++++++++--------------------------- srv/cmd/mediocre-blog/middleware.go | 78 -------------- srv/cmd/mediocre-blog/pow.go | 51 ---------- srv/cmd/mediocre-blog/utils.go | 60 ----------- srv/mailinglist/mailer.go | 39 +++++++ srv/mailinglist/mailinglist.go | 43 ++++++-- srv/pow/pow.go | 38 ++++++- 14 files changed, 653 insertions(+), 434 deletions(-) create mode 100644 srv/api/api.go create mode 100644 srv/api/mailinglist.go create mode 100644 srv/api/middleware.go create mode 100644 srv/api/pow.go create mode 100644 srv/api/utils.go create mode 100644 srv/cfg/cfg.go delete mode 100644 srv/cmd/mediocre-blog/mailinglist.go delete mode 100644 srv/cmd/mediocre-blog/middleware.go delete mode 100644 srv/cmd/mediocre-blog/pow.go delete mode 100644 srv/cmd/mediocre-blog/utils.go diff --git a/srv/api/api.go b/srv/api/api.go new file mode 100644 index 0000000..ae0970b --- /dev/null +++ b/srv/api/api.go @@ -0,0 +1,174 @@ +// Package api implements the HTTP-based api for the mediocre-blog. +package api + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + + "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" + "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" +) + +// Params are used to instantiate a new API instance. All fields are required +// unless otherwise noted. +type Params struct { + Logger *mlog.Logger + PowManager pow.Manager + MailingList mailinglist.MailingList + + // ListenProto and ListenAddr are passed into net.Listen to create the + // API's listener. Both "tcp" and "unix" protocols are explicitly + // supported. + ListenProto, ListenAddr string + + // StaticDir and StaticProxy are mutually exclusive. + // + // If StaticDir is set then that directory on the filesystem will be used to + // serve the static site. + // + // Otherwise if StaticProxy is set all requests for the static site will be + // reverse-proxied there. + StaticDir string + StaticProxy *url.URL +} + +// SetupCfg implement the cfg.Cfger interface. +func (p *Params) SetupCfg(cfg *cfg.Cfg) { + + cfg.StringVar(&p.ListenProto, "listen-proto", "tcp", "Protocol to listen for HTTP requests with") + cfg.StringVar(&p.ListenAddr, "listen-addr", ":4000", "Address/path to listen for HTTP requests on") + + cfg.StringVar(&p.StaticDir, "static-dir", "", "Directory from which static files are served (mutually exclusive with -static-proxy-url)") + staticProxyURLStr := cfg.String("static-proxy-url", "", "HTTP address from which static files are served (mutually exclusive with -static-dir)") + + cfg.OnInit(func(ctx context.Context) error { + if *staticProxyURLStr != "" { + var err error + if p.StaticProxy, err = url.Parse(*staticProxyURLStr); err != nil { + return fmt.Errorf("parsing -static-proxy-url: %w", err) + } + + } else if p.StaticDir == "" { + return errors.New("-static-dir or -static-proxy-url is required") + } + + return nil + }) +} + +// Annotate implements mctx.Annotator interface. +func (p *Params) Annotate(a mctx.Annotations) { + a["listenProto"] = p.ListenProto + a["listenAddr"] = p.ListenAddr + + if p.StaticProxy != nil { + a["staticProxy"] = p.StaticProxy.String() + return + } + + a["staticDir"] = p.StaticDir +} + +// API will listen on the port configured for it, and serve HTTP requests for +// the mediocre-blog. +type API interface { + Shutdown(ctx context.Context) error +} + +type api struct { + params Params + srv *http.Server +} + +// New initializes and returns a new API instance, including setting up all +// listening ports. +func New(params Params) (API, error) { + + l, err := net.Listen(params.ListenProto, params.ListenAddr) + if err != nil { + return nil, fmt.Errorf("creating listen socket: %w", err) + } + + if params.ListenProto == "unix" { + if err := os.Chmod(params.ListenAddr, 0777); err != nil { + return nil, fmt.Errorf("chmod-ing unix socket: %w", err) + } + } + + a := &api{ + params: params, + } + + a.srv = &http.Server{Handler: a.handler()} + + go func() { + + err := a.srv.Serve(l) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + ctx := mctx.Annotate(context.Background(), a.params) + params.Logger.Fatal(ctx, fmt.Sprintf("%s: %v", "serving http server", err)) + } + }() + + return a, nil +} + +func (a *api) Shutdown(ctx context.Context) error { + if err := a.srv.Shutdown(ctx); err != nil { + return err + } + + return nil +} + +func (a *api) handler() http.Handler { + + var staticHandler http.Handler + if a.params.StaticDir != "" { + staticHandler = http.FileServer(http.Dir(a.params.StaticDir)) + } else { + staticHandler = httputil.NewSingleHostReverseProxy(a.params.StaticProxy) + } + + // sugar + requirePow := func(h http.Handler) http.Handler { + return a.requirePowMiddleware(h) + } + + mux := http.NewServeMux() + + mux.Handle("/", staticHandler) + + apiMux := http.NewServeMux() + apiMux.Handle("/pow/challenge", a.newPowChallengeHandler()) + apiMux.Handle("/pow/check", + requirePow( + http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {}), + ), + ) + + apiMux.Handle("/mailinglist/subscribe", requirePow(a.mailingListSubscribeHandler())) + apiMux.Handle("/mailinglist/finalize", a.mailingListFinalizeHandler()) + apiMux.Handle("/mailinglist/unsubscribe", a.mailingListUnsubscribeHandler()) + + apiHandler := logMiddleware(a.params.Logger, apiMux) + apiHandler = annotateMiddleware(apiHandler) + apiHandler = addResponseHeaders(map[string]string{ + "Cache-Control": "no-store, max-age=0", + "Pragma": "no-cache", + "Expires": "0", + }, apiHandler) + + mux.Handle("/api/", http.StripPrefix("/api", apiHandler)) + + return mux +} diff --git a/srv/api/mailinglist.go b/srv/api/mailinglist.go new file mode 100644 index 0000000..2ddfbe6 --- /dev/null +++ b/srv/api/mailinglist.go @@ -0,0 +1,87 @@ +package api + +import ( + "errors" + "net/http" + "strings" + + "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist" +) + +func (a *api) mailingListSubscribeHandler() 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")) + return + } + + err := a.params.MailingList.BeginSubscription(email) + + if 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) + return + } + + jsonResult(rw, r, struct{}{}) + }) +} + +func (a *api) mailingListFinalizeHandler() http.Handler { + var errInvalidSubToken = errors.New("invalid subToken") + + 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, errInvalidSubToken) + return + } + + err := a.params.MailingList.FinalizeSubscription(subToken) + + if errors.Is(err, mailinglist.ErrNotFound) { + badRequest(rw, r, errInvalidSubToken) + return + + } else if errors.Is(err, mailinglist.ErrAlreadyVerified) { + // no problem + + } else if err != nil { + internalServerError(rw, r, err) + return + } + + jsonResult(rw, r, struct{}{}) + }) +} + +func (a *api) mailingListUnsubscribeHandler() http.Handler { + var errInvalidUnsubToken = errors.New("invalid unsubToken") + + 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, errInvalidUnsubToken) + return + } + + err := a.params.MailingList.Unsubscribe(unsubToken) + + if errors.Is(err, mailinglist.ErrNotFound) { + badRequest(rw, r, errInvalidUnsubToken) + return + + } else if err != nil { + internalServerError(rw, r, err) + return + } + + jsonResult(rw, r, struct{}{}) + }) +} diff --git a/srv/api/middleware.go b/srv/api/middleware.go new file mode 100644 index 0000000..e3e85bb --- /dev/null +++ b/srv/api/middleware.go @@ -0,0 +1,78 @@ +package api + +import ( + "net" + "net/http" + "time" + + "github.com/mediocregopher/mediocre-go-lib/v2/mctx" + "github.com/mediocregopher/mediocre-go-lib/v2/mlog" +) + +func addResponseHeaders(headers map[string]string, h http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + for k, v := range headers { + rw.Header().Set(k, v) + } + h.ServeHTTP(rw, r) + }) +} + +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/api/pow.go b/srv/api/pow.go new file mode 100644 index 0000000..096e252 --- /dev/null +++ b/srv/api/pow.go @@ -0,0 +1,51 @@ +package api + +import ( + "encoding/hex" + "errors" + "fmt" + "net/http" +) + +func (a *api) newPowChallengeHandler() http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + + challenge := a.params.PowManager.NewChallenge() + + jsonResult(rw, r, struct { + Seed string `json:"seed"` + Target uint32 `json:"target"` + }{ + Seed: hex.EncodeToString(challenge.Seed), + Target: challenge.Target, + }) + }) +} + +func (a *api) requirePowMiddleware(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 + } + + err = a.params.PowManager.CheckSolution(seed, solution) + + if err != nil { + badRequest(rw, r, fmt.Errorf("checking proof-of-work solution: %w", err)) + return + } + + h.ServeHTTP(rw, r) + }) +} diff --git a/srv/api/utils.go b/srv/api/utils.go new file mode 100644 index 0000000..8e2a63b --- /dev/null +++ b/srv/api/utils.go @@ -0,0 +1,60 @@ +package api + +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/cfg/cfg.go b/srv/cfg/cfg.go new file mode 100644 index 0000000..08a9e53 --- /dev/null +++ b/srv/cfg/cfg.go @@ -0,0 +1,52 @@ +// Package cfg implements a simple wrapper around go's flag package, in order to +// implement initialization hooks. +package cfg + +import ( + "context" + "flag" + "os" +) + +// Cfger is a component which can be used with Cfg to setup its initialization. +type Cfger interface { + SetupCfg(*Cfg) +} + +// Cfg is a wrapper around the stdlib's FlagSet and a set of initialization +// hooks. +type Cfg struct { + *flag.FlagSet + + hooks []func(ctx context.Context) error +} + +// New initializes and returns a new instance of *Cfg. +func New() *Cfg { + return &Cfg{ + FlagSet: flag.NewFlagSet("", flag.ExitOnError), + } +} + +// OnInit appends the given callback to the sequence of hooks which will run on +// a call to Init. +func (c *Cfg) OnInit(cb func(context.Context) error) { + c.hooks = append(c.hooks, cb) +} + +// Init runs all hooks registered using OnInit, in the same order OnInit was +// called. If one returns an error that error is returned and no further hooks +// are run. +func (c *Cfg) Init(ctx context.Context) error { + if err := c.FlagSet.Parse(os.Args[1:]); err != nil { + return err + } + + for _, h := range c.hooks { + if err := h(ctx); err != nil { + return err + } + } + + return nil +} diff --git a/srv/cmd/mediocre-blog/mailinglist.go b/srv/cmd/mediocre-blog/mailinglist.go deleted file mode 100644 index 39ab0d4..0000000 --- a/srv/cmd/mediocre-blog/mailinglist.go +++ /dev/null @@ -1,85 +0,0 @@ -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")) - return - } - - 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) - return - } - - jsonResult(rw, r, struct{}{}) - }) -} - -func mailingListFinalizeHandler(ml mailinglist.MailingList) http.Handler { - var errInvalidSubToken = errors.New("invalid subToken") - - 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, errInvalidSubToken) - return - } - - err := ml.FinalizeSubscription(subToken) - - if errors.Is(err, mailinglist.ErrNotFound) { - badRequest(rw, r, errInvalidSubToken) - return - - } else if errors.Is(err, mailinglist.ErrAlreadyVerified) { - // no problem - - } else if err != nil { - internalServerError(rw, r, err) - return - } - - jsonResult(rw, r, struct{}{}) - }) -} - -func mailingListUnsubscribeHandler(ml mailinglist.MailingList) http.Handler { - var errInvalidUnsubToken = errors.New("invalid unsubToken") - - 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, errInvalidUnsubToken) - return - } - - err := ml.Unsubscribe(unsubToken) - - if errors.Is(err, mailinglist.ErrNotFound) { - badRequest(rw, r, errInvalidUnsubToken) - return - - } else if err != nil { - internalServerError(rw, r, err) - return - } - - jsonResult(rw, r, struct{}{}) - }) -} diff --git a/srv/cmd/mediocre-blog/main.go b/srv/cmd/mediocre-blog/main.go index 0a5f8b7..7d3f722 100644 --- a/srv/cmd/mediocre-blog/main.go +++ b/srv/cmd/mediocre-blog/main.go @@ -2,22 +2,15 @@ package main import ( "context" - "errors" - "flag" "fmt" - "net" - "net/http" - "net/http/httputil" - "net/url" "os" "os/signal" "path" - "strconv" - "strings" "syscall" "time" - "github.com/emersion/go-sasl" + "github.com/mediocregopher/blog.mediocregopher.com/srv/api" + "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist" "github.com/mediocregopher/blog.mediocregopher.com/srv/pow" "github.com/mediocregopher/mediocre-go-lib/v2/mctx" @@ -32,111 +25,59 @@ func loggerFatalErr(ctx context.Context, logger *mlog.Logger, descr string, err func main() { ctx := context.Background() + cfg := cfg.New() - logger := mlog.NewLogger(nil) - defer logger.Close() - - logger.Info(ctx, "process started") - defer logger.Info(ctx, "process exiting") - - publicURLStr := flag.String("public-url", "http://localhost:4000", "URL this service is accessible at") - listenProto := flag.String("listen-proto", "tcp", "Protocol to listen for HTTP requests with") - listenAddr := flag.String("listen-addr", ":4000", "Address/path to listen for HTTP requests on") - dataDir := flag.String("data-dir", ".", "Directory to use for long term storage") + dataDir := cfg.String("data-dir", ".", "Directory to use for long term storage") - staticDir := flag.String("static-dir", "", "Directory from which static files are served (mutually exclusive with -static-proxy-url)") - staticProxyURLStr := flag.String("static-proxy-url", "", "HTTP address from which static files are served (mutually exclusive with -static-dir)") + var powMgrParams pow.ManagerParams + powMgrParams.SetupCfg(cfg) + ctx = mctx.WithAnnotator(ctx, &powMgrParams) - powTargetStr := flag.String("pow-target", "0x0000FFFF", "Proof-of-work target, lower is more difficult") - powSecret := flag.String("pow-secret", "", "Secret used to sign proof-of-work challenge seeds") + var mailerParams mailinglist.MailerParams + mailerParams.SetupCfg(cfg) + ctx = mctx.WithAnnotator(ctx, &mailerParams) - 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.") + var mlParams mailinglist.Params + mlParams.SetupCfg(cfg) + ctx = mctx.WithAnnotator(ctx, &mlParams) - // parse config + var apiParams api.Params + apiParams.SetupCfg(cfg) + ctx = mctx.WithAnnotator(ctx, &apiParams) - flag.Parse() - - switch { - case *staticDir == "" && *staticProxyURLStr == "": - logger.Fatal(ctx, "-static-dir or -static-proxy-url is required") - case *powSecret == "": - logger.Fatal(ctx, "-pow-secret is required") - } + // initialization + err := cfg.Init(ctx) - publicURL, err := url.Parse(*publicURLStr) - if err != nil { - loggerFatalErr(ctx, logger, "parsing -public-url", err) - } + logger := mlog.NewLogger(nil) + defer logger.Close() - var staticProxyURL *url.URL - if *staticProxyURLStr != "" { - var err error - if staticProxyURL, err = url.Parse(*staticProxyURLStr); err != nil { - loggerFatalErr(ctx, logger, "parsing -static-proxy-url", err) - } - } + logger.Info(ctx, "process started") + defer logger.Info(ctx, "process exiting") - powTargetUint, err := strconv.ParseUint(*powTargetStr, 0, 32) if err != nil { - loggerFatalErr(ctx, logger, "parsing -pow-target", err) - } - powTarget := uint32(powTargetUint) - - var mailerCfg mailinglist.MailerParams - - if *smtpAddr != "" { - mailerCfg.SMTPAddr = *smtpAddr - smtpAuthParts := strings.SplitN(*smtpAuthStr, ":", 2) - if len(smtpAuthParts) < 2 { - logger.Fatal(ctx, "invalid -ml-smtp-auth") - } - mailerCfg.SMTPAuth = sasl.NewPlainClient("", smtpAuthParts[0], smtpAuthParts[1]) - mailerCfg.SendAs = smtpAuthParts[0] - - ctx = mctx.Annotate(ctx, - "smtpAddr", mailerCfg.SMTPAddr, - "smtpSendAs", mailerCfg.SendAs, - ) + loggerFatalErr(ctx, logger, "initializing", err) } ctx = mctx.Annotate(ctx, - "publicURL", publicURL.String(), - "listenProto", *listenProto, - "listenAddr", *listenAddr, "dataDir", *dataDir, - "powTarget", fmt.Sprintf("%x", powTarget), ) - // initialization - - if *staticDir != "" { - ctx = mctx.Annotate(ctx, "staticDir", *staticDir) - } else { - ctx = mctx.Annotate(ctx, "staticProxyURL", *staticProxyURLStr) - } - clock := clock.Realtime() powStore := pow.NewMemoryStore(clock) defer powStore.Close() - powMgr := pow.NewManager(pow.ManagerParams{ - Clock: clock, - Store: powStore, - Secret: []byte(*powSecret), - Target: powTarget, - }) + powMgrParams.Store = powStore + powMgrParams.Clock = clock - // sugar - requirePow := func(h http.Handler) http.Handler { return requirePowMiddleware(powMgr, h) } + powMgr := pow.NewManager(powMgrParams) var mailer mailinglist.Mailer - if *smtpAddr == "" { + if mailerParams.SMTPAddr == "" { logger.Info(ctx, "-smtp-addr not given, using NullMailer") mailer = mailinglist.NullMailer } else { - mailer = mailinglist.NewMailer(mailerCfg) + mailer = mailinglist.NewMailer(mailerParams) } mlStore, err := mailinglist.NewStore(path.Join(*dataDir, "mailinglist.sqlite3")) @@ -145,80 +86,32 @@ func main() { } defer mlStore.Close() - ml := mailinglist.New(mailinglist.Params{ - Store: mlStore, - Mailer: mailer, - Clock: clock, - FinalizeSubURL: publicURL.String() + "/mailinglist/finalize.html", - UnsubURL: publicURL.String() + "/mailinglist/unsubscribe.html", - }) - - mux := http.NewServeMux() - - var staticHandler http.Handler - if *staticDir != "" { - staticHandler = http.FileServer(http.Dir(*staticDir)) - } else { - staticHandler = httputil.NewSingleHostReverseProxy(staticProxyURL) - } - - mux.Handle("/", staticHandler) - - 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)) + mlParams.Store = mlStore + mlParams.Mailer = mailer + mlParams.Clock = clock - apiHandler := logMiddleware(logger.WithNamespace("api"), apiMux) - apiHandler = annotateMiddleware(apiHandler) - apiHandler = addResponseHeaders(map[string]string{ - "Cache-Control": "no-store, max-age=0", - "Pragma": "no-cache", - "Expires": "0", - }, apiHandler) + ml := mailinglist.New(mlParams) - mux.Handle("/api/", http.StripPrefix("/api", apiHandler)) - - // run + apiParams.Logger = logger.WithNamespace("api") + apiParams.PowManager = powMgr + apiParams.MailingList = ml logger.Info(ctx, "listening") - - l, err := net.Listen(*listenProto, *listenAddr) + a, err := api.New(apiParams) if err != nil { - loggerFatalErr(ctx, logger, "creating listen socket", err) - } - - if *listenProto == "unix" { - if err := os.Chmod(*listenAddr, 0777); err != nil { - loggerFatalErr(ctx, logger, "chmod-ing unix socket", err) - } + loggerFatalErr(ctx, logger, "initializing api", err) } - - srv := &http.Server{Handler: mux} - go func() { - if err := srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) { - loggerFatalErr(ctx, logger, "serving http server", err) - } - }() - defer func() { - closeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - logger.Info(ctx, "beginning graceful shutdown of http server") - - if err := srv.Shutdown(closeCtx); err != nil { - loggerFatalErr(ctx, logger, "gracefully shutting down http server", err) + if err := a.Shutdown(shutdownCtx); err != nil { + loggerFatalErr(ctx, logger, "shutting down api", err) } }() + // wait + sigCh := make(chan os.Signal) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh diff --git a/srv/cmd/mediocre-blog/middleware.go b/srv/cmd/mediocre-blog/middleware.go deleted file mode 100644 index 165f82f..0000000 --- a/srv/cmd/mediocre-blog/middleware.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "net" - "net/http" - "time" - - "github.com/mediocregopher/mediocre-go-lib/v2/mctx" - "github.com/mediocregopher/mediocre-go-lib/v2/mlog" -) - -func addResponseHeaders(headers map[string]string, h http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - for k, v := range headers { - rw.Header().Set(k, v) - } - h.ServeHTTP(rw, r) - }) -} - -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 deleted file mode 100644 index a505a64..0000000 --- a/srv/cmd/mediocre-blog/pow.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "encoding/hex" - "errors" - "fmt" - "net/http" - - "github.com/mediocregopher/blog.mediocregopher.com/srv/pow" -) - -func newPowChallengeHandler(mgr pow.Manager) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - - challenge := mgr.NewChallenge() - - jsonResult(rw, r, struct { - Seed string `json:"seed"` - Target uint32 `json:"target"` - }{ - Seed: hex.EncodeToString(challenge.Seed), - Target: challenge.Target, - }) - }) -} - -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 deleted file mode 100644 index 1c9408c..0000000 --- a/srv/cmd/mediocre-blog/utils.go +++ /dev/null @@ -1,60 +0,0 @@ -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/mailinglist/mailer.go b/srv/mailinglist/mailer.go index 12fc398..b65ccb8 100644 --- a/srv/mailinglist/mailer.go +++ b/srv/mailinglist/mailer.go @@ -1,8 +1,14 @@ package mailinglist import ( + "context" + "errors" + "strings" + "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" + "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" + "github.com/mediocregopher/mediocre-go-lib/v2/mctx" ) // Mailer is used to deliver emails to arbitrary recipients. @@ -30,6 +36,39 @@ type MailerParams struct { SendAs string } +// SetupCfg implement the cfg.Cfger interface. +func (m *MailerParams) SetupCfg(cfg *cfg.Cfg) { + + cfg.StringVar(&m.SMTPAddr, "ml-smtp-addr", "", "Address of SMTP server to use for sending emails for the mailing list") + smtpAuthStr := cfg.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.") + + cfg.OnInit(func(ctx context.Context) error { + if m.SMTPAddr == "" { + return nil + } + + smtpAuthParts := strings.SplitN(*smtpAuthStr, ":", 2) + if len(smtpAuthParts) < 2 { + return errors.New("invalid -ml-smtp-auth") + } + + m.SMTPAuth = sasl.NewPlainClient("", smtpAuthParts[0], smtpAuthParts[1]) + m.SendAs = smtpAuthParts[0] + + return nil + }) +} + +// Annotate implements mctx.Annotator interface. +func (m *MailerParams) Annotate(a mctx.Annotations) { + if m.SMTPAddr == "" { + return + } + + a["smtpAddr"] = m.SMTPAddr + a["smtpSendAs"] = m.SendAs +} + type mailer struct { params MailerParams } diff --git a/srv/mailinglist/mailinglist.go b/srv/mailinglist/mailinglist.go index 2ebb952..60c1174 100644 --- a/srv/mailinglist/mailinglist.go +++ b/srv/mailinglist/mailinglist.go @@ -4,13 +4,17 @@ package mailinglist import ( "bytes" + "context" "errors" "fmt" "html/template" "io" + "net/url" "strings" "github.com/google/uuid" + "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" + "github.com/mediocregopher/mediocre-go-lib/v2/mctx" "github.com/tilinna/clock" ) @@ -42,13 +46,28 @@ type Params struct { Mailer Mailer Clock clock.Clock - // URL of the page which should be navigated to in order to finalize a - // subscription. - FinalizeSubURL string + // PublicURL is the base URL which site visitors can navigate to. + // MailingList will generate links based on this value. + PublicURL *url.URL +} + +// SetupCfg implement the cfg.Cfger interface. +func (p *Params) SetupCfg(cfg *cfg.Cfg) { + publicURLStr := cfg.String("public-url", "http://localhost:4000", "URL this service is accessible at") + + cfg.OnInit(func(ctx context.Context) error { + var err error + if p.PublicURL, err = url.Parse(*publicURLStr); err != nil { + return fmt.Errorf("parsing -public-url: %w", err) + } + + return nil + }) +} - // URL of the page which should be navigated to in order to remove a - // subscription. - UnsubURL string +// Annotate implements mctx.Annotator interface. +func (p *Params) Annotate(a mctx.Annotations) { + a["publicURL"] = p.PublicURL } // New initializes and returns a MailingList instance using the given Params. @@ -105,7 +124,11 @@ func (m *mailingList) BeginSubscription(email string) error { err = beginSubTpl.Execute(body, struct { SubLink string }{ - SubLink: fmt.Sprintf("%s?subToken=%s", m.params.FinalizeSubURL, emailRecord.SubToken), + SubLink: fmt.Sprintf( + "%s/mailinglist/finalize.html?subToken=%s", + m.params.PublicURL.String(), + emailRecord.SubToken, + ), }) if err != nil { @@ -217,7 +240,11 @@ func (m *mailingList) Publish(postTitle, postURL string) error { }{ PostTitle: postTitle, PostURL: postURL, - UnsubURL: fmt.Sprintf("%s?unsubToken=%s", m.params.UnsubURL, emailRecord.UnsubToken), + UnsubURL: fmt.Sprintf( + "%s/mailinglist/unsubscribe.html?unsubToken=%s", + m.params.PublicURL.String(), + emailRecord.UnsubToken, + ), }) if err != nil { diff --git a/srv/pow/pow.go b/srv/pow/pow.go index 8075103..ada8439 100644 --- a/srv/pow/pow.go +++ b/srv/pow/pow.go @@ -3,6 +3,7 @@ package pow import ( "bytes" + "context" "crypto/hmac" "crypto/md5" "crypto/rand" @@ -11,8 +12,11 @@ import ( "errors" "fmt" "hash" + "strconv" "time" + "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" + "github.com/mediocregopher/mediocre-go-lib/v2/mctx" "github.com/tilinna/clock" ) @@ -176,14 +180,42 @@ type ManagerParams struct { ChallengeTimeout time.Duration } -func (p ManagerParams) withDefaults() ManagerParams { +func (p *ManagerParams) setDefaults() { if p.Target == 0 { p.Target = 0x00FFFFFF } if p.ChallengeTimeout == 0 { p.ChallengeTimeout = 1 * time.Minute } - return p +} + +// SetupCfg implement the cfg.Cfger interface. +func (p *ManagerParams) SetupCfg(cfg *cfg.Cfg) { + powTargetStr := cfg.String("pow-target", "0x0000FFFF", "Proof-of-work target, lower is more difficult") + powSecretStr := cfg.String("pow-secret", "", "Secret used to sign proof-of-work challenge seeds") + + cfg.OnInit(func(ctx context.Context) error { + p.setDefaults() + + if *powSecretStr == "" { + return errors.New("-pow-secret is required") + } + + powTargetUint, err := strconv.ParseUint(*powTargetStr, 0, 32) + if err != nil { + return fmt.Errorf("parsing -pow-target: %w", err) + } + + p.Target = uint32(powTargetUint) + p.Secret = []byte(*powSecretStr) + + return nil + }) +} + +// Annotate implements mctx.Annotator interface. +func (p *ManagerParams) Annotate(a mctx.Annotations) { + a["powTarget"] = fmt.Sprintf("%x", p.Target) } type manager struct { @@ -193,7 +225,7 @@ type manager struct { // NewManager initializes and returns a Manager instance using the given // parameters. func NewManager(params ManagerParams) Manager { - params = params.withDefaults() + params.setDefaults() return &manager{ params: params, } -- cgit v1.2.3