diff options
author | Brian Picciano <mediocregopher@gmail.com> | 2022-05-20 11:17:31 -0600 |
---|---|---|
committer | Brian Picciano <mediocregopher@gmail.com> | 2022-05-20 11:17:31 -0600 |
commit | 09acb111a2b22f5794541fac175b024dd0f9100e (patch) | |
tree | 11d4578a42ad4aea968b42a2689f64c799f9176e /srv/src/http | |
parent | f69ed83de73bbfc4b7af0931de6ced8cf12dea61 (diff) |
Rename api package to http
Diffstat (limited to 'srv/src/http')
-rw-r--r-- | srv/src/http/api.go | 249 | ||||
-rw-r--r-- | srv/src/http/apiutil/apiutil.go | 139 | ||||
-rw-r--r-- | srv/src/http/assets.go | 198 | ||||
-rw-r--r-- | srv/src/http/auth.go | 74 | ||||
-rw-r--r-- | srv/src/http/auth_test.go | 21 | ||||
-rw-r--r-- | srv/src/http/chat.go | 211 | ||||
-rw-r--r-- | srv/src/http/csrf.go | 59 | ||||
-rw-r--r-- | srv/src/http/index.go | 60 | ||||
-rw-r--r-- | srv/src/http/mailinglist.go | 88 | ||||
-rw-r--r-- | srv/src/http/middleware.go | 95 | ||||
-rw-r--r-- | srv/src/http/posts.go | 274 | ||||
-rw-r--r-- | srv/src/http/pow.go | 53 | ||||
-rw-r--r-- | srv/src/http/tpl.go | 125 | ||||
-rw-r--r-- | srv/src/http/tpl/assets.html | 51 | ||||
-rw-r--r-- | srv/src/http/tpl/base.html | 66 | ||||
-rw-r--r-- | srv/src/http/tpl/edit-post.html | 101 | ||||
-rw-r--r-- | srv/src/http/tpl/follow.html | 152 | ||||
-rw-r--r-- | srv/src/http/tpl/index.html | 36 | ||||
-rw-r--r-- | srv/src/http/tpl/post.html | 48 | ||||
-rw-r--r-- | srv/src/http/tpl/posts.html | 61 | ||||
-rw-r--r-- | srv/src/http/tpl/redirect.html | 9 |
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 }} + • + {{ if not .Payload.LastUpdatedAt.IsZero }} + (Updated {{ DateTimeFormat .Payload.LastUpdatedAt }}) + • + {{ 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> |