Remove mailinglist and proof-of-work functionality
This commit is contained in:
parent
c4ec906406
commit
6c90010276
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
@ -11,13 +11,10 @@ import (
|
|||||||
cfgpkg "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
|
cfgpkg "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
|
||||||
"github.com/mediocregopher/blog.mediocregopher.com/srv/gmi"
|
"github.com/mediocregopher/blog.mediocregopher.com/srv/gmi"
|
||||||
"github.com/mediocregopher/blog.mediocregopher.com/srv/http"
|
"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"
|
||||||
"github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset"
|
"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/mctx"
|
||||||
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
|
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
|
||||||
"github.com/tilinna/clock"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -31,18 +28,6 @@ func main() {
|
|||||||
defer dataDir.Close()
|
defer dataDir.Close()
|
||||||
ctx = mctx.WithAnnotator(ctx, &dataDir)
|
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
|
var httpParams http.Params
|
||||||
httpParams.SetupCfg(cfg)
|
httpParams.SetupCfg(cfg)
|
||||||
ctx = mctx.WithAnnotator(ctx, &httpParams)
|
ctx = mctx.WithAnnotator(ctx, &httpParams)
|
||||||
@ -64,36 +49,6 @@ func main() {
|
|||||||
logger.Fatal(ctx, "initializing", err)
|
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)
|
postSQLDB, err := post.NewSQLDB(dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal(ctx, "initializing sql db for post data", err)
|
logger.Fatal(ctx, "initializing sql db for post data", err)
|
||||||
@ -114,12 +69,10 @@ func main() {
|
|||||||
|
|
||||||
httpParams.Logger = logger.WithNamespace("http")
|
httpParams.Logger = logger.WithNamespace("http")
|
||||||
httpParams.Cache = cache
|
httpParams.Cache = cache
|
||||||
httpParams.PowManager = powMgr
|
|
||||||
httpParams.PostStore = postStore
|
httpParams.PostStore = postStore
|
||||||
httpParams.PostAssetStore = postAssetStore
|
httpParams.PostAssetStore = postAssetStore
|
||||||
httpParams.PostAssetLoader = postAssetLoader
|
httpParams.PostAssetLoader = postAssetLoader
|
||||||
httpParams.PostDraftStore = postDraftStore
|
httpParams.PostDraftStore = postDraftStore
|
||||||
httpParams.MailingList = ml
|
|
||||||
httpParams.GeminiPublicURL = gmiParams.PublicURL
|
httpParams.GeminiPublicURL = gmiParams.PublicURL
|
||||||
|
|
||||||
logger.Info(ctx, "starting http api")
|
logger.Info(ctx, "starting http api")
|
||||||
|
@ -4,10 +4,7 @@ go 1.16
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
git.sr.ht/~adnano/go-gemini v0.2.3
|
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/gomarkdown/markdown v0.0.0-20220510115730-2372b9aa33e5
|
||||||
github.com/google/uuid v1.3.0
|
|
||||||
github.com/gorilla/feeds v1.1.1
|
github.com/gorilla/feeds v1.1.1
|
||||||
github.com/hashicorp/golang-lru v0.5.4
|
github.com/hashicorp/golang-lru v0.5.4
|
||||||
github.com/mattn/go-sqlite3 v1.14.8
|
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/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/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/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/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
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/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.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.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/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 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
|
||||||
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||||
|
@ -18,10 +18,8 @@ import (
|
|||||||
"github.com/mediocregopher/blog.mediocregopher.com/srv/cache"
|
"github.com/mediocregopher/blog.mediocregopher.com/srv/cache"
|
||||||
"github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
|
"github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
|
||||||
"github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil"
|
"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"
|
||||||
"github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset"
|
"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/mctx"
|
||||||
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
|
"github.com/mediocregopher/mediocre-go-lib/v2/mlog"
|
||||||
)
|
)
|
||||||
@ -33,7 +31,6 @@ var staticFS embed.FS
|
|||||||
// unless otherwise noted.
|
// unless otherwise noted.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
Logger *mlog.Logger
|
Logger *mlog.Logger
|
||||||
PowManager pow.Manager
|
|
||||||
Cache cache.Cache
|
Cache cache.Cache
|
||||||
|
|
||||||
PostStore post.Store
|
PostStore post.Store
|
||||||
@ -41,8 +38,6 @@ type Params struct {
|
|||||||
PostAssetLoader asset.Loader
|
PostAssetLoader asset.Loader
|
||||||
PostDraftStore post.DraftStore
|
PostDraftStore post.DraftStore
|
||||||
|
|
||||||
MailingList mailinglist.MailingList
|
|
||||||
|
|
||||||
// PublicURL is the base URL which site visitors can navigate to.
|
// PublicURL is the base URL which site visitors can navigate to.
|
||||||
PublicURL *url.URL
|
PublicURL *url.URL
|
||||||
|
|
||||||
@ -176,25 +171,6 @@ func (a *api) Shutdown(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (a *api) blogHandler() http.Handler {
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@ -237,8 +213,6 @@ func (a *api) blogHandler() http.Handler {
|
|||||||
mux.Handle("/static/", http.FileServer(http.FS(staticFS)))
|
mux.Handle("/static/", http.FileServer(http.FS(staticFS)))
|
||||||
mux.Handle("/follow", a.renderDumbTplHandler("follow.html"))
|
mux.Handle("/follow", a.renderDumbTplHandler("follow.html"))
|
||||||
mux.Handle("/admin", a.renderDumbTplHandler("admin.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("/feed.xml", a.renderFeedHandler())
|
||||||
mux.Handle("/", a.renderIndexHandler())
|
mux.Handle("/", a.renderIndexHandler())
|
||||||
|
|
||||||
@ -266,11 +240,6 @@ func (a *api) handler() http.Handler {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.Handle("/api/", applyMiddlewares(
|
|
||||||
http.StripPrefix("/api", a.apiHandler()),
|
|
||||||
logReqMiddleware,
|
|
||||||
))
|
|
||||||
|
|
||||||
mux.Handle("/", a.blogHandler())
|
mux.Handle("/", a.blogHandler())
|
||||||
|
|
||||||
noCacheMiddleware := addResponseHeadersMiddleware(map[string]string{
|
noCacheMiddleware := addResponseHeadersMiddleware(map[string]string{
|
||||||
|
@ -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{}{})
|
|
||||||
})
|
|
||||||
}
|
|
@ -418,7 +418,7 @@ func postFromPostReq(r *http.Request) (post.Post, error) {
|
|||||||
return p, nil
|
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())
|
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
|
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 {
|
if err := a.params.PostDraftStore.Delete(p.ID); err != nil {
|
||||||
return fmt.Errorf("deleting draft: %w", err)
|
return fmt.Errorf("deleting draft: %w", err)
|
||||||
}
|
}
|
||||||
@ -458,9 +451,9 @@ func (a *api) postPostHandler() http.Handler {
|
|||||||
|
|
||||||
ctx = mctx.Annotate(ctx, "postID", p.ID)
|
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(
|
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
|
return
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
@ -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,
|
|
||||||
}
|
|
@ -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" . }}
|
|
@ -1,113 +1,5 @@
|
|||||||
{{ define "body" }}
|
{{ 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>
|
<p>
|
||||||
RSS is the classic way to follow a site's updates, and we're bringing it back!
|
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:
|
Just give any RSS reader the following URL:
|
||||||
|
@ -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" . }}
|
|
@ -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()
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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()
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
321
src/pow/pow.go
321
src/pow/pow.go
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user