diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/cmd/mailinglist-cli/main.go | 118 | ||||
-rw-r--r-- | src/cmd/mediocre-blog/main.go | 47 | ||||
-rw-r--r-- | src/go.mod | 3 | ||||
-rw-r--r-- | src/go.sum | 5 | ||||
-rw-r--r-- | src/http/http.go | 35 | ||||
-rw-r--r-- | src/http/mailinglist.go | 92 | ||||
-rw-r--r-- | src/http/posts.go | 13 | ||||
-rw-r--r-- | src/http/pow.go | 53 | ||||
-rw-r--r-- | src/http/static/api.js | 118 | ||||
-rw-r--r-- | src/http/static/solvePow.js | 28 | ||||
-rw-r--r-- | src/http/static/utils.js | 12 | ||||
-rw-r--r-- | src/http/tpl/finalize.html | 45 | ||||
-rw-r--r-- | src/http/tpl/follow.html | 108 | ||||
-rw-r--r-- | src/http/tpl/unsubscribe.html | 44 | ||||
-rw-r--r-- | src/mailinglist/mailer.go | 143 | ||||
-rw-r--r-- | src/mailinglist/mailinglist.go | 273 | ||||
-rw-r--r-- | src/mailinglist/store.go | 245 | ||||
-rw-r--r-- | src/mailinglist/store_test.go | 95 | ||||
-rw-r--r-- | src/pow/pow.go | 321 | ||||
-rw-r--r-- | src/pow/pow_test.go | 120 | ||||
-rw-r--r-- | src/pow/store.go | 92 | ||||
-rw-r--r-- | src/pow/store_test.go | 52 |
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") @@ -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 @@ -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) -} |