summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cmd/mailinglist-cli/main.go118
-rw-r--r--src/cmd/mediocre-blog/main.go47
-rw-r--r--src/go.mod3
-rw-r--r--src/go.sum5
-rw-r--r--src/http/http.go35
-rw-r--r--src/http/mailinglist.go92
-rw-r--r--src/http/posts.go13
-rw-r--r--src/http/pow.go53
-rw-r--r--src/http/static/api.js118
-rw-r--r--src/http/static/solvePow.js28
-rw-r--r--src/http/static/utils.js12
-rw-r--r--src/http/tpl/finalize.html45
-rw-r--r--src/http/tpl/follow.html108
-rw-r--r--src/http/tpl/unsubscribe.html44
-rw-r--r--src/mailinglist/mailer.go143
-rw-r--r--src/mailinglist/mailinglist.go273
-rw-r--r--src/mailinglist/store.go245
-rw-r--r--src/mailinglist/store_test.go95
-rw-r--r--src/pow/pow.go321
-rw-r--r--src/pow/pow_test.go120
-rw-r--r--src/pow/store.go92
-rw-r--r--src/pow/store_test.go52
22 files changed, 5 insertions, 2057 deletions
diff --git a/src/cmd/mailinglist-cli/main.go b/src/cmd/mailinglist-cli/main.go
deleted file mode 100644
index c090f41..0000000
--- a/src/cmd/mailinglist-cli/main.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package main
-
-import (
- "context"
- "errors"
- "io"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
- cfgpkg "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
- "github.com/mediocregopher/mediocre-go-lib/v2/mctx"
- "github.com/mediocregopher/mediocre-go-lib/v2/mlog"
- "github.com/tilinna/clock"
-)
-
-func main() {
-
- ctx := context.Background()
-
- cfg := cfgpkg.NewBlogCfg(cfg.Params{})
-
- var dataDir cfgpkg.DataDir
- dataDir.SetupCfg(cfg)
- defer dataDir.Close()
- ctx = mctx.WithAnnotator(ctx, &dataDir)
-
- var mailerParams mailinglist.MailerParams
- mailerParams.SetupCfg(cfg)
- ctx = mctx.WithAnnotator(ctx, &mailerParams)
-
- var mlParams mailinglist.Params
- mlParams.SetupCfg(cfg)
- ctx = mctx.WithAnnotator(ctx, &mlParams)
-
- // initialization
- err := cfg.Init(ctx)
-
- logger := mlog.NewLogger(nil)
- defer logger.Close()
-
- logger.Info(ctx, "process started")
- defer logger.Info(ctx, "process exiting")
-
- if err != nil {
- logger.Fatal(ctx, "initializing", err)
- }
-
- clock := clock.Realtime()
-
- var mailer mailinglist.Mailer
- if mailerParams.SMTPAddr == "" {
- logger.Info(ctx, "-smtp-addr not given, using NullMailer")
- mailer = mailinglist.NullMailer
- } else {
- mailer = mailinglist.NewMailer(mailerParams)
- }
-
- mlStore, err := mailinglist.NewStore(dataDir)
- if err != nil {
- logger.Fatal(ctx, "initializing mailing list storage", err)
- }
- defer mlStore.Close()
-
- mlParams.Store = mlStore
- mlParams.Mailer = mailer
- mlParams.Clock = clock
-
- ml := mailinglist.New(mlParams)
-
- subCmd := cfg.SubCmd()
- ctx = mctx.Annotate(ctx, "subCmd", subCmd)
-
- switch subCmd {
-
- case "list":
-
- for it := mlStore.GetAll(); ; {
- email, err := it()
- if errors.Is(err, io.EOF) {
- break
- } else if err != nil {
- logger.Fatal(ctx, "retrieving next email", err)
- }
-
- ctx := mctx.Annotate(context.Background(),
- "email", email.Email,
- "createdAt", email.CreatedAt,
- "verifiedAt", email.VerifiedAt,
- )
-
- logger.Info(ctx, "next")
- }
-
- case "publish":
-
- title := cfg.String("title", "", "Title of the post which was published")
- url := cfg.String("url", "", "URL of the post which was published")
-
- if err := cfg.Init(ctx); err != nil {
- logger.Fatal(ctx, "initializing", err)
- }
-
- if *title == "" {
- logger.FatalString(ctx, "-title is required")
-
- } else if *url == "" {
- logger.FatalString(ctx, "-url is required")
- }
-
- err := ml.Publish(*title, *url)
- if err != nil {
- logger.Fatal(ctx, "publishing", err)
- }
-
- default:
- logger.FatalString(ctx, "invalid sub-command, must be list|publish")
- }
-}
diff --git a/src/cmd/mediocre-blog/main.go b/src/cmd/mediocre-blog/main.go
index ee09f92..71e6e7b 100644
--- a/src/cmd/mediocre-blog/main.go
+++ b/src/cmd/mediocre-blog/main.go
@@ -11,13 +11,10 @@ import (
cfgpkg "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
"github.com/mediocregopher/blog.mediocregopher.com/srv/gmi"
"github.com/mediocregopher/blog.mediocregopher.com/srv/http"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/pow"
"github.com/mediocregopher/mediocre-go-lib/v2/mctx"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
- "github.com/tilinna/clock"
)
func main() {
@@ -31,18 +28,6 @@ func main() {
defer dataDir.Close()
ctx = mctx.WithAnnotator(ctx, &dataDir)
- var powMgrParams pow.ManagerParams
- powMgrParams.SetupCfg(cfg)
- ctx = mctx.WithAnnotator(ctx, &powMgrParams)
-
- var mailerParams mailinglist.MailerParams
- mailerParams.SetupCfg(cfg)
- ctx = mctx.WithAnnotator(ctx, &mailerParams)
-
- var mlParams mailinglist.Params
- mlParams.SetupCfg(cfg)
- ctx = mctx.WithAnnotator(ctx, &mlParams)
-
var httpParams http.Params
httpParams.SetupCfg(cfg)
ctx = mctx.WithAnnotator(ctx, &httpParams)
@@ -64,36 +49,6 @@ func main() {
logger.Fatal(ctx, "initializing", err)
}
- clock := clock.Realtime()
-
- powStore := pow.NewMemoryStore(clock)
- defer powStore.Close()
-
- powMgrParams.Store = powStore
- powMgrParams.Clock = clock
-
- powMgr := pow.NewManager(powMgrParams)
-
- var mailer mailinglist.Mailer
- if mailerParams.SMTPAddr == "" {
- logger.Info(ctx, "-smtp-addr not given, using a fake Mailer")
- mailer = mailinglist.NewLogMailer(logger.WithNamespace("fake-mailer"))
- } else {
- mailer = mailinglist.NewMailer(mailerParams)
- }
-
- mlStore, err := mailinglist.NewStore(dataDir)
- if err != nil {
- logger.Fatal(ctx, "initializing mailing list storage", err)
- }
- defer mlStore.Close()
-
- mlParams.Store = mlStore
- mlParams.Mailer = mailer
- mlParams.Clock = clock
-
- ml := mailinglist.New(mlParams)
-
postSQLDB, err := post.NewSQLDB(dataDir)
if err != nil {
logger.Fatal(ctx, "initializing sql db for post data", err)
@@ -114,12 +69,10 @@ func main() {
httpParams.Logger = logger.WithNamespace("http")
httpParams.Cache = cache
- httpParams.PowManager = powMgr
httpParams.PostStore = postStore
httpParams.PostAssetStore = postAssetStore
httpParams.PostAssetLoader = postAssetLoader
httpParams.PostDraftStore = postDraftStore
- httpParams.MailingList = ml
httpParams.GeminiPublicURL = gmiParams.PublicURL
logger.Info(ctx, "starting http api")
diff --git a/src/go.mod b/src/go.mod
index 22b8bd5..e70aac9 100644
--- a/src/go.mod
+++ b/src/go.mod
@@ -4,10 +4,7 @@ go 1.16
require (
git.sr.ht/~adnano/go-gemini v0.2.3
- github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
- github.com/emersion/go-smtp v0.15.0
github.com/gomarkdown/markdown v0.0.0-20220510115730-2372b9aa33e5
- github.com/google/uuid v1.3.0
github.com/gorilla/feeds v1.1.1
github.com/hashicorp/golang-lru v0.5.4
github.com/mattn/go-sqlite3 v1.14.8
diff --git a/src/go.sum b/src/go.sum
index df16e62..03b7c79 100644
--- a/src/go.sum
+++ b/src/go.sum
@@ -39,10 +39,6 @@ github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27N
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
-github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
-github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
-github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
-github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -76,7 +72,6 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
diff --git a/src/http/http.go b/src/http/http.go
index ba81577..4b98d2b 100644
--- a/src/http/http.go
+++ b/src/http/http.go
@@ -18,10 +18,8 @@ import (
"github.com/mediocregopher/blog.mediocregopher.com/srv/cache"
"github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
"github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post"
"github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/pow"
"github.com/mediocregopher/mediocre-go-lib/v2/mctx"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
)
@@ -32,17 +30,14 @@ var staticFS embed.FS
// 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
- Cache cache.Cache
+ Logger *mlog.Logger
+ Cache cache.Cache
PostStore post.Store
PostAssetStore asset.Store
PostAssetLoader asset.Loader
PostDraftStore post.DraftStore
- MailingList mailinglist.MailingList
-
// PublicURL is the base URL which site visitors can navigate to.
PublicURL *url.URL
@@ -176,25 +171,6 @@ func (a *api) Shutdown(ctx context.Context) error {
return nil
}
-func (a *api) apiHandler() http.Handler {
- mux := http.NewServeMux()
-
- mux.Handle("/pow/challenge", a.newPowChallengeHandler())
- mux.Handle("/pow/check",
- a.requirePowMiddleware(
- http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {}),
- ),
- )
-
- mux.Handle("/mailinglist/subscribe", a.requirePowMiddleware(a.mailingListSubscribeHandler()))
- mux.Handle("/mailinglist/finalize", a.mailingListFinalizeHandler())
- mux.Handle("/mailinglist/unsubscribe", a.mailingListUnsubscribeHandler())
-
- return apiutil.MethodMux(map[string]http.Handler{
- "POST": mux,
- })
-}
-
func (a *api) blogHandler() http.Handler {
mux := http.NewServeMux()
@@ -237,8 +213,6 @@ func (a *api) blogHandler() http.Handler {
mux.Handle("/static/", http.FileServer(http.FS(staticFS)))
mux.Handle("/follow", a.renderDumbTplHandler("follow.html"))
mux.Handle("/admin", a.renderDumbTplHandler("admin.html"))
- mux.Handle("/mailinglist/unsubscribe", a.renderDumbTplHandler("unsubscribe.html"))
- mux.Handle("/mailinglist/finalize", a.renderDumbTplHandler("finalize.html"))
mux.Handle("/feed.xml", a.renderFeedHandler())
mux.Handle("/", a.renderIndexHandler())
@@ -266,11 +240,6 @@ func (a *api) handler() http.Handler {
mux := http.NewServeMux()
- mux.Handle("/api/", applyMiddlewares(
- http.StripPrefix("/api", a.apiHandler()),
- logReqMiddleware,
- ))
-
mux.Handle("/", a.blogHandler())
noCacheMiddleware := addResponseHeadersMiddleware(map[string]string{
diff --git a/src/http/mailinglist.go b/src/http/mailinglist.go
deleted file mode 100644
index eab2f51..0000000
--- a/src/http/mailinglist.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package http
-
-import (
- "errors"
- "net/http"
- "strings"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
-)
-
-func (a *api) mailingListSubscribeHandler() http.Handler {
- return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- email := r.PostFormValue("email")
- if parts := strings.Split(email, "@"); len(parts) != 2 ||
- parts[0] == "" ||
- parts[1] == "" ||
- len(email) >= 512 {
- apiutil.BadRequest(rw, r, errors.New("invalid email"))
- return
-
- } else if strings.ToLower(parts[1]) == "gmail.com" {
- apiutil.BadRequest(rw, r, errors.New("gmail does not allow its users to receive email from me, sorry"))
- 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/src/http/posts.go b/src/http/posts.go
index 5c7ac25..b1dbc35 100644
--- a/src/http/posts.go
+++ b/src/http/posts.go
@@ -418,7 +418,7 @@ func postFromPostReq(r *http.Request) (post.Post, error) {
return p, nil
}
-func (a *api) storeAndPublishPost(ctx context.Context, p post.Post) error {
+func (a *api) publishPost(ctx context.Context, p post.Post) error {
first, err := a.params.PostStore.Set(p, time.Now())
@@ -430,13 +430,6 @@ func (a *api) storeAndPublishPost(ctx context.Context, p post.Post) error {
return nil
}
- a.params.Logger.Info(ctx, "publishing blog post to mailing list")
- urlStr := a.postURL(p.ID, true)
-
- if err := a.params.MailingList.Publish(p.Title, urlStr); err != nil {
- return fmt.Errorf("publishing post to mailing list: %w", err)
- }
-
if err := a.params.PostDraftStore.Delete(p.ID); err != nil {
return fmt.Errorf("deleting draft: %w", err)
}
@@ -458,9 +451,9 @@ func (a *api) postPostHandler() http.Handler {
ctx = mctx.Annotate(ctx, "postID", p.ID)
- if err := a.storeAndPublishPost(ctx, p); err != nil {
+ if err := a.publishPost(ctx, p); err != nil {
apiutil.InternalServerError(
- rw, r, fmt.Errorf("storing/publishing post with id %q: %w", p.ID, err),
+ rw, r, fmt.Errorf("publishing post with id %q: %w", p.ID, err),
)
return
}
diff --git a/src/http/pow.go b/src/http/pow.go
deleted file mode 100644
index 1bd5cb5..0000000
--- a/src/http/pow.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package http
-
-import (
- "encoding/hex"
- "errors"
- "fmt"
- "net/http"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
-)
-
-func (a *api) newPowChallengeHandler() http.Handler {
- return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-
- challenge := a.params.PowManager.NewChallenge()
-
- apiutil.JSONResult(rw, r, struct {
- Seed string `json:"seed"`
- Target uint32 `json:"target"`
- }{
- Seed: hex.EncodeToString(challenge.Seed),
- Target: challenge.Target,
- })
- })
-}
-
-func (a *api) requirePowMiddleware(h http.Handler) http.Handler {
- return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
-
- seedHex := r.FormValue("powSeed")
- seed, err := hex.DecodeString(seedHex)
- if err != nil || len(seed) == 0 {
- apiutil.BadRequest(rw, r, errors.New("invalid powSeed"))
- return
- }
-
- solutionHex := r.FormValue("powSolution")
- solution, err := hex.DecodeString(solutionHex)
- if err != nil || len(seed) == 0 {
- apiutil.BadRequest(rw, r, errors.New("invalid powSolution"))
- return
- }
-
- err = a.params.PowManager.CheckSolution(seed, solution)
-
- if err != nil {
- apiutil.BadRequest(rw, r, fmt.Errorf("checking proof-of-work solution: %w", err))
- return
- }
-
- h.ServeHTTP(rw, r)
- })
-}
diff --git a/src/http/static/api.js b/src/http/static/api.js
deleted file mode 100644
index 55c9ecd..0000000
--- a/src/http/static/api.js
+++ /dev/null
@@ -1,118 +0,0 @@
-import * as utils from "/static/utils.js";
-
-const doFetch = async (req) => {
- let res, jsonRes;
- try {
- res = await fetch(req);
- jsonRes = await res.json();
-
- } catch (e) {
-
- if (e instanceof SyntaxError)
- e = new Error(`status ${res.status}, empty (or invalid) response body`);
-
- console.error(`api call ${req.method} ${req.url}: unexpected error:`, e);
- throw e;
- }
-
- if (jsonRes.error) {
- console.error(
- `api call ${req.method} ${req.url}: application error:`,
- res.status,
- jsonRes.error,
- );
-
- throw jsonRes.error;
- }
-
- return jsonRes;
-}
-
-// may throw
-const solvePow = async () => {
-
- const res = await call('/api/pow/challenge');
-
- const worker = new Worker('/static/solvePow.js');
-
- const p = new Promise((resolve, reject) => {
- worker.postMessage({seedHex: res.seed, target: res.target});
- worker.onmessage = resolve;
- });
-
- const powSol = (await p).data;
- worker.terminate();
-
- return {seed: res.seed, solution: powSol};
-}
-
-const call = async (route, opts = {}) => {
- const {
- method = 'POST',
- body = {},
- requiresPow = false,
- } = opts;
-
- const reqOpts = {
- method,
- };
-
- if (requiresPow) {
- const {seed, solution} = await solvePow();
- body.powSeed = seed;
- body.powSolution = solution;
- }
-
- if (Object.keys(body).length > 0) {
- const form = new FormData();
- for (const key in body) form.append(key, body[key]);
-
- reqOpts.body = form;
- }
-
- const req = new Request(route, reqOpts);
- return doFetch(req);
-}
-
-const ws = async (route, opts = {}) => {
- const {
- requiresPow = false,
- params = {},
- } = opts;
-
- const docURL = new URL(document.URL);
- const protocol = docURL.protocol == "http:" ? "ws:" : "wss:";
-
- const fullParams = new URLSearchParams(params);
-
- if (requiresPow) {
- const {seed, solution} = await solvePow();
- fullParams.set("powSeed", seed);
- fullParams.set("powSolution", solution);
- }
-
- const rawConn = new WebSocket(`${protocol}//${docURL.host}${route}?${fullParams.toString()}`);
-
- const conn = {
- next: () => new Promise((resolve, reject) => {
- rawConn.onmessage = (m) => {
- const mj = JSON.parse(m.data);
- resolve(mj);
- };
- rawConn.onerror = reject;
- rawConn.onclose = reject;
- }),
-
- close: rawConn.close,
- };
-
- return new Promise((resolve, reject) => {
- rawConn.onopen = () => resolve(conn);
- rawConn.onerror = reject;
- });
-}
-
-export {
- call,
- ws
-}
diff --git a/src/http/static/solvePow.js b/src/http/static/solvePow.js
deleted file mode 100644
index 900400c..0000000
--- a/src/http/static/solvePow.js
+++ /dev/null
@@ -1,28 +0,0 @@
-const fromHexString = hexString =>
- new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
-
-const toHexString = bytes =>
- bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
-
-onmessage = async (e) => {
- const seed = fromHexString(e.data.seedHex);
- const target = e.data.target;
-
- const fullBuf = new ArrayBuffer(seed.byteLength*2);
-
- const fullBufSeed = new Uint8Array(fullBuf, 0, seed.byteLength);
- seed.forEach((v, i) => fullBufSeed[i] = v);
-
- const randBuf = new Uint8Array(fullBuf, seed.byteLength);
-
- while (true) {
- crypto.getRandomValues(randBuf);
- const digest = await crypto.subtle.digest('SHA-512', fullBuf);
- const digestView = new DataView(digest);
- if (digestView.getUint32(0) < target) {
- postMessage(toHexString(randBuf));
- return;
- }
- }
-
-};
diff --git a/src/http/static/utils.js b/src/http/static/utils.js
deleted file mode 100644
index 96a2950..0000000
--- a/src/http/static/utils.js
+++ /dev/null
@@ -1,12 +0,0 @@
-const cookies = {};
-const cookieKVs = document.cookie
- .split(';')
- .map(cookie => cookie.trim().split('=', 2));
-
-for (const i in cookieKVs) {
- cookies[cookieKVs[i][0]] = cookieKVs[i][1];
-}
-
-export {
- cookies,
-}
diff --git a/src/http/tpl/finalize.html b/src/http/tpl/finalize.html
deleted file mode 100644
index 8bdfceb..0000000
--- a/src/http/tpl/finalize.html
+++ /dev/null
@@ -1,45 +0,0 @@
-{{ define "body" }}
-
-<script async type="module" src="{{ StaticURL "api.js" }}"></script>
-
-<style>
-#result.success { color: green; }
-#result.fail { color: red; }
-</style>
-
-<span id="result"></span>
-
-<script>
-
-(async () => {
-
- const resultSpan = document.getElementById("result");
-
- try {
-
- const urlParams = new URLSearchParams(window.location.search);
- const subToken = urlParams.get('subToken');
-
- if (!subToken) throw "No subscription token provided";
-
- const api = await import("{{ StaticURL "api.js" }}");
-
- await api.call('/api/mailinglist/finalize', {
- body: { subToken },
- });
-
- resultSpan.className = "success";
- resultSpan.innerHTML = "Your email subscription has been finalized! Please go on about your day.";
-
- } catch (e) {
- resultSpan.className = "fail";
- resultSpan.innerHTML = e;
- }
-
-})();
-
-</script>
-
-{{ end }}
-
-{{ template "base.html" . }}
diff --git a/src/http/tpl/follow.html b/src/http/tpl/follow.html
index 88fee46..1958f95 100644
--- a/src/http/tpl/follow.html
+++ b/src/http/tpl/follow.html
@@ -1,113 +1,5 @@
{{ define "body" }}
-<script async type="module" src="{{ StaticURL "api.js" }}"></script>
-
-<p>
- Here's your options for receiving updates about new posts:
-</p>
-
-<h2>Option 1: Email</h2>
-
-<p>
- Email is by far my preferred option for notifying followers of new posts. The
- entire email list system for this site 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 me 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>
- So smash that subscribe button!
-</p>
-
-<p>
- 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>
-
-<p style="color: var(--nc-lk-2);">
- Unfortunately Google considers all emails from my mail server to be spam, so
- gmail emails are not allowed. Sorry (not sorry).
-</p>
-
-<style>
-
-#emailStatus.success {
- color: green;
-}
-
-#emailStatus.fail {
- color: red;
-}
-
-</style>
-
-<form action="javascript:void(0);">
- <input type="email" placeholder="name@host.com" id="emailAddress" />
- <input class="button-primary" type="submit" value="Subscribe" id="emailSubscribe" />
- <span id="emailStatus"></span>
-</form>
-
-<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("{{ StaticURL "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 a site's updates, and we're bringing it back!
Just give any RSS reader the following URL:
diff --git a/src/http/tpl/unsubscribe.html b/src/http/tpl/unsubscribe.html
deleted file mode 100644
index ad01735..0000000
--- a/src/http/tpl/unsubscribe.html
+++ /dev/null
@@ -1,44 +0,0 @@
-{{ define "body" }}
-
-<script async type="module" src="{{ StaticURL "api.js" }}"></script>
-
-<style>
-#result.success { color: green; }
-#result.fail { color: red; }
-</style>
-
-<span id="result"></span>
-
-<script>
-
-(async () => {
-
- const resultSpan = document.getElementById("result");
-
- try {
- const urlParams = new URLSearchParams(window.location.search);
- const unsubToken = urlParams.get('unsubToken');
-
- if (!unsubToken) throw "No unsubscribe token provided";
-
- const api = await import("{{ StaticURL "api.js" }}");
-
- await api.call('/api/mailinglist/unsubscribe', {
- body: { unsubToken },
- });
-
- resultSpan.className = "success";
- resultSpan.innerHTML = "You have been unsubscribed! Please go on about your day.";
-
- } catch (e) {
- resultSpan.className = "fail";
- resultSpan.innerHTML = e;
- }
-
-})();
-
-</script>
-
-{{ end }}
-
-{{ template "base.html" . }}
diff --git a/src/mailinglist/mailer.go b/src/mailinglist/mailer.go
deleted file mode 100644
index 07d6c3a..0000000
--- a/src/mailinglist/mailer.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package mailinglist
-
-import (
- "context"
- "errors"
- "strings"
-
- "github.com/emersion/go-sasl"
- "github.com/emersion/go-smtp"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
- "github.com/mediocregopher/mediocre-go-lib/v2/mctx"
- "github.com/mediocregopher/mediocre-go-lib/v2/mlog"
-)
-
-// Mailer is used to deliver emails to arbitrary recipients.
-type Mailer interface {
- Send(to, subject, body string) error
-}
-
-type logMailer struct {
- logger *mlog.Logger
-}
-
-// NewLogMailer returns a Mailer instance which will not actually send any
-// emails, it will only log to the given Logger when Send is called.
-func NewLogMailer(logger *mlog.Logger) Mailer {
- return &logMailer{logger: logger}
-}
-
-func (l *logMailer) Send(to, subject, body string) error {
- ctx := mctx.Annotate(context.Background(),
- "to", to,
- "subject", subject,
- )
- l.logger.Info(ctx, "would have sent email")
- return nil
-}
-
-// NullMailer acts as a Mailer but actually just does nothing.
-var NullMailer = nullMailer{}
-
-type nullMailer struct{}
-
-func (nullMailer) Send(to, subject, body string) error {
- return nil
-}
-
-// MailerParams are used to initialize a new Mailer instance.
-type MailerParams struct {
- SMTPAddr string
-
- // Optional, if not given then no auth is attempted.
- SMTPAuth sasl.Client
-
- // The sending email address to use for all emails being sent.
- SendAs string
-}
-
-// SetupCfg implement the cfg.Cfger interface.
-func (m *MailerParams) SetupCfg(cfg *cfg.Cfg) {
-
- cfg.StringVar(&m.SMTPAddr, "ml-smtp-addr", "", "Address of SMTP server to use for sending emails for the mailing list")
- smtpAuthStr := cfg.String("ml-smtp-auth", "", "user:pass to use when authenticating with the mailing list SMTP server. The given user will also be used as the From address.")
-
- cfg.OnInit(func(ctx context.Context) error {
- if m.SMTPAddr == "" {
- return nil
- }
-
- smtpAuthParts := strings.SplitN(*smtpAuthStr, ":", 2)
- if len(smtpAuthParts) < 2 {
- return errors.New("invalid -ml-smtp-auth")
- }
-
- m.SMTPAuth = sasl.NewPlainClient("", smtpAuthParts[0], smtpAuthParts[1])
- m.SendAs = smtpAuthParts[0]
-
- return nil
- })
-}
-
-// Annotate implements mctx.Annotator interface.
-func (m *MailerParams) Annotate(a mctx.Annotations) {
- if m.SMTPAddr == "" {
- return
- }
-
- a["smtpAddr"] = m.SMTPAddr
- a["smtpSendAs"] = m.SendAs
-}
-
-type mailer struct {
- params MailerParams
-}
-
-// NewMailer initializes and returns a Mailer which will use an external SMTP
-// server to deliver email.
-func NewMailer(params MailerParams) Mailer {
- return &mailer{
- params: params,
- }
-}
-
-func (m *mailer) Send(to, subject, body string) error {
-
- msg := []byte("From: " + m.params.SendAs + "\r\n" +
- "To: " + to + "\r\n" +
- "Subject: " + subject + "\r\n\r\n" +
- body + "\r\n")
-
- c, err := smtp.Dial(m.params.SMTPAddr)
- if err != nil {
- return err
- }
- defer c.Close()
-
- if err = c.Auth(m.params.SMTPAuth); err != nil {
- return err
- }
-
- if err = c.Mail(m.params.SendAs, nil); err != nil {
- return err
- }
-
- if err = c.Rcpt(to); err != nil {
- return err
- }
-
- w, err := c.Data()
- if err != nil {
- return err
- }
-
- if _, err = w.Write(msg); err != nil {
- return err
- }
-
- if err = w.Close(); err != nil {
- return err
- }
-
- return c.Quit()
-}
diff --git a/src/mailinglist/mailinglist.go b/src/mailinglist/mailinglist.go
deleted file mode 100644
index d9bdcc0..0000000
--- a/src/mailinglist/mailinglist.go
+++ /dev/null
@@ -1,273 +0,0 @@
-// Package mailinglist manages the list of subscribed emails and allows emailing
-// out to them.
-package mailinglist
-
-import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "html/template"
- "io"
- "net/url"
- "strings"
-
- "github.com/google/uuid"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
- "github.com/mediocregopher/mediocre-go-lib/v2/mctx"
- "github.com/tilinna/clock"
-)
-
-var (
- // ErrAlreadyVerified is used when the email is already fully subscribed.
- ErrAlreadyVerified = errors.New("email is already subscribed")
-)
-
-// MailingList is able to subscribe, unsubscribe, and iterate through emails.
-type MailingList interface {
-
- // May return ErrAlreadyVerified.
- BeginSubscription(email string) error
-
- // May return ErrNotFound or ErrAlreadyVerified.
- FinalizeSubscription(subToken string) error
-
- // May return ErrNotFound.
- Unsubscribe(unsubToken string) error
-
- // Publish blasts the mailing list with an update about a new blog post.
- Publish(postTitle, postURL string) error
-}
-
-// Params are parameters used to initialize a new MailingList. All fields are
-// required unless otherwise noted.
-type Params struct {
- Store Store
- Mailer Mailer
- Clock clock.Clock
-
- // PublicURL is the base URL which site visitors can navigate to.
- // MailingList will generate links based on this value.
- PublicURL *url.URL
-}
-
-// SetupCfg implement the cfg.Cfger interface.
-func (p *Params) SetupCfg(cfg *cfg.Cfg) {
- publicURLStr := cfg.String("ml-public-url", "http://localhost:4000", "URL this service is accessible at")
-
- cfg.OnInit(func(ctx context.Context) error {
- var err error
- *publicURLStr = strings.TrimSuffix(*publicURLStr, "/")
- if p.PublicURL, err = url.Parse(*publicURLStr); err != nil {
- return fmt.Errorf("parsing -ml-public-url: %w", err)
- }
-
- return nil
- })
-}
-
-// Annotate implements mctx.Annotator interface.
-func (p *Params) Annotate(a mctx.Annotations) {
- a["mlPublicURL"] = p.PublicURL
-}
-
-// New initializes and returns a MailingList instance using the given Params.
-func New(params Params) MailingList {
- return &mailingList{params: params}
-}
-
-type mailingList struct {
- params Params
-}
-
-var beginSubTpl = template.Must(template.New("beginSub").Parse(`
-Welcome to the Mediocre Blog mailing list! By subscribing to this mailing list
-you are signing up to receive an email everytime a new blog post is published.
-
-In order to complete your subscription please navigate to the following link:
-
-{{ .SubLink }}
-
-This mailing list is built and run using my own hardware and software, and I
-solemnly swear that you'll never receive an email from it unless there's a new
-blog post.
-
-If you did not initiate this email, and/or do not wish to subscribe to the
-mailing list, then simply delete this email and pretend that nothing ever
-happened.
-
-- Brian
-`))
-
-func (m *mailingList) BeginSubscription(email string) error {
-
- emailRecord, err := m.params.Store.Get(email)
-
- if errors.Is(err, ErrNotFound) {
- emailRecord = Email{
- Email: email,
- SubToken: uuid.New().String(),
- CreatedAt: m.params.Clock.Now(),
- }
-
- if err := m.params.Store.Set(emailRecord); err != nil {
- return fmt.Errorf("storing pending email: %w", err)
- }
-
- } else if err != nil {
- return fmt.Errorf("finding existing email record: %w", err)
-
- } else if !emailRecord.VerifiedAt.IsZero() {
- return ErrAlreadyVerified
- }
-
- body := new(bytes.Buffer)
- err = beginSubTpl.Execute(body, struct {
- SubLink string
- }{
- SubLink: fmt.Sprintf(
- "%s/mailinglist/finalize?subToken=%s",
- m.params.PublicURL.String(),
- emailRecord.SubToken,
- ),
- })
-
- if err != nil {
- return fmt.Errorf("executing beginSubTpl: %w", err)
- }
-
- err = m.params.Mailer.Send(
- email,
- "Mediocre Blog - Please verify your email address",
- body.String(),
- )
-
- if err != nil {
- return fmt.Errorf("sending email: %w", err)
- }
-
- return nil
-}
-
-func (m *mailingList) FinalizeSubscription(subToken string) error {
- emailRecord, err := m.params.Store.GetBySubToken(subToken)
-
- if err != nil {
- return fmt.Errorf("retrieving email record: %w", err)
-
- } else if !emailRecord.VerifiedAt.IsZero() {
- return ErrAlreadyVerified
- }
-
- emailRecord.VerifiedAt = m.params.Clock.Now()
- emailRecord.UnsubToken = uuid.New().String()
-
- if err := m.params.Store.Set(emailRecord); err != nil {
- return fmt.Errorf("storing verified email: %w", err)
- }
-
- return nil
-}
-
-func (m *mailingList) Unsubscribe(unsubToken string) error {
- emailRecord, err := m.params.Store.GetByUnsubToken(unsubToken)
-
- if err != nil {
- return fmt.Errorf("retrieving email record: %w", err)
- }
-
- if err := m.params.Store.Delete(emailRecord.Email); err != nil {
- return fmt.Errorf("deleting email record: %w", err)
- }
-
- return nil
-}
-
-var publishTpl = template.Must(template.New("publish").Parse(`
-A new post has been published to the Mediocre Blog!
-
-{{ .PostTitle }}
-{{ .PostURL }}
-
-If you're interested then please check it out!
-
-If you'd like to unsubscribe from this mailing list then visit the following
-link instead:
-
-{{ .UnsubURL }}
-
-- Brian
-`))
-
-type multiErr []error
-
-func (m multiErr) Error() string {
- if len(m) == 0 {
- panic("multiErr with no members")
- }
-
- b := new(strings.Builder)
- fmt.Fprintln(b, "The following errors were encountered:")
- for _, err := range m {
- fmt.Fprintf(b, "\t- %s\n", err.Error())
- }
-
- return b.String()
-}
-
-func (m *mailingList) Publish(postTitle, postURL string) error {
-
- var mErr multiErr
-
- iter := m.params.Store.GetAll()
- for {
- emailRecord, err := iter()
- if errors.Is(err, io.EOF) {
- break
-
- } else if err != nil {
- mErr = append(mErr, fmt.Errorf("iterating through email records: %w", err))
- break
-
- } else if emailRecord.VerifiedAt.IsZero() {
- continue
- }
-
- body := new(bytes.Buffer)
- err = publishTpl.Execute(body, struct {
- PostTitle string
- PostURL string
- UnsubURL string
- }{
- PostTitle: postTitle,
- PostURL: postURL,
- UnsubURL: fmt.Sprintf(
- "%s/mailinglist/unsubscribe?unsubToken=%s",
- m.params.PublicURL.String(),
- emailRecord.UnsubToken,
- ),
- })
-
- if err != nil {
- mErr = append(mErr, fmt.Errorf("rendering publish email template for %q: %w", emailRecord.Email, err))
- continue
- }
-
- err = m.params.Mailer.Send(
- emailRecord.Email,
- fmt.Sprintf("Mediocre Blog - New Post! - %s", postTitle),
- body.String(),
- )
-
- if err != nil {
- mErr = append(mErr, fmt.Errorf("sending email to %q: %w", emailRecord.Email, err))
- continue
- }
- }
-
- if len(mErr) > 0 {
- return mErr
- }
-
- return nil
-}
diff --git a/src/mailinglist/store.go b/src/mailinglist/store.go
deleted file mode 100644
index 49e7617..0000000
--- a/src/mailinglist/store.go
+++ /dev/null
@@ -1,245 +0,0 @@
-package mailinglist
-
-import (
- "crypto/sha512"
- "database/sql"
- "encoding/base64"
- "errors"
- "fmt"
- "io"
- "path"
- "strings"
- "time"
-
- _ "github.com/mattn/go-sqlite3"
- "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
- migrate "github.com/rubenv/sql-migrate"
-)
-
-var (
- // ErrNotFound is used to indicate an email could not be found in the
- // database.
- ErrNotFound = errors.New("no record found")
-)
-
-// EmailIterator will iterate through a sequence of emails, returning the next
-// email in the sequence on each call, or returning io.EOF.
-type EmailIterator func() (Email, error)
-
-// Email describes all information related to an email which has yet
-// to be verified.
-type Email struct {
- Email string
- SubToken string
- CreatedAt time.Time
-
- UnsubToken string
- VerifiedAt time.Time
-}
-
-// Store is used for storing MailingList related information.
-type Store interface {
-
- // Set is used to set the information related to an email.
- Set(Email) error
-
- // Get will return the record for the given email, or ErrNotFound.
- Get(email string) (Email, error)
-
- // GetBySubToken will return the record for the given SubToken, or
- // ErrNotFound.
- GetBySubToken(subToken string) (Email, error)
-
- // GetByUnsubToken will return the record for the given UnsubToken, or
- // ErrNotFound.
- GetByUnsubToken(unsubToken string) (Email, error)
-
- // Delete will delete the record for the given email.
- Delete(email string) error
-
- // GetAll returns all emails for which there is a record.
- GetAll() EmailIterator
-
- Close() error
-}
-
-var migrations = []*migrate.Migration{
- &migrate.Migration{
- Id: "1",
- Up: []string{
- `CREATE TABLE emails (
- id TEXT PRIMARY KEY,
- email TEXT NOT NULL,
- sub_token TEXT NOT NULL,
- created_at INTEGER NOT NULL,
-
- unsub_token TEXT,
- verified_at INTEGER
- )`,
- },
- Down: []string{"DROP TABLE emails"},
- },
-}
-
-type store struct {
- db *sql.DB
-}
-
-// NewStore initializes a new Store using a sqlite3 database in the given
-// DataDir.
-func NewStore(dataDir cfg.DataDir) (Store, error) {
-
- path := path.Join(dataDir.Path, "mailinglist.sqlite3")
-
- db, err := sql.Open("sqlite3", path)
- if err != nil {
- return nil, fmt.Errorf("opening sqlite file at %q: %w", path, err)
- }
-
- migrations := &migrate.MemoryMigrationSource{Migrations: migrations}
-
- if _, err := migrate.Exec(db, "sqlite3", migrations, migrate.Up); err != nil {
- return nil, fmt.Errorf("running migrations: %w", err)
- }
-
- return &store{
- db: db,
- }, nil
-}
-
-func (s *store) emailID(email string) string {
- email = strings.ToLower(email)
- h := sha512.New()
- h.Write([]byte(email))
- return base64.URLEncoding.EncodeToString(h.Sum(nil))
-}
-
-func (s *store) Set(email Email) error {
- _, err := s.db.Exec(
- `INSERT INTO emails (
- id, email, sub_token, created_at, unsub_token, verified_at
- )
- VALUES
- (?, ?, ?, ?, ?, ?)
- ON CONFLICT (id) DO UPDATE SET
- email=excluded.email,
- sub_token=excluded.sub_token,
- unsub_token=excluded.unsub_token,
- verified_at=excluded.verified_at
- `,
- s.emailID(email.Email),
- email.Email,
- email.SubToken,
- email.CreatedAt.Unix(),
- email.UnsubToken,
- sql.NullInt64{
- Int64: email.VerifiedAt.Unix(),
- Valid: !email.VerifiedAt.IsZero(),
- },
- )
-
- return err
-}
-
-var scanCols = []string{
- "email", "sub_token", "created_at", "unsub_token", "verified_at",
-}
-
-type row interface {
- Scan(...interface{}) error
-}
-
-func (s *store) scanRow(row row) (Email, error) {
- var email Email
- var createdAt int64
- var verifiedAt sql.NullInt64
-
- err := row.Scan(
- &email.Email,
- &email.SubToken,
- &createdAt,
- &email.UnsubToken,
- &verifiedAt,
- )
- if err != nil {
- return Email{}, err
- }
-
- email.CreatedAt = time.Unix(createdAt, 0)
- if verifiedAt.Valid {
- email.VerifiedAt = time.Unix(verifiedAt.Int64, 0)
- }
-
- return email, nil
-}
-
-func (s *store) scanSingleRow(row *sql.Row) (Email, error) {
- email, err := s.scanRow(row)
- if errors.Is(err, sql.ErrNoRows) {
- return Email{}, ErrNotFound
- }
-
- return email, err
-}
-
-func (s *store) Get(email string) (Email, error) {
- row := s.db.QueryRow(
- `SELECT `+strings.Join(scanCols, ",")+`
- FROM emails
- WHERE id=?`,
- s.emailID(email),
- )
-
- return s.scanSingleRow(row)
-}
-
-func (s *store) GetBySubToken(subToken string) (Email, error) {
- row := s.db.QueryRow(
- `SELECT `+strings.Join(scanCols, ",")+`
- FROM emails
- WHERE sub_token=?`,
- subToken,
- )
-
- return s.scanSingleRow(row)
-}
-
-func (s *store) GetByUnsubToken(unsubToken string) (Email, error) {
- row := s.db.QueryRow(
- `SELECT `+strings.Join(scanCols, ",")+`
- FROM emails
- WHERE unsub_token=?`,
- unsubToken,
- )
-
- return s.scanSingleRow(row)
-}
-
-func (s *store) Delete(email string) error {
- _, err := s.db.Exec(
- `DELETE FROM emails WHERE id=?`,
- s.emailID(email),
- )
- return err
-}
-
-func (s *store) GetAll() EmailIterator {
- rows, err := s.db.Query(
- `SELECT ` + strings.Join(scanCols, ",") + `
- FROM emails`,
- )
-
- return func() (Email, error) {
- if err != nil {
- return Email{}, err
-
- } else if !rows.Next() {
- return Email{}, io.EOF
- }
- return s.scanRow(rows)
- }
-}
-
-func (s *store) Close() error {
- return s.db.Close()
-}
diff --git a/src/mailinglist/store_test.go b/src/mailinglist/store_test.go
deleted file mode 100644
index 9093d90..0000000
--- a/src/mailinglist/store_test.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package mailinglist
-
-import (
- "io"
- "testing"
- "time"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
- "github.com/stretchr/testify/assert"
-)
-
-func TestStore(t *testing.T) {
-
- var dataDir cfg.DataDir
-
- if err := dataDir.Init(); err != nil {
- t.Fatal(err)
- }
-
- t.Cleanup(func() { dataDir.Close() })
-
- store, err := NewStore(dataDir)
- assert.NoError(t, err)
-
- t.Cleanup(func() {
- assert.NoError(t, store.Close())
- })
-
- now := func() time.Time {
- return time.Now().Truncate(time.Second)
- }
-
- assertGet := func(t *testing.T, email Email) {
- t.Helper()
-
- gotEmail, err := store.Get(email.Email)
- assert.NoError(t, err)
- assert.Equal(t, email, gotEmail)
-
- gotEmail, err = store.GetBySubToken(email.SubToken)
- assert.NoError(t, err)
- assert.Equal(t, email, gotEmail)
-
- if email.UnsubToken != "" {
- gotEmail, err = store.GetByUnsubToken(email.UnsubToken)
- assert.NoError(t, err)
- assert.Equal(t, email, gotEmail)
- }
- }
-
- assertNotFound := func(t *testing.T, email string) {
- t.Helper()
- _, err := store.Get(email)
- assert.ErrorIs(t, err, ErrNotFound)
- }
-
- // now start actual tests
-
- // GetAll should not do anything, there's no data
- _, err = store.GetAll()()
- assert.ErrorIs(t, err, io.EOF)
-
- emailFoo := Email{
- Email: "foo",
- SubToken: "subTokenFoo",
- CreatedAt: now(),
- }
-
- // email isn't stored yet, shouldn't exist
- assertNotFound(t, emailFoo.Email)
-
- // Set an email, now it should exist
- assert.NoError(t, store.Set(emailFoo))
- assertGet(t, emailFoo)
-
- // Update the email with an unsub token
- emailFoo.UnsubToken = "unsubTokenFoo"
- emailFoo.VerifiedAt = now()
- assert.NoError(t, store.Set(emailFoo))
- assertGet(t, emailFoo)
-
- // GetAll should now only return that email
- iter := store.GetAll()
- gotEmail, err := iter()
- assert.NoError(t, err)
- assert.Equal(t, emailFoo, gotEmail)
- _, err = iter()
- assert.ErrorIs(t, err, io.EOF)
-
- // Delete the email, it should be gone
- assert.NoError(t, store.Delete(emailFoo.Email))
- assertNotFound(t, emailFoo.Email)
- _, err = store.GetAll()()
- assert.ErrorIs(t, err, io.EOF)
-}
diff --git a/src/pow/pow.go b/src/pow/pow.go
deleted file mode 100644
index ada8439..0000000
--- a/src/pow/pow.go
+++ /dev/null
@@ -1,321 +0,0 @@
-// Package pow creates proof-of-work challenges and validates their solutions.
-package pow
-
-import (
- "bytes"
- "context"
- "crypto/hmac"
- "crypto/md5"
- "crypto/rand"
- "crypto/sha512"
- "encoding/binary"
- "errors"
- "fmt"
- "hash"
- "strconv"
- "time"
-
- "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
- "github.com/mediocregopher/mediocre-go-lib/v2/mctx"
- "github.com/tilinna/clock"
-)
-
-type challengeParams struct {
- Target uint32
- ExpiresAt int64
- Random []byte
-}
-
-func (c challengeParams) MarshalBinary() ([]byte, error) {
- buf := new(bytes.Buffer)
-
- var err error
- write := func(v interface{}) {
- if err != nil {
- return
- }
- err = binary.Write(buf, binary.BigEndian, v)
- }
-
- write(c.Target)
- write(c.ExpiresAt)
-
- if err != nil {
- return nil, err
- }
-
- if _, err := buf.Write(c.Random); err != nil {
- panic(err)
- }
-
- return buf.Bytes(), nil
-}
-
-func (c *challengeParams) UnmarshalBinary(b []byte) error {
- buf := bytes.NewBuffer(b)
-
- var err error
- read := func(into interface{}) {
- if err != nil {
- return
- }
- err = binary.Read(buf, binary.BigEndian, into)
- }
-
- read(&c.Target)
- read(&c.ExpiresAt)
-
- if buf.Len() > 0 {
- c.Random = buf.Bytes() // whatever is left
- }
-
- return err
-}
-
-// The seed takes the form:
-//
-// (version)+(signature of challengeParams)+(challengeParams)
-//
-// Version is currently always 0.
-func newSeed(c challengeParams, secret []byte) ([]byte, error) {
- buf := new(bytes.Buffer)
- buf.WriteByte(0) // version
-
- cb, err := c.MarshalBinary()
- if err != nil {
- return nil, err
- }
-
- h := hmac.New(md5.New, secret)
- h.Write(cb)
- buf.Write(h.Sum(nil))
-
- buf.Write(cb)
-
- return buf.Bytes(), nil
-}
-
-var errMalformedSeed = errors.New("malformed seed")
-
-func challengeParamsFromSeed(seed, secret []byte) (challengeParams, error) {
- h := hmac.New(md5.New, secret)
- hSize := h.Size()
-
- if len(seed) < hSize+1 || seed[0] != 0 {
- return challengeParams{}, errMalformedSeed
- }
- seed = seed[1:]
-
- sig, cb := seed[:hSize], seed[hSize:]
-
- // check signature
- h.Write(cb)
- if !hmac.Equal(sig, h.Sum(nil)) {
- return challengeParams{}, errMalformedSeed
- }
-
- var c challengeParams
- if err := c.UnmarshalBinary(cb); err != nil {
- return challengeParams{}, fmt.Errorf("unmarshaling challenge parameters: %w", err)
- }
-
- return c, nil
-}
-
-// Challenge is a set of fields presented to a client, with which they must
-// generate a solution.
-//
-// Generating a solution is done by:
-//
-// - Collect up to len(Seed) random bytes. These will be the potential
-// solution.
-//
-// - Calculate the sha512 of the concatenation of Seed and PotentialSolution.
-//
-// - Parse the first 4 bytes of the sha512 result as a big-endian uint32.
-//
-// - If the resulting number is _less_ than Target, the solution has been
-// found. Otherwise go back to step 1 and try again.
-//
-type Challenge struct {
- Seed []byte
- Target uint32
-}
-
-// Errors which may be produced by a Manager.
-var (
- ErrInvalidSolution = errors.New("invalid solution")
- ErrExpiredSeed = errors.New("expired seed")
-)
-
-// Manager is used to both produce proof-of-work challenges and check their
-// solutions.
-type Manager interface {
- NewChallenge() Challenge
-
- // Will produce ErrInvalidSolution if the solution is invalid, or
- // ErrExpiredSeed if the seed has expired.
- CheckSolution(seed, solution []byte) error
-}
-
-// ManagerParams are used to initialize a new Manager instance. All fields are
-// required unless otherwise noted.
-type ManagerParams struct {
- Clock clock.Clock
- Store Store
-
- // Secret is used to sign each Challenge's Seed, it should _not_ be shared
- // with clients.
- Secret []byte
-
- // The Target which Challenges should hit. Lower is more difficult.
- //
- // Defaults to 0x00FFFFFF
- Target uint32
-
- // ChallengeTimeout indicates how long before Challenges are considered
- // expired and cannot be solved.
- //
- // Defaults to 1 minute.
- ChallengeTimeout time.Duration
-}
-
-func (p *ManagerParams) setDefaults() {
- if p.Target == 0 {
- p.Target = 0x00FFFFFF
- }
- if p.ChallengeTimeout == 0 {
- p.ChallengeTimeout = 1 * time.Minute
- }
-}
-
-// SetupCfg implement the cfg.Cfger interface.
-func (p *ManagerParams) SetupCfg(cfg *cfg.Cfg) {
- powTargetStr := cfg.String("pow-target", "0x0000FFFF", "Proof-of-work target, lower is more difficult")
- powSecretStr := cfg.String("pow-secret", "", "Secret used to sign proof-of-work challenge seeds")
-
- cfg.OnInit(func(ctx context.Context) error {
- p.setDefaults()
-
- if *powSecretStr == "" {
- return errors.New("-pow-secret is required")
- }
-
- powTargetUint, err := strconv.ParseUint(*powTargetStr, 0, 32)
- if err != nil {
- return fmt.Errorf("parsing -pow-target: %w", err)
- }
-
- p.Target = uint32(powTargetUint)
- p.Secret = []byte(*powSecretStr)
-
- return nil
- })
-}
-
-// Annotate implements mctx.Annotator interface.
-func (p *ManagerParams) Annotate(a mctx.Annotations) {
- a["powTarget"] = fmt.Sprintf("%x", p.Target)
-}
-
-type manager struct {
- params ManagerParams
-}
-
-// NewManager initializes and returns a Manager instance using the given
-// parameters.
-func NewManager(params ManagerParams) Manager {
- params.setDefaults()
- return &manager{
- params: params,
- }
-}
-
-func (m *manager) NewChallenge() Challenge {
- target := m.params.Target
-
- c := challengeParams{
- Target: target,
- ExpiresAt: m.params.Clock.Now().Add(m.params.ChallengeTimeout).Unix(),
- Random: make([]byte, 8),
- }
-
- if _, err := rand.Read(c.Random); err != nil {
- panic(err)
- }
-
- seed, err := newSeed(c, m.params.Secret)
- if err != nil {
- panic(err)
- }
-
- return Challenge{
- Seed: seed,
- Target: target,
- }
-}
-
-// SolutionChecker can be used to check possible Challenge solutions. It will
-// cache certain values internally to save on allocations when used in a loop
-// (e.g. when generating a solution).
-//
-// SolutionChecker is not thread-safe.
-type SolutionChecker struct {
- h hash.Hash // sha512
- sum []byte
-}
-
-// Check returns true if the given bytes are a solution to the given Challenge.
-func (s SolutionChecker) Check(challenge Challenge, solution []byte) bool {
- if s.h == nil {
- s.h = sha512.New()
- }
- s.h.Reset()
-
- s.h.Write(challenge.Seed)
- s.h.Write(solution)
- s.sum = s.h.Sum(s.sum[:0])
-
- i := binary.BigEndian.Uint32(s.sum[:4])
- return i < challenge.Target
-}
-
-func (m *manager) CheckSolution(seed, solution []byte) error {
- c, err := challengeParamsFromSeed(seed, m.params.Secret)
- if err != nil {
- return fmt.Errorf("parsing challenge parameters from seed: %w", err)
-
- } else if now := m.params.Clock.Now().Unix(); c.ExpiresAt <= now {
- return ErrExpiredSeed
- }
-
- ok := (SolutionChecker{}).Check(
- Challenge{Seed: seed, Target: c.Target}, solution,
- )
-
- if !ok {
- return ErrInvalidSolution
- }
-
- expiresAt := time.Unix(c.ExpiresAt, 0)
- if err := m.params.Store.MarkSolved(seed, expiresAt.Add(1*time.Minute)); err != nil {
- return fmt.Errorf("marking solution as solved: %w", err)
- }
-
- return nil
-}
-
-// Solve returns a solution for the given Challenge. This may take a while.
-func Solve(challenge Challenge) []byte {
-
- chk := SolutionChecker{}
- b := make([]byte, len(challenge.Seed))
-
- for {
- if _, err := rand.Read(b); err != nil {
- panic(err)
- } else if chk.Check(challenge, b) {
- return b
- }
- }
-}
diff --git a/src/pow/pow_test.go b/src/pow/pow_test.go
deleted file mode 100644
index cc868b1..0000000
--- a/src/pow/pow_test.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package pow
-
-import (
- "encoding/hex"
- "strconv"
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/tilinna/clock"
-)
-
-func TestChallengeParams(t *testing.T) {
- tests := []challengeParams{
- {},
- {
- Target: 1,
- ExpiresAt: 3,
- },
- {
- Target: 2,
- ExpiresAt: -10,
- Random: []byte{0, 1, 2},
- },
- {
- Random: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
- },
- }
-
- t.Run("marshal_unmarshal", func(t *testing.T) {
- for i, test := range tests {
- t.Run(strconv.Itoa(i), func(t *testing.T) {
- b, err := test.MarshalBinary()
- assert.NoError(t, err)
-
- var c2 challengeParams
- assert.NoError(t, c2.UnmarshalBinary(b))
- assert.Equal(t, test, c2)
-
- b2, err := c2.MarshalBinary()
- assert.NoError(t, err)
- assert.Equal(t, b, b2)
- })
- }
- })
-
- secret := []byte("shhh")
-
- t.Run("to_from_seed", func(t *testing.T) {
-
- for i, test := range tests {
- t.Run(strconv.Itoa(i), func(t *testing.T) {
- seed, err := newSeed(test, secret)
- assert.NoError(t, err)
-
- // generating seed should be deterministic
- seed2, err := newSeed(test, secret)
- assert.NoError(t, err)
- assert.Equal(t, seed, seed2)
-
- c, err := challengeParamsFromSeed(seed, secret)
- assert.NoError(t, err)
- assert.Equal(t, test, c)
- })
- }
- })
-
- t.Run("malformed_seed", func(t *testing.T) {
- tests := []string{
- "",
- "01",
- "0000",
- "00374a1ad84d6b7a93e68042c1f850cbb100000000000000000000000000000102030405060708A0", // changed one byte from a good seed
- }
-
- for i, test := range tests {
- t.Run(strconv.Itoa(i), func(t *testing.T) {
- seed, err := hex.DecodeString(test)
- if err != nil {
- panic(err)
- }
-
- _, err = challengeParamsFromSeed(seed, secret)
- assert.ErrorIs(t, errMalformedSeed, err)
- })
- }
- })
-}
-
-func TestManager(t *testing.T) {
- clock := clock.NewMock(time.Now().Truncate(time.Hour))
-
- store := NewMemoryStore(clock)
- defer store.Close()
-
- mgr := NewManager(ManagerParams{
- Clock: clock,
- Store: store,
- Secret: []byte("shhhh"),
- Target: 0x00FFFFFF,
- ChallengeTimeout: 1 * time.Second,
- })
-
- {
- c := mgr.NewChallenge()
- solution := Solve(c)
- assert.NoError(t, mgr.CheckSolution(c.Seed, solution))
-
- // doing again should fail, the seed should already be marked as solved
- assert.ErrorIs(t, mgr.CheckSolution(c.Seed, solution), ErrSeedSolved)
- }
-
- {
- c := mgr.NewChallenge()
- solution := Solve(c)
- clock.Add(2 * time.Second)
- assert.ErrorIs(t, mgr.CheckSolution(c.Seed, solution), ErrExpiredSeed)
- }
-
-}
diff --git a/src/pow/store.go b/src/pow/store.go
deleted file mode 100644
index 0b5e7d0..0000000
--- a/src/pow/store.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package pow
-
-import (
- "errors"
- "sync"
- "time"
-
- "github.com/tilinna/clock"
-)
-
-// ErrSeedSolved is used to indicate a seed has already been solved.
-var ErrSeedSolved = errors.New("seed already solved")
-
-// Store is used to track information related to proof-of-work challenges and
-// solutions.
-type Store interface {
-
- // MarkSolved will return ErrSeedSolved if the seed was already marked. The
- // seed will be cleared from the Store once expiresAt is reached.
- MarkSolved(seed []byte, expiresAt time.Time) error
-
- Close() error
-}
-
-type inMemStore struct {
- clock clock.Clock
-
- m map[string]time.Time
- l sync.Mutex
- closeCh chan struct{}
- spinLoopCh chan struct{} // only used by tests
-}
-
-const inMemStoreGCPeriod = 5 * time.Second
-
-// NewMemoryStore initializes and returns an in-memory Store implementation.
-func NewMemoryStore(clock clock.Clock) Store {
- s := &inMemStore{
- clock: clock,
- m: map[string]time.Time{},
- closeCh: make(chan struct{}),
- spinLoopCh: make(chan struct{}, 1),
- }
- go s.spin(s.clock.NewTicker(inMemStoreGCPeriod))
- return s
-}
-
-func (s *inMemStore) spin(ticker *clock.Ticker) {
- defer ticker.Stop()
-
- for {
- select {
- case <-ticker.C:
- now := s.clock.Now()
-
- s.l.Lock()
- for seed, expiresAt := range s.m {
- if !now.Before(expiresAt) {
- delete(s.m, seed)
- }
- }
- s.l.Unlock()
-
- case <-s.closeCh:
- return
- }
-
- select {
- case s.spinLoopCh <- struct{}{}:
- default:
- }
- }
-}
-
-func (s *inMemStore) MarkSolved(seed []byte, expiresAt time.Time) error {
- seedStr := string(seed)
-
- s.l.Lock()
- defer s.l.Unlock()
-
- if _, ok := s.m[seedStr]; ok {
- return ErrSeedSolved
- }
-
- s.m[seedStr] = expiresAt
- return nil
-}
-
-func (s *inMemStore) Close() error {
- close(s.closeCh)
- return nil
-}
diff --git a/src/pow/store_test.go b/src/pow/store_test.go
deleted file mode 100644
index 324a40c..0000000
--- a/src/pow/store_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package pow
-
-import (
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
- "github.com/tilinna/clock"
-)
-
-func TestStore(t *testing.T) {
- clock := clock.NewMock(time.Now().Truncate(time.Hour))
- now := clock.Now()
-
- s := NewMemoryStore(clock)
- defer s.Close()
-
- seed := []byte{0}
-
- // mark solved should work
- err := s.MarkSolved(seed, now.Add(time.Second))
- assert.NoError(t, err)
-
- // mark again, should not work
- err = s.MarkSolved(seed, now.Add(time.Hour))
- assert.ErrorIs(t, err, ErrSeedSolved)
-
- // marking a different seed should still work
- seed2 := []byte{1}
- err = s.MarkSolved(seed2, now.Add(inMemStoreGCPeriod*2))
- assert.NoError(t, err)
- err = s.MarkSolved(seed2, now.Add(time.Hour))
- assert.ErrorIs(t, err, ErrSeedSolved)
-
- now = clock.Add(inMemStoreGCPeriod)
- <-s.(*inMemStore).spinLoopCh
-
- // first one should be markable again, second shouldnt
- err = s.MarkSolved(seed, now.Add(time.Second))
- assert.NoError(t, err)
- err = s.MarkSolved(seed2, now.Add(time.Hour))
- assert.ErrorIs(t, err, ErrSeedSolved)
-
- now = clock.Add(inMemStoreGCPeriod)
- <-s.(*inMemStore).spinLoopCh
-
- // now both should be expired
- err = s.MarkSolved(seed, now.Add(time.Second))
- assert.NoError(t, err)
- err = s.MarkSolved(seed2, now.Add(time.Second))
- assert.NoError(t, err)
-}