diff options
Diffstat (limited to 'srv/src/api')
-rw-r--r-- | srv/src/api/api.go | 249 | ||||
-rw-r--r-- | srv/src/api/apiutil/apiutil.go | 139 | ||||
-rw-r--r-- | srv/src/api/assets.go | 198 | ||||
-rw-r--r-- | srv/src/api/auth.go | 74 | ||||
-rw-r--r-- | srv/src/api/auth_test.go | 21 | ||||
-rw-r--r-- | srv/src/api/chat.go | 211 | ||||
-rw-r--r-- | srv/src/api/csrf.go | 59 | ||||
-rw-r--r-- | srv/src/api/index.go | 60 | ||||
-rw-r--r-- | srv/src/api/mailinglist.go | 88 | ||||
-rw-r--r-- | srv/src/api/middleware.go | 95 | ||||
-rw-r--r-- | srv/src/api/posts.go | 274 | ||||
-rw-r--r-- | srv/src/api/pow.go | 53 | ||||
-rw-r--r-- | srv/src/api/tpl.go | 125 | ||||
-rw-r--r-- | srv/src/api/tpl/assets.html | 51 | ||||
-rw-r--r-- | srv/src/api/tpl/base.html | 66 | ||||
-rw-r--r-- | srv/src/api/tpl/edit-post.html | 101 | ||||
-rw-r--r-- | srv/src/api/tpl/follow.html | 152 | ||||
-rw-r--r-- | srv/src/api/tpl/index.html | 36 | ||||
-rw-r--r-- | srv/src/api/tpl/post.html | 48 | ||||
-rw-r--r-- | srv/src/api/tpl/posts.html | 61 | ||||
-rw-r--r-- | srv/src/api/tpl/redirect.html | 9 |
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 }} - • - {{ 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/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> |