summaryrefslogtreecommitdiff
path: root/srv/src/http
diff options
context:
space:
mode:
authorBrian Picciano <mediocregopher@gmail.com>2022-05-20 11:17:31 -0600
committerBrian Picciano <mediocregopher@gmail.com>2022-05-20 11:17:31 -0600
commit09acb111a2b22f5794541fac175b024dd0f9100e (patch)
tree11d4578a42ad4aea968b42a2689f64c799f9176e /srv/src/http
parentf69ed83de73bbfc4b7af0931de6ced8cf12dea61 (diff)
Rename api package to http
Diffstat (limited to 'srv/src/http')
-rw-r--r--srv/src/http/api.go249
-rw-r--r--srv/src/http/apiutil/apiutil.go139
-rw-r--r--srv/src/http/assets.go198
-rw-r--r--srv/src/http/auth.go74
-rw-r--r--srv/src/http/auth_test.go21
-rw-r--r--srv/src/http/chat.go211
-rw-r--r--srv/src/http/csrf.go59
-rw-r--r--srv/src/http/index.go60
-rw-r--r--srv/src/http/mailinglist.go88
-rw-r--r--srv/src/http/middleware.go95
-rw-r--r--srv/src/http/posts.go274
-rw-r--r--srv/src/http/pow.go53
-rw-r--r--srv/src/http/tpl.go125
-rw-r--r--srv/src/http/tpl/assets.html51
-rw-r--r--srv/src/http/tpl/base.html66
-rw-r--r--srv/src/http/tpl/edit-post.html101
-rw-r--r--srv/src/http/tpl/follow.html152
-rw-r--r--srv/src/http/tpl/index.html36
-rw-r--r--srv/src/http/tpl/post.html48
-rw-r--r--srv/src/http/tpl/posts.html61
-rw-r--r--srv/src/http/tpl/redirect.html9
21 files changed, 2170 insertions, 0 deletions
diff --git a/srv/src/http/api.go b/srv/src/http/api.go
new file mode 100644
index 0000000..bbf4419
--- /dev/null
+++ b/srv/src/http/api.go
@@ -0,0 +1,249 @@
+// Package api implements the HTTP-based api for the mediocre-blog.
+package http
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html/template"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/chat"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/post"
+ "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
+
+ // PathPrefix, if given, will be prefixed to all url paths which are
+ // rendered by the API's templating system.
+ PathPrefix string
+
+ PostStore post.Store
+ PostAssetStore post.AssetStore
+
+ MailingList mailinglist.MailingList
+
+ GlobalRoom chat.Room
+ UserIDCalculator *chat.UserIDCalculator
+
+ // 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
+
+ // AuthUsers keys are usernames which are allowed to edit server-side data,
+ // and the values are the password hash which accompanies those users. The
+ // password hash must have been produced by NewPasswordHash.
+ AuthUsers map[string]string
+}
+
+// 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
+
+ redirectTpl *template.Template
+}
+
+// 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.redirectTpl = a.mustParseTpl("redirect.html")
+
+ 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, "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)
+ }
+
+ formMiddleware := func(h http.Handler) http.Handler {
+ h = checkCSRFMiddleware(h)
+ h = disallowGetMiddleware(h)
+ h = logReqMiddleware(h)
+ h = addResponseHeaders(map[string]string{
+ "Cache-Control": "no-store, max-age=0",
+ "Pragma": "no-cache",
+ "Expires": "0",
+ }, h)
+ return h
+ }
+
+ auther := NewAuther(a.params.AuthUsers)
+
+ 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())
+
+ apiMux.Handle("/chat/global/", http.StripPrefix("/chat/global", newChatHandler(
+ a.params.GlobalRoom,
+ a.params.UserIDCalculator,
+ a.requirePowMiddleware,
+ )))
+
+ mux.Handle("/api/", http.StripPrefix("/api", formMiddleware(apiMux)))
+ }
+
+ {
+ v2Mux := http.NewServeMux()
+ v2Mux.Handle("/follow.html", a.renderDumbTplHandler("follow.html"))
+ v2Mux.Handle("/posts/", http.StripPrefix("/posts",
+ apiutil.MethodMux(map[string]http.Handler{
+ "GET": a.renderPostHandler(),
+ "EDIT": a.editPostHandler(),
+ "POST": authMiddleware(auther,
+ formMiddleware(a.postPostHandler()),
+ ),
+ "DELETE": authMiddleware(auther,
+ formMiddleware(a.deletePostHandler()),
+ ),
+ "PREVIEW": formMiddleware(a.previewPostHandler()),
+ }),
+ ))
+ v2Mux.Handle("/assets/", http.StripPrefix("/assets",
+ apiutil.MethodMux(map[string]http.Handler{
+ "GET": a.getPostAssetHandler(),
+ "POST": authMiddleware(auther,
+ formMiddleware(a.postPostAssetHandler()),
+ ),
+ "DELETE": authMiddleware(auther,
+ formMiddleware(a.deletePostAssetHandler()),
+ ),
+ }),
+ ))
+ v2Mux.Handle("/", a.renderIndexHandler())
+
+ mux.Handle("/v2/", http.StripPrefix("/v2", v2Mux))
+ }
+
+ var globalHandler http.Handler = mux
+ globalHandler = setCSRFMiddleware(globalHandler)
+ globalHandler = setLoggerMiddleware(a.params.Logger, globalHandler)
+
+ return globalHandler
+}
diff --git a/srv/src/http/apiutil/apiutil.go b/srv/src/http/apiutil/apiutil.go
new file mode 100644
index 0000000..d427b65
--- /dev/null
+++ b/srv/src/http/apiutil/apiutil.go
@@ -0,0 +1,139 @@
+// Package apiutil contains utilities which are useful for implementing api
+// endpoints.
+package apiutil
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/mediocregopher/mediocre-go-lib/v2/mlog"
+)
+
+type loggerCtxKey int
+
+// SetRequestLogger sets the given Logger onto the given Request's Context,
+// returning a copy.
+func SetRequestLogger(r *http.Request, logger *mlog.Logger) *http.Request {
+ ctx := r.Context()
+ ctx = context.WithValue(ctx, loggerCtxKey(0), logger)
+ return r.WithContext(ctx)
+}
+
+// GetRequestLogger returns the Logger which was set by SetRequestLogger onto
+// this Request, or nil.
+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
+}
+
+// JSONResult writes the JSON encoding of the given value as the response body.
+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)
+}
+
+// BadRequest writes a 400 status and a JSON encoded error struct containing the
+// given error as the response body.
+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(),
+ })
+}
+
+// InternalServerError writes a 500 status and a JSON encoded error struct
+// containing a generic error as the response body (though it will log the given
+// one).
+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",
+ })
+}
+
+// StrToInt parses the given string as an integer, or returns the given default
+// integer if the string is empty.
+func StrToInt(str string, defaultVal int) (int, error) {
+ if str == "" {
+ return defaultVal, nil
+ }
+ return strconv.Atoi(str)
+}
+
+// GetCookie returns the namd cookie's value, or the given default value if the
+// cookie is not set.
+//
+// This will only return an error if there was an unexpected error parsing the
+// Request's cookies.
+func GetCookie(r *http.Request, cookieName, defaultVal string) (string, error) {
+ c, err := r.Cookie(cookieName)
+ if errors.Is(err, http.ErrNoCookie) {
+ return defaultVal, nil
+ } else if err != nil {
+ return "", fmt.Errorf("reading cookie %q: %w", cookieName, err)
+ }
+
+ return c.Value, nil
+}
+
+// RandStr returns a human-readable random string with the given number of bytes
+// of randomness.
+func RandStr(numBytes int) string {
+ b := make([]byte, numBytes)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return hex.EncodeToString(b)
+}
+
+// MethodMux will take the request method (GET, POST, etc...) and handle the
+// request using the corresponding Handler in the given map.
+//
+// If no Handler is defined for a method then a 405 Method Not Allowed error is
+// returned.
+func MethodMux(handlers map[string]http.Handler) http.Handler {
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ method := strings.ToUpper(r.FormValue("method"))
+
+ if method == "" {
+ method = strings.ToUpper(r.Method)
+ }
+
+ handler, ok := handlers[method]
+
+ if !ok {
+ http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ handler.ServeHTTP(rw, r)
+ })
+}
diff --git a/srv/src/http/assets.go b/srv/src/http/assets.go
new file mode 100644
index 0000000..f782c69
--- /dev/null
+++ b/srv/src/http/assets.go
@@ -0,0 +1,198 @@
+package http
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "image"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/post"
+ "golang.org/x/image/draw"
+)
+
+func resizeImage(out io.Writer, in io.Reader, maxWidth float64) error {
+
+ img, format, err := image.Decode(in)
+ if err != nil {
+ return fmt.Errorf("decoding image: %w", err)
+ }
+
+ imgRect := img.Bounds()
+ imgW, imgH := float64(imgRect.Dx()), float64(imgRect.Dy())
+
+ if imgW > maxWidth {
+
+ newH := imgH * maxWidth / imgW
+ newImg := image.NewRGBA(image.Rect(0, 0, int(maxWidth), int(newH)))
+
+ // Resize
+ draw.BiLinear.Scale(
+ newImg, newImg.Bounds(), img, img.Bounds(), draw.Over, nil,
+ )
+
+ img = newImg
+ }
+
+ switch format {
+ case "jpeg":
+ return jpeg.Encode(out, img, nil)
+ case "png":
+ return png.Encode(out, img)
+ default:
+ return fmt.Errorf("unknown image format %q", format)
+ }
+}
+
+func (a *api) renderPostAssetsIndexHandler() http.Handler {
+
+ tpl := a.mustParseBasedTpl("assets.html")
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ ids, err := a.params.PostAssetStore.List()
+
+ if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("getting list of asset ids: %w", err),
+ )
+ return
+ }
+
+ tplPayload := struct {
+ IDs []string
+ }{
+ IDs: ids,
+ }
+
+ executeTemplate(rw, r, tpl, tplPayload)
+ })
+}
+
+func (a *api) getPostAssetHandler() http.Handler {
+
+ renderIndexHandler := a.renderPostAssetsIndexHandler()
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ id := filepath.Base(r.URL.Path)
+
+ if id == "/" {
+ renderIndexHandler.ServeHTTP(rw, r)
+ return
+ }
+
+ maxWidth, err := apiutil.StrToInt(r.FormValue("w"), 0)
+ if err != nil {
+ apiutil.BadRequest(rw, r, fmt.Errorf("invalid w parameter: %w", err))
+ return
+ }
+
+ buf := new(bytes.Buffer)
+
+ err = a.params.PostAssetStore.Get(id, buf)
+
+ if errors.Is(err, post.ErrAssetNotFound) {
+ http.Error(rw, "Asset not found", 404)
+ return
+ } else if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("fetching asset with id %q: %w", id, err),
+ )
+ return
+ }
+
+ if maxWidth == 0 {
+
+ if _, err := io.Copy(rw, buf); err != nil {
+ apiutil.InternalServerError(
+ rw, r,
+ fmt.Errorf(
+ "copying asset with id %q to response writer: %w",
+ id, err,
+ ),
+ )
+ }
+
+ return
+ }
+
+ switch ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(id), ".")); ext {
+ case "jpg", "jpeg", "png":
+
+ if err := resizeImage(rw, buf, float64(maxWidth)); err != nil {
+ apiutil.InternalServerError(
+ rw, r,
+ fmt.Errorf(
+ "resizing image with id %q to size %d: %w",
+ id, maxWidth, err,
+ ),
+ )
+ }
+
+ default:
+ apiutil.BadRequest(rw, r, fmt.Errorf("cannot resize file with extension %q", ext))
+ return
+ }
+
+ })
+}
+
+func (a *api) postPostAssetHandler() http.Handler {
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ id := r.PostFormValue("id")
+ if id == "/" {
+ apiutil.BadRequest(rw, r, errors.New("id is required"))
+ return
+ }
+
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ apiutil.BadRequest(rw, r, fmt.Errorf("reading multipart file: %w", err))
+ return
+ }
+ defer file.Close()
+
+ if err := a.params.PostAssetStore.Set(id, file); err != nil {
+ apiutil.InternalServerError(rw, r, fmt.Errorf("storing file: %w", err))
+ return
+ }
+
+ a.executeRedirectTpl(rw, r, "assets/")
+ })
+}
+
+func (a *api) deletePostAssetHandler() http.Handler {
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ id := filepath.Base(r.URL.Path)
+
+ if id == "/" {
+ apiutil.BadRequest(rw, r, errors.New("id is required"))
+ return
+ }
+
+ err := a.params.PostAssetStore.Delete(id)
+
+ if errors.Is(err, post.ErrAssetNotFound) {
+ http.Error(rw, "Asset not found", 404)
+ return
+ } else if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("deleting asset with id %q: %w", id, err),
+ )
+ return
+ }
+
+ a.executeRedirectTpl(rw, r, "assets/")
+ })
+}
diff --git a/srv/src/http/auth.go b/srv/src/http/auth.go
new file mode 100644
index 0000000..cd247a3
--- /dev/null
+++ b/srv/src/http/auth.go
@@ -0,0 +1,74 @@
+package http
+
+import (
+ "net/http"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// NewPasswordHash returns the hash of the given plaintext password, for use
+// with Auther.
+func NewPasswordHash(plaintext string) string {
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plaintext), 13)
+ if err != nil {
+ panic(err)
+ }
+ return string(hashedPassword)
+}
+
+// Auther determines who can do what.
+type Auther interface {
+ Allowed(username, password string) bool
+}
+
+type auther struct {
+ users map[string]string
+}
+
+// NewAuther initializes and returns an Auther will which allow the given
+// username and password hash combinations. Password hashes must have been
+// created using NewPasswordHash.
+func NewAuther(users map[string]string) Auther {
+ return &auther{users: users}
+}
+
+func (a *auther) Allowed(username, password string) bool {
+
+ hashedPassword, ok := a.users[username]
+ if !ok {
+ return false
+ }
+
+ err := bcrypt.CompareHashAndPassword(
+ []byte(hashedPassword), []byte(password),
+ )
+
+ return err == nil
+}
+
+func authMiddleware(auther Auther, h http.Handler) http.Handler {
+
+ respondUnauthorized := func(rw http.ResponseWriter, r *http.Request) {
+ rw.Header().Set("WWW-Authenticate", `Basic realm="NOPE"`)
+ rw.WriteHeader(http.StatusUnauthorized)
+ apiutil.GetRequestLogger(r).WarnString(r.Context(), "unauthorized")
+ }
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ username, password, ok := r.BasicAuth()
+
+ if !ok {
+ respondUnauthorized(rw, r)
+ return
+ }
+
+ if !auther.Allowed(username, password) {
+ respondUnauthorized(rw, r)
+ return
+ }
+
+ h.ServeHTTP(rw, r)
+ })
+}
diff --git a/srv/src/http/auth_test.go b/srv/src/http/auth_test.go
new file mode 100644
index 0000000..2a1e6e9
--- /dev/null
+++ b/srv/src/http/auth_test.go
@@ -0,0 +1,21 @@
+package http
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAuther(t *testing.T) {
+
+ password := "foo"
+ hashedPassword := NewPasswordHash(password)
+
+ auther := NewAuther(map[string]string{
+ "FOO": hashedPassword,
+ })
+
+ assert.False(t, auther.Allowed("BAR", password))
+ assert.False(t, auther.Allowed("FOO", "bar"))
+ assert.True(t, auther.Allowed("FOO", password))
+}
diff --git a/srv/src/http/chat.go b/srv/src/http/chat.go
new file mode 100644
index 0000000..f76e4ad
--- /dev/null
+++ b/srv/src/http/chat.go
@@ -0,0 +1,211 @@
+package http
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "unicode"
+
+ "github.com/gorilla/websocket"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/chat"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+)
+
+type chatHandler struct {
+ *http.ServeMux
+
+ room chat.Room
+ userIDCalc *chat.UserIDCalculator
+
+ wsUpgrader websocket.Upgrader
+}
+
+func newChatHandler(
+ room chat.Room, userIDCalc *chat.UserIDCalculator,
+ requirePowMiddleware func(http.Handler) http.Handler,
+) http.Handler {
+ c := &chatHandler{
+ ServeMux: http.NewServeMux(),
+ room: room,
+ userIDCalc: userIDCalc,
+
+ wsUpgrader: websocket.Upgrader{},
+ }
+
+ c.Handle("/history", c.historyHandler())
+ c.Handle("/user-id", requirePowMiddleware(c.userIDHandler()))
+ c.Handle("/append", requirePowMiddleware(c.appendHandler()))
+ c.Handle("/listen", c.listenHandler())
+
+ return c
+}
+
+func (c *chatHandler) historyHandler() http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ limit, err := apiutil.StrToInt(r.PostFormValue("limit"), 0)
+ if err != nil {
+ apiutil.BadRequest(rw, r, fmt.Errorf("invalid limit parameter: %w", err))
+ return
+ }
+
+ cursor := r.PostFormValue("cursor")
+
+ cursor, msgs, err := c.room.History(r.Context(), chat.HistoryOpts{
+ Limit: limit,
+ Cursor: cursor,
+ })
+
+ if argErr := (chat.ErrInvalidArg{}); errors.As(err, &argErr) {
+ apiutil.BadRequest(rw, r, argErr.Err)
+ return
+ } else if err != nil {
+ apiutil.InternalServerError(rw, r, err)
+ }
+
+ apiutil.JSONResult(rw, r, struct {
+ Cursor string `json:"cursor"`
+ Messages []chat.Message `json:"messages"`
+ }{
+ Cursor: cursor,
+ Messages: msgs,
+ })
+ })
+}
+
+func (c *chatHandler) userID(r *http.Request) (chat.UserID, error) {
+ name := r.PostFormValue("name")
+ if l := len(name); l == 0 {
+ return chat.UserID{}, errors.New("name is required")
+ } else if l > 16 {
+ return chat.UserID{}, errors.New("name too long")
+ }
+
+ nameClean := strings.Map(func(r rune) rune {
+ if !unicode.IsPrint(r) {
+ return -1
+ }
+ return r
+ }, name)
+
+ if nameClean != name {
+ return chat.UserID{}, errors.New("name contains invalid characters")
+ }
+
+ password := r.PostFormValue("password")
+ if l := len(password); l == 0 {
+ return chat.UserID{}, errors.New("password is required")
+ } else if l > 128 {
+ return chat.UserID{}, errors.New("password too long")
+ }
+
+ return c.userIDCalc.Calculate(name, password), nil
+}
+
+func (c *chatHandler) userIDHandler() http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ userID, err := c.userID(r)
+ if err != nil {
+ apiutil.BadRequest(rw, r, err)
+ return
+ }
+
+ apiutil.JSONResult(rw, r, struct {
+ UserID chat.UserID `json:"userID"`
+ }{
+ UserID: userID,
+ })
+ })
+}
+
+func (c *chatHandler) appendHandler() http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ userID, err := c.userID(r)
+ if err != nil {
+ apiutil.BadRequest(rw, r, err)
+ return
+ }
+
+ body := r.PostFormValue("body")
+
+ if l := len(body); l == 0 {
+ apiutil.BadRequest(rw, r, errors.New("body is required"))
+ return
+
+ } else if l > 300 {
+ apiutil.BadRequest(rw, r, errors.New("body too long"))
+ return
+ }
+
+ msg, err := c.room.Append(r.Context(), chat.Message{
+ UserID: userID,
+ Body: body,
+ })
+
+ if err != nil {
+ apiutil.InternalServerError(rw, r, err)
+ return
+ }
+
+ apiutil.JSONResult(rw, r, struct {
+ MessageID string `json:"messageID"`
+ }{
+ MessageID: msg.ID,
+ })
+ })
+}
+
+func (c *chatHandler) listenHandler() http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ ctx := r.Context()
+ sinceID := r.FormValue("sinceID")
+
+ conn, err := c.wsUpgrader.Upgrade(rw, r, nil)
+ if err != nil {
+ apiutil.BadRequest(rw, r, err)
+ return
+ }
+ defer conn.Close()
+
+ it, err := c.room.Listen(ctx, sinceID)
+
+ if errors.As(err, new(chat.ErrInvalidArg)) {
+ apiutil.BadRequest(rw, r, err)
+ return
+
+ } else if errors.Is(err, context.Canceled) {
+ return
+
+ } else if err != nil {
+ apiutil.InternalServerError(rw, r, err)
+ return
+ }
+
+ defer it.Close()
+
+ for {
+
+ msg, err := it.Next(ctx)
+ if errors.Is(err, context.Canceled) {
+ return
+
+ } else if err != nil {
+ apiutil.InternalServerError(rw, r, err)
+ return
+ }
+
+ err = conn.WriteJSON(struct {
+ Message chat.Message `json:"message"`
+ }{
+ Message: msg,
+ })
+
+ if err != nil {
+ apiutil.GetRequestLogger(r).Error(ctx, "couldn't write message", err)
+ return
+ }
+ }
+ })
+}
diff --git a/srv/src/http/csrf.go b/srv/src/http/csrf.go
new file mode 100644
index 0000000..1c80dee
--- /dev/null
+++ b/srv/src/http/csrf.go
@@ -0,0 +1,59 @@
+package http
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+)
+
+const (
+ csrfTokenCookieName = "csrf_token"
+ csrfTokenHeaderName = "X-CSRF-Token"
+ csrfTokenFormName = "csrfToken"
+)
+
+func setCSRFMiddleware(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ csrfTok, err := apiutil.GetCookie(r, csrfTokenCookieName, "")
+
+ if err != nil {
+ apiutil.InternalServerError(rw, r, err)
+ return
+
+ } else if csrfTok == "" {
+ http.SetCookie(rw, &http.Cookie{
+ Name: csrfTokenCookieName,
+ Value: apiutil.RandStr(32),
+ Secure: true,
+ })
+ }
+
+ h.ServeHTTP(rw, r)
+ })
+}
+
+func checkCSRFMiddleware(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ csrfTok, err := apiutil.GetCookie(r, csrfTokenCookieName, "")
+
+ if err != nil {
+ apiutil.InternalServerError(rw, r, err)
+ return
+ }
+
+ givenCSRFTok := r.Header.Get(csrfTokenHeaderName)
+ if givenCSRFTok == "" {
+ givenCSRFTok = r.FormValue(csrfTokenFormName)
+ }
+
+ if csrfTok == "" || givenCSRFTok != csrfTok {
+ apiutil.BadRequest(rw, r, errors.New("invalid CSRF token"))
+ return
+ }
+
+ h.ServeHTTP(rw, r)
+ })
+}
diff --git a/srv/src/http/index.go b/srv/src/http/index.go
new file mode 100644
index 0000000..bb76568
--- /dev/null
+++ b/srv/src/http/index.go
@@ -0,0 +1,60 @@
+package http
+
+import (
+ "fmt"
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/post"
+)
+
+func (a *api) renderIndexHandler() http.Handler {
+
+ tpl := a.mustParseBasedTpl("index.html")
+ const pageCount = 10
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ if path := r.URL.Path; !strings.HasSuffix(path, "/") && filepath.Base(path) != "index.html" {
+ http.Error(rw, "Page not found", 404)
+ return
+ }
+
+ page, err := apiutil.StrToInt(r.FormValue("p"), 0)
+ if err != nil {
+ apiutil.BadRequest(
+ rw, r, fmt.Errorf("invalid page number: %w", err),
+ )
+ return
+ }
+
+ posts, hasMore, err := a.params.PostStore.WithOrderDesc().Get(page, pageCount)
+ if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("fetching page %d of posts: %w", page, err),
+ )
+ return
+ }
+
+ tplPayload := struct {
+ Posts []post.StoredPost
+ PrevPage, NextPage int
+ }{
+ Posts: posts,
+ PrevPage: -1,
+ NextPage: -1,
+ }
+
+ if page > 0 {
+ tplPayload.PrevPage = page - 1
+ }
+
+ if hasMore {
+ tplPayload.NextPage = page + 1
+ }
+
+ executeTemplate(rw, r, tpl, tplPayload)
+ })
+}
diff --git a/srv/src/http/mailinglist.go b/srv/src/http/mailinglist.go
new file mode 100644
index 0000000..90e602c
--- /dev/null
+++ b/srv/src/http/mailinglist.go
@@ -0,0 +1,88 @@
+package http
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+ "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 {
+ apiutil.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 {
+ apiutil.InternalServerError(rw, r, err)
+ return
+ }
+
+ apiutil.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 {
+ apiutil.BadRequest(rw, r, errInvalidSubToken)
+ return
+ }
+
+ err := a.params.MailingList.FinalizeSubscription(subToken)
+
+ if errors.Is(err, mailinglist.ErrNotFound) {
+ apiutil.BadRequest(rw, r, errInvalidSubToken)
+ return
+
+ } else if errors.Is(err, mailinglist.ErrAlreadyVerified) {
+ // no problem
+
+ } else if err != nil {
+ apiutil.InternalServerError(rw, r, err)
+ return
+ }
+
+ apiutil.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 {
+ apiutil.BadRequest(rw, r, errInvalidUnsubToken)
+ return
+ }
+
+ err := a.params.MailingList.Unsubscribe(unsubToken)
+
+ if errors.Is(err, mailinglist.ErrNotFound) {
+ apiutil.BadRequest(rw, r, errInvalidUnsubToken)
+ return
+
+ } else if err != nil {
+ apiutil.InternalServerError(rw, r, err)
+ return
+ }
+
+ apiutil.JSONResult(rw, r, struct{}{})
+ })
+}
diff --git a/srv/src/http/middleware.go b/srv/src/http/middleware.go
new file mode 100644
index 0000000..8299a71
--- /dev/null
+++ b/srv/src/http/middleware.go
@@ -0,0 +1,95 @@
+package http
+
+import (
+ "net"
+ "net/http"
+ "time"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+ "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 setLoggerMiddleware(logger *mlog.Logger, 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)
+ r = apiutil.SetRequestLogger(r, logger)
+ h.ServeHTTP(rw, r)
+ })
+}
+
+type logResponseWriter struct {
+ http.ResponseWriter
+ http.Hijacker
+ statusCode int
+}
+
+func newLogResponseWriter(rw http.ResponseWriter) *logResponseWriter {
+ h, _ := rw.(http.Hijacker)
+ return &logResponseWriter{
+ ResponseWriter: rw,
+ Hijacker: h,
+ statusCode: 200,
+ }
+}
+
+func (lrw *logResponseWriter) WriteHeader(statusCode int) {
+ lrw.statusCode = statusCode
+ lrw.ResponseWriter.WriteHeader(statusCode)
+}
+
+func logReqMiddleware(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ 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,
+ )
+
+ apiutil.GetRequestLogger(r).Info(ctx, "handled HTTP request")
+ })
+}
+
+func disallowGetMiddleware(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ // we allow websockets to be GETs because, well, they must be
+ if r.Method != "GET" || r.Header.Get("Upgrade") == "websocket" {
+ h.ServeHTTP(rw, r)
+ return
+ }
+
+ apiutil.GetRequestLogger(r).WarnString(r.Context(), "method not allowed")
+ rw.WriteHeader(405)
+ })
+}
diff --git a/srv/src/http/posts.go b/srv/src/http/posts.go
new file mode 100644
index 0000000..fd583ea
--- /dev/null
+++ b/srv/src/http/posts.go
@@ -0,0 +1,274 @@
+package http
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/gomarkdown/markdown"
+ "github.com/gomarkdown/markdown/html"
+ "github.com/gomarkdown/markdown/parser"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/post"
+)
+
+type postTplPayload struct {
+ post.StoredPost
+ SeriesPrevious, SeriesNext *post.StoredPost
+ Body template.HTML
+}
+
+func (a *api) postToPostTplPayload(storedPost post.StoredPost) (postTplPayload, error) {
+ parserExt := parser.CommonExtensions | parser.AutoHeadingIDs
+ parser := parser.NewWithExtensions(parserExt)
+
+ htmlFlags := html.CommonFlags | html.HrefTargetBlank
+ htmlRenderer := html.NewRenderer(html.RendererOptions{Flags: htmlFlags})
+
+ renderedBody := markdown.ToHTML([]byte(storedPost.Body), parser, htmlRenderer)
+
+ tplPayload := postTplPayload{
+ StoredPost: storedPost,
+ Body: template.HTML(renderedBody),
+ }
+
+ if series := storedPost.Series; series != "" {
+
+ seriesPosts, err := a.params.PostStore.GetBySeries(series)
+ if err != nil {
+ return postTplPayload{}, fmt.Errorf(
+ "fetching posts for series %q: %w", series, err,
+ )
+ }
+
+ var foundThis bool
+
+ for i := range seriesPosts {
+
+ seriesPost := seriesPosts[i]
+
+ if seriesPost.ID == storedPost.ID {
+ foundThis = true
+ continue
+ }
+
+ if !foundThis {
+ tplPayload.SeriesPrevious = &seriesPost
+ continue
+ }
+
+ tplPayload.SeriesNext = &seriesPost
+ break
+ }
+ }
+
+ return tplPayload, nil
+}
+
+func (a *api) renderPostHandler() http.Handler {
+
+ tpl := a.mustParseBasedTpl("post.html")
+ renderIndexHandler := a.renderPostsIndexHandler()
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ id := strings.TrimSuffix(filepath.Base(r.URL.Path), ".html")
+
+ if id == "/" {
+ renderIndexHandler.ServeHTTP(rw, r)
+ return
+ }
+
+ storedPost, err := a.params.PostStore.GetByID(id)
+
+ if errors.Is(err, post.ErrPostNotFound) {
+ http.Error(rw, "Post not found", 404)
+ return
+ } else if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("fetching post with id %q: %w", id, err),
+ )
+ return
+ }
+
+ tplPayload, err := a.postToPostTplPayload(storedPost)
+
+ if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf(
+ "generating template payload for post with id %q: %w",
+ id, err,
+ ),
+ )
+ return
+ }
+
+ executeTemplate(rw, r, tpl, tplPayload)
+ })
+}
+
+func (a *api) renderPostsIndexHandler() http.Handler {
+
+ tpl := a.mustParseBasedTpl("posts.html")
+ const pageCount = 20
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ page, err := apiutil.StrToInt(r.FormValue("p"), 0)
+ if err != nil {
+ apiutil.BadRequest(
+ rw, r, fmt.Errorf("invalid page number: %w", err),
+ )
+ return
+ }
+
+ posts, hasMore, err := a.params.PostStore.WithOrderDesc().Get(page, pageCount)
+ if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("fetching page %d of posts: %w", page, err),
+ )
+ return
+ }
+
+ tplPayload := struct {
+ Posts []post.StoredPost
+ PrevPage, NextPage int
+ }{
+ Posts: posts,
+ PrevPage: -1,
+ NextPage: -1,
+ }
+
+ if page > 0 {
+ tplPayload.PrevPage = page - 1
+ }
+
+ if hasMore {
+ tplPayload.NextPage = page + 1
+ }
+
+ executeTemplate(rw, r, tpl, tplPayload)
+ })
+}
+
+func (a *api) editPostHandler() http.Handler {
+
+ tpl := a.mustParseBasedTpl("edit-post.html")
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ id := filepath.Base(r.URL.Path)
+
+ var storedPost post.StoredPost
+
+ if id != "/" {
+
+ var err error
+ storedPost, err = a.params.PostStore.GetByID(id)
+
+ if errors.Is(err, post.ErrPostNotFound) {
+ http.Error(rw, "Post not found", 404)
+ return
+ } else if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("fetching post with id %q: %w", id, err),
+ )
+ return
+ }
+ }
+
+ executeTemplate(rw, r, tpl, storedPost)
+ })
+}
+
+func postFromPostReq(r *http.Request) post.Post {
+
+ p := post.Post{
+ ID: r.PostFormValue("id"),
+ Title: r.PostFormValue("title"),
+ Description: r.PostFormValue("description"),
+ Tags: strings.Fields(r.PostFormValue("tags")),
+ Series: r.PostFormValue("series"),
+ }
+
+ p.Body = strings.TrimSpace(r.PostFormValue("body"))
+ // textareas encode newlines as CRLF for historical reasons
+ p.Body = strings.ReplaceAll(p.Body, "\r\n", "\n")
+
+ return p
+}
+
+func (a *api) postPostHandler() http.Handler {
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ p := postFromPostReq(r)
+
+ if err := a.params.PostStore.Set(p, time.Now()); err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("storing post with id %q: %w", p.ID, err),
+ )
+ return
+ }
+
+ redirectPath := fmt.Sprintf("posts/%s?method=edit", p.ID)
+
+ a.executeRedirectTpl(rw, r, redirectPath)
+ })
+}
+
+func (a *api) deletePostHandler() http.Handler {
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ id := filepath.Base(r.URL.Path)
+
+ if id == "/" {
+ apiutil.BadRequest(rw, r, errors.New("id is required"))
+ return
+ }
+
+ err := a.params.PostStore.Delete(id)
+
+ if errors.Is(err, post.ErrPostNotFound) {
+ http.Error(rw, "Post not found", 404)
+ return
+ } else if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("deleting post with id %q: %w", id, err),
+ )
+ return
+ }
+
+ a.executeRedirectTpl(rw, r, "posts/")
+
+ })
+}
+
+func (a *api) previewPostHandler() http.Handler {
+
+ tpl := a.mustParseBasedTpl("post.html")
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ storedPost := post.StoredPost{
+ Post: postFromPostReq(r),
+ PublishedAt: time.Now(),
+ }
+
+ tplPayload, err := a.postToPostTplPayload(storedPost)
+
+ if err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("generating template payload: %w", err),
+ )
+ return
+ }
+
+ executeTemplate(rw, r, tpl, tplPayload)
+ })
+}
diff --git a/srv/src/http/pow.go b/srv/src/http/pow.go
new file mode 100644
index 0000000..1bd5cb5
--- /dev/null
+++ b/srv/src/http/pow.go
@@ -0,0 +1,53 @@
+package http
+
+import (
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+)
+
+func (a *api) newPowChallengeHandler() http.Handler {
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+
+ challenge := a.params.PowManager.NewChallenge()
+
+ apiutil.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.FormValue("powSeed")
+ seed, err := hex.DecodeString(seedHex)
+ if err != nil || len(seed) == 0 {
+ apiutil.BadRequest(rw, r, errors.New("invalid powSeed"))
+ return
+ }
+
+ solutionHex := r.FormValue("powSolution")
+ solution, err := hex.DecodeString(solutionHex)
+ if err != nil || len(seed) == 0 {
+ apiutil.BadRequest(rw, r, errors.New("invalid powSolution"))
+ return
+ }
+
+ err = a.params.PowManager.CheckSolution(seed, solution)
+
+ if err != nil {
+ apiutil.BadRequest(rw, r, fmt.Errorf("checking proof-of-work solution: %w", err))
+ return
+ }
+
+ h.ServeHTTP(rw, r)
+ })
+}
diff --git a/srv/src/http/tpl.go b/srv/src/http/tpl.go
new file mode 100644
index 0000000..d647317
--- /dev/null
+++ b/srv/src/http/tpl.go
@@ -0,0 +1,125 @@
+package http
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
+)
+
+//go:embed tpl
+var tplFS embed.FS
+
+func mustReadTplFile(fileName string) string {
+ path := filepath.Join("tpl", fileName)
+
+ b, err := fs.ReadFile(tplFS, path)
+ if err != nil {
+ panic(fmt.Errorf("reading file %q from tplFS: %w", path, err))
+ }
+
+ return string(b)
+}
+
+func (a *api) mustParseTpl(name string) *template.Template {
+
+ blogURL := func(path string) string {
+
+ trailingSlash := strings.HasSuffix(path, "/")
+ path = filepath.Join(a.params.PathPrefix, "/v2", path)
+
+ if trailingSlash {
+ path += "/"
+ }
+
+ return path
+ }
+
+ tpl := template.New("").Funcs(template.FuncMap{
+ "BlogURL": blogURL,
+ "AssetURL": func(id string) string {
+ path := filepath.Join("assets", id)
+ return blogURL(path)
+ },
+ "PostURL": func(id string) string {
+ path := filepath.Join("posts", id)
+ return blogURL(path)
+ },
+ "DateTimeFormat": func(t time.Time) string {
+ return t.Format("2006-01-02")
+ },
+ })
+
+ tpl = template.Must(tpl.Parse(mustReadTplFile(name)))
+
+ return tpl
+}
+
+func (a *api) mustParseBasedTpl(name string) *template.Template {
+ tpl := a.mustParseTpl(name)
+ tpl = template.Must(tpl.New("base.html").Parse(mustReadTplFile("base.html")))
+ return tpl
+}
+
+type tplData struct {
+ Payload interface{}
+ CSRFToken string
+}
+
+func (t tplData) CSRFFormInput() template.HTML {
+ return template.HTML(fmt.Sprintf(
+ `<input type="hidden" name="%s" value="%s" />`,
+ csrfTokenFormName, t.CSRFToken,
+ ))
+}
+
+// executeTemplate expects to be the final action in an http.Handler
+func executeTemplate(
+ rw http.ResponseWriter, r *http.Request,
+ tpl *template.Template, payload interface{},
+) {
+
+ csrfToken, _ := apiutil.GetCookie(r, csrfTokenCookieName, "")
+
+ tplData := tplData{
+ Payload: payload,
+ CSRFToken: csrfToken,
+ }
+
+ if err := tpl.Execute(rw, tplData); err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("rendering template: %w", err),
+ )
+ return
+ }
+}
+
+func (a *api) executeRedirectTpl(
+ rw http.ResponseWriter, r *http.Request, path string,
+) {
+ executeTemplate(rw, r, a.redirectTpl, struct {
+ Path string
+ }{
+ Path: path,
+ })
+}
+
+func (a *api) renderDumbTplHandler(tplName string) http.Handler {
+
+ tpl := a.mustParseBasedTpl(tplName)
+
+ return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ if err := tpl.Execute(rw, nil); err != nil {
+ apiutil.InternalServerError(
+ rw, r, fmt.Errorf("rendering %q: %w", tplName, err),
+ )
+ return
+ }
+ })
+}
diff --git a/srv/src/http/tpl/assets.html b/srv/src/http/tpl/assets.html
new file mode 100644
index 0000000..aa5e422
--- /dev/null
+++ b/srv/src/http/tpl/assets.html
@@ -0,0 +1,51 @@
+{{ define "body" }}
+
+{{ $csrfFormInput := .CSRFFormInput }}
+
+<h2>Upload Asset</h2>
+
+<p>
+ If the given ID is the same as an existing asset's ID, then that asset will be
+ overwritten.
+</p>
+
+<form action="{{ BlogURL "assets/" }}" method="POST" enctype="multipart/form-data">
+ {{ $csrfFormInput }}
+ <div class="row">
+ <div class="four columns">
+ <input type="text" placeholder="Unique ID" name="id" />
+ </div>
+ <div class="four columns">
+ <input type="file" name="file" /><br/>
+ </div>
+ <div class="four columns">
+ <input type="submit" value="Upload" />
+ </div>
+ </div>
+</form>
+
+<h2>Existing Assets</h2>
+
+<table>
+
+ {{ range .Payload.IDs }}
+ <tr>
+ <td><a href="{{ AssetURL . }}" target="_blank">{{ . }}</a></td>
+ <td>
+ <form
+ action="{{ AssetURL . }}?method=delete"
+ method="POST"
+ style="margin-bottom: 0;"
+ >
+ {{ $csrfFormInput }}
+ <input type="submit" value="Delete" />
+ </form>
+ </td>
+ </tr>
+ {{ end }}
+
+</table>
+
+{{ end }}
+
+{{ template "base.html" . }}
diff --git a/srv/src/http/tpl/base.html b/srv/src/http/tpl/base.html
new file mode 100644
index 0000000..6031919
--- /dev/null
+++ b/srv/src/http/tpl/base.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html lang="en">
+
+ <head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" href="/assets/normalize.css">
+ <link rel="stylesheet" href="/assets/skeleton.css">
+ <link rel="stylesheet" href="/assets/friendly.css">
+ <link rel="stylesheet" href="/assets/main.css">
+ <link rel="stylesheet" href="/assets/fontawesome/css/all.css">
+ </head>
+
+ <body>
+
+ <div class="container">
+
+ <header id="title-header" role="banner">
+ <div class="row">
+ <div class="seven columns" style="margin-bottom: 3rem;">
+ <h1 class="title">
+ <a href="{{ BlogURL "/" }}">Mediocre Blog</a>
+ </h1>
+ <div class="light social">
+ <span>By Brian Picciano</span>
+ <span>
+ Even more @
+ <a href="https://mediocregopher.eth.link" target="_blank">https://mediocregopher.eth.link</a>
+ </span>
+ </div>
+ </div>
+
+ <div class="five columns light">
+ <span style="display:block; margin-bottom:0.5rem;">Get notified when new posts are published!</span>
+ <a href="{{ BlogURL "follow.html" }}">
+ <button class="button-primary">
+ <i class="far fa-envelope"></i>
+ Follow
+ </button>
+ </a>
+
+ <a href="{{ BlogURL "feed.xml" }}">
+ <button class="button">
+ <i class="fas fa-rss"></i>
+ RSS
+ </button>
+ </a>
+ </div>
+
+ </div>
+ </header>
+
+ {{ template "body" . }}
+
+ <footer>
+ <p class="license light">
+ Unless otherwised specified, all works are licensed under the
+ <a href="/assets/wtfpl.txt">WTFPL</a>.
+ </p>
+ </footer>
+
+ </div>
+
+ </body>
+
+</html>
+
diff --git a/srv/src/http/tpl/edit-post.html b/srv/src/http/tpl/edit-post.html
new file mode 100644
index 0000000..9ccfa2a
--- /dev/null
+++ b/srv/src/http/tpl/edit-post.html
@@ -0,0 +1,101 @@
+{{ define "body" }}
+
+ <form method="POST" action="{{ BlogURL "posts/" }}">
+
+ {{ .CSRFFormInput }}
+
+ <div class="row">
+
+ <div class="columns six">
+ <label for="idInput">Unique ID</label>
+ {{ if eq .Payload.ID "" }}
+ <input
+ id="idInput"
+ name="id"
+ class="u-full-width"
+ type="text"
+ placeholder="e.g. how-to-fly-a-kite"
+ value="{{ .Payload.ID }}" />
+ {{ else }}
+ <a href="{{ PostURL .Payload.ID }}" target="_blank">{{ .Payload.ID }}</a>
+ <input name="id" type="hidden" value="{{ .Payload.ID }}" />
+ {{ end }}
+ </div>
+
+ <div class="columns three">
+ <label for="tagsInput">Tags (space separated)</label>
+ <input
+ id="tagsInput"
+ name="tags"
+ class="u-full-width"
+ type="text"
+ value="{{ range $i, $tag := .Payload.Tags }}{{ if ne $i 0 }} {{ end }}{{ $tag }}{{ end }}" />
+ </div>
+
+ <div class="columns three">
+ <label for="seriesInput">Series</label>
+ <input
+ id="seriesInput"
+ name="series"
+ class="u-full-width"
+ type="text"
+ value="{{ .Payload.Series }}" />
+ </div>
+
+ </div>
+
+ <div class="row">
+
+ <div class="columns six">
+ <label for="titleInput">Title</label>
+ <input
+ id="titleInput"
+ name="title"
+ class="u-full-width"
+ type="text"
+ value="{{ .Payload.Title }}" />
+ </div>
+
+ <div class="columns six">
+ <label for="descrInput">Description</label>
+ <input
+ id="descrInput"
+ name="description"
+ class="u-full-width"
+ type="text"
+ value="{{ .Payload.Description }}" />
+ </div>
+
+ </div>
+
+ <div class="row">
+ <div class="columns twelve">
+ <textarea
+ name="body"
+ class="u-full-width"
+ placeholder="Blog body"
+ style="height: 50vh;"
+ >
+ {{- .Payload.Body -}}
+ </textarea>
+ </div>
+ </div>
+
+ <input
+ type="submit"
+ value="Preview"
+ formaction="{{ BlogURL "posts/" }}{{ .Payload.ID }}?method=preview"
+ formtarget="_blank"
+ />
+
+ <input type="submit" value="Save" formaction="{{ BlogURL "posts/" }}" />
+
+ <a href="{{ BlogURL "posts/" }}">
+ <button type="button">Cancel</button>
+ </a>
+
+ </form>
+
+{{ end }}
+
+{{ template "base.html" . }}
diff --git a/srv/src/http/tpl/follow.html b/srv/src/http/tpl/follow.html
new file mode 100644
index 0000000..8cf9dc6
--- /dev/null
+++ b/srv/src/http/tpl/follow.html
@@ -0,0 +1,152 @@
+{{ define "body" }}
+
+<script async type="module" src="/assets/api.js"></script>
+
+<p>
+ Here's your options for receiving updates about new blog posts:
+</p>
+
+<h2>Option 1: Email</h2>
+
+<p>
+ Email is by far my preferred option for notifying followers of new posts.
+</p>
+
+<p>
+ The entire email list system for this blog, from storing subscriber email
+ addresses to the email server which sends the notifications out, has been
+ designed from scratch and is completely self-hosted in my living room.
+</p>
+
+<p>
+ I solemnly swear that:
+</p>
+
+<ul>
+
+ <li>
+ You will never receive an email from this blog except to notify of a new
+ post.
+ </li>
+
+ <li>
+ Your email will never be provided or sold to anyone else for any reason.
+ </li>
+
+</ul>
+
+<p>
+ With all that said, if you'd like to receive an email everytime a new blog
+ post is published then input your email below and smash that subscribe button!
+ You will need to verify your email, so be sure to check your spam folder to
+ complete the process if you don't immediately see anything in your inbox.
+</p>
+
+<style>
+
+#emailStatus.success {
+ color: green;
+}
+
+#emailStatus.fail {
+ color: red;
+}
+
+</style>
+
+<input type="email" placeholder="name@host.com" id="emailAddress" />
+<input class="button-primary" type="submit" value="Subscribe" id="emailSubscribe" />
+<span id="emailStatus"></span>
+
+<script>
+
+const emailAddress = document.getElementById("emailAddress");
+const emailSubscribe = document.getElementById("emailSubscribe");
+const emailSubscribeOrigValue = emailSubscribe.value;
+const emailStatus = document.getElementById("emailStatus");
+
+emailSubscribe.onclick = async () => {
+
+ const api = await import("/assets/api.js");
+
+ emailSubscribe.disabled = true;
+ emailSubscribe.className = "";
+ emailSubscribe.value = "Please hold...";
+ emailStatus.innerHTML = '';
+
+ try {
+
+ if (!window.isSecureContext) {
+ throw "The browser environment is not secure.";
+ }
+
+ await api.call('/api/mailinglist/subscribe', {
+ body: { email: emailAddress.value },
+ requiresPow: true,
+ });
+
+ emailStatus.className = "success";
+ emailStatus.innerHTML = "Verification email sent (check your spam folder)";
+
+ } catch (e) {
+ emailStatus.className = "fail";
+ emailStatus.innerHTML = e;
+
+ } finally {
+ emailSubscribe.disabled = false;
+ emailSubscribe.className = "button-primary";
+ emailSubscribe.value = emailSubscribeOrigValue;
+ }
+
+};
+
+</script>
+
+<h2>Option 2: RSS</h2>
+
+<p>
+ RSS is the classic way to follow any blog. It comes from a time before
+ aggregators like reddit and twitter stole the show, when people felt capable
+ to manage their own content feeds. We should use it again.
+</p>
+
+<p>
+ To follow over RSS give any RSS reader the following URL...
+</p>
+
+<p>
+ <a href="{{ BlogURL "feed.xml" }}">{{ BlogURL "feed.xml" }}</a>
+</p>
+
+<p>
+ ...and posts from this blog will show up in your RSS feed as soon as they are
+ published. There are literally thousands of RSS readers out there. Here's some
+ recommendations:
+</p>
+
+<ul>
+ <li>
+ <a href="https://chrome.google.com/webstore/detail/rss-feed-reader/pnjaodmkngahhkoihejjehlcdlnohgmp">
+ Google Chrome Browser Extension
+ </a>
+ </li>
+
+ <li>
+ <a href="https://f-droid.org/en/packages/net.etuldan.sparss.floss/">
+ spaRSS
+ </a>
+ is my preferred android RSS reader, but you'll need to install
+ <a href="https://f-droid.org/">f-droid</a> on your device to use it (a
+ good thing to do anyway, imo).
+ </li>
+
+ <li>
+ <a href="https://ranchero.com/netnewswire/">NetNewsWire</a>
+ is a good reader for iPhone/iPad/Mac devices, so I'm told. Their homepage
+ description makes a much better sales pitch for RSS than I ever could.
+ </li>
+</ul>
+
+{{ end }}
+
+{{ template "base.html" . }}
diff --git a/srv/src/http/tpl/index.html b/srv/src/http/tpl/index.html
new file mode 100644
index 0000000..e27cbef
--- /dev/null
+++ b/srv/src/http/tpl/index.html
@@ -0,0 +1,36 @@
+{{ define "body" }}
+
+ <ul id="posts-list">
+
+ {{ range .Payload.Posts }}
+ <li>
+ <h2>
+ <a href="{{ PostURL .ID }}">{{ .Title }}</a>
+ </h2>
+ <span>{{ DateTimeFormat .PublishedAt }}</span>
+ {{ if not .LastUpdatedAt.IsZero }}
+ <span>(Updated {{ DateTimeFormat .LastUpdatedAt }})</span>
+ {{ end }}
+ <p>{{ .Description }}</p>
+ </li>
+ {{ end }}
+
+ </ul>
+
+ {{ if or (ge .Payload.PrevPage 0) (ge .Payload.NextPage 0) }}
+ <div id="page-turner">
+
+ {{ if ge .Payload.PrevPage 0 }}
+ <a style="float: left;" href="?p={{ .Payload.PrevPage}}">Newer</a>
+ {{ end }}
+
+ {{ if ge .Payload.NextPage 0 }}
+ <a style="float:right;" href="?p={{ .Payload.NextPage}}">Older</a>
+ {{ end }}
+
+ </div>
+ {{ end }}
+
+{{ end }}
+
+{{ template "base.html" . }}
diff --git a/srv/src/http/tpl/post.html b/srv/src/http/tpl/post.html
new file mode 100644
index 0000000..474d7c2
--- /dev/null
+++ b/srv/src/http/tpl/post.html
@@ -0,0 +1,48 @@
+{{ define "body" }}
+
+<header id="post-header">
+ <h1 id="post-headline">
+ {{ .Payload.Title }}
+ </h1>
+ <div class="light">
+ {{ DateTimeFormat .Payload.PublishedAt }}
+ &nbsp;•&nbsp;
+ {{ if not .Payload.LastUpdatedAt.IsZero }}
+ (Updated {{ DateTimeFormat .Payload.LastUpdatedAt }})
+ &nbsp;•&nbsp;
+ {{ end }}
+ <em>{{ .Payload.Description }}</em>
+ </div>
+</header>
+
+{{ if (or .Payload.SeriesPrevious .Payload.SeriesNext) }}
+<p class="light"><em>
+ This post is part of a series:<br/>
+ {{ if .Payload.SeriesPrevious }}
+ Previously: <a href="{{ PostURL .Payload.SeriesPrevious.ID }}">{{ .Payload.SeriesPrevious.Title }}</a></br>
+ {{ end }}
+ {{ if .Payload.SeriesNext }}
+ Next: <a href="{{ PostURL .Payload.SeriesNext.ID }}">{{ .Payload.SeriesNext.Title }}</a></br>
+ {{ end }}
+</em></p>
+{{ end }}
+
+<div id="post-content">
+ {{ .Payload.Body }}
+</div>
+
+{{ if (or .Payload.SeriesPrevious .Payload.SeriesNext) }}
+<p class="light"><em>
+ If you liked this post, consider checking out other posts in the series:<br/>
+ {{ if .Payload.SeriesPrevious }}
+ Previously: <a href="{{ PostURL .Payload.SeriesPrevious.ID }}">{{ .Payload.SeriesPrevious.Title }}</a></br>
+ {{ end }}
+ {{ if .Payload.SeriesNext }}
+ Next: <a href="{{ PostURL .Payload.SeriesNext.ID }}">{{ .Payload.SeriesNext.Title }}</a></br>
+ {{ end }}
+</em></p>
+{{ end }}
+
+{{ end }}
+
+{{ template "base.html" . }}
diff --git a/srv/src/http/tpl/posts.html b/srv/src/http/tpl/posts.html
new file mode 100644
index 0000000..714cf07
--- /dev/null
+++ b/srv/src/http/tpl/posts.html
@@ -0,0 +1,61 @@
+{{ define "posts-nextprev" }}
+
+ {{ if or (ge .Payload.PrevPage 0) (ge .Payload.NextPage 0) }}
+ <div id="page-turner">
+
+ {{ if ge .Payload.PrevPage 0 }}
+ <a style="float: left;" href="?p={{ .Payload.PrevPage}}">Newer</a>
+ {{ end }}
+
+ {{ if ge .Payload.NextPage 0 }}
+ <a style="float:right;" href="?p={{ .Payload.NextPage}}">Older</a>
+ {{ end }}
+
+ </div>
+ {{ end }}
+
+{{ end }}
+
+{{ define "body" }}
+
+ {{ $csrfFormInput := .CSRFFormInput }}
+
+
+ <p style="text-align: center;">
+ <a href="{{ BlogURL "posts/" }}?method=edit">
+ <button>New Post</button>
+ </a>
+ </p>
+
+ {{ template "posts-nextprev" . }}
+
+ <table style="margin-top: 2rem;">
+
+ {{ range .Payload.Posts }}
+ <tr>
+ <td>{{ .PublishedAt }}</td>
+ <td><a href="{{ PostURL .ID }}" target="_blank">{{ .Title }}</a></td>
+ <td>
+ <a href="{{ PostURL .ID }}?method=edit">
+ <button>Edit</button>
+ </a>
+ </td>
+ <td>
+ <form
+ action="{{ PostURL .ID }}?method=delete"
+ method="POST"
+ >
+ {{ $csrfFormInput }}
+ <input type="submit" value="Delete" />
+ </form>
+ </td>
+ </tr>
+ {{ end }}
+
+ </table>
+
+ {{ template "posts-nextprev" . }}
+
+{{ end }}
+
+{{ template "base.html" . }}
diff --git a/srv/src/http/tpl/redirect.html b/srv/src/http/tpl/redirect.html
new file mode 100644
index 0000000..ed12a2e
--- /dev/null
+++ b/srv/src/http/tpl/redirect.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="refresh" content="0; url='{{ BlogURL .Payload.Path }}'" />
+ </head>
+ <body>
+ <p>Redirecting...</p>
+ </body>
+</html>