Remove mailinglist and proof-of-work functionality

main
Brian Picciano 9 months ago
parent c4ec906406
commit 78bbfa42fa
  1. 2
      flake.nix
  2. 118
      src/cmd/mailinglist-cli/main.go
  3. 47
      src/cmd/mediocre-blog/main.go
  4. 3
      src/go.mod
  5. 5
      src/go.sum
  6. 35
      src/http/http.go
  7. 92
      src/http/mailinglist.go
  8. 13
      src/http/posts.go
  9. 53
      src/http/pow.go
  10. 118
      src/http/static/api.js
  11. 28
      src/http/static/solvePow.js
  12. 12
      src/http/static/utils.js
  13. 45
      src/http/tpl/finalize.html
  14. 108
      src/http/tpl/follow.html
  15. 44
      src/http/tpl/unsubscribe.html
  16. 143
      src/mailinglist/mailer.go
  17. 273
      src/mailinglist/mailinglist.go
  18. 245
      src/mailinglist/store.go
  19. 95
      src/mailinglist/store_test.go
  20. 321
      src/pow/pow.go
  21. 120
      src/pow/pow_test.go
  22. 92
      src/pow/store.go
  23. 52
      src/pow/store_test.go

@ -17,7 +17,7 @@
version = "dev";
src = ./src;
vendorSha256 = "sha256-utJLLquF5X/sYiT3OJhs9UmbCWebqyaaa/TabW3i47w=";
vendorSha256 = "sha256-dK7SDo9wvWKs6S8nnKFbTLvqipTFXVuV6rYTwgdbLBQ=";
subPackages = [ "cmd/mediocre-blog" ];

@ -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"
"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=

@ -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{

@ -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
}
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
}

@ -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" }}
<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:

@ -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)