commit b58a2fe46e6a33043e3779c0e57f14d874edc9b5 Spenser Truex <truex@equwal.com> 2026-09-22 23:28:15 -0700 Add accounts: email on new posts and a private feed Add accounts/, a small Go service for the paths under /account/. The web server continues to serve the static site. - Sign-in with an email link. The link works one time and expires in 15 minutes. A GET request shows a button, so mail scanners cannot use the link. - Settings: email on or off, and the tags to receive. - A private RSS feed for each user, for feed reader apps on Android and iOS. The user can replace the URL. The old URL then stops working. - One email for each check that finds new posts. A row in deliveries is written before the send, so no user gets a post two times. - Unsubscribe with a link in each email and with RFC 8058 one-click. - Account deletion. build.sh now writes postdata/index.tsv and postdata/<slug>.html for the service, and the nav links to account/. Secrets come only from environment variables. See accounts/.env.example.
.gitignore | 6 + accounts/.env.example | 27 +++ accounts/deploy/nginx-location.conf | 9 + accounts/deploy/rw-accounts.service | 24 +++ accounts/feed.go | 94 +++++++++++ accounts/feed_test.go | 162 ++++++++++++++++++ accounts/go.mod | 20 +++ accounts/go.sum | 52 ++++++ accounts/handlers.go | 324 ++++++++++++++++++++++++++++++++++++ accounts/handlers_test.go | 245 +++++++++++++++++++++++++++ accounts/helpers_test.go | 116 +++++++++++++ accounts/mail.go | 100 +++++++++++ accounts/mail_test.go | 98 +++++++++++ accounts/main.go | 132 +++++++++++++++ accounts/notify.go | 165 ++++++++++++++++++ accounts/notify_test.go | 225 +++++++++++++++++++++++++ accounts/posts.go | 104 ++++++++++++ accounts/store.go | 239 ++++++++++++++++++++++++++ accounts/store_test.go | 110 ++++++++++++ accounts/templates.html | 110 ++++++++++++ build.sh | 14 ++ index.html | 1 + 22 files changed, 2377 insertions(+)
diff --git a/.gitignore b/.gitignore index e84915c..53991d8 100755 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ # the working tree, so they have to be kept on disk (and backed up) by hand. /static/book/ /site/static/book/ + +# Build output and local settings of the accounts service. +/accounts/accounts +/accounts/accounts.exe +/accounts/.env +/accounts/*.db* diff --git a/accounts/.env.example b/accounts/.env.example new file mode 100644 index 0000000..2e0e973 --- /dev/null +++ b/accounts/.env.example @@ -0,0 +1,27 @@ +# Settings for the accounts service. Copy this file to +# /etc/rw-accounts.env on the server, fill in the values, and make it +# readable only by root (chmod 600). Do not commit the filled-in copy. + +# The public URL of the site, with no final slash. +RW_BASE_URL=https://recentlywritten.com +# The web root that build.sh deploys. The service reads postdata/ in it. +RW_SITE_DIR=/var/www/recentlywritten +# The SQLite database file. The directory must be writable by the service. +RW_DB_PATH=/var/lib/rw-accounts/rw.db +# The address and port that the service listens on. Optional. +RW_LISTEN=127.0.0.1:8081 +# The time between two checks for new posts. Optional. Default: 15m. +RW_NOTIFY_INTERVAL=15m + +# The sender address. Its domain needs SPF and DKIM records for the +# SMTP provider. +RW_MAIL_FROM=Recently Written <posts@recentlywritten.com> +# Any SMTP server with STARTTLS: a local MTA, or the SMTP endpoint of a +# provider such as Resend (smtp.resend.com), Postmark +# (smtp.postmarkapp.com), or Amazon SES. +SMTP_HOST= +# Optional. Default: 587. +SMTP_PORT=587 +# Leave empty for a server that needs no sign-in (for example a local MTA). +SMTP_USERNAME= +SMTP_PASSWORD= diff --git a/accounts/deploy/nginx-location.conf b/accounts/deploy/nginx-location.conf new file mode 100644 index 0000000..f8b6b1d --- /dev/null +++ b/accounts/deploy/nginx-location.conf @@ -0,0 +1,9 @@ +# Example for nginx. Add this block to the server block of recentlywritten.com. nginx +# continues to serve the static site. It sends only /account/ to the +# accounts service. +location /account/ { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} diff --git a/accounts/deploy/rw-accounts.service b/accounts/deploy/rw-accounts.service new file mode 100644 index 0000000..516049e --- /dev/null +++ b/accounts/deploy/rw-accounts.service @@ -0,0 +1,24 @@ +# systemd unit for the accounts service. +# Install: +# GOOS=linux GOARCH=amd64 go build -o accounts . (in accounts/) +# copy the binary to /usr/local/bin/rw-accounts +# copy this file to /etc/systemd/system/rw-accounts.service +# systemctl daemon-reload && systemctl enable --now rw-accounts +[Unit] +Description=Recently Written accounts, email and private feeds +After=network-online.target +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/rw-accounts.env +ExecStart=/usr/local/bin/rw-accounts +DynamicUser=yes +StateDirectory=rw-accounts +ReadOnlyPaths=/var/www/recentlywritten +ProtectSystem=strict +ProtectHome=yes +NoNewPrivileges=yes +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/accounts/feed.go b/accounts/feed.go new file mode 100644 index 0000000..e3a4a45 --- /dev/null +++ b/accounts/feed.go @@ -0,0 +1,94 @@ +package main + +import ( + "encoding/xml" + "io" + "time" +) + +// The rss types give the RSS 2.0 shape. encoding/xml does the escaping. +type rss struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel channel `xml:"channel"` +} + +type channel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Language string `xml:"language"` + Items []item `xml:"item"` +} + +type item struct { + Title string `xml:"title"` + Link string `xml:"link"` + GUID guid `xml:"guid"` + PubDate string `xml:"pubDate,omitempty"` + Categories []string `xml:"category"` + Description string `xml:"description"` +} + +type guid struct { + IsPermaLink string `xml:"isPermaLink,attr"` + Value string `xml:",chardata"` +} + +// feedSize is the number of items in a private feed. +const feedSize = 20 + +// buildFeed makes the private feed for a user who muted the tags in muted. +// body returns the HTML body of a post. +func buildFeed(baseURL string, posts []Post, muted map[string]bool, body func(slug string) string) channel { + ch := channel{ + Title: "Recently Written", + Link: baseURL + "/", + Description: "A site full of things which I have recently written.", + Language: "en-us", + } + for _, p := range posts { + if len(ch.Items) == feedSize { + break + } + if !wanted(p, muted) { + continue + } + url := baseURL + "/" + p.Slug + ".html" + ch.Items = append(ch.Items, item{ + Title: p.Title, + Link: url, + GUID: guid{IsPermaLink: "true", Value: url}, + PubDate: feedDate(p.Date), + Categories: p.Tags, + Description: body(p.Slug), + }) + } + return ch +} + +// feedDate gives the RFC 822 date that RSS requires, in the format that +// build.sh uses. A date that does not parse gives an empty string, and the +// item then has no pubDate. +func feedDate(d string) string { + t, err := time.Parse("2006-01-02", d) + if err != nil { + return "" + } + return t.Format(time.RFC1123Z) +} + +// writeFeed writes ch as an RSS 2.0 document. encoding/xml escapes the +// text and replaces each character that XML 1.0 does not permit. +func writeFeed(w io.Writer, ch channel) error { + if _, err := io.WriteString(w, xml.Header); err != nil { + return err + } + enc := xml.NewEncoder(w) + enc.Indent("", " ") + if err := enc.Encode(rss{Version: "2.0", Channel: ch}); err != nil { + return err + } + _, err := io.WriteString(w, "\n") + return err +} diff --git a/accounts/feed_test.go b/accounts/feed_test.go new file mode 100644 index 0000000..9098372 --- /dev/null +++ b/accounts/feed_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "bytes" + "encoding/xml" + "errors" + "io" + "slices" + "strings" + "testing" + + "pgregory.net/rapid" +) + +// wellFormed reads each token of doc with a strict XML parser. +func wellFormed(doc []byte) error { + d := xml.NewDecoder(bytes.NewReader(doc)) + for { + _, err := d.Token() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + } +} + +// anyText draws any string, also strings that are not valid UTF-8 and +// strings with characters that XML 1.0 does not permit. +func anyText() *rapid.Generator[string] { + return rapid.OneOf( + rapid.String(), + rapid.Map(rapid.SliceOf(rapid.Byte()), func(b []byte) string { return string(b) }), + rapid.SampledFrom([]string{"]]>", "<![CDATA[", "&", "</item>", "\x00", "\r\n", "\uFFFE"}), + ) +} + +// xmlText draws strings of characters that XML 1.0 permits. +func xmlText() *rapid.Generator[string] { + return rapid.StringOf(rapid.Rune().Filter(func(r rune) bool { + return r == '\t' || r == '\n' || r == '\r' || + r >= 0x20 && r <= 0xD7FF || r >= 0xE000 && r <= 0xFFFD || r >= 0x10000 && r <= 0x10FFFF + })) +} + +func drawChannel(t *rapid.T, text *rapid.Generator[string]) channel { + ch := channel{Title: text.Draw(t, "title"), Link: text.Draw(t, "link"), + Description: text.Draw(t, "desc"), Language: "en-us"} + for range rapid.IntRange(0, 3).Draw(t, "items") { + ch.Items = append(ch.Items, item{ + Title: text.Draw(t, "item title"), + Link: text.Draw(t, "item link"), + GUID: guid{IsPermaLink: "true", Value: text.Draw(t, "guid")}, + PubDate: text.Draw(t, "date"), + Categories: rapid.SliceOfN(text, 0, 2).Draw(t, "tags"), + Description: text.Draw(t, "body"), + }) + } + return ch +} + +func TestFeedIsWellFormedProperty(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + var b bytes.Buffer + if err := writeFeed(&b, drawChannel(t, anyText())); err != nil { + t.Fatal(err) + } + if err := wellFormed(b.Bytes()); err != nil { + t.Fatalf("feed is not well-formed: %v\n%s", err, b.String()) + } + }) +} + +// TestFeedRoundTripProperty checks the escaping: an XML parser gives back +// each text exactly. +func TestFeedRoundTripProperty(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + want := drawChannel(t, xmlText()) + var b bytes.Buffer + if err := writeFeed(&b, want); err != nil { + t.Fatal(err) + } + var got rss + if err := xml.Unmarshal(b.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, b.String()) + } + g := got.Channel + if g.Title != want.Title || g.Link != want.Link || g.Description != want.Description || + len(g.Items) != len(want.Items) { + t.Fatalf("channel: got %+v, want %+v", g, want) + } + for i, w := range want.Items { + gi := g.Items[i] + if gi.Title != w.Title || gi.Link != w.Link || gi.GUID != w.GUID || + gi.PubDate != w.PubDate || gi.Description != w.Description || + !slices.Equal(gi.Categories, w.Categories) { + t.Fatalf("item %d: got %+v, want %+v", i, gi, w) + } + } + }) +} + +func TestBuildFeedFiltersAndLimits(t *testing.T) { + var posts []Post + for i := range 30 { + tag := "articles" + if i%2 == 1 { + tag = "projects" + } + posts = append(posts, Post{Date: "2026-09-01", Slug: "p" + string(rune('a'+i)), Tags: []string{tag}, Title: "T"}) + } + posts = append(posts, Post{Date: "bad", Slug: "untagged", Title: "U"}) + + ch := buildFeed("https://example.com", posts, map[string]bool{"projects": true}, func(string) string { return "" }) + if len(ch.Items) != 16 { // 15 articles and the untagged post + t.Fatalf("got %d items, want 16", len(ch.Items)) + } + for _, it := range ch.Items { + if slices.Contains(it.Categories, "projects") { + t.Errorf("muted tag in feed: %+v", it) + } + } + if got := ch.Items[0].PubDate; got != "Tue, 01 Sep 2026 00:00:00 +0000" { + t.Errorf("pubDate = %q", got) + } + if got := ch.Items[15].PubDate; got != "" { + t.Errorf("pubDate for a bad date = %q, want empty", got) + } + + ch = buildFeed("https://example.com", posts, nil, func(string) string { return "" }) + if len(ch.Items) != feedSize { + t.Fatalf("got %d items, want %d", len(ch.Items), feedSize) + } +} + +func TestParseIndex(t *testing.T) { + in := "2026-09-22\trebind\tprojects\tRebind: a tool\r\n\n2025-01-01\tx\tlisp, unix\tA \"b\" & c\n" + posts, err := parseIndex(strings.NewReader(in)) + if err != nil { + t.Fatal(err) + } + want := []Post{ + {Date: "2026-09-22", Slug: "rebind", Tags: []string{"projects"}, Title: "Rebind: a tool"}, + {Date: "2025-01-01", Slug: "x", Tags: []string{"lisp", "unix"}, Title: `A "b" & c`}, + } + if len(posts) != len(want) { + t.Fatalf("got %+v", posts) + } + for i := range want { + if posts[i].Slug != want[i].Slug || posts[i].Title != want[i].Title || + posts[i].Date != want[i].Date || !slices.Equal(posts[i].Tags, want[i].Tags) { + t.Errorf("post %d = %+v, want %+v", i, posts[i], want[i]) + } + } + + for _, bad := range []string{"a\t../etc/passwd\tx\tT\n", "a\t.hidden\tx\tT\n", "only\tthree\tfields\n"} { + if _, err := parseIndex(strings.NewReader(bad)); err == nil { + t.Errorf("parseIndex(%q) gave no error", bad) + } + } +} diff --git a/accounts/go.mod b/accounts/go.mod new file mode 100644 index 0000000..688ddca --- /dev/null +++ b/accounts/go.mod @@ -0,0 +1,20 @@ +module github.com/equwal/recentlywritten/accounts + +go 1.26.2 + +require ( + modernc.org/sqlite v1.59.0 + pgregory.net/rapid v1.3.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/accounts/go.sum b/accounts/go.sum new file mode 100644 index 0000000..50c775d --- /dev/null +++ b/accounts/go.sum @@ -0,0 +1,52 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc= +pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk= diff --git a/accounts/handlers.go b/accounts/handlers.go new file mode 100644 index 0000000..b7b826b --- /dev/null +++ b/accounts/handlers.go @@ -0,0 +1,324 @@ +package main + +import ( + "bytes" + "database/sql" + _ "embed" + "errors" + "html/template" + "log" + "net/http" + "net/mail" + "strings" + "time" +) + +//go:embed templates.html +var templateText string + +var pages = template.Must(template.New("").Parse(templateText)) + +type app struct { + cfg config + db *sql.DB + mail mailer + now func() time.Time + log *log.Logger +} + +const sessionCookie = "rw_session" + +// routes gives the handler for all paths under /account/. The web server +// in front sends only these paths to this service. +// +// Each change of state uses POST. A GET request from a link in an email +// only shows a page with a button, because mail scanners open the links +// in email and must not use a sign-in link or unsubscribe a user. The +// session cookie is SameSite=Lax, so a form on another site cannot send a +// POST request with the cookie. +func (a *app) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /account/{$}", a.home) + mux.HandleFunc("POST /account/signin", a.signin) + mux.HandleFunc("GET /account/verify", a.verifyPage) + mux.HandleFunc("POST /account/verify", a.verify) + mux.HandleFunc("POST /account/settings", a.withUser(a.settings)) + mux.HandleFunc("POST /account/feed/rotate", a.withUser(a.rotateFeed)) + mux.HandleFunc("POST /account/signout", a.signout) + mux.HandleFunc("POST /account/delete", a.withUser(a.deleteAccount)) + mux.HandleFunc("GET /account/unsubscribe", a.unsubscribePage) + mux.HandleFunc("POST /account/unsubscribe", a.unsubscribe) + mux.HandleFunc("GET /account/feed/{token}", a.feed) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The URLs of some pages contain tokens. These headers keep the + // tokens out of caches and out of the Referer header. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + mux.ServeHTTP(w, r) + }) +} + +func (a *app) render(w http.ResponseWriter, status int, name string, data any) { + var b bytes.Buffer + if err := pages.ExecuteTemplate(&b, name, data); err != nil { + a.fail(w, err) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + b.WriteTo(w) +} + +func (a *app) fail(w http.ResponseWriter, err error) { + a.log.Printf("error: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) +} + +func (a *app) currentUser(r *http.Request) (user, error) { + c, err := r.Cookie(sessionCookie) + if err != nil { + return user{}, errNotFound + } + return sessionUser(a.db, c.Value, a.now()) +} + +func (a *app) withUser(h func(http.ResponseWriter, *http.Request, user)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u, err := a.currentUser(r) + if errors.Is(err, errNotFound) { + http.Redirect(w, r, "/account/", http.StatusSeeOther) + return + } + if err != nil { + a.fail(w, err) + return + } + h(w, r, u) + } +} + +func (a *app) setSessionCookie(w http.ResponseWriter, value string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: value, + Path: "/account/", + MaxAge: maxAge, + HttpOnly: true, + Secure: strings.HasPrefix(a.cfg.BaseURL, "https://"), + SameSite: http.SameSiteLaxMode, + }) +} + +type tagChoice struct { + Name string + On bool +} + +func (a *app) home(w http.ResponseWriter, r *http.Request) { + u, err := a.currentUser(r) + if errors.Is(err, errNotFound) { + a.render(w, http.StatusOK, "signin", map[string]string{}) + return + } + if err != nil { + a.fail(w, err) + return + } + muted, err := mutedTags(a.db, u.ID) + if err != nil { + a.fail(w, err) + return + } + posts, err := loadPosts(a.cfg.SiteDir) + if err != nil { + a.fail(w, err) + return + } + var tags []tagChoice + for _, t := range allTags(posts) { + tags = append(tags, tagChoice{Name: t, On: !muted[t]}) + } + a.render(w, http.StatusOK, "settings", map[string]any{ + "Email": u.Email, + "EmailOn": u.EmailOn, + "Tags": tags, + "FeedURL": a.cfg.BaseURL + "/account/feed/" + u.FeedToken, + }) +} + +// normalizeEmail returns the address in lower case, or an error if s is +// not one plain address such as "name@example.com". +func normalizeEmail(s string) (string, error) { + s = strings.TrimSpace(s) + addr, err := mail.ParseAddress(s) + if err != nil || addr.Name != "" || addr.Address != s || len(s) > 254 { + return "", errors.New("not a valid address") + } + return strings.ToLower(s), nil +} + +func (a *app) signin(w http.ResponseWriter, r *http.Request) { + email, err := normalizeEmail(r.PostFormValue("email")) + if err != nil { + a.render(w, http.StatusBadRequest, "signin", map[string]string{"Error": "Type a valid email address."}) + return + } + token, err := createLoginToken(a.db, email, a.now()) + if err != nil { + a.fail(w, err) + return + } + // The page is the same when createLoginToken sends nothing, so the + // page does not tell which addresses have an account. + if token != "" { + link := a.cfg.BaseURL + "/account/verify?t=" + token + err = a.mail.Send(message{ + To: email, + Subject: "Sign in to Recently Written", + Body: "Open this link to sign in to Recently Written:\n\n" + link + + "\n\nThe link works one time and expires in 15 minutes.\n" + + "If you did not ask for this email, ignore it.\n", + }) + if err != nil { + a.fail(w, err) + return + } + } + a.render(w, http.StatusOK, "sent", nil) +} + +func (a *app) verifyPage(w http.ResponseWriter, r *http.Request) { + a.render(w, http.StatusOK, "verify", r.URL.Query().Get("t")) +} + +func (a *app) verify(w http.ResponseWriter, r *http.Request) { + u, err := consumeLoginToken(a.db, r.PostFormValue("t"), a.now()) + if errors.Is(err, errNotFound) { + a.render(w, http.StatusBadRequest, "badlink", nil) + return + } + if err != nil { + a.fail(w, err) + return + } + token, err := createSession(a.db, u.ID, a.now()) + if err != nil { + a.fail(w, err) + return + } + a.setSessionCookie(w, token, int(sessionTTL.Seconds())) + http.Redirect(w, r, "/account/", http.StatusSeeOther) +} + +func (a *app) settings(w http.ResponseWriter, r *http.Request, u user) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + posts, err := loadPosts(a.cfg.SiteDir) + if err != nil { + a.fail(w, err) + return + } + checked := map[string]bool{} + for _, t := range r.PostForm["tag"] { + checked[t] = true + } + // A tag is muted if the form shows it and the user cleared its box. + var muted []string + for _, t := range allTags(posts) { + if !checked[t] { + muted = append(muted, t) + } + } + if err := saveSettings(a.db, u.ID, r.PostForm.Get("email_on") == "1", muted); err != nil { + a.fail(w, err) + return + } + http.Redirect(w, r, "/account/", http.StatusSeeOther) +} + +func (a *app) rotateFeed(w http.ResponseWriter, r *http.Request, u user) { + if err := rotateFeedToken(a.db, u.ID); err != nil { + a.fail(w, err) + return + } + http.Redirect(w, r, "/account/", http.StatusSeeOther) +} + +func (a *app) signout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + if err := deleteSession(a.db, c.Value); err != nil { + a.fail(w, err) + return + } + } + a.setSessionCookie(w, "", -1) + http.Redirect(w, r, "/account/", http.StatusSeeOther) +} + +func (a *app) deleteAccount(w http.ResponseWriter, r *http.Request, u user) { + if err := deleteUser(a.db, u.ID); err != nil { + a.fail(w, err) + return + } + a.setSessionCookie(w, "", -1) + a.render(w, http.StatusOK, "deleted", nil) +} + +func (a *app) unsubscribePage(w http.ResponseWriter, r *http.Request) { + a.render(w, http.StatusOK, "unsubscribe", r.URL.Query().Get("t")) +} + +// unsubscribe takes the token from the form, or from the URL. A mail +// client that does RFC 8058 one-click unsubscribe sends a POST request to +// the URL in the List-Unsubscribe header, with the token in the URL. +func (a *app) unsubscribe(w http.ResponseWriter, r *http.Request) { + token := r.PostFormValue("t") + if token == "" { + token = r.URL.Query().Get("t") + } + err := unsubscribe(a.db, token) + if errors.Is(err, errNotFound) { + a.render(w, http.StatusBadRequest, "badlink", nil) + return + } + if err != nil { + a.fail(w, err) + return + } + a.render(w, http.StatusOK, "unsubscribed", nil) +} + +func (a *app) feed(w http.ResponseWriter, r *http.Request) { + u, err := userByFeedToken(a.db, r.PathValue("token")) + if errors.Is(err, errNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + a.fail(w, err) + return + } + muted, err := mutedTags(a.db, u.ID) + if err != nil { + a.fail(w, err) + return + } + posts, err := loadPosts(a.cfg.SiteDir) + if err != nil { + a.fail(w, err) + return + } + ch := buildFeed(a.cfg.BaseURL, posts, muted, func(slug string) string { + return loadBody(a.cfg.SiteDir, slug) + }) + var b bytes.Buffer + if err := writeFeed(&b, ch); err != nil { + a.fail(w, err) + return + } + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + b.WriteTo(w) +} diff --git a/accounts/handlers_test.go b/accounts/handlers_test.go new file mode 100644 index 0000000..04ba7d7 --- /dev/null +++ b/accounts/handlers_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "errors" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" +) + +type browser struct { + t *testing.T + client *http.Client + base string +} + +// newBrowser starts the app on a test server and returns a client with a +// cookie jar. +func newBrowser(t *testing.T, e *testEnv) *browser { + srv := httptest.NewServer(e.app.routes()) + t.Cleanup(srv.Close) + e.app.cfg.BaseURL = srv.URL + jar, _ := cookiejar.New(nil) + return &browser{t: t, client: &http.Client{Jar: jar}, base: srv.URL} +} + +func (b *browser) do(method, path string, form url.Values) (int, string) { + b.t.Helper() + var body io.Reader + if form != nil { + body = strings.NewReader(form.Encode()) + } + req, err := http.NewRequest(method, b.base+path, body) + if err != nil { + b.t.Fatal(err) + } + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + resp, err := b.client.Do(req) + if err != nil { + b.t.Fatal(err) + } + defer resp.Body.Close() + text, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(text) +} + +var linkToken = regexp.MustCompile(`/account/verify\?t=([A-Z2-7]+)`) + +// signIn does the sign-in steps of a person in a browser and returns the +// settings page. +func (b *browser) signIn(e *testEnv, email string) string { + b.t.Helper() + n := len(e.mail.sent) + if code, page := b.do("POST", "/account/signin", url.Values{"email": {email}}); code != 200 || !strings.Contains(page, "Check your email") { + b.t.Fatalf("signin: %d\n%s", code, page) + } + if len(e.mail.sent) != n+1 { + b.t.Fatalf("sent %d sign-in messages, want 1", len(e.mail.sent)-n) + } + m := linkToken.FindStringSubmatch(e.mail.sent[n].Body) + if m == nil { + b.t.Fatalf("no link in:\n%s", e.mail.sent[n].Body) + } + // A GET request, as from a mail scanner, must not use the token. + if code, page := b.do("GET", "/account/verify?t="+m[1], nil); code != 200 || !strings.Contains(page, `value="`+m[1]+`"`) { + b.t.Fatalf("verify page: %d\n%s", code, page) + } + code, page := b.do("POST", "/account/verify", url.Values{"t": {m[1]}}) + if code != 200 || !strings.Contains(page, "Signed in as "+strings.ToLower(email)) { + b.t.Fatalf("verify: %d\n%s", code, page) + } + return page +} + +var feedURL = regexp.MustCompile(`/account/feed/[A-Z2-7]+`) + +func TestSignUpAndPrivateFeed(t *testing.T) { + e := newTestEnv(t, oldPosts) + b := newBrowser(t, e) + + if code, page := b.do("GET", "/account/", nil); code != 200 || !strings.Contains(page, `action="/account/signin"`) { + t.Fatalf("signed-out page: %d\n%s", code, page) + } + page := b.signIn(e, "Reader@Example.com") + if e.mail.sent[0].To != "reader@example.com" { + t.Errorf("sign-in mail went to %q", e.mail.sent[0].To) + } + + feed := feedURL.FindString(page) + if feed == "" { + t.Fatalf("no feed URL on the settings page:\n%s", page) + } + // The feed needs no cookie, because a feed reader app has none. + anon := &browser{t: t, client: http.DefaultClient, base: b.base} + code, doc := anon.do("GET", feed, nil) + if code != 200 || wellFormed([]byte(doc)) != nil { + t.Fatalf("feed: %d\n%s", code, doc) + } + for _, want := range []string{"<title>Old article</title>", "old-project.html", "Body of old-article &amp; more."} { + if !strings.Contains(doc, want) { + t.Errorf("feed has no %q:\n%s", want, doc) + } + } + + // Mute "projects". The feed then leaves out the project post. + b.do("POST", "/account/settings", url.Values{"email_on": {"1"}, "tag": {"articles"}}) + if _, doc := anon.do("GET", feed, nil); strings.Contains(doc, "old-project.html") || !strings.Contains(doc, "old-article.html") { + t.Errorf("muted tag still in feed:\n%s", doc) + } + + // A new feed URL revokes the old one. + _, page = b.do("POST", "/account/feed/rotate", nil) + newFeed := feedURL.FindString(page) + if newFeed == "" || newFeed == feed { + t.Fatalf("feed URL did not change: %q", newFeed) + } + if code, _ := anon.do("GET", feed, nil); code != 404 { + t.Errorf("old feed URL gave %d, want 404", code) + } + if code, _ := anon.do("GET", newFeed, nil); code != 200 { + t.Errorf("new feed URL gave %d, want 200", code) + } + + // After sign-out, the settings page asks for an address again. + if _, page := b.do("POST", "/account/signout", nil); !strings.Contains(page, `action="/account/signin"`) { + t.Errorf("still signed in after sign-out:\n%s", page) + } +} + +func TestSignInRejectsBadAddress(t *testing.T) { + e := newTestEnv(t, oldPosts) + b := newBrowser(t, e) + for _, bad := range []string{"", "no-at-sign", "Name <a@example.com>", "a@example.com\r\nBcc: x@example.com"} { + if code, _ := b.do("POST", "/account/signin", url.Values{"email": {bad}}); code != 400 { + t.Errorf("address %q gave %d, want 400", bad, code) + } + } + if len(e.mail.sent) != 0 { + t.Errorf("sent mail for a bad address: %+v", e.mail.sent) + } +} + +func TestUsedLinkFails(t *testing.T) { + e := newTestEnv(t, oldPosts) + b := newBrowser(t, e) + b.signIn(e, "a@example.com") + tok := linkToken.FindStringSubmatch(e.mail.sent[0].Body)[1] + if code, page := b.do("POST", "/account/verify", url.Values{"t": {tok}}); code != 400 || !strings.Contains(page, "not valid") { + t.Errorf("second use: %d\n%s", code, page) + } +} + +func TestUnsubscribe(t *testing.T) { + e := newTestEnv(t, oldPosts) + b := newBrowser(t, e) + u := e.signUp(t, "a@example.com") + + // The link in the email body shows a button, and changes nothing. + if code, page := b.do("GET", "/account/unsubscribe?t="+u.UnsubToken, nil); code != 200 || !strings.Contains(page, "Stop the emails") { + t.Fatalf("unsubscribe page: %d\n%s", code, page) + } + if on := emailOn(t, e, u.ID); !on { + t.Fatal("GET request turned off email") + } + + // RFC 8058 one-click: the mail client sends a POST to the header URL. + code, _ := b.do("POST", "/account/unsubscribe?t="+u.UnsubToken, url.Values{"List-Unsubscribe": {"One-Click"}}) + if code != 200 { + t.Fatalf("one-click unsubscribe gave %d", code) + } + if emailOn(t, e, u.ID) { + t.Fatal("email is still on after unsubscribe") + } + + if code, _ := b.do("POST", "/account/unsubscribe", url.Values{"t": {"WRONG"}}); code != 400 { + t.Errorf("wrong token gave %d, want 400", code) + } +} + +func emailOn(t *testing.T, e *testEnv, id int64) bool { + t.Helper() + var on bool + if err := e.app.db.QueryRow(`SELECT email_on FROM users WHERE id = ?`, id).Scan(&on); err != nil { + t.Fatal(err) + } + return on +} + +func TestDeleteAccount(t *testing.T) { + e := newTestEnv(t, oldPosts) + b := newBrowser(t, e) + page := b.signIn(e, "a@example.com") + feed := feedURL.FindString(page) + u, err := userByFeedToken(e.app.db, strings.TrimPrefix(feed, "/account/feed/")) + if err != nil { + t.Fatal(err) + } + if err := saveSettings(e.app.db, u.ID, true, []string{"projects"}); err != nil { + t.Fatal(err) + } + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + + if code, page := b.do("POST", "/account/delete", nil); code != 200 || !strings.Contains(page, "Account deleted") { + t.Fatalf("delete: %d\n%s", code, page) + } + for _, table := range []string{"users", "sessions", "muted_tags", "deliveries"} { + var n int + if err := e.app.db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("%s has %d rows after delete", table, n) + } + } + if code, _ := b.do("GET", feed, nil); code != 404 { + t.Errorf("feed after delete gave %d, want 404", code) + } + if _, err := userByFeedToken(e.app.db, u.FeedToken); !errors.Is(err, errNotFound) { + t.Errorf("user still there: %v", err) + } +} + +func TestStateChangesNeedSession(t *testing.T) { + e := newTestEnv(t, oldPosts) + u := e.signUp(t, "a@example.com") + b := newBrowser(t, e) + // No cookie: each POST goes to the sign-in page and changes nothing. + for _, path := range []string{"/account/delete", "/account/settings", "/account/feed/rotate"} { + if _, page := b.do("POST", path, url.Values{}); !strings.Contains(page, `action="/account/signin"`) { + t.Errorf("%s without a session:\n%s", path, page) + } + } + after, err := userByFeedToken(e.app.db, u.FeedToken) + if err != nil || !after.EmailOn { + t.Errorf("user changed: %+v, %v", after, err) + } +} diff --git a/accounts/helpers_test.go b/accounts/helpers_test.go new file mode 100644 index 0000000..478ec84 --- /dev/null +++ b/accounts/helpers_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "errors" + "io" + "log" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// captureMailer keeps each message. If fail is true, Send returns an error +// and keeps nothing. +type captureMailer struct { + mu sync.Mutex + sent []message + fail bool +} + +func (c *captureMailer) Send(m message) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.fail { + return errors.New("smtp is down") + } + c.sent = append(c.sent, m) + return nil +} + +// clock is a time source that a test moves forward. +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +type testEnv struct { + app *app + mail *captureMailer + clock *clock +} + +// fataler is the part of testing.TB that the helpers use. *rapid.T has +// these methods too. +type fataler interface { + Helper() + Fatal(args ...any) + Fatalf(format string, args ...any) +} + +// newTestEnv makes an app with a :memory: database and a site directory +// that has the posts in index. +func newTestEnv(t *testing.T, index []Post) *testEnv { + t.Helper() + db, err := openDB(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + site := t.TempDir() + if err := os.MkdirAll(filepath.Join(site, "postdata"), 0o755); err != nil { + t.Fatal(err) + } + e := &testEnv{ + mail: &captureMailer{}, + clock: &clock{t: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)}, + } + e.app = &app{ + cfg: config{BaseURL: "https://example.com", SiteDir: site}, + db: db, + mail: e.mail, + log: log.New(io.Discard, "", 0), + } + e.app.now = e.clock.now + e.writeIndex(t, index) + return e +} + +// writeIndex writes postdata/index.tsv and a body for each post, as +// build.sh does. +func (e *testEnv) writeIndex(t fataler, posts []Post) { + t.Helper() + var b strings.Builder + for _, p := range posts { + b.WriteString(p.Date + "\t" + p.Slug + "\t" + strings.Join(p.Tags, " ") + "\t" + p.Title + "\n") + body := "<p>Body of " + p.Slug + " & more.</p>\n" + if err := os.WriteFile(filepath.Join(e.app.cfg.SiteDir, "postdata", p.Slug+".html"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(e.app.cfg.SiteDir, "postdata", "index.tsv"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } +} + +// signUp makes a verified user for email and returns it. +func (e *testEnv) signUp(t fataler, email string) user { + t.Helper() + tok, err := createLoginToken(e.app.db, email, e.clock.now()) + if err != nil || tok == "" { + t.Fatalf("createLoginToken: %q, %v", tok, err) + } + u, err := consumeLoginToken(e.app.db, tok, e.clock.now()) + if err != nil { + t.Fatal(err) + } + return u +} + +func (e *testEnv) advance(d time.Duration) { e.clock.t = e.clock.t.Add(d) } + +var oldPosts = []Post{ + {Date: "2025-09-11", Slug: "old-article", Tags: []string{"articles"}, Title: "Old article"}, + {Date: "2025-09-06", Slug: "old-project", Tags: []string{"projects"}, Title: "Old project"}, +} diff --git a/accounts/mail.go b/accounts/mail.go new file mode 100644 index 0000000..bbc4e9e --- /dev/null +++ b/accounts/mail.go @@ -0,0 +1,100 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "mime" + "mime/quotedprintable" + "net" + "net/mail" + "net/smtp" + "strings" + "time" +) + +// message is one plain text email to one address. +type message struct { + To string + Subject string + Body string + // Unsubscribe is a URL for the List-Unsubscribe header (RFC 2369) and + // for one-click unsubscribe (RFC 8058). It is empty for sign-in email. + Unsubscribe string +} + +type mailer interface { + Send(m message) error +} + +// smtpMailer sends through any SMTP server: a local MTA, or the SMTP +// endpoint of a hosted provider (Resend, Postmark, Amazon SES). +type smtpMailer struct { + Addr string // host:port + Username string // empty for a server that needs no sign-in + Password string + From *mail.Address +} + +// Send uses STARTTLS when the server offers it. net/smtp PlainAuth does +// not send the password on a connection without TLS, except to localhost. +func (s smtpMailer) Send(m message) error { + var auth smtp.Auth + if s.Username != "" { + host, _, _ := net.SplitHostPort(s.Addr) + auth = smtp.PlainAuth("", s.Username, s.Password, host) + } + data, err := formatMessage(s.From, m, time.Now()) + if err != nil { + return err + } + return smtp.SendMail(s.Addr, auth, s.From.Address, []string{m.To}, data) +} + +// logMailer writes each message to w. Use it only for development: the +// messages contain sign-in links. +type logMailer struct{ W io.Writer } + +func (l logMailer) Send(m message) error { + _, err := fmt.Fprintf(l.W, "--- mail to %s\nSubject: %s\n\n%s\n---\n", m.To, m.Subject, m.Body) + return err +} + +// formatMessage makes an RFC 5322 message. The subject is encoded as an +// RFC 2047 word, and the body as quoted-printable, so that non-ASCII text +// and long lines arrive intact. +func formatMessage(from *mail.Address, m message, now time.Time) ([]byte, error) { + to, err := mail.ParseAddress(m.To) + if err != nil { + return nil, err + } + // A line break in a header value would start a new header. + subject := strings.NewReplacer("\r", " ", "\n", " ").Replace(m.Subject) + domain := from.Address[strings.LastIndex(from.Address, "@")+1:] + + var b bytes.Buffer + h := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) } + h("From", from.String()) + h("To", to.String()) + h("Subject", mime.QEncoding.Encode("utf-8", subject)) + h("Date", now.Format(time.RFC1123Z)) + h("Message-ID", "<"+strings.ToLower(newToken())+"@"+domain+">") + h("MIME-Version", "1.0") + h("Content-Type", "text/plain; charset=utf-8") + h("Content-Transfer-Encoding", "quoted-printable") + if m.Unsubscribe != "" { + h("List-Unsubscribe", "<"+m.Unsubscribe+">") + h("List-Unsubscribe-Post", "List-Unsubscribe=One-Click") + } + b.WriteString("\r\n") + qp := quotedprintable.NewWriter(&b) + // Email lines end with CRLF. A CR or LF alone becomes CRLF. + crlf := strings.NewReplacer("\r\n", "\r\n", "\r", "\r\n", "\n", "\r\n") + if _, err := qp.Write([]byte(crlf.Replace(m.Body))); err != nil { + return nil, err + } + if err := qp.Close(); err != nil { + return nil, err + } + return b.Bytes(), nil +} diff --git a/accounts/mail_test.go b/accounts/mail_test.go new file mode 100644 index 0000000..295c0ac --- /dev/null +++ b/accounts/mail_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "bytes" + "io" + "mime" + "mime/quotedprintable" + "net/mail" + "strings" + "testing" + "time" + "unicode/utf8" + + "pgregory.net/rapid" +) + +var testFrom = &mail.Address{Name: "Recently Written", Address: "posts@example.com"} + +// TestFormatMessageProperty checks that any subject and body give a +// message that parses, has exactly the expected headers, and decodes back +// to the same subject and body. +func TestFormatMessageProperty(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + valid := rapid.String().Filter(utf8.ValidString) + m := message{ + To: "reader@example.com", + Subject: valid.Draw(t, "subject"), + Body: valid.Draw(t, "body"), + Unsubscribe: rapid.SampledFrom([]string{"", "https://example.com/account/unsubscribe?t=ABC"}).Draw(t, "unsub"), + } + data, err := formatMessage(testFrom, m, time.Unix(0, 0)) + if err != nil { + t.Fatal(err) + } + msg, err := mail.ReadMessage(bytes.NewReader(data)) + if err != nil { + t.Fatalf("message does not parse: %v\n%q", err, data) + } + wantHeaders := 8 + if m.Unsubscribe != "" { + wantHeaders += 2 + } + if len(msg.Header) != wantHeaders { + t.Fatalf("got headers %v, want %d (a line break in the subject must not add one)", msg.Header, wantHeaders) + } + subject, err := new(mime.WordDecoder).DecodeHeader(msg.Header.Get("Subject")) + if err != nil { + t.Fatal(err) + } + wantSubject := strings.NewReplacer("\r", " ", "\n", " ").Replace(m.Subject) + // net/mail removes spaces at the two ends of a plain header value, + // but not in an encoded word. + if strings.Trim(subject, " \t") != strings.Trim(wantSubject, " \t") { + t.Fatalf("subject: got %q, want %q", subject, wantSubject) + } + body, err := io.ReadAll(quotedprintable.NewReader(msg.Body)) + if err != nil { + t.Fatal(err) + } + lf := strings.NewReplacer("\r\n", "\n", "\r", "\n") + if got := lf.Replace(string(body)); got != lf.Replace(m.Body) { + t.Fatalf("body: got %q, want %q", got, m.Body) + } + if got := msg.Header.Get("List-Unsubscribe"); m.Unsubscribe != "" && got != "<"+m.Unsubscribe+">" { + t.Fatalf("List-Unsubscribe = %q", got) + } + }) +} + +func TestFormatMessageRejectsBadAddress(t *testing.T) { + if _, err := formatMessage(testFrom, message{To: "not an address"}, time.Now()); err == nil { + t.Fatal("no error for a bad address") + } +} + +func TestLoadConfigNamesMissingVariables(t *testing.T) { + env := map[string]string{"RW_BASE_URL": "https://example.com/", "SMTP_PORT": "2525"} + _, err := loadConfig(func(k string) string { return env[k] }, false) + if err == nil || err.Error() != "set these environment variables: RW_SITE_DIR, RW_DB_PATH, RW_MAIL_FROM, SMTP_HOST" { + t.Fatalf("err = %v", err) + } + + env["RW_SITE_DIR"], env["RW_DB_PATH"] = "/srv/site", "/var/lib/rw/rw.db" + env["RW_MAIL_FROM"], env["SMTP_HOST"] = "Recently Written <posts@example.com>", "smtp.example.com" + c, err := loadConfig(func(k string) string { return env[k] }, false) + if err != nil { + t.Fatal(err) + } + if c.BaseURL != "https://example.com" || c.SMTPAddr != "smtp.example.com:2525" || + c.MailFrom.Address != "posts@example.com" || c.Listen != "127.0.0.1:8081" { + t.Fatalf("config = %+v", c) + } + + delete(env, "SMTP_HOST") + if _, err := loadConfig(func(k string) string { return env[k] }, true); err != nil { + t.Fatalf("dev mode needs no SMTP: %v", err) + } +} diff --git a/accounts/main.go b/accounts/main.go new file mode 100644 index 0000000..dadedd8 --- /dev/null +++ b/accounts/main.go @@ -0,0 +1,132 @@ +// Command accounts is the sign-in, email, and private feed service for +// recentlywritten.com. It serves the paths under /account/ behind the web +// server that serves the static site, and it reads the post data that +// build.sh writes to postdata/ in the site directory. +// +// All settings and secrets come from environment variables. See +// .env.example. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "net" + "net/http" + "net/mail" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +type config struct { + BaseURL string // for example https://recentlywritten.com, with no final slash + SiteDir string // the web root that build.sh deploys + DBPath string + Listen string + MailFrom *mail.Address + SMTPAddr string + SMTPUsername string + SMTPPassword string + Interval time.Duration +} + +// loadConfig reads the configuration from getenv. In dev mode the mail +// settings are not necessary. The error names each missing variable. +func loadConfig(getenv func(string) string, dev bool) (config, error) { + c := config{ + BaseURL: strings.TrimRight(getenv("RW_BASE_URL"), "/"), + SiteDir: getenv("RW_SITE_DIR"), + DBPath: getenv("RW_DB_PATH"), + Listen: getenv("RW_LISTEN"), + SMTPUsername: getenv("SMTP_USERNAME"), + SMTPPassword: getenv("SMTP_PASSWORD"), + Interval: 15 * time.Minute, + } + if c.Listen == "" { + c.Listen = "127.0.0.1:8081" + } + required := []string{"RW_BASE_URL", "RW_SITE_DIR", "RW_DB_PATH"} + if !dev { + required = append(required, "RW_MAIL_FROM", "SMTP_HOST") + } + var missing []string + for _, k := range required { + if getenv(k) == "" { + missing = append(missing, k) + } + } + if len(missing) > 0 { + return c, fmt.Errorf("set these environment variables: %s", strings.Join(missing, ", ")) + } + if host := getenv("SMTP_HOST"); host != "" { + port := getenv("SMTP_PORT") + if port == "" { + port = "587" + } + c.SMTPAddr = net.JoinHostPort(host, port) + } + if from := getenv("RW_MAIL_FROM"); from != "" { + addr, err := mail.ParseAddress(from) + if err != nil { + return c, fmt.Errorf("RW_MAIL_FROM: %w", err) + } + c.MailFrom = addr + } + if s := getenv("RW_NOTIFY_INTERVAL"); s != "" { + d, err := time.ParseDuration(s) + if err != nil || d <= 0 { + return c, fmt.Errorf("RW_NOTIFY_INTERVAL: not a positive duration such as 15m") + } + c.Interval = d + } + return c, nil +} + +func main() { + dev := flag.Bool("dev", false, "write email to standard error, not to SMTP (development only)") + flag.Parse() + logger := log.New(os.Stderr, "", log.LstdFlags) + + cfg, err := loadConfig(os.Getenv, *dev) + if err != nil { + logger.Fatal(err) + } + db, err := openDB(cfg.DBPath) + if err != nil { + logger.Fatal(err) + } + defer db.Close() + + var m mailer = logMailer{W: os.Stderr} + if !*dev { + m = smtpMailer{Addr: cfg.SMTPAddr, Username: cfg.SMTPUsername, Password: cfg.SMTPPassword, From: cfg.MailFrom} + } + a := &app{cfg: cfg, db: db, mail: m, now: time.Now, log: logger} + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go a.notifyLoop(cfg.Interval, ctx.Done()) + + srv := &http.Server{ + Addr: cfg.Listen, + Handler: a.routes(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + } + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + srv.Shutdown(shutdown) + }() + logger.Printf("listening on %s", cfg.Listen) + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + logger.Fatal(err) + } +} diff --git a/accounts/notify.go b/accounts/notify.go new file mode 100644 index 0000000..bfccda3 --- /dev/null +++ b/accounts/notify.go @@ -0,0 +1,165 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +// notify sends one email to each subscriber who has new posts. +// +// A post is new for a user if the service first saw it in the index at or +// after the time the user signed up. On the first run the posts table is +// empty, and notify records every post with first_seen 0, so that the +// posts that were on the site before the service started go to nobody. +// +// notify writes a row in deliveries for each new (user, post) before it +// sends the email, and sends only the posts whose rows it wrote. It also +// writes the row when the user does not want the post (email off, or all +// tags of the post muted), so a later change of settings does not send +// old posts. A second run, or a second process at the same time, never +// sends a post two times to one user. If the send fails, notify removes the rows, and the next +// run tries again. If the process stops between the write and the send, +// the user does not get that email. This design accepts a lost email to +// prevent a duplicate email. +func (a *app) notify() error { + posts, err := loadPosts(a.cfg.SiteDir) + if err != nil { + return err + } + firstSeen, err := recordPosts(a.db, posts, a.now().Unix()) + if err != nil { + return err + } + + rows, err := a.db.Query(`SELECT id, email, created_at, email_on, unsub_token FROM users`) + if err != nil { + return err + } + var users []user + for rows.Next() { + var u user + if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt, &u.EmailOn, &u.UnsubToken); err != nil { + rows.Close() + return err + } + users = append(users, u) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + var errs []error + for _, u := range users { + if err := a.notifyUser(u, posts, firstSeen); err != nil { + errs = append(errs, fmt.Errorf("user %d: %w", u.ID, err)) + } + } + return errors.Join(errs...) +} + +// recordPosts adds the posts that the posts table does not have, and +// returns first_seen for each post in posts. +func recordPosts(db *sql.DB, posts []Post, now int64) (map[string]int64, error) { + tx, err := db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + var known int + if err := tx.QueryRow(`SELECT count(*) FROM posts`).Scan(&known); err != nil { + return nil, err + } + seen := now + if known == 0 { + seen = 0 + } + firstSeen := map[string]int64{} + for _, p := range posts { + if _, err := tx.Exec(`INSERT OR IGNORE INTO posts (slug, first_seen) VALUES (?, ?)`, p.Slug, seen); err != nil { + return nil, err + } + var t int64 + if err := tx.QueryRow(`SELECT first_seen FROM posts WHERE slug = ?`, p.Slug).Scan(&t); err != nil { + return nil, err + } + firstSeen[p.Slug] = t + } + return firstSeen, tx.Commit() +} + +func (a *app) notifyUser(u user, posts []Post, firstSeen map[string]int64) error { + muted, err := mutedTags(a.db, u.ID) + if err != nil { + return err + } + var claimed, send []Post + for _, p := range posts { + if firstSeen[p.Slug] < u.CreatedAt { + continue + } + res, err := a.db.Exec(`INSERT OR IGNORE INTO deliveries (user_id, slug) VALUES (?, ?)`, u.ID, p.Slug) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 1 { + claimed = append(claimed, p) + if u.EmailOn && wanted(p, muted) { + send = append(send, p) + } + } + } + if len(send) == 0 { + return nil + } + if err := a.mail.Send(a.digest(u, send)); err != nil { + for _, p := range claimed { + // The next run sends the post again only if this row is gone. + if _, derr := a.db.Exec(`DELETE FROM deliveries WHERE user_id = ? AND slug = ?`, u.ID, p.Slug); derr != nil { + err = errors.Join(err, derr) + } + } + return err + } + return nil +} + +func (a *app) unsubscribeURL(u user) string { + return a.cfg.BaseURL + "/account/unsubscribe?t=" + url.QueryEscape(u.UnsubToken) +} + +func (a *app) digest(u user, posts []Post) message { + subject := fmt.Sprintf("%d new posts on Recently Written", len(posts)) + if len(posts) == 1 { + subject = "New post: " + posts[0].Title + } + var b strings.Builder + b.WriteString("New on Recently Written:\n\n") + for _, p := range posts { + fmt.Fprintf(&b, "%s\n%s/%s.html\n\n", p.Title, a.cfg.BaseURL, p.Slug) + } + fmt.Fprintf(&b, "-- \nChange your settings: %s/account/\nStop these emails: %s\n", + a.cfg.BaseURL, a.unsubscribeURL(u)) + return message{To: u.Email, Subject: subject, Body: b.String(), Unsubscribe: a.unsubscribeURL(u)} +} + +// notifyLoop runs notify now and then one time in each interval, until +// stop closes. +func (a *app) notifyLoop(interval time.Duration, stop <-chan struct{}) { + t := time.NewTicker(interval) + defer t.Stop() + for { + if err := a.notify(); err != nil { + a.log.Printf("notify: %v", err) + } + select { + case <-stop: + return + case <-t.C: + } + } +} diff --git a/accounts/notify_test.go b/accounts/notify_test.go new file mode 100644 index 0000000..09f440c --- /dev/null +++ b/accounts/notify_test.go @@ -0,0 +1,225 @@ +package main + +import ( + "fmt" + "strings" + "testing" + "time" + + "pgregory.net/rapid" +) + +func TestNotifyFirstRunSendsNoOldPosts(t *testing.T) { + e := newTestEnv(t, oldPosts) + e.signUp(t, "a@example.com") + e.advance(time.Minute) + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + if len(e.mail.sent) != 0 { + t.Fatalf("sent %d messages for posts that were on the site before the first run", len(e.mail.sent)) + } +} + +func TestNotifySendsNewPostOneTime(t *testing.T) { + e := newTestEnv(t, oldPosts) + if err := e.app.notify(); err != nil { // first run records the old posts + t.Fatal(err) + } + u := e.signUp(t, "a@example.com") + e.advance(time.Hour) + newPost := Post{Date: "2026-09-02", Slug: "new-post", Tags: []string{"articles"}, Title: "New post"} + e.writeIndex(t, append([]Post{newPost}, oldPosts...)) + + for range 3 { + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + e.advance(time.Minute) + } + if len(e.mail.sent) != 1 { + t.Fatalf("sent %d messages, want 1", len(e.mail.sent)) + } + m := e.mail.sent[0] + if m.To != "a@example.com" || m.Subject != "New post: New post" { + t.Errorf("message = %+v", m) + } + if !strings.Contains(m.Body, "https://example.com/new-post.html") { + t.Errorf("body has no link to the post:\n%s", m.Body) + } + if strings.Contains(m.Body, "old-article") { + t.Errorf("body has an old post:\n%s", m.Body) + } + wantUnsub := "https://example.com/account/unsubscribe?t=" + u.UnsubToken + if m.Unsubscribe != wantUnsub || !strings.Contains(m.Body, wantUnsub) { + t.Errorf("unsubscribe link missing: header %q, body:\n%s", m.Unsubscribe, m.Body) + } +} + +func TestNotifyRetriesAfterSendFailure(t *testing.T) { + e := newTestEnv(t, oldPosts) + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + e.signUp(t, "a@example.com") + e.advance(time.Hour) + e.writeIndex(t, append([]Post{{Date: "2026-09-02", Slug: "new-post", Title: "New post"}}, oldPosts...)) + + e.mail.fail = true + if err := e.app.notify(); err == nil { + t.Fatal("notify returned no error when the send failed") + } + e.mail.fail = false + for range 2 { + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + } + if len(e.mail.sent) != 1 { + t.Fatalf("sent %d messages, want 1", len(e.mail.sent)) + } +} + +func TestNotifySkipsPostsFromBeforeSignUp(t *testing.T) { + e := newTestEnv(t, oldPosts) + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + e.advance(time.Hour) + e.writeIndex(t, append([]Post{{Date: "2026-09-02", Slug: "earlier", Title: "Earlier"}}, oldPosts...)) + if err := e.app.notify(); err != nil { // no users yet + t.Fatal(err) + } + e.advance(time.Hour) + e.signUp(t, "late@example.com") + e.advance(time.Hour) + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + if len(e.mail.sent) != 0 { + t.Fatalf("a new user got a post from before the sign-up: %+v", e.mail.sent) + } +} + +func TestNotifyObeysSettings(t *testing.T) { + e := newTestEnv(t, oldPosts) + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + u := e.signUp(t, "a@example.com") + if err := saveSettings(e.app.db, u.ID, true, []string{"projects"}); err != nil { + t.Fatal(err) + } + e.advance(time.Hour) + e.writeIndex(t, append([]Post{ + {Date: "2026-09-02", Slug: "proj", Tags: []string{"projects"}, Title: "Proj"}, + {Date: "2026-09-02", Slug: "art", Tags: []string{"articles"}, Title: "Art"}, + }, oldPosts...)) + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + if len(e.mail.sent) != 1 || strings.Contains(e.mail.sent[0].Body, "proj.html") { + t.Fatalf("muted tag was sent: %+v", e.mail.sent) + } + + // Unmute the tag. The muted post must not arrive later. + if err := saveSettings(e.app.db, u.ID, true, nil); err != nil { + t.Fatal(err) + } + e.advance(time.Hour) + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + if len(e.mail.sent) != 1 { + t.Fatalf("unmute sent an old post: %+v", e.mail.sent[1:]) + } + + // With email off, nothing goes out. + if err := saveSettings(e.app.db, u.ID, false, nil); err != nil { + t.Fatal(err) + } + e.advance(time.Hour) + e.writeIndex(t, append([]Post{{Date: "2026-09-03", Slug: "third", Title: "Third"}}, oldPosts...)) + if err := e.app.notify(); err != nil { + t.Fatal(err) + } + if len(e.mail.sent) != 1 { + t.Fatalf("email off, but sent: %+v", e.mail.sent[1:]) + } +} + +// TestNotifyNoDuplicatesProperty runs random sequences of new posts, new +// users, send failures, and notify runs. At the end no user has a post two +// times, and each user has each post that notify first saw at or after +// the sign-up. +func TestNotifyNoDuplicatesProperty(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + e := newTestEnv(t, oldPosts) + if err := e.app.notify(); err != nil { // the first run records the old posts + rt.Fatal(err) + } + index := append([]Post(nil), oldPosts...) + seenAt := map[string]time.Time{} // slug -> time of the first notify run that saw it + joinedAt := map[string]time.Time{} // email -> sign-up time + run := func() { + for _, p := range index { + if _, ok := seenAt[p.Slug]; !ok { + seenAt[p.Slug] = e.clock.now() + } + } + _ = e.app.notify() + } + for _, p := range oldPosts { + seenAt[p.Slug] = time.Time{} + } + + steps := rapid.SliceOfN(rapid.IntRange(0, 3), 1, 30).Draw(rt, "steps") + for i, step := range steps { + e.advance(time.Minute) + switch step { + case 0: + p := Post{Date: "2026-09-02", Slug: fmt.Sprintf("p%d", i), Title: fmt.Sprintf("P%d", i)} + index = append([]Post{p}, index...) + e.writeIndex(rt, index) + case 1: + email := fmt.Sprintf("u%d@example.com", i) + e.signUp(rt, email) + joinedAt[email] = e.clock.now() + case 2: + e.mail.fail = !e.mail.fail + case 3: + run() + } + } + e.advance(time.Minute) + e.mail.fail = false + run() + + got := map[string]map[string]int{} // email -> slug -> count + for _, m := range e.mail.sent { + if got[m.To] == nil { + got[m.To] = map[string]int{} + } + for _, line := range strings.Split(m.Body, "\n") { + if s, ok := strings.CutPrefix(line, "https://example.com/"); ok { + got[m.To][strings.TrimSuffix(s, ".html")]++ + } + } + } + for email, joined := range joinedAt { + want := 0 + for _, p := range index { + if seenAt[p.Slug].Before(joined) { + continue + } + want++ + if n := got[email][p.Slug]; n != 1 { + rt.Fatalf("%s got %s %d times, want 1", email, p.Slug, n) + } + } + if len(got[email]) != want { + rt.Fatalf("%s got %v, want %d posts", email, got[email], want) + } + } + }) +} diff --git a/accounts/posts.go b/accounts/posts.go new file mode 100644 index 0000000..dc0ed5a --- /dev/null +++ b/accounts/posts.go @@ -0,0 +1,104 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Post is one line of the post index that build.sh writes. +type Post struct { + Date string // YYYY-MM-DD, or empty + Slug string + Tags []string + Title string +} + +// validSlug accepts only the file names that build.sh makes. The check +// keeps a bad index line from reading a file outside postdata/. +var validSlug = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*$`) + +// parseIndex reads postdata/index.tsv. Each line has four fields that +// tabs separate: date, slug, tags, title. The lines are newest first. +func parseIndex(r io.Reader) ([]Post, error) { + var posts []Post + sc := bufio.NewScanner(r) + for n := 1; sc.Scan(); n++ { + line := strings.TrimRight(sc.Text(), "\r") + if line == "" { + continue + } + f := strings.SplitN(line, "\t", 4) + if len(f) != 4 || !validSlug.MatchString(f[1]) { + return nil, fmt.Errorf("index line %d is not valid", n) + } + posts = append(posts, Post{ + Date: f[0], + Slug: f[1], + Tags: splitTags(f[2]), + Title: f[3], + }) + } + return posts, sc.Err() +} + +// splitTags reads a front matter tags value such as "articles" or +// "lisp, unix". +func splitTags(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' + }) +} + +func loadPosts(siteDir string) ([]Post, error) { + f, err := os.Open(filepath.Join(siteDir, "postdata", "index.tsv")) + if err != nil { + return nil, err + } + defer f.Close() + return parseIndex(f) +} + +// loadBody returns the HTML body of a post. A missing body gives an +// empty string, because a feed item without a body is still useful. +func loadBody(siteDir, slug string) string { + b, err := os.ReadFile(filepath.Join(siteDir, "postdata", slug+".html")) + if err != nil { + return "" + } + return string(b) +} + +// allTags returns each tag that a post uses, in the order of first use. +func allTags(posts []Post) []string { + seen := map[string]bool{} + var tags []string + for _, p := range posts { + for _, t := range p.Tags { + if !seen[t] { + seen[t] = true + tags = append(tags, t) + } + } + } + return tags +} + +// wanted tells if a user who muted the tags in muted gets post p. A post +// without tags always goes out. A post with tags goes out if one or more +// of its tags is not muted. +func wanted(p Post, muted map[string]bool) bool { + if len(p.Tags) == 0 { + return true + } + for _, t := range p.Tags { + if !muted[t] { + return true + } + } + return false +} diff --git a/accounts/store.go b/accounts/store.go new file mode 100644 index 0000000..423ce05 --- /dev/null +++ b/accounts/store.go @@ -0,0 +1,239 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "time" + + _ "modernc.org/sqlite" +) + +// The users table holds only addresses that a sign-in link verified. +// login_tokens and sessions hold SHA-256 hashes of their tokens, so a copy +// of the database does not let a person sign in. feed_token and +// unsub_token are stored as plain text: the site must show them again, +// and they give access only to public posts and to the unsubscribe step. +const schema = ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + email_on INTEGER NOT NULL DEFAULT 1, + feed_token TEXT NOT NULL UNIQUE, + unsub_token TEXT NOT NULL UNIQUE +); +CREATE TABLE IF NOT EXISTS muted_tags ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (user_id, tag) +); +CREATE TABLE IF NOT EXISTS login_tokens ( + hash TEXT PRIMARY KEY, + email TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS posts ( + slug TEXT PRIMARY KEY, + first_seen INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS deliveries ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + PRIMARY KEY (user_id, slug) +); +` + +const ( + loginTTL = 15 * time.Minute + loginSpacing = 2 * time.Minute // minimum time between two links to one address + sessionTTL = 30 * 24 * time.Hour +) + +var errNotFound = errors.New("not found") + +func openDB(path string) (*sql.DB, error) { + db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") + if err != nil { + return nil, err + } + // One connection serializes all writes. The site is small, and this + // removes the need to handle SQLITE_BUSY. It also keeps a :memory: + // database the same for all queries in tests. + db.SetMaxOpenConns(1) + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +// newToken returns a random token with 128 bits of entropy. +func newToken() string { return rand.Text() } + +func hashToken(t string) string { + s := sha256.Sum256([]byte(t)) + return hex.EncodeToString(s[:]) +} + +type user struct { + ID int64 + Email string + CreatedAt int64 + EmailOn bool + FeedToken string + UnsubToken string +} + +const userColumns = `id, email, created_at, email_on, feed_token, unsub_token` + +func scanUser(row *sql.Row) (user, error) { + var u user + err := row.Scan(&u.ID, &u.Email, &u.CreatedAt, &u.EmailOn, &u.FeedToken, &u.UnsubToken) + if errors.Is(err, sql.ErrNoRows) { + err = errNotFound + } + return u, err +} + +// createLoginToken makes a sign-in token for email. It returns an empty +// token and no error if a token for email was made less than loginSpacing +// ago. This stops a person who uses the form to send many emails to one +// address. +func createLoginToken(db *sql.DB, email string, now time.Time) (string, error) { + if _, err := db.Exec(`DELETE FROM login_tokens WHERE expires_at <= ?`, now.Unix()); err != nil { + return "", err + } + var recent int + err := db.QueryRow(`SELECT count(*) FROM login_tokens WHERE email = ? AND created_at > ?`, + email, now.Add(-loginSpacing).Unix()).Scan(&recent) + if err != nil || recent > 0 { + return "", err + } + t := newToken() + _, err = db.Exec(`INSERT INTO login_tokens (hash, email, created_at, expires_at) VALUES (?, ?, ?, ?)`, + hashToken(t), email, now.Unix(), now.Add(loginTTL).Unix()) + if err != nil { + return "", err + } + return t, nil +} + +// consumeLoginToken removes the token and returns the user for its +// address. It makes the user if the address has no account. A token works +// one time only. +func consumeLoginToken(db *sql.DB, token string, now time.Time) (user, error) { + var email string + err := db.QueryRow(`DELETE FROM login_tokens WHERE hash = ? AND expires_at > ? RETURNING email`, + hashToken(token), now.Unix()).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + return user{}, errNotFound + } + if err != nil { + return user{}, err + } + _, err = db.Exec(`INSERT INTO users (email, created_at, feed_token, unsub_token) VALUES (?, ?, ?, ?) + ON CONFLICT (email) DO NOTHING`, email, now.Unix(), newToken(), newToken()) + if err != nil { + return user{}, err + } + return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE email = ?`, email)) +} + +func createSession(db *sql.DB, userID int64, now time.Time) (string, error) { + if _, err := db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, now.Unix()); err != nil { + return "", err + } + t := newToken() + _, err := db.Exec(`INSERT INTO sessions (hash, user_id, expires_at) VALUES (?, ?, ?)`, + hashToken(t), userID, now.Add(sessionTTL).Unix()) + return t, err +} + +func sessionUser(db *sql.DB, token string, now time.Time) (user, error) { + return scanUser(db.QueryRow(`SELECT u.id, u.email, u.created_at, u.email_on, u.feed_token, u.unsub_token + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.hash = ? AND s.expires_at > ?`, hashToken(token), now.Unix())) +} + +func deleteSession(db *sql.DB, token string) error { + _, err := db.Exec(`DELETE FROM sessions WHERE hash = ?`, hashToken(token)) + return err +} + +func userByFeedToken(db *sql.DB, token string) (user, error) { + return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE feed_token = ?`, token)) +} + +func mutedTags(db *sql.DB, userID int64) (map[string]bool, error) { + rows, err := db.Query(`SELECT tag FROM muted_tags WHERE user_id = ?`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + muted := map[string]bool{} + for rows.Next() { + var t string + if err := rows.Scan(&t); err != nil { + return nil, err + } + muted[t] = true + } + return muted, rows.Err() +} + +// saveSettings sets the email switch and replaces the muted tags. +func saveSettings(db *sql.DB, userID int64, emailOn bool, muted []string) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`UPDATE users SET email_on = ? WHERE id = ?`, emailOn, userID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM muted_tags WHERE user_id = ?`, userID); err != nil { + return err + } + for _, t := range muted { + if _, err := tx.Exec(`INSERT OR IGNORE INTO muted_tags (user_id, tag) VALUES (?, ?)`, userID, t); err != nil { + return err + } + } + return tx.Commit() +} + +// rotateFeedToken gives the user a new feed token. The old feed URL then +// stops working. +func rotateFeedToken(db *sql.DB, userID int64) error { + _, err := db.Exec(`UPDATE users SET feed_token = ? WHERE id = ?`, newToken(), userID) + return err +} + +// unsubscribe turns off email for the user with the unsubscribe token. It +// returns errNotFound for an unknown token. +func unsubscribe(db *sql.DB, token string) error { + res, err := db.Exec(`UPDATE users SET email_on = 0 WHERE unsub_token = ?`, token) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return errNotFound + } + return nil +} + +// deleteUser removes the user. The foreign keys remove the sessions, +// muted tags and delivery records of the user. +func deleteUser(db *sql.DB, userID int64) error { + _, err := db.Exec(`DELETE FROM users WHERE id = ?`, userID) + return err +} diff --git a/accounts/store_test.go b/accounts/store_test.go new file mode 100644 index 0000000..3a0f9e8 --- /dev/null +++ b/accounts/store_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "errors" + "regexp" + "testing" + "time" + + "pgregory.net/rapid" +) + +var tokenForm = regexp.MustCompile(`^[A-Z2-7]{26}$`) + +// TestTokensProperty checks that tokens are URL-safe, are all different, +// and that different tokens have different hashes. +func TestTokensProperty(t *testing.T) { + seen := map[string]bool{} + rapid.Check(t, func(t *rapid.T) { + tok := newToken() + if !tokenForm.MatchString(tok) { + t.Fatalf("token %q is not 26 base32 characters", tok) + } + if seen[tok] { + t.Fatalf("token %q came two times", tok) + } + seen[tok] = true + other := rapid.String().Draw(t, "other") + if other != tok && hashToken(other) == hashToken(tok) { + t.Fatal("two tokens have one hash") + } + }) +} + +// TestLoginTokenProperty checks that a sign-in token works one time for +// its address, and that no other string works. +func TestLoginTokenProperty(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + e := newTestEnv(t, nil) + email := rapid.StringMatching(`[a-z0-9]{1,10}@[a-z]{1,10}\.[a-z]{2,4}`).Draw(rt, "email") + tok, err := createLoginToken(e.app.db, email, e.clock.now()) + if err != nil || tok == "" { + rt.Fatalf("createLoginToken: %q, %v", tok, err) + } + guess := rapid.String().Filter(func(s string) bool { return s != tok }).Draw(rt, "guess") + if _, err := consumeLoginToken(e.app.db, guess, e.clock.now()); !errors.Is(err, errNotFound) { + rt.Fatalf("a wrong token gave %v", err) + } + e.advance(time.Duration(rapid.IntRange(0, int(loginTTL/time.Second)-1).Draw(rt, "wait")) * time.Second) + u, err := consumeLoginToken(e.app.db, tok, e.clock.now()) + if err != nil || u.Email != email { + rt.Fatalf("consume: %+v, %v", u, err) + } + if _, err := consumeLoginToken(e.app.db, tok, e.clock.now()); !errors.Is(err, errNotFound) { + rt.Fatalf("second use gave %v", err) + } + }) +} + +func TestLoginTokenExpires(t *testing.T) { + e := newTestEnv(t, nil) + tok, _ := createLoginToken(e.app.db, "a@example.com", e.clock.now()) + e.advance(loginTTL) + if _, err := consumeLoginToken(e.app.db, tok, e.clock.now()); !errors.Is(err, errNotFound) { + t.Fatalf("expired token gave %v", err) + } +} + +func TestLoginTokenSpacing(t *testing.T) { + e := newTestEnv(t, nil) + if tok, _ := createLoginToken(e.app.db, "a@example.com", e.clock.now()); tok == "" { + t.Fatal("first token is empty") + } + e.advance(loginSpacing - time.Second) + if tok, _ := createLoginToken(e.app.db, "a@example.com", e.clock.now()); tok != "" { + t.Fatal("second token came too soon") + } + if tok, _ := createLoginToken(e.app.db, "b@example.com", e.clock.now()); tok == "" { + t.Fatal("other address got no token") + } + e.advance(time.Second) + if tok, _ := createLoginToken(e.app.db, "a@example.com", e.clock.now()); tok == "" { + t.Fatal("no token after the spacing") + } +} + +func TestSignInTwiceKeepsOneUser(t *testing.T) { + e := newTestEnv(t, nil) + a := e.signUp(t, "a@example.com") + e.advance(loginSpacing) + b := e.signUp(t, "a@example.com") + if a.ID != b.ID || a.FeedToken != b.FeedToken || a.CreatedAt != b.CreatedAt { + t.Fatalf("second sign-in changed the user: %+v, %+v", a, b) + } +} + +func TestSessionExpires(t *testing.T) { + e := newTestEnv(t, nil) + u := e.signUp(t, "a@example.com") + s, err := createSession(e.app.db, u.ID, e.clock.now()) + if err != nil { + t.Fatal(err) + } + if got, err := sessionUser(e.app.db, s, e.clock.now()); err != nil || got.ID != u.ID { + t.Fatalf("sessionUser: %+v, %v", got, err) + } + e.advance(sessionTTL) + if _, err := sessionUser(e.app.db, s, e.clock.now()); !errors.Is(err, errNotFound) { + t.Fatalf("expired session gave %v", err) + } +} diff --git a/accounts/templates.html b/accounts/templates.html new file mode 100644 index 0000000..1a47790 --- /dev/null +++ b/accounts/templates.html @@ -0,0 +1,110 @@ +{{define "head"}}<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="robots" content="noindex" /> + <title>{{.}} — Recently Written</title> + <link rel="stylesheet" href="/style.css" /> +</head> +<body> +<div id="container"> + + <div id="top"> + <a class="site-title" href="/index.html">Recently Written</a> + <nav> + <a href="/index.html">Home</a> + <a href="/rss.xml">rss</a> + <a href="/account/">subscribe</a> + </nav> + </div> + + <h1 class="post-title">{{.}}</h1> +{{end}} + +{{define "foot"}} + <div id="footer"> + <a href="/index.html">← Home</a> + </div> + +</div> +</body> +</html> +{{end}} + +{{define "signin"}}{{template "head" "Subscribe"}} + <p>Get an email when a new post goes up, and a private feed for your + feed reader app. Type your address. We send you a link to sign in. You + need no password.</p> + {{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}} + <form method="post" action="/account/signin"> + <input type="email" name="email" required maxlength="254" autocomplete="email" /> + <button type="submit">Send the link</button> + </form> + <p>The public feed needs no account: <a href="/rss.xml">rss.xml</a>.</p> +{{template "foot"}}{{end}} + +{{define "sent"}}{{template "head" "Check your email"}} + <p>If the address is valid, a sign-in link is on its way. The link works + one time and expires in 15 minutes.</p> +{{template "foot"}}{{end}} + +{{define "verify"}}{{template "head" "Sign in"}} + <form method="post" action="/account/verify"> + <input type="hidden" name="t" value="{{.}}" /> + <button type="submit">Sign in</button> + </form> +{{template "foot"}}{{end}} + +{{define "badlink"}}{{template "head" "Link not valid"}} + <p>The link is not valid. It was used before, or it expired. + <a href="/account/">Ask for a new link</a>.</p> +{{template "foot"}}{{end}} + +{{define "settings"}}{{template "head" "Your subscription"}} + <p>Signed in as {{.Email}}.</p> + + <h2 class="section-heading">Email</h2> + <form method="post" action="/account/settings"> + <p><label><input type="checkbox" name="email_on" value="1"{{if .EmailOn}} checked{{end}} /> + Email me when a new post goes up</label></p> + {{if .Tags}}<p>Send posts with these tags (the private feed uses them too):</p> + {{range .Tags}}<p><label><input type="checkbox" name="tag" value="{{.Name}}"{{if .On}} checked{{end}} /> + {{.Name}}</label></p> + {{end}}{{end}} + <button type="submit">Save</button> + </form> + + <h2 class="section-heading">Private feed</h2> + <p>Add this URL to a feed reader app on Android, iOS, or a computer + (for example Feedly, Inoreader, NetNewsWire, or Feeder). Keep it private.</p> + <p><input type="text" readonly value="{{.FeedURL}}" size="60" onclick="this.select()" /></p> + <form method="post" action="/account/feed/rotate"> + <button type="submit">Make a new URL</button> The old URL then stops working. + </form> + + <h2 class="section-heading">Account</h2> + <form method="post" action="/account/signout"> + <button type="submit">Sign out</button> + </form> + <form method="post" action="/account/delete" onsubmit="return confirm('Delete your account?')"> + <p><button type="submit">Delete my account</button> + This removes your address and settings. You cannot undo it.</p> + </form> +{{template "foot"}}{{end}} + +{{define "unsubscribe"}}{{template "head" "Unsubscribe"}} + <form method="post" action="/account/unsubscribe"> + <input type="hidden" name="t" value="{{.}}" /> + <button type="submit">Stop the emails</button> + </form> +{{template "foot"}}{{end}} + +{{define "unsubscribed"}}{{template "head" "Unsubscribed"}} + <p>You get no more emails. To start them again, or to delete your + account, <a href="/account/">sign in</a>.</p> +{{template "foot"}}{{end}} + +{{define "deleted"}}{{template "head" "Account deleted"}} + <p>We removed your account.</p> +{{template "foot"}}{{end}} diff --git a/build.sh b/build.sh index c9b7663..f005760 100755 --- a/build.sh +++ b/build.sh @@ -63,6 +63,7 @@ cat > "$TEMPLATE" << 'TMPL' <a href="esperanto.html">esperanto</a> <a href="call.html">contact</a> <a href="rss.xml">rss</a> + <a href="account/">subscribe</a> </nav> </div> @@ -219,6 +220,19 @@ absolute() { printf '</channel>\n</rss>\n' } > "$SITE_DIR/rss.xml" +# ── post data for the accounts service ────────────────────── +# accounts/ reads these files to send email about new posts and to make +# the private feed of each user. index.tsv has one line for each post, +# newest first: date, slug, tags, title. postdata/<slug>.html has the body +# of the post, with absolute links. +mkdir -p "$SITE_DIR/postdata" +while IFS="$TAB" read -r order date slug title; do + [ -z "$slug" ] && continue + printf '%s\t%s\t%s\t%s\n' "$date" "$slug" "$(meta "$POSTS_DIR/$slug.md" tags)" "$title" + pandoc --from markdown --to html5 "$POSTS_DIR/$slug.md" < /dev/null | + absolute > "$SITE_DIR/postdata/$slug.html" +done < "$WORK/sorted.tsv" > "$SITE_DIR/postdata/index.tsv" + echo "Built $(ls "$SITE_DIR"/*.html | wc -l | tr -d ' ') pages → $SITE_DIR/" echo " posts: $(wc -l < "$WORK/posts.tsv" | tr -d ' ') pages: $(wc -l < "$WORK/pages.tsv" | tr -d ' ') feed: $(grep -c '<item>' "$SITE_DIR/rss.xml") items" diff --git a/index.html b/index.html index 6afa43b..95e04dc 100755 --- a/index.html +++ b/index.html @@ -29,6 +29,7 @@ <a href="esperanto.html">esperanto</a> <a href="call.html">contact</a> <a href="rss.xml">rss</a> + <a href="account/">subscribe</a> </nav> </div>