summaryrefslogtreecommitdiff
path: root/srv/src/api
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/api
parentf69ed83de73bbfc4b7af0931de6ced8cf12dea61 (diff)
Rename api package to http
Diffstat (limited to 'srv/src/api')
-rw-r--r--srv/src/api/api.go249
-rw-r--r--srv/src/api/apiutil/apiutil.go139
-rw-r--r--srv/src/api/assets.go198
-rw-r--r--srv/src/api/auth.go74
-rw-r--r--srv/src/api/auth_test.go21
-rw-r--r--srv/src/api/chat.go211
-rw-r--r--srv/src/api/csrf.go59
-rw-r--r--srv/src/api/index.go60
-rw-r--r--srv/src/api/mailinglist.go88
-rw-r--r--srv/src/api/middleware.go95
-rw-r--r--srv/src/api/posts.go274
-rw-r--r--srv/src/api/pow.go53
-rw-r--r--srv/src/api/tpl.go125
-rw-r--r--srv/src/api/tpl/assets.html51
-rw-r--r--srv/src/api/tpl/base.html66
-rw-r--r--srv/src/api/tpl/edit-post.html101
-rw-r--r--srv/src/api/tpl/follow.html152
-rw-r--r--srv/src/api/tpl/index.html36
-rw-r--r--srv/src/api/tpl/post.html48
-rw-r--r--srv/src/api/tpl/posts.html61
-rw-r--r--srv/src/api/tpl/redirect.html9
21 files changed, 0 insertions, 2170 deletions
diff --git a/srv/src/api/api.go b/srv/src/api/api.go
deleted file mode 100644
index 191a7bf..0000000
--- a/srv/src/api/api.go
+++ /dev/null
@@ -1,249 +0,0 @@
-// Package api implements the HTTP-based api for the mediocre-blog.
-package api
-
-import (
- "context"
- "errors"
- "fmt"
- "html/template"
- "net"
- "net/http"
- "net/http/httputil"
- "net/url"
- "os"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/chat"
- "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/api/apiutil/apiutil.go b/srv/src/api/apiutil/apiutil.go
deleted file mode 100644
index d427b65..0000000
--- a/srv/src/api/apiutil/apiutil.go
+++ /dev/null
@@ -1,139 +0,0 @@
-// 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/api/assets.go b/srv/src/api/assets.go
deleted file mode 100644
index 47be14c..0000000
--- a/srv/src/api/assets.go
+++ /dev/null
@@ -1,198 +0,0 @@
-package api
-
-import (
- "bytes"
- "errors"
- "fmt"
- "image"
- "image/jpeg"
- "image/png"
- "io"
- "net/http"
- "path/filepath"
- "strings"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/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/api/auth.go b/srv/src/api/auth.go
deleted file mode 100644
index 0d946a3..0000000
--- a/srv/src/api/auth.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package api
-
-import (
- "net/http"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/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/api/auth_test.go b/srv/src/api/auth_test.go
deleted file mode 100644
index cdf83ef..0000000
--- a/srv/src/api/auth_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package api
-
-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/api/chat.go b/srv/src/api/chat.go
deleted file mode 100644
index f4b90ef..0000000
--- a/srv/src/api/chat.go
+++ /dev/null
@@ -1,211 +0,0 @@
-package api
-
-import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "strings"
- "unicode"
-
- "github.com/gorilla/websocket"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/chat"
-)
-
-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/api/csrf.go b/srv/src/api/csrf.go
deleted file mode 100644
index 2a93ed7..0000000
--- a/srv/src/api/csrf.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package api
-
-import (
- "errors"
- "net/http"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/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/api/index.go b/srv/src/api/index.go
deleted file mode 100644
index 5fb5a4f..0000000
--- a/srv/src/api/index.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package api
-
-import (
- "fmt"
- "net/http"
- "path/filepath"
- "strings"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/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/api/mailinglist.go b/srv/src/api/mailinglist.go
deleted file mode 100644
index c12e75d..0000000
--- a/srv/src/api/mailinglist.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package api
-
-import (
- "errors"
- "net/http"
- "strings"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/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/api/middleware.go b/srv/src/api/middleware.go
deleted file mode 100644
index 974889b..0000000
--- a/srv/src/api/middleware.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package api
-
-import (
- "net"
- "net/http"
- "time"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/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/api/posts.go b/srv/src/api/posts.go
deleted file mode 100644
index ab11474..0000000
--- a/srv/src/api/posts.go
+++ /dev/null
@@ -1,274 +0,0 @@
-package api
-
-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/api/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/api/pow.go b/srv/src/api/pow.go
deleted file mode 100644
index ae2d2f1..0000000
--- a/srv/src/api/pow.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package api
-
-import (
- "encoding/hex"
- "errors"
- "fmt"
- "net/http"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/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/api/tpl.go b/srv/src/api/tpl.go
deleted file mode 100644
index 9818b10..0000000
--- a/srv/src/api/tpl.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package api
-
-import (
- "embed"
- "fmt"
- "html/template"
- "io/fs"
- "net/http"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/api/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/api/tpl/assets.html b/srv/src/api/tpl/assets.html
deleted file mode 100644
index aa5e422..0000000
--- a/srv/src/api/tpl/assets.html
+++ /dev/null
@@ -1,51 +0,0 @@
-{{ 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/api/tpl/base.html b/srv/src/api/tpl/base.html
deleted file mode 100644
index 6031919..0000000
--- a/srv/src/api/tpl/base.html
+++ /dev/null
@@ -1,66 +0,0 @@
-<!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/api/tpl/edit-post.html b/srv/src/api/tpl/edit-post.html
deleted file mode 100644
index 9ccfa2a..0000000
--- a/srv/src/api/tpl/edit-post.html
+++ /dev/null
@@ -1,101 +0,0 @@
-{{ 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/api/tpl/follow.html b/srv/src/api/tpl/follow.html
deleted file mode 100644
index 8cf9dc6..0000000
--- a/srv/src/api/tpl/follow.html
+++ /dev/null
@@ -1,152 +0,0 @@
-{{ 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/api/tpl/index.html b/srv/src/api/tpl/index.html
deleted file mode 100644
index e27cbef..0000000
--- a/srv/src/api/tpl/index.html
+++ /dev/null
@@ -1,36 +0,0 @@
-{{ 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/api/tpl/post.html b/srv/src/api/tpl/post.html
deleted file mode 100644
index 474d7c2..0000000
--- a/srv/src/api/tpl/post.html
+++ /dev/null
@@ -1,48 +0,0 @@
-{{ 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/api/tpl/posts.html b/srv/src/api/tpl/posts.html
deleted file mode 100644
index 714cf07..0000000
--- a/srv/src/api/tpl/posts.html
+++ /dev/null
@@ -1,61 +0,0 @@
-{{ 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/api/tpl/redirect.html b/srv/src/api/tpl/redirect.html
deleted file mode 100644
index ed12a2e..0000000
--- a/srv/src/api/tpl/redirect.html
+++ /dev/null
@@ -1,9 +0,0 @@
-<!DOCTYPE html>
-<html>
- <head>
- <meta http-equiv="refresh" content="0; url='{{ BlogURL .Payload.Path }}'" />
- </head>
- <body>
- <p>Redirecting...</p>
- </body>
-</html>