summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--srv/cmd/mediocre-blog/mailinglist.go8
-rw-r--r--srv/cmd/mediocre-blog/main.go37
-rw-r--r--srv/cmd/mediocre-blog/pow.go2
-rw-r--r--srv/pow/pow.go9
-rw-r--r--static/default.nix2
-rw-r--r--static/src/assets/solvePow.js28
-rw-r--r--static/src/follow.md123
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)