parent
069ee93de1
commit
20b9213010
@ -0,0 +1 @@ |
||||
mailinglist.sqlite3 |
@ -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) |
||||
} |
@ -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 |
||||
} |
||||
}) |
||||
} |
@ -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) |
||||
} |
||||
|
@ -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") |
||||
}) |
||||
} |
@ -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", |
||||
}) |
||||
} |
Loading…
Reference in new issue