parent
c4ec906406
commit
78bbfa42fa
@ -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") |
||||
} |
||||
} |
@ -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{}{}) |
||||
}) |
||||
} |
@ -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,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) |