diff options
-rw-r--r-- | srv/cmd/mediocre-blog/mailinglist.go | 8 | ||||
-rw-r--r-- | srv/cmd/mediocre-blog/main.go | 37 | ||||
-rw-r--r-- | srv/cmd/mediocre-blog/pow.go | 2 | ||||
-rw-r--r-- | srv/pow/pow.go | 9 | ||||
-rw-r--r-- | static/default.nix | 2 | ||||
-rw-r--r-- | static/src/assets/solvePow.js | 28 | ||||
-rw-r--r-- | static/src/follow.md | 123 |
7 files changed, 191 insertions, 18 deletions
diff --git a/srv/cmd/mediocre-blog/mailinglist.go b/srv/cmd/mediocre-blog/mailinglist.go index 75e5b6d..4a1ddce 100644 --- a/srv/cmd/mediocre-blog/mailinglist.go +++ b/srv/cmd/mediocre-blog/mailinglist.go @@ -16,6 +16,7 @@ func mailingListSubscribeHandler(ml mailinglist.MailingList) http.Handler { parts[1] == "" || len(email) >= 512 { badRequest(rw, r, errors.New("invalid email")) + return } if err := ml.BeginSubscription(email); errors.Is(err, mailinglist.ErrAlreadyVerified) { @@ -23,7 +24,10 @@ func mailingListSubscribeHandler(ml mailinglist.MailingList) http.Handler { // verification email was sent. } else if err != nil { internalServerError(rw, r, err) + return } + + jsonResult(rw, r, struct{}{}) }) } @@ -44,6 +48,8 @@ func mailingListFinalizeHandler(ml mailinglist.MailingList) http.Handler { internalServerError(rw, r, err) return } + + jsonResult(rw, r, struct{}{}) }) } @@ -63,5 +69,7 @@ func mailingListUnsubscribeHandler(ml mailinglist.MailingList) http.Handler { internalServerError(rw, r, err) return } + + jsonResult(rw, r, struct{}{}) }) } diff --git a/srv/cmd/mediocre-blog/main.go b/srv/cmd/mediocre-blog/main.go index 5a00a48..748e10b 100644 --- a/srv/cmd/mediocre-blog/main.go +++ b/srv/cmd/mediocre-blog/main.go @@ -5,6 +5,8 @@ import ( "flag" "fmt" "net/http" + "net/http/httputil" + "net/url" "path" "strconv" "strings" @@ -26,11 +28,13 @@ func main() { logger := mlog.NewLogger(nil) hostname := flag.String("hostname", "localhost:4000", "Hostname to advertise this server as") - staticDir := flag.String("static-dir", "", "Directory from which static files are served") listenAddr := flag.String("listen-addr", ":4000", "Address to listen for HTTP requests on") dataDir := flag.String("data-dir", ".", "Directory to use for long term storage") - powTargetStr := flag.String("pow-target", "0x000FFFF", "Proof-of-work target, lower is more difficult") + staticDir := flag.String("static-dir", "", "Directory from which static files are served (mutually exclusive with -static-proxy-url)") + staticProxyURLStr := flag.String("static-proxy-url", "", "HTTP address from which static files are served (mutually exclusive with -static-dir)") + + powTargetStr := flag.String("pow-target", "0x0000FFFF", "Proof-of-work target, lower is more difficult") powSecret := flag.String("pow-secret", "", "Secret used to sign proof-of-work challenge seeds") smtpAddr := flag.String("ml-smtp-addr", "", "Address of SMTP server to use for sending emails for the mailing list") @@ -41,8 +45,8 @@ func main() { flag.Parse() switch { - case *staticDir == "": - logger.Fatal(context.Background(), "-static-dir is required") + case *staticDir == "" && *staticProxyURLStr == "": + logger.Fatal(context.Background(), "-static-dir or -static-proxy-url is required") case *powSecret == "": logger.Fatal(context.Background(), "-pow-secret is required") case *smtpAddr == "": @@ -51,6 +55,14 @@ func main() { logger.Fatal(context.Background(), "-ml-smtp-auth is required") } + var staticProxyURL *url.URL + if *staticProxyURLStr != "" { + var err error + if staticProxyURL, err = url.Parse(*staticProxyURLStr); err != nil { + loggerFatalErr(context.Background(), logger, "parsing -static-proxy-url", err) + } + } + powTargetUint, err := strconv.ParseUint(*powTargetStr, 0, 32) if err != nil { loggerFatalErr(context.Background(), logger, "parsing -pow-target", err) @@ -68,7 +80,6 @@ func main() { ctx := mctx.Annotate(context.Background(), "hostname", *hostname, - "staticDir", *staticDir, "listenAddr", *listenAddr, "dataDir", *dataDir, "powTarget", fmt.Sprintf("%x", powTarget), @@ -76,6 +87,12 @@ func main() { "smtpSendAs", smtpSendAs, ) + if *staticDir != "" { + ctx = mctx.Annotate(ctx, "staticDir", *staticDir) + } else { + ctx = mctx.Annotate(ctx, "staticProxyURL", *staticProxyURLStr) + } + clock := clock.Realtime() powStore := pow.NewMemoryStore(clock) @@ -112,7 +129,15 @@ func main() { }) mux := http.NewServeMux() - mux.Handle("/", http.FileServer(http.Dir(*staticDir))) + + var staticHandler http.Handler + if *staticDir != "" { + staticHandler = http.FileServer(http.Dir(*staticDir)) + } else { + staticHandler = httputil.NewSingleHostReverseProxy(staticProxyURL) + } + + mux.Handle("/", staticHandler) apiMux := http.NewServeMux() apiMux.Handle("/pow/challenge", newPowChallengeHandler(powMgr)) diff --git a/srv/cmd/mediocre-blog/pow.go b/srv/cmd/mediocre-blog/pow.go index 8e64739..a505a64 100644 --- a/srv/cmd/mediocre-blog/pow.go +++ b/srv/cmd/mediocre-blog/pow.go @@ -11,6 +11,7 @@ import ( func newPowChallengeHandler(mgr pow.Manager) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + challenge := mgr.NewChallenge() jsonResult(rw, r, struct { @@ -25,6 +26,7 @@ func newPowChallengeHandler(mgr pow.Manager) http.Handler { func requirePowMiddleware(mgr pow.Manager, h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + seedHex := r.PostFormValue("powSeed") seed, err := hex.DecodeString(seedHex) if err != nil || len(seed) == 0 { diff --git a/srv/pow/pow.go b/srv/pow/pow.go index 3de1450..8075103 100644 --- a/srv/pow/pow.go +++ b/srv/pow/pow.go @@ -141,7 +141,7 @@ type Challenge struct { // Errors which may be produced by a Manager. var ( ErrInvalidSolution = errors.New("invalid solution") - ErrExpiredSolution = errors.New("expired solution") + ErrExpiredSeed = errors.New("expired seed") ) // Manager is used to both produce proof-of-work challenges and check their @@ -150,7 +150,7 @@ type Manager interface { NewChallenge() Challenge // Will produce ErrInvalidSolution if the solution is invalid, or - // ErrExpiredSolution if the solution has expired. + // ErrExpiredSeed if the seed has expired. CheckSolution(seed, solution []byte) error } @@ -193,6 +193,7 @@ type manager struct { // NewManager initializes and returns a Manager instance using the given // parameters. func NewManager(params ManagerParams) Manager { + params = params.withDefaults() return &manager{ params: params, } @@ -252,8 +253,8 @@ func (m *manager) CheckSolution(seed, solution []byte) error { if err != nil { return fmt.Errorf("parsing challenge parameters from seed: %w", err) - } else if c.ExpiresAt <= m.params.Clock.Now().Unix() { - return ErrExpiredSolution + } else if now := m.params.Clock.Now().Unix(); c.ExpiresAt <= now { + return ErrExpiredSeed } ok := (SolutionChecker{}).Check( diff --git a/static/default.nix b/static/default.nix index 1a146b0..74bd3bf 100644 --- a/static/default.nix +++ b/static/default.nix @@ -37,7 +37,7 @@ in name = "mediocre-blog-static-dev"; buildInputs = all_inputs; shellHook = '' - exec ${jekyll_env}/bin/jekyll serve -s ./src -d ./_site -w -I -D -H 0.0.0.0 + exec ${jekyll_env}/bin/jekyll serve -s ./src -d ./_site -w -I -D -H 0.0.0.0 -P 4001 ''; }; diff --git a/static/src/assets/solvePow.js b/static/src/assets/solvePow.js new file mode 100644 index 0000000..900400c --- /dev/null +++ b/static/src/assets/solvePow.js @@ -0,0 +1,28 @@ +const fromHexString = hexString => + new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); + +const toHexString = bytes => + bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), ''); + +onmessage = async (e) => { + const seed = fromHexString(e.data.seedHex); + const target = e.data.target; + + const fullBuf = new ArrayBuffer(seed.byteLength*2); + + const fullBufSeed = new Uint8Array(fullBuf, 0, seed.byteLength); + seed.forEach((v, i) => fullBufSeed[i] = v); + + const randBuf = new Uint8Array(fullBuf, seed.byteLength); + + while (true) { + crypto.getRandomValues(randBuf); + const digest = await crypto.subtle.digest('SHA-512', fullBuf); + const digestView = new DataView(digest); + if (digestView.getUint32(0) < target) { + postMessage(toHexString(randBuf)); + return; + } + } + +}; diff --git a/static/src/follow.md b/static/src/follow.md index 8093267..4e949dd 100644 --- a/static/src/follow.md +++ b/static/src/follow.md @@ -6,7 +6,121 @@ nofollow: true Here's your options for receiving updates about new blog posts: -## Option 1: RSS +## Option 1: Email + +Email is by far my preferred option for notifying followers of new posts. + +The entire email list system for this blog, from storing subscriber email +addresses to the email server which sends the notifications out, has been +designed from scratch and is completely self-hosted in my living room. + +I solemnly swear that: + +* You will never receive an email from this blog except to notify of a new post. + +* Your email will never be provided or sold to anyone else for any reason. + +With all that said, if you'd like to receive an email everytime a new blog post +is published then input your email below and smash that subscribe button! + +<style> + +#emailStatus.success { + color: green; +} + +#emailStatus.fail { + color: red; +} + +</style> + +<input type="email" placeholder="name@host.com" id="emailAddress" /> +<input class="button-primary" type="submit" value="Subscribe" id="emailSubscribe" /> +<span id="emailStatus"></span> + +<script> +const emailAddress = document.getElementById("emailAddress"); +const emailSubscribe = document.getElementById("emailSubscribe"); +const emailSubscribeOrigValue = emailSubscribe.value; +const emailStatus = document.getElementById("emailStatus"); + +const solvePow = async (seedHex, target) => { + + const worker = new Worker('/assets/solvePow.js'); + + const p = new Promise((resolve, reject) => { + worker.postMessage({seedHex, target}); + worker.onmessage = resolve; + }); + + const solutionHex = (await p).data; + worker.terminate(); + + return solutionHex; +} + +emailSubscribe.onclick = async () => { + + emailSubscribe.disabled = true; + emailSubscribe.className = ""; + emailSubscribe.value = "Please hold..."; + + await (async () => { + + setErr = (errStr, retry) => { + emailStatus.className = "fail"; + emailStatus.innerHTML = errStr + if (retry) emailStatus.innerHTML += " (please try again)"; + }; + + if (!window.isSecureContext) { + setErr("The browser environment is not secure.", false); + return; + } + + const getPowReq = new Request('/api/pow/challenge'); + const res = await fetch(getPowReq) + .then(response => response.json()) + + if (res.error) { + setErr(res.error, true); + return; + } + + const powSol = await solvePow(res.seed, res.target); + + const subscribeForm = new FormData(); + subscribeForm.append('powSeed', res.seed); + subscribeForm.append('powSolution', powSol); + subscribeForm.append('email', emailAddress.value); + + const subscribeReq = new Request('/api/mailinglist/subscribe', { + method: 'POST', + body: subscribeForm, + }); + + const subRes = await fetch(subscribeReq) + .then(response => response.json()); + + if (subRes.error) { + setErr(subRes.error, true); + return; + } + + emailStatus.className = "success"; + emailStatus.innerHTML = "Verification email sent (check your spam folder)"; + + })(); + + emailSubscribe.disabled = false; + emailSubscribe.className = "button-primary"; + emailSubscribe.value = emailSubscribeOrigValue; +}; + +</script> + +## Option 2: RSS RSS is the classic way to follow any blog. It comes from a time before aggregators like reddit and twitter stole the show, when people felt capable to @@ -31,14 +145,9 @@ recommendations: iPhone/iPad/Mac devices, so I'm told. Their homepage description makes a much better sales pitch for RSS than I ever could. -## Option 2: Twitter +## Option 3: Twitter New posts are automatically published to [my Twitter](https://twitter.com/{{ site.twitter_username }}). Simply follow me there and pray the algorithm smiles upon my tweets enough to show them to you! :pray: :pray: :pray: -## Option 3: Email? - -I tried setting up an RSS-to-Email list thing on Mailchimp but it doesn't seem -to like my RSS feed. If anyone knows a better alternative please [email -me.](mailto:mediocregopher@gmail.com) |