repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
e908a4c
parent
403635e
author
Eric Bower
date
2022-07-29 03:34:04 +0000 UTC
refactor: merge all services code
113 files changed,  +9551, -40
M go.mod
M go.sum
A .env.example
+23, -0
 1@@ -0,0 +1,23 @@
 2+DATABASE_URL="postgresql://postgres:secret@localhost:5432/pico?sslmode=disable"
 3+POSTGRES_PASSWORD="secret"
 4+
 5+LISTS_SSH_PORT="2222"
 6+LISTS_WEB_PORT="3000"
 7+LISTS_DOMAIN="lists.test:3000"
 8+LISTS_EMAIL="my@email.com"
 9+LISTS_SUBDOMAINS=1
10+LISTS_PROTOCOL="http"
11+
12+PASTES_SSH_PORT="2222"
13+PASTES_WEB_PORT="3000"
14+PASTES_DOMAIN="pastes.test:3000"
15+PASTES_EMAIL="my@email.com"
16+PASTES_SUBDOMAINS=1
17+PASTES_PROTOCOL="http"
18+
19+PROSE_SSH_PORT="2222"
20+PROSE_WEB_PORT="3000"
21+PROSE_DOMAIN="prose.test:3000"
22+PROSE_EMAIL="my@email.com"
23+PROSE_SUBDOMAINS=1
24+PROSE_PROTOCOL="http"
A Dockerfile.caddy
+8, -0
1@@ -0,0 +1,8 @@
2+FROM caddy:builder-alpine AS builder
3+
4+RUN xcaddy build \
5+    --with github.com/caddy-dns/cloudflare
6+
7+FROM caddy:alpine
8+
9+COPY --from=builder /usr/bin/caddy /usr/bin/caddy
A cmd/lists/gemini/main.go
+7, -0
1@@ -0,0 +1,7 @@
2+package main
3+
4+import "git.sr.ht/~erock/pico/lists/gemini"
5+
6+func main() {
7+	gemini.StartServer()
8+}
A cmd/lists/ssh/main.go
+94, -0
 1@@ -0,0 +1,94 @@
 2+package main
 3+
 4+import (
 5+	"context"
 6+	"fmt"
 7+	"os"
 8+	"os/signal"
 9+	"syscall"
10+	"time"
11+
12+	"git.sr.ht/~erock/pico/lists"
13+	"git.sr.ht/~erock/wish/cms"
14+	"git.sr.ht/~erock/wish/cms/db/postgres"
15+	"git.sr.ht/~erock/wish/proxy"
16+	"git.sr.ht/~erock/wish/send/scp"
17+	"git.sr.ht/~erock/wish/send/sftp"
18+	"github.com/charmbracelet/wish"
19+	bm "github.com/charmbracelet/wish/bubbletea"
20+	lm "github.com/charmbracelet/wish/logging"
21+	"github.com/gliderlabs/ssh"
22+)
23+
24+type SSHServer struct{}
25+
26+func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
27+	return true
28+}
29+
30+func createRouter(handler *internal.DbHandler) proxy.Router {
31+	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
32+		cmd := s.Command()
33+		mdw := []wish.Middleware{}
34+
35+		if len(cmd) == 0 {
36+			mdw = append(mdw,
37+				bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg)),
38+				lm.Middleware(),
39+			)
40+		} else if cmd[0] == "scp" {
41+			mdw = append(mdw, scp.Middleware(handler))
42+		}
43+
44+		return mdw
45+	}
46+}
47+
48+func withProxy(handler *internal.DbHandler) ssh.Option {
49+	return func(server *ssh.Server) error {
50+		err := sftp.SSHOption(handler)(server)
51+		if err != nil {
52+			return err
53+		}
54+
55+		return proxy.WithProxy(createRouter(handler))(server)
56+	}
57+}
58+
59+func main() {
60+	host := internal.GetEnv("PROSE_HOST", "0.0.0.0")
61+	port := internal.GetEnv("PROSE_SSH_PORT", "2222")
62+	cfg := internal.NewConfigSite()
63+	logger := cfg.Logger
64+	dbh := postgres.NewDB(&cfg.ConfigCms)
65+	defer dbh.Close()
66+	handler := internal.NewDbHandler(dbh, cfg)
67+
68+	sshServer := &SSHServer{}
69+	s, err := wish.NewServer(
70+		wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
71+		wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
72+		wish.WithPublicKeyAuth(sshServer.authHandler),
73+		withProxy(handler),
74+	)
75+	if err != nil {
76+		logger.Fatal(err)
77+	}
78+
79+	done := make(chan os.Signal, 1)
80+	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
81+	logger.Infof("Starting SSH server on %s:%s", host, port)
82+	go func() {
83+		if err = s.ListenAndServe(); err != nil {
84+			logger.Fatal(err)
85+		}
86+	}()
87+
88+	<-done
89+	logger.Info("Stopping SSH server")
90+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
91+	defer func() { cancel() }()
92+	if err := s.Shutdown(ctx); err != nil {
93+		logger.Fatal(err)
94+	}
95+}
A cmd/lists/web/main.go
+7, -0
1@@ -0,0 +1,7 @@
2+package main
3+
4+import "git.sr.ht/~erock/pico/lists"
5+
6+func main() {
7+	internal.StartApiServer()
8+}
A cmd/pastes/ssh/main.go
+94, -0
 1@@ -0,0 +1,94 @@
 2+package main
 3+
 4+import (
 5+	"context"
 6+	"fmt"
 7+	"os"
 8+	"os/signal"
 9+	"syscall"
10+	"time"
11+
12+	"git.sr.ht/~erock/pico/pastes"
13+	"git.sr.ht/~erock/wish/cms"
14+	"git.sr.ht/~erock/wish/cms/db/postgres"
15+	"git.sr.ht/~erock/wish/proxy"
16+	"git.sr.ht/~erock/wish/send/scp"
17+	"git.sr.ht/~erock/wish/send/sftp"
18+	"github.com/charmbracelet/wish"
19+	bm "github.com/charmbracelet/wish/bubbletea"
20+	lm "github.com/charmbracelet/wish/logging"
21+	"github.com/gliderlabs/ssh"
22+)
23+
24+type SSHServer struct{}
25+
26+func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
27+	return true
28+}
29+
30+func createRouter(handler *internal.DbHandler) proxy.Router {
31+	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
32+		cmd := s.Command()
33+		mdw := []wish.Middleware{}
34+
35+		if len(cmd) == 0 {
36+			mdw = append(mdw,
37+				bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg)),
38+				lm.Middleware(),
39+			)
40+		} else if cmd[0] == "scp" {
41+			mdw = append(mdw, scp.Middleware(handler))
42+		}
43+
44+		return mdw
45+	}
46+}
47+
48+func withProxy(handler *internal.DbHandler) ssh.Option {
49+	return func(server *ssh.Server) error {
50+		err := sftp.SSHOption(handler)(server)
51+		if err != nil {
52+			return err
53+		}
54+
55+		return proxy.WithProxy(createRouter(handler))(server)
56+	}
57+}
58+
59+func main() {
60+	host := internal.GetEnv("PROSE_HOST", "0.0.0.0")
61+	port := internal.GetEnv("PROSE_SSH_PORT", "2222")
62+	cfg := internal.NewConfigSite()
63+	logger := cfg.Logger
64+	dbh := postgres.NewDB(&cfg.ConfigCms)
65+	defer dbh.Close()
66+	handler := internal.NewDbHandler(dbh, cfg)
67+
68+	sshServer := &SSHServer{}
69+	s, err := wish.NewServer(
70+		wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
71+		wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
72+		wish.WithPublicKeyAuth(sshServer.authHandler),
73+		withProxy(handler),
74+	)
75+	if err != nil {
76+		logger.Fatal(err)
77+	}
78+
79+	done := make(chan os.Signal, 1)
80+	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
81+	logger.Infof("Starting SSH server on %s:%s", host, port)
82+	go func() {
83+		if err = s.ListenAndServe(); err != nil {
84+			logger.Fatal(err)
85+		}
86+	}()
87+
88+	<-done
89+	logger.Info("Stopping SSH server")
90+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
91+	defer func() { cancel() }()
92+	if err := s.Shutdown(ctx); err != nil {
93+		logger.Fatal(err)
94+	}
95+}
A cmd/pastes/web/main.go
+7, -0
1@@ -0,0 +1,7 @@
2+package main
3+
4+import "git.sr.ht/~erock/pico/pastes"
5+
6+func main() {
7+	internal.StartApiServer()
8+}
A cmd/prose/ssh/main.go
+94, -0
 1@@ -0,0 +1,94 @@
 2+package main
 3+
 4+import (
 5+	"context"
 6+	"fmt"
 7+	"os"
 8+	"os/signal"
 9+	"syscall"
10+	"time"
11+
12+	"git.sr.ht/~erock/pico/prose"
13+	"git.sr.ht/~erock/wish/cms"
14+	"git.sr.ht/~erock/wish/cms/db/postgres"
15+	"git.sr.ht/~erock/wish/proxy"
16+	"git.sr.ht/~erock/wish/send/scp"
17+	"git.sr.ht/~erock/wish/send/sftp"
18+	"github.com/charmbracelet/wish"
19+	bm "github.com/charmbracelet/wish/bubbletea"
20+	lm "github.com/charmbracelet/wish/logging"
21+	"github.com/gliderlabs/ssh"
22+)
23+
24+type SSHServer struct{}
25+
26+func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
27+	return true
28+}
29+
30+func createRouter(handler *internal.DbHandler) proxy.Router {
31+	return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
32+		cmd := s.Command()
33+		mdw := []wish.Middleware{}
34+
35+		if len(cmd) == 0 {
36+			mdw = append(mdw,
37+				bm.Middleware(cms.Middleware(&handler.Cfg.ConfigCms, handler.Cfg)),
38+				lm.Middleware(),
39+			)
40+		} else if cmd[0] == "scp" {
41+			mdw = append(mdw, scp.Middleware(handler))
42+		}
43+
44+		return mdw
45+	}
46+}
47+
48+func withProxy(handler *internal.DbHandler) ssh.Option {
49+	return func(server *ssh.Server) error {
50+		err := sftp.SSHOption(handler)(server)
51+		if err != nil {
52+			return err
53+		}
54+
55+		return proxy.WithProxy(createRouter(handler))(server)
56+	}
57+}
58+
59+func main() {
60+	host := internal.GetEnv("PROSE_HOST", "0.0.0.0")
61+	port := internal.GetEnv("PROSE_SSH_PORT", "2222")
62+	cfg := internal.NewConfigSite()
63+	logger := cfg.Logger
64+	dbh := postgres.NewDB(&cfg.ConfigCms)
65+	defer dbh.Close()
66+	handler := internal.NewDbHandler(dbh, cfg)
67+
68+	sshServer := &SSHServer{}
69+	s, err := wish.NewServer(
70+		wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
71+		wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
72+		wish.WithPublicKeyAuth(sshServer.authHandler),
73+		withProxy(handler),
74+	)
75+	if err != nil {
76+		logger.Fatal(err)
77+	}
78+
79+	done := make(chan os.Signal, 1)
80+	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
81+	logger.Infof("Starting SSH server on %s:%s", host, port)
82+	go func() {
83+		if err = s.ListenAndServe(); err != nil {
84+			logger.Fatal(err)
85+		}
86+	}()
87+
88+	<-done
89+	logger.Info("Stopping SSH server")
90+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
91+	defer func() { cancel() }()
92+	if err := s.Shutdown(ctx); err != nil {
93+		logger.Fatal(err)
94+	}
95+}
A cmd/prose/web/main.go
+7, -0
1@@ -0,0 +1,7 @@
2+package main
3+
4+import "git.sr.ht/~erock/pico/prose"
5+
6+func main() {
7+	internal.StartApiServer()
8+}
M go.mod
+42, -4
 1@@ -5,13 +5,51 @@ go 1.18
 2 // replace git.sr.ht/~erock/wish => /home/erock/pico/wish
 3 
 4 require (
 5-	git.sr.ht/~erock/wish v0.0.0-20220728012620-699415a43292
 6+	git.sr.ht/~adnano/go-gemini v0.2.3
 7+	git.sr.ht/~aw/gorilla-feeds v1.1.4
 8+	git.sr.ht/~erock/lists.sh v0.0.0-20220729004305-bc7f4fd43f42
 9+	git.sr.ht/~erock/pastes.sh v0.0.0-20220728142200-14b39ac0d571
10+	git.sr.ht/~erock/prose.sh v0.0.0-20220729004314-0162e0d18c80
11+	git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120
12+	github.com/alecthomas/chroma v0.10.0
13+	github.com/charmbracelet/wish v0.5.0
14+	github.com/gliderlabs/ssh v0.3.4
15+	github.com/gorilla/feeds v1.1.1
16+	github.com/yuin/goldmark v1.4.12
17+	github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
18+	github.com/yuin/goldmark-meta v1.1.0
19 	go.uber.org/zap v1.21.0
20+	golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75
21 )
22 
23 require (
24+	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
25+	github.com/atotto/clipboard v0.1.4 // indirect
26+	github.com/caarlos0/sshmarshal v0.1.0 // indirect
27+	github.com/charmbracelet/bubbles v0.13.0 // indirect
28+	github.com/charmbracelet/bubbletea v0.22.0 // indirect
29+	github.com/charmbracelet/keygen v0.3.0 // indirect
30+	github.com/charmbracelet/lipgloss v0.5.0 // indirect
31+	github.com/containerd/console v1.0.3 // indirect
32+	github.com/dlclark/regexp2 v1.7.0 // indirect
33+	github.com/kr/fs v0.1.0 // indirect
34 	github.com/lib/pq v1.10.6 // indirect
35-	go.uber.org/atomic v1.7.0 // indirect
36-	go.uber.org/multierr v1.6.0 // indirect
37-	golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d // indirect
38+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
39+	github.com/mattn/go-isatty v0.0.14 // indirect
40+	github.com/mattn/go-runewidth v0.0.13 // indirect
41+	github.com/mitchellh/go-homedir v1.1.0 // indirect
42+	github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
43+	github.com/muesli/cancelreader v0.2.2 // indirect
44+	github.com/muesli/reflow v0.3.0 // indirect
45+	github.com/muesli/termenv v0.12.0 // indirect
46+	github.com/pkg/sftp v1.13.5 // indirect
47+	github.com/rivo/uniseg v0.2.0 // indirect
48+	go.uber.org/atomic v1.9.0 // indirect
49+	go.uber.org/multierr v1.8.0 // indirect
50+	golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect
51+	golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect
52+	golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
53+	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
54+	golang.org/x/text v0.3.7 // indirect
55+	gopkg.in/yaml.v2 v2.4.0 // indirect
56 )
M go.sum
+118, -7
  1@@ -1,52 +1,161 @@
  2-git.sr.ht/~erock/wish v0.0.0-20220728012620-699415a43292 h1:KnP4IH79pVSf+yw8qe59KlzhOG9H+qbTMlXpFcDXopw=
  3-git.sr.ht/~erock/wish v0.0.0-20220728012620-699415a43292/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=
  4+git.sr.ht/~adnano/go-gemini v0.2.3 h1:oJ+Y0/mheZ4Vg0ABjtf5dlmvq1yoONStiaQvmWWkofc=
  5+git.sr.ht/~adnano/go-gemini v0.2.3/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
  6+git.sr.ht/~aw/gorilla-feeds v1.1.4 h1:bL78pZ1DtHEhumHK0iWQi30uwEkWtetMfnyt9TFcdlc=
  7+git.sr.ht/~aw/gorilla-feeds v1.1.4/go.mod h1:VLpbtNDEWoaJKU41Crj6r3ChvlqYvBm56c0O6IM457g=
  8+git.sr.ht/~erock/lists.sh v0.0.0-20220729004305-bc7f4fd43f42 h1:ZT0cN4f8dXOq5zvXVMfqC8IYjNBVzrmNI1rZMLotAYs=
  9+git.sr.ht/~erock/lists.sh v0.0.0-20220729004305-bc7f4fd43f42/go.mod h1:dnJtCor8uSE2ZciYWTOqfhcm5VtzP0uLAaDQw0rfzOk=
 10+git.sr.ht/~erock/pastes.sh v0.0.0-20220728142200-14b39ac0d571 h1:L7oqAflvoaLIdJsbikOEzA9gw49D/d3HRtC7PVo36AQ=
 11+git.sr.ht/~erock/pastes.sh v0.0.0-20220728142200-14b39ac0d571/go.mod h1:vzhDghntGRBVsWsV/sIC06A4/W+etmLp8y5ThNRR19A=
 12+git.sr.ht/~erock/prose.sh v0.0.0-20220729004314-0162e0d18c80 h1:e1bpAu44UjEbeObVfUC+oKoyXR+rJ25vz/n0rlcPAJE=
 13+git.sr.ht/~erock/prose.sh v0.0.0-20220729004314-0162e0d18c80/go.mod h1:WYeWQcd9FWzofSswFSlmQWWEHFWClWj9CGFwA3t+Gi4=
 14+git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120 h1:9O4PKFF8JGvK9g3aVHr2wgozHK0s6BaVISPRl8MAovs=
 15+git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=
 16+github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
 17+github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
 18+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 19+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 20+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 21+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 22 github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
 23 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 24+github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I=
 25+github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
 26+github.com/charmbracelet/bubbles v0.13.0 h1:zP/ROH3wJEBqZWKIsD50ZKKlx3ydLInq3LdD/Nrlb8w=
 27+github.com/charmbracelet/bubbles v0.13.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
 28+github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
 29+github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
 30+github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
 31+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
 32+github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y=
 33+github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM=
 34+github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
 35+github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
 36+github.com/charmbracelet/wish v0.5.0 h1:FkkdNBFqrLABR1ciNrAL2KCxoyWfKhXnIGZw6GfAtPg=
 37+github.com/charmbracelet/wish v0.5.0/go.mod h1:5GAn5SrDSZ7cgKjnC+3kDmiIo7I6k4/AYiRzC4+tpCk=
 38+github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 39+github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 40 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 41 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 42 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 43+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 44+github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
 45+github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 46+github.com/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw=
 47+github.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914=
 48+github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
 49+github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
 50+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
 51+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 52+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 53 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 54 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 55 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 56+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 57+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 58 github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
 59 github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 60+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 61+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 62+github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
 63+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 64+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 65+github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 66+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 67+github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
 68+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 69+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 70+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 71+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
 72+github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
 73+github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
 74+github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 75+github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 76+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 77+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 78+github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
 79+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 80+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
 81+github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
 82+github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
 83+github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
 84+github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
 85 github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
 86 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 87+github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go=
 88+github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg=
 89 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 90 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 91+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 92+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 93+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 94+github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 95 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 96 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 97 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 98 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 99 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
100-go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
101+github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
102+github.com/yuin/goldmark v1.4.12 h1:6hffw6vALvEDqJ19dOJvJKOoAOKe4NDaTqvd2sktGN0=
103+github.com/yuin/goldmark v1.4.12/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
104+github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg=
105+github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU=
106+github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
107+github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
108 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
109+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
110+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
111 go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
112 go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
113-go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
114 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
115+go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
116+go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
117 go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
118 go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
119 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
120 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
121-golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0=
122-golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
123+golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
124+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
125+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
126+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
127+golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75 h1:x03zeu7B2B11ySp+daztnwM5oBJ/8wGUSqrwcw9L0RA=
128+golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
129 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
130 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
131 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
132 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
133 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
134+golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
135+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
136 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
137+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
138+golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ=
139+golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
140 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
141 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
142 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
143 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
144 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
145+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
146 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
147+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
148 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
149+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
150+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
151+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
152+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
153+golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
154+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
155+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
156+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
157 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
158+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
159+golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
160+golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
161 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
162 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
163+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
164+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
165+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
166 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
167 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
168 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
169@@ -55,9 +164,11 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
170 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
171 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
172 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
173+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
174 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
175-gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
176 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
177+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
178+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
179 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
180 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
181 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
A lists/Dockerfile
+29, -0
 1@@ -0,0 +1,29 @@
 2+FROM golang:1.18.1-alpine3.15 AS builder
 3+
 4+RUN apk add --no-cache git
 5+
 6+WORKDIR /app
 7+COPY . ./
 8+
 9+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/ssh ./cmd/lists/ssh
10+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/web ./cmd/lists/web
11+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/gemini ./cmd/lists/gemini
12+
13+FROM alpine:3.15 AS ssh
14+WORKDIR /app
15+COPY --from=0 /app/build/ssh ./
16+CMD ["./ssh"]
17+
18+FROM alpine:3.15 AS web
19+WORKDIR /app
20+COPY --from=0 /app/build/web ./
21+COPY --from=0 /app/html ./html
22+COPY --from=0 /app/public ./public
23+CMD ["./web"]
24+
25+FROM alpine:3.15 AS gemini
26+WORKDIR /app
27+COPY --from=0 /app/build/gemini ./
28+COPY --from=0 /app/gmi ./gmi
29+ENV LISTS_SUBDOMAINS=0
30+CMD ["./gemini"]
A lists/Dockerfile.caddy
+8, -0
1@@ -0,0 +1,8 @@
2+FROM caddy:builder-alpine AS builder
3+
4+RUN xcaddy build \
5+    --with github.com/caddy-dns/cloudflare
6+
7+FROM caddy:alpine
8+
9+COPY --from=builder /usr/bin/caddy /usr/bin/caddy
A lists/api.go
+709, -0
  1@@ -0,0 +1,709 @@
  2+package internal
  3+
  4+import (
  5+	"bytes"
  6+	"fmt"
  7+	"html/template"
  8+	"io/ioutil"
  9+	"net/http"
 10+	"net/url"
 11+	"strconv"
 12+	"time"
 13+
 14+	"git.sr.ht/~erock/lists.sh/pkg"
 15+	"git.sr.ht/~erock/wish/cms/db"
 16+	"git.sr.ht/~erock/wish/cms/db/postgres"
 17+	"github.com/gorilla/feeds"
 18+	"golang.org/x/exp/slices"
 19+)
 20+
 21+type PageData struct {
 22+	Site SitePageData
 23+}
 24+
 25+type PostItemData struct {
 26+	URL            template.URL
 27+	BlogURL        template.URL
 28+	Username       string
 29+	Title          string
 30+	Description    string
 31+	PublishAtISO   string
 32+	PublishAt      string
 33+	UpdatedAtISO   string
 34+	UpdatedTimeAgo string
 35+	Padding        string
 36+}
 37+
 38+type BlogPageData struct {
 39+	Site      SitePageData
 40+	PageTitle string
 41+	URL       template.URL
 42+	RSSURL    template.URL
 43+	Username  string
 44+	Readme    *ReadmeTxt
 45+	Header    *HeaderTxt
 46+	Posts     []PostItemData
 47+}
 48+
 49+type ReadPageData struct {
 50+	Site     SitePageData
 51+	NextPage string
 52+	PrevPage string
 53+	Posts    []PostItemData
 54+}
 55+
 56+type PostPageData struct {
 57+	Site         SitePageData
 58+	PageTitle    string
 59+	URL          template.URL
 60+	BlogURL      template.URL
 61+	Title        string
 62+	Description  string
 63+	Username     string
 64+	BlogName     string
 65+	ListType     string
 66+	Items        []*pkg.ListItem
 67+	PublishAtISO string
 68+	PublishAt    string
 69+}
 70+
 71+type TransparencyPageData struct {
 72+	Site      SitePageData
 73+	Analytics *db.Analytics
 74+}
 75+
 76+func isRequestTrackable(r *http.Request) bool {
 77+	return true
 78+}
 79+
 80+func renderTemplate(templates []string) (*template.Template, error) {
 81+	files := make([]string, len(templates))
 82+	copy(files, templates)
 83+	files = append(
 84+		files,
 85+		"./html/footer.partial.tmpl",
 86+		"./html/marketing-footer.partial.tmpl",
 87+		"./html/base.layout.tmpl",
 88+	)
 89+
 90+	ts, err := template.ParseFiles(files...)
 91+	if err != nil {
 92+		return nil, err
 93+	}
 94+	return ts, nil
 95+}
 96+
 97+func createPageHandler(fname string) http.HandlerFunc {
 98+	return func(w http.ResponseWriter, r *http.Request) {
 99+		logger := GetLogger(r)
100+		cfg := GetCfg(r)
101+		ts, err := renderTemplate([]string{fname})
102+
103+		if err != nil {
104+			logger.Error(err)
105+			http.Error(w, err.Error(), http.StatusInternalServerError)
106+			return
107+		}
108+
109+		data := PageData{
110+			Site: *cfg.GetSiteData(),
111+		}
112+		err = ts.Execute(w, data)
113+		if err != nil {
114+			logger.Error(err)
115+			http.Error(w, err.Error(), http.StatusInternalServerError)
116+		}
117+	}
118+}
119+
120+type HeaderTxt struct {
121+	Title    string
122+	Bio      string
123+	Nav      []*pkg.ListItem
124+	HasItems bool
125+}
126+
127+type ReadmeTxt struct {
128+	HasItems bool
129+	ListType string
130+	Items    []*pkg.ListItem
131+}
132+
133+func GetUsernameFromRequest(r *http.Request) string {
134+	subdomain := GetSubdomain(r)
135+	cfg := GetCfg(r)
136+
137+	if !cfg.IsSubdomains() || subdomain == "" {
138+		return GetField(r, 0)
139+	}
140+	return subdomain
141+}
142+
143+func blogHandler(w http.ResponseWriter, r *http.Request) {
144+	username := GetUsernameFromRequest(r)
145+	dbpool := GetDB(r)
146+	logger := GetLogger(r)
147+	cfg := GetCfg(r)
148+
149+	user, err := dbpool.FindUserForName(username)
150+	if err != nil {
151+		logger.Infof("blog not found: %s", username)
152+		http.Error(w, "blog not found", http.StatusNotFound)
153+		return
154+	}
155+	posts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)
156+	if err != nil {
157+		logger.Error(err)
158+		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
159+		return
160+	}
161+
162+	ts, err := renderTemplate([]string{
163+		"./html/blog.page.tmpl",
164+		"./html/list.partial.tmpl",
165+	})
166+
167+	if err != nil {
168+		logger.Error(err)
169+		http.Error(w, err.Error(), http.StatusInternalServerError)
170+		return
171+	}
172+
173+	headerTxt := &HeaderTxt{
174+		Title: GetBlogName(username),
175+		Bio:   "",
176+	}
177+	readmeTxt := &ReadmeTxt{}
178+
179+	postCollection := make([]PostItemData, 0, len(posts))
180+	for _, post := range posts {
181+		if post.Filename == "_header" {
182+			parsedText := pkg.ParseText(post.Text)
183+			if parsedText.MetaData.Title != "" {
184+				headerTxt.Title = parsedText.MetaData.Title
185+			}
186+
187+			if parsedText.MetaData.Description != "" {
188+				headerTxt.Bio = parsedText.MetaData.Description
189+			}
190+
191+			headerTxt.Nav = parsedText.Items
192+			if len(headerTxt.Nav) > 0 {
193+				headerTxt.HasItems = true
194+			}
195+		} else if post.Filename == "_readme" {
196+			parsedText := pkg.ParseText(post.Text)
197+			readmeTxt.Items = parsedText.Items
198+			readmeTxt.ListType = parsedText.MetaData.ListType
199+			if len(readmeTxt.Items) > 0 {
200+				readmeTxt.HasItems = true
201+			}
202+		} else {
203+			p := PostItemData{
204+				URL:            template.URL(cfg.PostURL(post.Username, post.Filename)),
205+				BlogURL:        template.URL(cfg.BlogURL(post.Username)),
206+				Title:          FilenameToTitle(post.Filename, post.Title),
207+				PublishAt:      post.PublishAt.Format("02 Jan, 2006"),
208+				PublishAtISO:   post.PublishAt.Format(time.RFC3339),
209+				UpdatedTimeAgo: TimeAgo(post.UpdatedAt),
210+				UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
211+			}
212+			postCollection = append(postCollection, p)
213+		}
214+	}
215+
216+	data := BlogPageData{
217+		Site:      *cfg.GetSiteData(),
218+		PageTitle: headerTxt.Title,
219+		URL:       template.URL(cfg.BlogURL(username)),
220+		RSSURL:    template.URL(cfg.RssBlogURL(username)),
221+		Readme:    readmeTxt,
222+		Header:    headerTxt,
223+		Username:  username,
224+		Posts:     postCollection,
225+	}
226+
227+	err = ts.Execute(w, data)
228+	if err != nil {
229+		logger.Error(err)
230+		http.Error(w, err.Error(), http.StatusInternalServerError)
231+	}
232+}
233+
234+func GetPostTitle(post *db.Post) string {
235+	if post.Description == "" {
236+		return post.Title
237+	}
238+
239+	return fmt.Sprintf("%s: %s", post.Title, post.Description)
240+}
241+
242+func GetBlogName(username string) string {
243+	return fmt.Sprintf("%s's lists", username)
244+}
245+
246+func postHandler(w http.ResponseWriter, r *http.Request) {
247+	username := GetUsernameFromRequest(r)
248+	subdomain := GetSubdomain(r)
249+	cfg := GetCfg(r)
250+
251+	var filename string
252+	if !cfg.IsSubdomains() || subdomain == "" {
253+		filename, _ = url.PathUnescape(GetField(r, 1))
254+	} else {
255+		filename, _ = url.PathUnescape(GetField(r, 0))
256+	}
257+
258+	dbpool := GetDB(r)
259+	logger := GetLogger(r)
260+
261+	user, err := dbpool.FindUserForName(username)
262+	if err != nil {
263+		logger.Infof("blog not found: %s", username)
264+		http.Error(w, "blog not found", http.StatusNotFound)
265+		return
266+	}
267+
268+	header, _ := dbpool.FindPostWithFilename("_header", user.ID, cfg.Space)
269+	blogName := GetBlogName(username)
270+	if header != nil {
271+		headerParsed := pkg.ParseText(header.Text)
272+		if headerParsed.MetaData.Title != "" {
273+			blogName = headerParsed.MetaData.Title
274+		}
275+	}
276+
277+	var data PostPageData
278+	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
279+	if err == nil {
280+		parsedText := pkg.ParseText(post.Text)
281+
282+		// we need the blog name from the readme unfortunately
283+		readme, err := dbpool.FindPostWithFilename("_readme", user.ID, cfg.Space)
284+		if err == nil {
285+			readmeParsed := pkg.ParseText(readme.Text)
286+			if readmeParsed.MetaData.Title != "" {
287+				blogName = readmeParsed.MetaData.Title
288+			}
289+		}
290+
291+		// validate and fire off analytic event
292+		if isRequestTrackable(r) {
293+			_, err := dbpool.AddViewCount(post.ID)
294+			if err != nil {
295+				logger.Error(err)
296+			}
297+		}
298+
299+		data = PostPageData{
300+			Site:         *cfg.GetSiteData(),
301+			PageTitle:    GetPostTitle(post),
302+			URL:          template.URL(cfg.PostURL(post.Username, post.Filename)),
303+			BlogURL:      template.URL(cfg.BlogURL(username)),
304+			Description:  post.Description,
305+			ListType:     parsedText.MetaData.ListType,
306+			Title:        FilenameToTitle(post.Filename, post.Title),
307+			PublishAt:    post.PublishAt.Format("02 Jan, 2006"),
308+			PublishAtISO: post.PublishAt.Format(time.RFC3339),
309+			Username:     username,
310+			BlogName:     blogName,
311+			Items:        parsedText.Items,
312+		}
313+	} else {
314+		logger.Infof("post not found %s/%s", username, filename)
315+		data = PostPageData{
316+			Site:         *cfg.GetSiteData(),
317+			PageTitle:    "Post not found",
318+			Description:  "Post not found",
319+			Title:        "Post not found",
320+			ListType:     "none",
321+			BlogURL:      template.URL(cfg.BlogURL(username)),
322+			PublishAt:    time.Now().Format("02 Jan, 2006"),
323+			PublishAtISO: time.Now().Format(time.RFC3339),
324+			Username:     username,
325+			BlogName:     blogName,
326+			Items: []*pkg.ListItem{
327+				{
328+					Value:  "oops!  we can't seem to find this post.",
329+					IsText: true,
330+				},
331+			},
332+		}
333+	}
334+
335+	ts, err := renderTemplate([]string{
336+		"./html/post.page.tmpl",
337+		"./html/list.partial.tmpl",
338+	})
339+
340+	if err != nil {
341+		http.Error(w, err.Error(), http.StatusInternalServerError)
342+	}
343+
344+	err = ts.Execute(w, data)
345+	if err != nil {
346+		logger.Error(err)
347+		http.Error(w, err.Error(), http.StatusInternalServerError)
348+	}
349+}
350+
351+func transparencyHandler(w http.ResponseWriter, r *http.Request) {
352+	dbpool := GetDB(r)
353+	logger := GetLogger(r)
354+	cfg := GetCfg(r)
355+
356+	analytics, err := dbpool.FindSiteAnalytics(cfg.Space)
357+	if err != nil {
358+		logger.Error(err)
359+		http.Error(w, err.Error(), http.StatusInternalServerError)
360+		return
361+	}
362+
363+	ts, err := template.ParseFiles(
364+		"./html/transparency.page.tmpl",
365+		"./html/footer.partial.tmpl",
366+		"./html/marketing-footer.partial.tmpl",
367+		"./html/base.layout.tmpl",
368+	)
369+
370+	if err != nil {
371+		http.Error(w, err.Error(), http.StatusInternalServerError)
372+	}
373+
374+	data := TransparencyPageData{
375+		Site:      *cfg.GetSiteData(),
376+		Analytics: analytics,
377+	}
378+	err = ts.Execute(w, data)
379+	if err != nil {
380+		logger.Error(err)
381+		http.Error(w, err.Error(), http.StatusInternalServerError)
382+	}
383+}
384+
385+func readHandler(w http.ResponseWriter, r *http.Request) {
386+	dbpool := GetDB(r)
387+	logger := GetLogger(r)
388+	cfg := GetCfg(r)
389+
390+	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
391+	pager, err := dbpool.FindAllUpdatedPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)
392+	if err != nil {
393+		logger.Error(err)
394+		http.Error(w, err.Error(), http.StatusInternalServerError)
395+		return
396+	}
397+
398+	ts, err := renderTemplate([]string{
399+		"./html/read.page.tmpl",
400+	})
401+
402+	if err != nil {
403+		http.Error(w, err.Error(), http.StatusInternalServerError)
404+	}
405+
406+	nextPage := ""
407+	if page < pager.Total-1 {
408+		nextPage = fmt.Sprintf("/read?page=%d", page+1)
409+	}
410+
411+	prevPage := ""
412+	if page > 0 {
413+		prevPage = fmt.Sprintf("/read?page=%d", page-1)
414+	}
415+
416+	data := ReadPageData{
417+		Site:     *cfg.GetSiteData(),
418+		NextPage: nextPage,
419+		PrevPage: prevPage,
420+	}
421+	for _, post := range pager.Data {
422+		item := PostItemData{
423+			URL:            template.URL(cfg.PostURL(post.Username, post.Filename)),
424+			BlogURL:        template.URL(cfg.BlogURL(post.Username)),
425+			Title:          FilenameToTitle(post.Filename, post.Title),
426+			Description:    post.Description,
427+			Username:       post.Username,
428+			PublishAt:      post.PublishAt.Format("02 Jan, 2006"),
429+			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
430+			UpdatedTimeAgo: TimeAgo(post.UpdatedAt),
431+			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
432+		}
433+		data.Posts = append(data.Posts, item)
434+	}
435+
436+	err = ts.Execute(w, data)
437+	if err != nil {
438+		logger.Error(err)
439+		http.Error(w, err.Error(), http.StatusInternalServerError)
440+	}
441+}
442+
443+func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
444+	username := GetUsernameFromRequest(r)
445+	dbpool := GetDB(r)
446+	logger := GetLogger(r)
447+	cfg := GetCfg(r)
448+
449+	user, err := dbpool.FindUserForName(username)
450+	if err != nil {
451+		logger.Infof("rss feed not found: %s", username)
452+		http.Error(w, "rss feed not found", http.StatusNotFound)
453+		return
454+	}
455+	posts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)
456+	if err != nil {
457+		logger.Error(err)
458+		http.Error(w, err.Error(), http.StatusInternalServerError)
459+		return
460+	}
461+
462+	ts, err := template.ParseFiles("./html/rss.page.tmpl", "./html/list.partial.tmpl")
463+	if err != nil {
464+		logger.Error(err)
465+		http.Error(w, err.Error(), http.StatusInternalServerError)
466+		return
467+	}
468+
469+	headerTxt := &HeaderTxt{
470+		Title: GetBlogName(username),
471+	}
472+
473+	for _, post := range posts {
474+		if post.Filename == "_header" {
475+			parsedText := pkg.ParseText(post.Text)
476+			if parsedText.MetaData.Title != "" {
477+				headerTxt.Title = parsedText.MetaData.Title
478+			}
479+
480+			if parsedText.MetaData.Description != "" {
481+				headerTxt.Bio = parsedText.MetaData.Description
482+			}
483+
484+			break
485+		}
486+	}
487+
488+	feed := &feeds.Feed{
489+		Title:       headerTxt.Title,
490+		Link:        &feeds.Link{Href: cfg.BlogURL(username)},
491+		Description: headerTxt.Bio,
492+		Author:      &feeds.Author{Name: username},
493+		Created:     time.Now(),
494+	}
495+
496+	var feedItems []*feeds.Item
497+	for _, post := range posts {
498+		if slices.Contains(HiddenPosts, post.Filename) {
499+			continue
500+		}
501+
502+		parsed := pkg.ParseText(post.Text)
503+		var tpl bytes.Buffer
504+		data := &PostPageData{
505+			ListType: parsed.MetaData.ListType,
506+			Items:    parsed.Items,
507+		}
508+		if err := ts.Execute(&tpl, data); err != nil {
509+			continue
510+		}
511+
512+		item := &feeds.Item{
513+			Id:      cfg.PostURL(post.Username, post.Filename),
514+			Title:   FilenameToTitle(post.Filename, post.Title),
515+			Link:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},
516+			Content: tpl.String(),
517+			Created: *post.PublishAt,
518+		}
519+
520+		if post.Description != "" {
521+			item.Description = post.Description
522+		}
523+
524+		feedItems = append(feedItems, item)
525+	}
526+	feed.Items = feedItems
527+
528+	rss, err := feed.ToAtom()
529+	if err != nil {
530+		logger.Error(err)
531+		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
532+	}
533+
534+	w.Header().Add("Content-Type", "application/atom+xml")
535+	_, err = w.Write([]byte(rss))
536+	if err != nil {
537+		logger.Error(err)
538+	}
539+}
540+
541+func rssHandler(w http.ResponseWriter, r *http.Request) {
542+	dbpool := GetDB(r)
543+	logger := GetLogger(r)
544+	cfg := GetCfg(r)
545+
546+	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
547+	if err != nil {
548+		logger.Error(err)
549+		http.Error(w, err.Error(), http.StatusInternalServerError)
550+		return
551+	}
552+
553+	ts, err := template.ParseFiles("./html/rss.page.tmpl", "./html/list.partial.tmpl")
554+	if err != nil {
555+		logger.Error(err)
556+		http.Error(w, err.Error(), http.StatusInternalServerError)
557+		return
558+	}
559+
560+	feed := &feeds.Feed{
561+		Title:       fmt.Sprintf("%s discovery feed", cfg.Domain),
562+		Link:        &feeds.Link{Href: cfg.ReadURL()},
563+		Description: fmt.Sprintf("%s latest posts", cfg.Domain),
564+		Author:      &feeds.Author{Name: cfg.Domain},
565+		Created:     time.Now(),
566+	}
567+
568+	var feedItems []*feeds.Item
569+	for _, post := range pager.Data {
570+		parsed := pkg.ParseText(post.Text)
571+		var tpl bytes.Buffer
572+		data := &PostPageData{
573+			ListType: parsed.MetaData.ListType,
574+			Items:    parsed.Items,
575+		}
576+		if err := ts.Execute(&tpl, data); err != nil {
577+			continue
578+		}
579+
580+		item := &feeds.Item{
581+			Id:      cfg.PostURL(post.Username, post.Filename),
582+			Title:   post.Title,
583+			Link:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},
584+			Content: tpl.String(),
585+			Created: *post.PublishAt,
586+		}
587+
588+		if post.Description != "" {
589+			item.Description = post.Description
590+		}
591+
592+		feedItems = append(feedItems, item)
593+	}
594+	feed.Items = feedItems
595+
596+	rss, err := feed.ToAtom()
597+	if err != nil {
598+		logger.Error(err)
599+		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
600+	}
601+
602+	w.Header().Add("Content-Type", "application/atom+xml")
603+	_, err = w.Write([]byte(rss))
604+	if err != nil {
605+		logger.Error(err)
606+	}
607+}
608+
609+func serveFile(file string, contentType string) http.HandlerFunc {
610+	return func(w http.ResponseWriter, r *http.Request) {
611+		logger := GetLogger(r)
612+
613+		contents, err := ioutil.ReadFile(fmt.Sprintf("./public/%s", file))
614+		if err != nil {
615+			logger.Error(err)
616+			http.Error(w, "file not found", 404)
617+		}
618+
619+		w.Header().Add("Content-Type", contentType)
620+
621+		_, err = w.Write(contents)
622+		if err != nil {
623+			logger.Error(err)
624+		}
625+	}
626+}
627+
628+func createStaticRoutes() []Route {
629+	return []Route{
630+		NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
631+		NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
632+		NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
633+		NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
634+		NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
635+		NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
636+		NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
637+	}
638+}
639+
640+func createMainRoutes(staticRoutes []Route) []Route {
641+	routes := []Route{
642+		NewRoute("GET", "/", createPageHandler("./html/marketing.page.tmpl")),
643+		NewRoute("GET", "/spec", createPageHandler("./html/spec.page.tmpl")),
644+		NewRoute("GET", "/ops", createPageHandler("./html/ops.page.tmpl")),
645+		NewRoute("GET", "/privacy", createPageHandler("./html/privacy.page.tmpl")),
646+		NewRoute("GET", "/help", createPageHandler("./html/help.page.tmpl")),
647+		NewRoute("GET", "/transparency", transparencyHandler),
648+		NewRoute("GET", "/read", readHandler),
649+	}
650+
651+	routes = append(
652+		routes,
653+		staticRoutes...,
654+	)
655+
656+	routes = append(
657+		routes,
658+		NewRoute("GET", "/rss", rssHandler),
659+		NewRoute("GET", "/rss.xml", rssHandler),
660+		NewRoute("GET", "/atom.xml", rssHandler),
661+		NewRoute("GET", "/feed.xml", rssHandler),
662+
663+		NewRoute("GET", "/([^/]+)", blogHandler),
664+		NewRoute("GET", "/([^/]+)/rss", rssBlogHandler),
665+		NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
666+	)
667+
668+	return routes
669+}
670+
671+func createSubdomainRoutes(staticRoutes []Route) []Route {
672+	routes := []Route{
673+		NewRoute("GET", "/", blogHandler),
674+		NewRoute("GET", "/rss", rssBlogHandler),
675+	}
676+
677+	routes = append(
678+		routes,
679+		staticRoutes...,
680+	)
681+
682+	routes = append(
683+		routes,
684+		NewRoute("GET", "/([^/]+)", postHandler),
685+	)
686+
687+	return routes
688+}
689+
690+func StartApiServer() {
691+	cfg := NewConfigSite()
692+	db := postgres.NewDB(&cfg.ConfigCms)
693+	defer db.Close()
694+	logger := cfg.Logger
695+
696+	staticRoutes := createStaticRoutes()
697+	mainRoutes := createMainRoutes(staticRoutes)
698+	subdomainRoutes := createSubdomainRoutes(staticRoutes)
699+
700+	handler := CreateServe(mainRoutes, subdomainRoutes, cfg, db, logger)
701+	router := http.HandlerFunc(handler)
702+
703+	portStr := fmt.Sprintf(":%s", cfg.Port)
704+	logger.Infof("Starting server on port %s", cfg.Port)
705+	logger.Infof("Subdomains enabled: %t", cfg.SubdomainsEnabled)
706+	logger.Infof("Domain: %s", cfg.Domain)
707+	logger.Infof("Email: %s", cfg.Email)
708+
709+	logger.Fatal(http.ListenAndServe(portStr, router))
710+}
A lists/config.go
+119, -0
  1@@ -0,0 +1,119 @@
  2+package internal
  3+
  4+import (
  5+	"fmt"
  6+	"html/template"
  7+	"log"
  8+	"net/url"
  9+
 10+	"git.sr.ht/~erock/wish/cms/config"
 11+	"go.uber.org/zap"
 12+)
 13+
 14+type SitePageData struct {
 15+	Domain  template.URL
 16+	HomeURL template.URL
 17+	Email   string
 18+}
 19+
 20+type ConfigSite struct {
 21+	config.ConfigCms
 22+	config.ConfigURL
 23+	SubdomainsEnabled bool
 24+}
 25+
 26+func NewConfigSite() *ConfigSite {
 27+	domain := GetEnv("LISTS_DOMAIN", "lists.sh")
 28+	email := GetEnv("LISTS_EMAIL", "support@lists.sh")
 29+	subdomains := GetEnv("LISTS_SUBDOMAINS", "0")
 30+	port := GetEnv("LISTS_WEB_PORT", "3000")
 31+	protocol := GetEnv("LISTS_PROTOCOL", "https")
 32+	dbURL := GetEnv("DATABASE_URL", "")
 33+	subdomainsEnabled := false
 34+	if subdomains == "1" {
 35+		subdomainsEnabled = true
 36+	}
 37+
 38+	intro := "To get started, enter a username.\n"
 39+	intro += "Then create a folder locally (e.g. ~/blog).\n"
 40+	intro += "Then write your lists in plain text files (e.g. hello-world.txt).\n"
 41+	intro += "Finally, send your list files to us:\n\n"
 42+	intro += fmt.Sprintf("scp ~/blog/*.txt %s:/\n\n", domain)
 43+
 44+	return &ConfigSite{
 45+		SubdomainsEnabled: subdomainsEnabled,
 46+		ConfigCms: config.ConfigCms{
 47+			Domain:      domain,
 48+			Email:       email,
 49+			Port:        port,
 50+			Protocol:    protocol,
 51+			DbURL:       dbURL,
 52+			Description: "A microblog for your lists.",
 53+			IntroText:   intro,
 54+			Space:       "lists",
 55+			Logger:      CreateLogger(),
 56+		},
 57+	}
 58+}
 59+
 60+func (c *ConfigSite) GetSiteData() *SitePageData {
 61+	return &SitePageData{
 62+		Domain:  template.URL(c.Domain),
 63+		HomeURL: template.URL(c.HomeURL()),
 64+		Email:   c.Email,
 65+	}
 66+}
 67+
 68+func (c *ConfigSite) BlogURL(username string) string {
 69+	if c.IsSubdomains() {
 70+		return fmt.Sprintf("%s://%s.%s", c.Protocol, username, c.Domain)
 71+	}
 72+
 73+	return fmt.Sprintf("/%s", username)
 74+}
 75+
 76+func (c *ConfigSite) PostURL(username, filename string) string {
 77+	fname := url.PathEscape(filename)
 78+	if c.IsSubdomains() {
 79+		return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
 80+	}
 81+
 82+	return fmt.Sprintf("/%s/%s", username, fname)
 83+}
 84+
 85+func (c *ConfigSite) IsSubdomains() bool {
 86+	return c.SubdomainsEnabled
 87+}
 88+
 89+func (c *ConfigSite) RssBlogURL(username string) string {
 90+	if c.IsSubdomains() {
 91+		return fmt.Sprintf("%s://%s.%s/rss", c.Protocol, username, c.Domain)
 92+	}
 93+
 94+	return fmt.Sprintf("/%s/rss", username)
 95+}
 96+
 97+func (c *ConfigSite) HomeURL() string {
 98+	if c.IsSubdomains() {
 99+		return fmt.Sprintf("%s://%s", c.Protocol, c.Domain)
100+	}
101+
102+	return "/"
103+}
104+
105+func (c *ConfigSite) ReadURL() string {
106+	if c.IsSubdomains() {
107+		return fmt.Sprintf("%s://%s/read", c.Protocol, c.Domain)
108+	}
109+
110+	return "/read"
111+}
112+
113+func CreateLogger() *zap.SugaredLogger {
114+	logger, err := zap.NewProduction()
115+	if err != nil {
116+		log.Fatal(err)
117+	}
118+
119+	return logger.Sugar()
120+}
A lists/db_handler.go
+133, -0
  1@@ -0,0 +1,133 @@
  2+package internal
  3+
  4+import (
  5+	"fmt"
  6+	"io"
  7+	"time"
  8+
  9+	"git.sr.ht/~erock/lists.sh/pkg"
 10+	"git.sr.ht/~erock/wish/cms/db"
 11+	"git.sr.ht/~erock/wish/cms/util"
 12+	sendutils "git.sr.ht/~erock/wish/send/utils"
 13+	"github.com/gliderlabs/ssh"
 14+	"golang.org/x/exp/slices"
 15+)
 16+
 17+var HiddenPosts = []string{"_readme", "_header"}
 18+
 19+type Opener struct {
 20+	entry *sendutils.FileEntry
 21+}
 22+
 23+func (o *Opener) Open(name string) (io.Reader, error) {
 24+	return o.entry.Reader, nil
 25+}
 26+
 27+type DbHandler struct {
 28+	User   *db.User
 29+	DBPool db.DB
 30+	Cfg    *ConfigSite
 31+}
 32+
 33+func NewDbHandler(dbpool db.DB, cfg *ConfigSite) *DbHandler {
 34+	return &DbHandler{
 35+		DBPool: dbpool,
 36+		Cfg:    cfg,
 37+	}
 38+}
 39+
 40+func (h *DbHandler) Validate(s ssh.Session) error {
 41+	var err error
 42+	key, err := util.KeyText(s)
 43+	if err != nil {
 44+		return fmt.Errorf("key not found")
 45+	}
 46+
 47+	user, err := h.DBPool.FindUserForKey(s.User(), key)
 48+	if err != nil {
 49+		return err
 50+	}
 51+
 52+	if user.Name == "" {
 53+		return fmt.Errorf("must have username set")
 54+	}
 55+
 56+	h.User = user
 57+	return nil
 58+}
 59+
 60+func (h *DbHandler) Write(s ssh.Session, entry *sendutils.FileEntry) (string, error) {
 61+	logger := h.Cfg.Logger
 62+	userID := h.User.ID
 63+	filename := SanitizeFileExt(entry.Name)
 64+	title := filename
 65+
 66+	post, err := h.DBPool.FindPostWithFilename(filename, userID, h.Cfg.Space)
 67+	if err != nil {
 68+		logger.Debug("unable to load post, continuing:", err)
 69+	}
 70+
 71+	user, err := h.DBPool.FindUser(userID)
 72+	if err != nil {
 73+		return "", fmt.Errorf("error for %s: %v", filename, err)
 74+	}
 75+
 76+	var text string
 77+	if b, err := io.ReadAll(entry.Reader); err == nil {
 78+		text = string(b)
 79+	}
 80+
 81+	if !IsTextFile(text, entry.Filepath) {
 82+		return "", fmt.Errorf("WARNING: (%s) invalid file, format must be '.txt' and the contents must be plain text, skipping", entry.Name)
 83+	}
 84+
 85+	parsedText := pkg.ParseText(text)
 86+	if parsedText.MetaData.Title != "" {
 87+		title = parsedText.MetaData.Title
 88+	}
 89+	description := parsedText.MetaData.Description
 90+
 91+	// if the file is empty we remove it from our database
 92+	if len(text) == 0 {
 93+		// skip empty files from being added to db
 94+		if post == nil {
 95+			logger.Infof("(%s) is empty, skipping record", filename)
 96+			return "", nil
 97+		}
 98+
 99+		err := h.DBPool.RemovePosts([]string{post.ID})
100+		logger.Infof("(%s) is empty, removing record", filename)
101+		if err != nil {
102+			return "", fmt.Errorf("error for %s: %v", filename, err)
103+		}
104+	} else if post == nil {
105+		publishAt := time.Now()
106+		if parsedText.MetaData.PublishAt != nil {
107+			publishAt = *parsedText.MetaData.PublishAt
108+		}
109+		hidden := slices.Contains(HiddenPosts, filename)
110+
111+		logger.Infof("(%s) not found, adding record", filename)
112+		_, err = h.DBPool.InsertPost(userID, filename, title, text, description, &publishAt, hidden, h.Cfg.Space)
113+		if err != nil {
114+			return "", fmt.Errorf("error for %s: %v", filename, err)
115+		}
116+	} else {
117+		publishAt := post.PublishAt
118+		if parsedText.MetaData.PublishAt != nil {
119+			publishAt = parsedText.MetaData.PublishAt
120+		}
121+		if text == post.Text {
122+			logger.Infof("(%s) found, but text is identical, skipping", filename)
123+			return h.Cfg.PostURL(user.Name, filename), nil
124+		}
125+
126+		logger.Infof("(%s) found, updating record", filename)
127+		_, err = h.DBPool.UpdatePost(post.ID, title, text, description, publishAt)
128+		if err != nil {
129+			return "", fmt.Errorf("error for %s: %v", filename, err)
130+		}
131+	}
132+
133+	return h.Cfg.PostURL(user.Name, filename), nil
134+}
A lists/gemini/gemini.go
+569, -0
  1@@ -0,0 +1,569 @@
  2+package gemini
  3+
  4+import (
  5+	"bytes"
  6+	"context"
  7+	"fmt"
  8+	html "html/template"
  9+	"net/url"
 10+	"os"
 11+	"os/signal"
 12+	"strconv"
 13+	"strings"
 14+	"text/template"
 15+	"time"
 16+
 17+	"git.sr.ht/~adnano/go-gemini"
 18+	"git.sr.ht/~adnano/go-gemini/certificate"
 19+	feeds "git.sr.ht/~aw/gorilla-feeds"
 20+	"git.sr.ht/~erock/lists.sh/internal"
 21+	"git.sr.ht/~erock/lists.sh/pkg"
 22+	"git.sr.ht/~erock/wish/cms/db"
 23+	"git.sr.ht/~erock/wish/cms/db/postgres"
 24+	"golang.org/x/exp/slices"
 25+)
 26+
 27+func renderTemplate(templates []string) (*template.Template, error) {
 28+	files := make([]string, len(templates))
 29+	copy(files, templates)
 30+	files = append(
 31+		files,
 32+		"./gmi/footer.partial.tmpl",
 33+		"./gmi/marketing-footer.partial.tmpl",
 34+		"./gmi/base.layout.tmpl",
 35+	)
 36+
 37+	ts, err := template.ParseFiles(files...)
 38+	if err != nil {
 39+		return nil, err
 40+	}
 41+	return ts, nil
 42+}
 43+
 44+func createPageHandler(fname string) gemini.HandlerFunc {
 45+	return func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
 46+		logger := GetLogger(ctx)
 47+		cfg := GetCfg(ctx)
 48+		ts, err := renderTemplate([]string{fname})
 49+
 50+		if err != nil {
 51+			logger.Error(err)
 52+			w.WriteHeader(gemini.StatusTemporaryFailure, "Internal Service Error")
 53+			return
 54+		}
 55+
 56+		data := internal.PageData{
 57+			Site: *cfg.GetSiteData(),
 58+		}
 59+		err = ts.Execute(w, data)
 60+		if err != nil {
 61+			logger.Error(err)
 62+			w.WriteHeader(gemini.StatusTemporaryFailure, "Internal Service Error")
 63+		}
 64+	}
 65+}
 66+
 67+func blogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
 68+	username := GetField(ctx, 0)
 69+	dbpool := GetDB(ctx)
 70+	logger := GetLogger(ctx)
 71+	cfg := GetCfg(ctx)
 72+
 73+	user, err := dbpool.FindUserForName(username)
 74+	if err != nil {
 75+		logger.Infof("blog not found: %s", username)
 76+		w.WriteHeader(gemini.StatusNotFound, "blog not found")
 77+		return
 78+	}
 79+	posts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)
 80+	if err != nil {
 81+		logger.Error(err)
 82+		w.WriteHeader(gemini.StatusTemporaryFailure, "could not fetch posts for blog")
 83+		return
 84+	}
 85+
 86+	ts, err := renderTemplate([]string{
 87+		"./gmi/blog.page.tmpl",
 88+		"./gmi/list.partial.tmpl",
 89+	})
 90+
 91+	if err != nil {
 92+		logger.Error(err)
 93+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
 94+		return
 95+	}
 96+
 97+	headerTxt := &internal.HeaderTxt{
 98+		Title: internal.GetBlogName(username),
 99+		Bio:   "",
100+	}
101+	readmeTxt := &internal.ReadmeTxt{}
102+
103+	postCollection := make([]internal.PostItemData, 0, len(posts))
104+	for _, post := range posts {
105+		if post.Filename == "_header" {
106+			parsedText := pkg.ParseText(post.Text)
107+			if parsedText.MetaData.Title != "" {
108+				headerTxt.Title = parsedText.MetaData.Title
109+			}
110+
111+			if parsedText.MetaData.Description != "" {
112+				headerTxt.Bio = parsedText.MetaData.Description
113+			}
114+
115+			headerTxt.Nav = parsedText.Items
116+			if len(headerTxt.Nav) > 0 {
117+				headerTxt.HasItems = true
118+			}
119+		} else if post.Filename == "_readme" {
120+			parsedText := pkg.ParseText(post.Text)
121+			readmeTxt.Items = parsedText.Items
122+			readmeTxt.ListType = parsedText.MetaData.ListType
123+			if len(readmeTxt.Items) > 0 {
124+				readmeTxt.HasItems = true
125+			}
126+		} else {
127+			p := internal.PostItemData{
128+				URL:            html.URL(cfg.PostURL(post.Username, post.Filename)),
129+				BlogURL:        html.URL(cfg.BlogURL(post.Username)),
130+				Title:          internal.FilenameToTitle(post.Filename, post.Title),
131+				PublishAt:      post.PublishAt.Format("02 Jan, 2006"),
132+				PublishAtISO:   post.PublishAt.Format(time.RFC3339),
133+				UpdatedTimeAgo: internal.TimeAgo(post.UpdatedAt),
134+				UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
135+			}
136+			postCollection = append(postCollection, p)
137+		}
138+	}
139+
140+	data := internal.BlogPageData{
141+		Site:      *cfg.GetSiteData(),
142+		PageTitle: headerTxt.Title,
143+		URL:       html.URL(cfg.BlogURL(username)),
144+		RSSURL:    html.URL(cfg.RssBlogURL(username)),
145+		Readme:    readmeTxt,
146+		Header:    headerTxt,
147+		Username:  username,
148+		Posts:     postCollection,
149+	}
150+
151+	err = ts.Execute(w, data)
152+	if err != nil {
153+		logger.Error(err)
154+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
155+	}
156+}
157+
158+func readHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
159+	dbpool := GetDB(ctx)
160+	logger := GetLogger(ctx)
161+	cfg := GetCfg(ctx)
162+
163+	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
164+	pager, err := dbpool.FindAllUpdatedPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)
165+	if err != nil {
166+		logger.Error(err)
167+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
168+		return
169+	}
170+
171+	ts, err := renderTemplate([]string{
172+		"./gmi/read.page.tmpl",
173+	})
174+
175+	if err != nil {
176+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
177+		return
178+	}
179+
180+	nextPage := ""
181+	if page < pager.Total-1 {
182+		nextPage = fmt.Sprintf("/read?page=%d", page+1)
183+	}
184+
185+	prevPage := ""
186+	if page > 0 {
187+		prevPage = fmt.Sprintf("/read?page=%d", page-1)
188+	}
189+
190+	data := internal.ReadPageData{
191+		Site:     *cfg.GetSiteData(),
192+		NextPage: nextPage,
193+		PrevPage: prevPage,
194+	}
195+
196+	longest := 0
197+	for _, post := range pager.Data {
198+		size := len(internal.TimeAgo(post.UpdatedAt))
199+		if size > longest {
200+			longest = size
201+		}
202+	}
203+
204+	for _, post := range pager.Data {
205+		item := internal.PostItemData{
206+			URL:            html.URL(cfg.PostURL(post.Username, post.Filename)),
207+			BlogURL:        html.URL(cfg.BlogURL(post.Username)),
208+			Title:          internal.FilenameToTitle(post.Filename, post.Title),
209+			Description:    post.Description,
210+			Username:       post.Username,
211+			PublishAt:      post.PublishAt.Format("02 Jan, 2006"),
212+			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
213+			UpdatedTimeAgo: internal.TimeAgo(post.UpdatedAt),
214+			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
215+		}
216+
217+		item.Padding = strings.Repeat(" ", longest-len(item.UpdatedTimeAgo))
218+		data.Posts = append(data.Posts, item)
219+	}
220+
221+	err = ts.Execute(w, data)
222+	if err != nil {
223+		logger.Error(err)
224+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
225+	}
226+}
227+
228+func postHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
229+	username := GetField(ctx, 0)
230+	filename, _ := url.PathUnescape(GetField(ctx, 1))
231+
232+	dbpool := GetDB(ctx)
233+	logger := GetLogger(ctx)
234+	cfg := GetCfg(ctx)
235+
236+	user, err := dbpool.FindUserForName(username)
237+	if err != nil {
238+		logger.Infof("blog not found: %s", username)
239+		w.WriteHeader(gemini.StatusNotFound, "blog not found")
240+		return
241+	}
242+
243+	header, _ := dbpool.FindPostWithFilename("_header", user.ID, cfg.Space)
244+	blogName := internal.GetBlogName(username)
245+	if header != nil {
246+		headerParsed := pkg.ParseText(header.Text)
247+		if headerParsed.MetaData.Title != "" {
248+			blogName = headerParsed.MetaData.Title
249+		}
250+	}
251+
252+	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
253+	if err != nil {
254+		logger.Infof("post not found %s/%s", username, filename)
255+		w.WriteHeader(gemini.StatusNotFound, "post not found")
256+		return
257+	}
258+
259+	parsedText := pkg.ParseText(post.Text)
260+
261+	// we need the blog name from the readme unfortunately
262+	readme, err := dbpool.FindPostWithFilename("_readme", user.ID, cfg.Space)
263+	if err == nil {
264+		readmeParsed := pkg.ParseText(readme.Text)
265+		if readmeParsed.MetaData.Title != "" {
266+			blogName = readmeParsed.MetaData.Title
267+		}
268+	}
269+
270+	_, err = dbpool.AddViewCount(post.ID)
271+	if err != nil {
272+		logger.Error(err)
273+	}
274+
275+	data := internal.PostPageData{
276+		Site:         *cfg.GetSiteData(),
277+		PageTitle:    internal.GetPostTitle(post),
278+		URL:          html.URL(cfg.PostURL(post.Username, post.Filename)),
279+		BlogURL:      html.URL(cfg.BlogURL(username)),
280+		Description:  post.Description,
281+		ListType:     parsedText.MetaData.ListType,
282+		Title:        internal.FilenameToTitle(post.Filename, post.Title),
283+		PublishAt:    post.PublishAt.Format("02 Jan, 2006"),
284+		PublishAtISO: post.PublishAt.Format(time.RFC3339),
285+		Username:     username,
286+		BlogName:     blogName,
287+		Items:        parsedText.Items,
288+	}
289+
290+	ts, err := renderTemplate([]string{
291+		"./gmi/post.page.tmpl",
292+		"./gmi/list.partial.tmpl",
293+	})
294+
295+	if err != nil {
296+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
297+		return
298+	}
299+
300+	err = ts.Execute(w, data)
301+	if err != nil {
302+		logger.Error(err)
303+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
304+	}
305+}
306+
307+func transparencyHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
308+	dbpool := GetDB(ctx)
309+	logger := GetLogger(ctx)
310+	cfg := GetCfg(ctx)
311+
312+	analytics, err := dbpool.FindSiteAnalytics(cfg.Space)
313+	if err != nil {
314+		logger.Error(err)
315+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
316+		return
317+	}
318+
319+	ts, err := template.ParseFiles(
320+		"./gmi/transparency.page.tmpl",
321+		"./gmi/footer.partial.tmpl",
322+		"./gmi/marketing-footer.partial.tmpl",
323+		"./gmi/base.layout.tmpl",
324+	)
325+
326+	if err != nil {
327+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
328+		return
329+	}
330+
331+	data := internal.TransparencyPageData{
332+		Site:      *cfg.GetSiteData(),
333+		Analytics: analytics,
334+	}
335+	err = ts.Execute(w, data)
336+	if err != nil {
337+		logger.Error(err)
338+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
339+	}
340+}
341+
342+func rssBlogHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
343+	username := GetField(ctx, 0)
344+	dbpool := GetDB(ctx)
345+	logger := GetLogger(ctx)
346+	cfg := GetCfg(ctx)
347+
348+	user, err := dbpool.FindUserForName(username)
349+	if err != nil {
350+		logger.Infof("rss feed not found: %s", username)
351+		w.WriteHeader(gemini.StatusNotFound, "rss feed not found")
352+		return
353+	}
354+	posts, err := dbpool.FindUpdatedPostsForUser(user.ID, cfg.Space)
355+	if err != nil {
356+		logger.Error(err)
357+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
358+		return
359+	}
360+
361+	ts, err := template.ParseFiles("./gmi/rss.page.tmpl", "./gmi/list.partial.tmpl")
362+	if err != nil {
363+		logger.Error(err)
364+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
365+		return
366+	}
367+
368+	headerTxt := &internal.HeaderTxt{
369+		Title: internal.GetBlogName(username),
370+	}
371+
372+	for _, post := range posts {
373+		if post.Filename == "_header" {
374+			parsedText := pkg.ParseText(post.Text)
375+			if parsedText.MetaData.Title != "" {
376+				headerTxt.Title = parsedText.MetaData.Title
377+			}
378+
379+			if parsedText.MetaData.Description != "" {
380+				headerTxt.Bio = parsedText.MetaData.Description
381+			}
382+
383+			break
384+		}
385+	}
386+
387+	feed := &feeds.Feed{
388+		Title:       headerTxt.Title,
389+		Link:        &feeds.Link{Href: cfg.BlogURL(username)},
390+		Description: headerTxt.Bio,
391+		Author:      &feeds.Author{Name: username},
392+		Created:     time.Now(),
393+	}
394+
395+	var feedItems []*feeds.Item
396+	for _, post := range posts {
397+		if slices.Contains(internal.HiddenPosts, post.Filename) {
398+			continue
399+		}
400+		parsed := pkg.ParseText(post.Text)
401+		var tpl bytes.Buffer
402+		data := &internal.PostPageData{
403+			ListType: parsed.MetaData.ListType,
404+			Items:    parsed.Items,
405+		}
406+		if err := ts.Execute(&tpl, data); err != nil {
407+			continue
408+		}
409+
410+		item := &feeds.Item{
411+			Id:      cfg.PostURL(post.Username, post.Filename),
412+			Title:   internal.FilenameToTitle(post.Filename, post.Title),
413+			Link:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},
414+			Content: tpl.String(),
415+			Created: *post.PublishAt,
416+		}
417+
418+		if post.Description != "" {
419+			item.Description = post.Description
420+		}
421+
422+		feedItems = append(feedItems, item)
423+	}
424+	feed.Items = feedItems
425+
426+	rss, err := feed.ToAtom()
427+	if err != nil {
428+		logger.Error(err)
429+		w.WriteHeader(gemini.StatusTemporaryFailure, "Could not generate atom rss feed")
430+		return
431+	}
432+
433+	// w.Header().Add("Content-Type", "application/atom+xml")
434+	_, err = w.Write([]byte(rss))
435+	if err != nil {
436+		logger.Error(err)
437+	}
438+}
439+
440+func rssHandler(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
441+	dbpool := GetDB(ctx)
442+	logger := GetLogger(ctx)
443+	cfg := GetCfg(ctx)
444+
445+	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
446+	if err != nil {
447+		logger.Error(err)
448+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
449+		return
450+	}
451+
452+	ts, err := template.ParseFiles("./gmi/rss.page.tmpl", "./gmi/list.partial.tmpl")
453+	if err != nil {
454+		logger.Error(err)
455+		w.WriteHeader(gemini.StatusTemporaryFailure, err.Error())
456+		return
457+	}
458+
459+	feed := &feeds.Feed{
460+		Title:       fmt.Sprintf("%s discovery feed", cfg.Domain),
461+		Link:        &feeds.Link{Href: cfg.ReadURL()},
462+		Description: fmt.Sprintf("%s latest posts", cfg.Domain),
463+		Author:      &feeds.Author{Name: cfg.Domain},
464+		Created:     time.Now(),
465+	}
466+
467+	var feedItems []*feeds.Item
468+	for _, post := range pager.Data {
469+		parsed := pkg.ParseText(post.Text)
470+		var tpl bytes.Buffer
471+		data := &internal.PostPageData{
472+			ListType: parsed.MetaData.ListType,
473+			Items:    parsed.Items,
474+		}
475+		if err := ts.Execute(&tpl, data); err != nil {
476+			continue
477+		}
478+
479+		item := &feeds.Item{
480+			Id:      cfg.PostURL(post.Username, post.Filename),
481+			Title:   post.Title,
482+			Link:    &feeds.Link{Href: cfg.PostURL(post.Username, post.Filename)},
483+			Content: tpl.String(),
484+			Created: *post.PublishAt,
485+		}
486+
487+		if post.Description != "" {
488+			item.Description = post.Description
489+		}
490+
491+		feedItems = append(feedItems, item)
492+	}
493+	feed.Items = feedItems
494+
495+	rss, err := feed.ToAtom()
496+	if err != nil {
497+		logger.Error(err)
498+		w.WriteHeader(gemini.StatusTemporaryFailure, "Could not generate atom rss feed")
499+	}
500+
501+	// w.Header().Add("Content-Type", "application/atom+xml")
502+	_, err = w.Write([]byte(rss))
503+	if err != nil {
504+		logger.Error(err)
505+	}
506+}
507+
508+func StartServer() {
509+	cfg := internal.NewConfigSite()
510+	db := postgres.NewDB(&cfg.ConfigCms)
511+	logger := cfg.Logger
512+
513+	certificates := &certificate.Store{}
514+	certificates.Register("localhost")
515+	certificates.Register(cfg.Domain)
516+	certificates.Register(fmt.Sprintf("*.%s", cfg.Domain))
517+	if err := certificates.Load("/var/lib/gemini/certs"); err != nil {
518+		logger.Fatal(err)
519+	}
520+
521+	routes := []Route{
522+		NewRoute("/", createPageHandler("./gmi/marketing.page.tmpl")),
523+		NewRoute("/spec", createPageHandler("./gmi/spec.page.tmpl")),
524+		NewRoute("/help", createPageHandler("./gmi/help.page.tmpl")),
525+		NewRoute("/ops", createPageHandler("./gmi/ops.page.tmpl")),
526+		NewRoute("/privacy", createPageHandler("./gmi/privacy.page.tmpl")),
527+		NewRoute("/transparency", transparencyHandler),
528+		NewRoute("/read", readHandler),
529+		NewRoute("/rss", rssHandler),
530+		NewRoute("/([^/]+)", blogHandler),
531+		NewRoute("/([^/]+)/rss", rssBlogHandler),
532+		NewRoute("/([^/]+)/([^/]+)", postHandler),
533+	}
534+	handler := CreateServe(routes, cfg, db, logger)
535+	router := gemini.HandlerFunc(handler)
536+
537+	server := &gemini.Server{
538+		Addr:           "0.0.0.0:1965",
539+		Handler:        gemini.LoggingMiddleware(router),
540+		ReadTimeout:    30 * time.Second,
541+		WriteTimeout:   1 * time.Minute,
542+		GetCertificate: certificates.Get,
543+	}
544+
545+	// Listen for interrupt signal
546+	c := make(chan os.Signal, 1)
547+	signal.Notify(c, os.Interrupt)
548+
549+	errch := make(chan error)
550+	go func() {
551+		logger.Info("Starting server")
552+		ctx := context.Background()
553+		errch <- server.ListenAndServe(ctx)
554+	}()
555+
556+	select {
557+	case err := <-errch:
558+		logger.Fatal(err)
559+	case <-c:
560+		// Shutdown the server
561+		logger.Info("Shutting down...")
562+		db.Close()
563+		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
564+		defer cancel()
565+		err := server.Shutdown(ctx)
566+		if err != nil {
567+			logger.Fatal(err)
568+		}
569+	}
570+}
A lists/gemini/router.go
+66, -0
 1@@ -0,0 +1,66 @@
 2+package gemini
 3+
 4+import (
 5+	"context"
 6+	"regexp"
 7+
 8+	"git.sr.ht/~adnano/go-gemini"
 9+	"git.sr.ht/~erock/lists.sh/internal"
10+	"git.sr.ht/~erock/wish/cms/db"
11+	"go.uber.org/zap"
12+)
13+
14+type ctxKey struct{}
15+type ctxDBKey struct{}
16+type ctxLoggerKey struct{}
17+type ctxCfgKey struct{}
18+
19+func GetLogger(ctx context.Context) *zap.SugaredLogger {
20+	return ctx.Value(ctxLoggerKey{}).(*zap.SugaredLogger)
21+}
22+
23+func GetCfg(ctx context.Context) *internal.ConfigSite {
24+	return ctx.Value(ctxCfgKey{}).(*internal.ConfigSite)
25+}
26+
27+func GetDB(ctx context.Context) db.DB {
28+	return ctx.Value(ctxDBKey{}).(db.DB)
29+}
30+
31+func GetField(ctx context.Context, index int) string {
32+	fields := ctx.Value(ctxKey{}).([]string)
33+	return fields[index]
34+}
35+
36+type Route struct {
37+	regex   *regexp.Regexp
38+	handler gemini.HandlerFunc
39+}
40+
41+func NewRoute(pattern string, handler gemini.HandlerFunc) Route {
42+	return Route{
43+		regexp.MustCompile("^" + pattern + "$"),
44+		handler,
45+	}
46+}
47+
48+type ServeFn func(context.Context, gemini.ResponseWriter, *gemini.Request)
49+
50+func CreateServe(routes []Route, cfg *internal.ConfigSite, dbpool db.DB, logger *zap.SugaredLogger) ServeFn {
51+	return func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
52+		curRoutes := routes
53+
54+		for _, route := range curRoutes {
55+			matches := route.regex.FindStringSubmatch(r.URL.Path)
56+			if len(matches) > 0 {
57+				ctx = context.WithValue(ctx, ctxLoggerKey{}, logger)
58+				ctx = context.WithValue(ctx, ctxDBKey{}, dbpool)
59+				ctx = context.WithValue(ctx, ctxCfgKey{}, cfg)
60+				ctx = context.WithValue(ctx, ctxKey{}, matches[1:])
61+				route.handler(ctx, w, r)
62+				return
63+			}
64+		}
65+		w.WriteHeader(gemini.StatusTemporaryFailure, "Internal Service Error")
66+	}
67+}
A lists/gmi/base.layout.tmpl
+3, -0
1@@ -0,0 +1,3 @@
2+{{define "base"}}
3+{{template "body" .}}
4+{{end}}
A lists/gmi/blog.page.tmpl
+19, -0
 1@@ -0,0 +1,19 @@
 2+{{template "base" .}}
 3+{{define "body"}}
 4+# {{.Header.Title}}
 5+{{.Header.Bio}}
 6+{{range .Header.Nav}}
 7+{{if .IsURL}}=> {{.URL}} {{.Value}}{{end}}
 8+{{- end}}
 9+=> {{.RSSURL}} rss
10+
11+{{- if .Readme.HasItems}}
12+
13+---
14+{{- template "list" .Readme -}}
15+{{- end}}
16+{{- range .Posts}}
17+=> {{.URL}} {{.Title}} ({{.UpdatedTimeAgo}})
18+{{- end}}
19+{{- template "footer" . -}}
20+{{end}}
A lists/gmi/footer.partial.tmpl
+5, -0
1@@ -0,0 +1,5 @@
2+{{define "footer"}}
3+---
4+
5+=> / published with {{.Site.Domain}}
6+{{end}}
A lists/gmi/help.page.tmpl
+124, -0
  1@@ -0,0 +1,124 @@
  2+{{template "base" .}}
  3+
  4+{{define "body"}}
  5+# Need help?
  6+
  7+Here are some common questions on using this platform that we would like to answer.
  8+
  9+## I get a permission denied when trying to SSH
 10+
 11+Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, Soft Serve does not currently support access via new SSH RSA keys: only the old SHA-1 ones will work. Until we sort this out you’ll either need an SHA-1 RSA key or a key with another algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the following:
 12+
 13+```
 14+$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;
 15+```
 16+
 17+If you’re curious about the inner workings of this problem have a look at:
 18+
 19+=> https://github.com/golang/go/issues/37278 golang/go#37278
 20+=> https://go-review.googlesource.com/c/crypto/+/220037 go-review
 21+=> https://github.com/golang/crypto/pull/197 golang/crypto#197
 22+
 23+## Generating a new SSH key
 24+
 25+=> https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent Github reference
 26+
 27+```
 28+ssh-keygen -t ed25519 -C "your_email@example.com"
 29+```
 30+
 31+* When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.
 32+* At the prompt, type a secure passphrase.
 33+
 34+## What should my blog folder look like?
 35+
 36+Currently {{.Site.Domain}} only supports a flat folder structure.  Therefore, `scp -r` is not permitted.  We also only allow `.txt` files to be uploaded.
 37+
 38+=> https://github.com/neurosnap/lists-blog Here is the source to my blog on this platform
 39+
 40+Below is an example of what your blog folder should look like:
 41+
 42+```
 43+blog/
 44+  first-post.txt
 45+  second-post.txt
 46+  third-post.txt
 47+```
 48+
 49+Underscores and hyphens are permitted and will be automatically removed from the title of the list.
 50+
 51+## How do I update a list?
 52+
 53+Updating a list requires that you update the source document and then run the `scp` command again.  If the filename remains the same, then the list will be updated.
 54+
 55+## How do I delete a list?
 56+
 57+Because `scp` does not natively support deleting files, I didn't want to bake that behavior into my ssh server.
 58+
 59+However, if a user wants to delete a post they can delete the contents of the file and then upload it to our server.  If the file contains 0 bytes, we will remove the post. For example, if you want to delete `delete.txt` you could:
 60+
 61+```
 62+cp /dev/null delete.txt
 63+scp ./delete.txt {{.Site.Domain}}:/
 64+```
 65+
 66+Alternatively, you can go to `ssh <username>@{{.Site.Domain}}` and select "Manage posts." Then you can highlight the post you want to delete and then press "X."  It will ask for confirmation before actually removing the list.
 67+
 68+## When I want to publish a new post, do I have to upload all posts everytime?
 69+
 70+Nope!  Just `scp` the file you want to publish.  For example, if you created a new post called `taco-tuesday.txt` then you would publish it like this:
 71+
 72+```
 73+scp ./taco-tuesday.txt {{.Site.Domain}}:/
 74+```
 75+
 76+## How do I change my blog's name?
 77+
 78+All you have to do is create a post titled `_header.txt` and add some information to the list.
 79+
 80+```
 81+=: title My new blog!
 82+=: description My blog description!
 83+=> https://xyz.com website
 84+=> https://twitter.com/xyz twitter
 85+```
 86+
 87+* `title` will change your blog name
 88+* `description` will add a blurb right under your blog name (and add meta descriptions)
 89+* The links will show up next to the `rss` link to your blog
 90+
 91+## How do I add an introduction to my blog?
 92+
 93+All you have to do is create a post titled `_readme.txt` and add some information to the list.
 94+
 95+```
 96+=: list_type none
 97+# Hi my name is Bob!
 98+I like to sing. Dance. And I like to have fun fun fun!
 99+```
100+
101+Whatever is inside the `_readme` file will get rendered (as a list) right above your blog posts. Neat!
102+
103+## What is my blog URL?
104+
105+```
106+gemini://{{.Site.Domain}}/{username}
107+```
108+
109+## How can I automatically publish my post?
110+
111+There is a github action that we built to make it easy to publish your blog automatically.
112+
113+=> https://github.com/marketplace/actions/scp-publish-action github marketplace
114+=> https://github.com/neurosnap/lists-official-blog/blob/main/.github/workflows/publish.yml example workflow
115+
116+A user also created a systemd task to automatically publish new posts.
117+
118+=> https://github.com/neurosnap/lists.sh/discussions/24 Check out this github discussion for more details.
119+
120+## Can I create multiple accounts?
121+
122+Yes!  You can either a) create a new keypair and use that for authentication or b) use the same keypair and ssh into our CMS using our special username `ssh new@{{.Site.Domain}}`.
123+Please note that if you use the same keypair for multiple accounts, you will need to always specify the user when logging into our CMS.
124+{{template "marketing-footer" .}}
125+{{end}}
A lists/gmi/list.partial.tmpl
+25, -0
 1@@ -0,0 +1,25 @@
 2+{{define "list"}}
 3+{{range .Items}}
 4+{{- if .IsText}}
 5+{{- if .Value}}
 6+* {{.Value}}
 7+{{- end}}
 8+{{- else if .IsURL}}
 9+=> {{.URL}} {{.Value}}
10+{{- else if .IsImg}}
11+=> {{.URL}} {{.Value}}
12+{{- else if .IsBlock}}
13+> {{.Value}}
14+{{- else if .IsHeaderOne}}
15+
16+## {{.Value}}
17+{{- else if .IsHeaderTwo}}
18+
19+### {{.Value}}
20+{{- else if .IsPre}}
21+```
22+{{.Value}}
23+```
24+{{- end}}
25+{{- end}}
26+{{end}}
A lists/gmi/marketing-footer.partial.tmpl
+13, -0
 1@@ -0,0 +1,13 @@
 2+{{define "marketing-footer"}}
 3+---
 4+
 5+Built and maintained by pico.sh
 6+=> https://pico.sh
 7+
 8+=> / home
 9+=> /spec spec
10+=> /ops ops
11+=> /help help
12+=> /rss rss
13+=> https://github.com/neurosnap/lists.sh source
14+{{end}}
A lists/gmi/marketing.page.tmpl
+95, -0
 1@@ -0,0 +1,95 @@
 2+{{template "base" .}}
 3+
 4+{{define "body"}}
 5+# {{.Site.Domain}}
 6+A microblog for lists
 7+
 8+=> /read discover some interesting lists
 9+
10+---
11+
12+## Examples
13+
14+=> /news official blog
15+=> https://git.sr.ht/~erock/lists-official-blog blog source
16+
17+## Create your account
18+
19+We don't want your email address.
20+
21+To get started, simply ssh into our content management system:
22+
23+```
24+ssh new@{{.Site.Domain}}
25+```
26+
27+=> /help#permission-denied note: getting permission denied?
28+
29+After that, just set a username and you're ready to start writing!  When you SSH again, use your username that you set in the CMS.
30+
31+## You control the source files
32+
33+Create lists using your favorite editor in plain text files.
34+
35+`~/blog/days-in-week.txt`
36+
37+```
38+Sunday
39+Monday
40+Tuesday
41+Wednesday
42+Thursday
43+Friday
44+Saturday
45+```
46+
47+## Publish your posts with one command
48+
49+When your post is ready to be published, copy the file to our server with a familiar command:
50+
51+```
52+scp ~/blog/*.txt {{.Site.Domain}}
53+```
54+
55+We'll either create or update the lists for you.
56+
57+## Terminal workflow without installation
58+
59+Since we are leveraging tools you already have on your computer (`ssh` and `scp`), there is nothing to install. This provides the convenience of a web app, but from inside your terminal!
60+
61+## Plain text format
62+
63+A simple specification that is flexible and with no frills.
64+
65+=> /spec specification
66+
67+## Features
68+
69+* Just lists
70+* Looks great on any device
71+* Bring your own editor
72+* You control the source files
73+* Terminal workflow with no installation
74+* Public-key based authentication
75+* No ads, zero tracking
76+* No platform lock-in
77+* No javascript
78+* Subscriptions via RSS
79+* Not a platform for todos
80+* Minimalist design
81+* 100% open source
82+
83+## Philosophy
84+
85+I love writing lists.  I think restricting writing to a set of lists can really help improve clarity in thought.  The goal of this blogging platform is to make it simple to use the tools you love to write and publish lists.  There is no installation, signup is as easy as SSH'ing into our CMS, and publishing content is as easy as copying files to our server.
86+
87+Another goal of this microblog platform is to satisfy my own needs.  I like to write and share lists with people because I find it's one of the best way to disseminate knowledge.  Whether it's a list of links or a list of paragraphs, writing in lists is very satisfying and I welcome you to explore it on this site!
88+
89+Other blogging platforms support writing lists, but they don't emphasize them.  Writing lists is pretty popular on Twitter, but discoverability is terrible.  Other blogging platforms focus on prose, but there really is nothing out there catered specifically for lists ... until now.
90+
91+## Roadmap
92+
93+* Feature complete?
94+
95+{{template "marketing-footer" .}}
96+{{end}}
A lists/gmi/ops.page.tmpl
+60, -0
 1@@ -0,0 +1,60 @@
 2+{{template "base" .}}
 3+
 4+{{define "body"}}
 5+# Operations
 6+
 7+=> /privacy privacy
 8+=> /transparency transparency
 9+
10+## Purpose
11+
12+{{.Site.Domain}} exists to allow people to create and share their lists without the need to set up their own server or be part of a platform that shows ads or tracks its users.
13+
14+## Ethics
15+
16+We are committed to:
17+
18+* No tracking of user or visitor behaviour.
19+* Never sell any user or visitor data.
20+* No ads — ever.
21+
22+## Code of Content Publication
23+
24+Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any combination of words and pixels except for: content of animosity or disparagement of an individual or a group on account of a group characteristic such as race, color, national origin, sex, disability, religion, or sexual orientation, which will be taken down immediately.
25+
26+If one notices something along those lines in a blog please let us know at {{.Site.Email}}.
27+
28+## Liability
29+
30+The user expressly understands and agrees that Eric Bower, the operator of this website shall not be liable, in law or in equity, to them or to any third party for any direct, indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
31+
32+## Account Terms
33+
34+* The user is responsible for all content posted and all actions performed with their account.
35+* We reserve the right to disable or delete a user's account for any reason at any time. We have this clause because, statistically speaking, there will be people trying to do something nefarious.
36+
37+## Service Availability
38+
39+We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer service-level agreements but do take uptime seriously.
40+
41+## Contact and Support
42+
43+Email us at {{.Site.Email}} with any questions.
44+
45+## Acknowledgments
46+
47+{{.Site.Domain}} was inspired by Mataroa Blog[0] and Bear Blog[1].
48+
49+=> https://mataroa.blog [0]mataroa blog
50+=> https://bearblog.dev [1]bearblog
51+
52+{{.Site.Domain}} is built with many open source technologies.
53+
54+In particular we would like to thank:
55+
56+=> https://charm.sh The charm community
57+=> https://go.dev The golang community
58+=> https://www.postgresql.org The postgresql community
59+=> https://github.com/caddyserver/caddy The caddy community
60+{{template "marketing-footer" .}}
61+{{end}}
A lists/gmi/post.page.tmpl
+12, -0
 1@@ -0,0 +1,12 @@
 2+{{template "base" .}}
 3+
 4+{{define "body"}}
 5+# {{.Title}}
 6+{{.PublishAt}}
 7+{{if .Description}}{{.Description}}{{end}}
 8+=> {{.BlogURL}} on {{.BlogName}}
 9+
10+---
11+{{- template "list" . -}}
12+{{- template "footer" . -}}
13+{{end}}
A lists/gmi/privacy.page.tmpl
+28, -0
 1@@ -0,0 +1,28 @@
 2+{{template "base" .}}
 3+
 4+{{define "body"}}
 5+# Privacy
 6+
 7+Details on our privacy and security approach.
 8+
 9+## Account Data
10+
11+In order to have a functional account at {{.Site.Domain}}, we need to store your public key.  That is the only piece of information we record for a user.
12+
13+Because we use public-key cryptography, our security posture is a battle-tested and proven technique for authentication.
14+
15+## Third parties
16+
17+We have a strong commitment to never share any user data with any third-parties.
18+
19+## Service Providers
20+
21+We host our server on digital ocean [0]
22+
23+=> https://digitalocean.com [0]
24+
25+## Cookies
26+
27+We do not use any cookies, not even account authentication.
28+{{template "marketing-footer" .}}
29+{{end}}
A lists/gmi/read.page.tmpl
+13, -0
 1@@ -0,0 +1,13 @@
 2+{{template "base" .}}
 3+
 4+{{define "body"}}
 5+# read
 6+recently updated lists
 7+
 8+{{if .NextPage}}=> {{.NextPage}} next{{end}}
 9+{{if .PrevPage}}=> {{.PrevPage}} prev{{end}}
10+{{range .Posts}}
11+=> {{.URL}} {{.UpdatedTimeAgo}}{{.Padding}} {{.Title}} ({{.Username}})
12+{{- end}}
13+{{template "marketing-footer" .}}
14+{{end}}
A lists/gmi/rss.page.tmpl
+1, -0
1@@ -0,0 +1 @@
2+{{template "list" .}}
A lists/gmi/spec.page.tmpl
+125, -0
  1@@ -0,0 +1,125 @@
  2+{{template "base" .}}
  3+
  4+{{define "body"}}
  5+# Plain text list
  6+Speculative specification
  7+
  8+## Overview
  9+
 10+Version: 2022.05.02.dev
 11+Status: Draft
 12+Author: Eric Bower
 13+
 14+The goal of this specification is to understand how we render plain text lists. The overall design of this format is to be easy to parse and render.
 15+
 16+The format is line-oriented, and a satisfactory rendering can be achieved with a single pass of a document, processing each line independently. As per gopher, links can only be displayed one per line, encouraging neat, list-like structure.
 17+
 18+Feedback on any part of this is extremely welcome, please email {{.Site.Email}}.
 19+
 20+The source code for our parser can be found on github[0].
 21+
 22+=> https://github.com/neurosnap/lists.sh/blob/main/pkg/parser.go [0]github
 23+
 24+The source code for an example list demonstrating all the features can be found on github[1].
 25+
 26+=> https://github.com/neurosnap/lists-official-blog/blob/main/spec-example.txt [1]lists-official-blog
 27+
 28+## Parameters
 29+
 30+As a subtype of the top-level media type "text", "text/plain" inherits the "charset" parameter defined in RFC 2046[2]. The default value of "charset" is "UTF-8" for "text" content.
 31+
 32+=> https://datatracker.ietf.org/doc/html/rfc2046#section-4.1 [2]rfc 2046
 33+
 34+## Line orientation
 35+
 36+As mentioned, the text format is line-oriented. Each line of a document has a single "line type". It is possible to unambiguously determine a line's type purely by inspecting its first (3) characters. A line's type determines the manner in which it should be presented to the user. Any details of presentation or rendering associated with a particular line type are strictly limited in scope to that individual line.
 37+
 38+## File extensions
 39+
 40+{{.Site.Domain}} only supports the `.txt` file extension and will ignore all other file extensions.
 41+
 42+## List item
 43+
 44+List items are separated by newline characters `\n`. Each list item is on its own line.  A list item does not require any special formatting. A list item can contain as much text as it wants.  We encourage soft wrapping for readability in your editor of choice.  Hard wrapping is not permitted as it will create a new list item.
 45+
 46+Empty lines will be completely removed and not rendered to the end user.
 47+
 48+## Hyperlinks
 49+
 50+Hyperlinks are denoted by the prefix `=>`.  The following text should then be the hyperlink.
 51+
 52+```
 53+=> https://{{.Site.Domain}}
 54+```
 55+
 56+Optionally you can supply the hyperlink text immediately following the link.
 57+
 58+```
 59+=> https://{{.Site.Domain}} microblog for lists
 60+```
 61+
 62+## Images
 63+
 64+List items can be represented as images by prefixing the line with <code>=<</code>.
 65+
 66+```
 67+=< https://i.imgur.com/iXMNUN5.jpg
 68+```
 69+
 70+Optionally you can supply the image alt text immediately following the link.
 71+
 72+```
 73+=< https://i.imgur.com/iXMNUN5.jpg I use arch, btw
 74+```
 75+
 76+## Headers
 77+
 78+List items can be represented as headers.  We support two headers currently.  Headers will end the previous list and then create a new one after it.  This allows a single document to contain multiple lists.
 79+
 80+```
 81+# Header One
 82+## Header Two
 83+```
 84+
 85+## Blockquotes
 86+
 87+List items can be represented as blockquotes.
 88+
 89+```
 90+> This is a blockquote.
 91+```
 92+
 93+## Preformatted
 94+
 95+List items can be represented as preformatted text where newline characters are not considered part of new list items.  They can be represented by prefixing the line with ```.
 96+
 97+```
 98+#!/usr/bin/env bash
 99+
100+set -x
101+
102+echo "this is a preformatted list item!
103+```
104+
105+You must also close the preformatted text with another ``` on its own line. The next example with NOT work.
106+
107+## Variables
108+
109+Variables allow us to store metadata within our system.  Variables are list items with key value pairs denoted by `=:` followed by the key, a whitespace character, and then the value.
110+
111+```
112+=: publish_at 2022-04-20
113+```
114+
115+These variables will not be rendered to the user inside the list.
116+
117+### List of available variables:
118+
119+* `title` (custom title not dependent on filename)
120+* `description` (what is the purpose of this list?)
121+* `publish_at` (format must be `YYYY-MM-DD`)
122+* `list_type` (customize bullets; value gets sent directly to css property list-style-type[3])
123+
124+=> https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type [3]list-style-type
125+{{template "marketing-footer" .}}
126+{{end}}
A lists/gmi/transparency.page.tmpl
+31, -0
 1@@ -0,0 +1,31 @@
 2+{{template "base" .}}
 3+
 4+{{define "body"}}
 5+# Transparency
 6+
 7+## Analytics
 8+
 9+Here are some interesting stats on usage.
10+
11+Total users:
12+{{.Analytics.TotalUsers}}
13+
14+New users in the last month:
15+{{.Analytics.UsersLastMonth}}
16+
17+Total posts:
18+{{.Analytics.TotalPosts}}
19+
20+New posts in the last month:
21+{{.Analytics.PostsLastMonth}}
22+
23+Users with at least one post:
24+{{.Analytics.UsersWithPost}}
25+
26+Service maintenance costs:
27+
28+* Server $5.00/mo
29+* Domain name $3.25/mo
30+* Programmer $0.00/mo
31+{{template "marketing-footer" .}}
32+{{end}}
A lists/html/base.layout.tmpl
+18, -0
 1@@ -0,0 +1,18 @@
 2+{{define "base"}}
 3+<!doctype html>
 4+<html lang="en">
 5+    <head>
 6+        <meta charset='utf-8'>
 7+        <meta name="viewport" content="width=device-width, initial-scale=1" />
 8+        <title>{{template "title" .}}</title>
 9+
10+        <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
11+
12+        <meta name="keywords" content="blog, blogging, write, writing, lists" />
13+        {{template "meta" .}}
14+
15+        <link rel="stylesheet" href="/main.css" />
16+    </head>
17+    <body>{{template "body" .}}</body>
18+</html>
19+{{end}}
A lists/html/blog.page.tmpl
+62, -0
 1@@ -0,0 +1,62 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{if .Header.Bio}}{{.Header.Bio}}{{else}}{{.Header.Title}}{{end}}" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="{{.URL}}">
12+<meta property="og:title" content="{{.Header.Title}}">
13+{{if .Header.Bio}}<meta property="og:description" content="{{.Header.Bio}}">{{end}}
14+<meta property="og:image:width" content="300" />
15+<meta property="og:image:height" content="300" />
16+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18+
19+<meta property="twitter:card" content="summary">
20+<meta property="twitter:url" content="{{.URL}}">
21+<meta property="twitter:title" content="{{.Header.Title}}">
22+{{if .Header.Bio}}<meta property="twitter:description" content="{{.Header.Bio}}">{{end}}
23+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25+{{end}}
26+
27+{{define "body"}}
28+<header class="text-center">
29+    <h1 class="text-2xl font-bold">{{.Header.Title}}</h1>
30+    {{if .Header.Bio}}<p class="text-lg">{{.Header.Bio}}</p>{{end}}
31+    <nav>
32+        {{range .Header.Nav}}
33+            {{if .IsURL}}
34+            <a href="{{.URL}}" class="text-lg">{{.Value}}</a> |
35+            {{end}}
36+        {{end}}
37+        <a href="{{.RSSURL}}" class="text-lg">rss</a>
38+    </nav>
39+    <hr />
40+</header>
41+<main>
42+    {{if .Readme.HasItems}}
43+    <section>
44+        <article>
45+            {{template "list" .Readme}}
46+        </article>
47+        <hr />
48+    </section>
49+    {{end}}
50+
51+    <section class="posts">
52+        {{range .Posts}}
53+        <article>
54+            <div class="flex items-center">
55+                <time datetime="{{.UpdatedAtISO}}" class="font-italic text-sm post-date">{{.UpdatedTimeAgo}}</time>
56+                <h2 class="font-bold flex-1"><a href="{{.URL}}">{{.Title}}</a></h2>
57+            </div>
58+        </article>
59+        {{end}}
60+    </section>
61+</main>
62+{{template "footer" .}}
63+{{end}}
A lists/html/footer.partial.tmpl
+6, -0
1@@ -0,0 +1,6 @@
2+{{define "footer"}}
3+<footer>
4+    <hr />
5+    published with <a href={{.Site.HomeURL}}>{{.Site.Domain}}</a>
6+</footer>
7+{{end}}
A lists/html/help.page.tmpl
+214, -0
  1@@ -0,0 +1,214 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}help -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="questions and answers" />
  8+{{end}}
  9+
 10+{{define "body"}}
 11+<header>
 12+    <h1 class="text-2xl">Need help?</h1>
 13+    <p>Here are some common questions on using this platform that we would like to answer.</p>
 14+</header>
 15+<main>
 16+    <section id="permission-denied">
 17+        <h2 class="text-xl">
 18+            <a href="#permission-denied" rel="nofollow noopener">#</a>
 19+            I get a permission denied when trying to SSH
 20+        </h2>
 21+        <p>
 22+            Unfortunately SHA-2 RSA keys are <strong>not</strong> currently supported.
 23+        </p>
 24+        <p>
 25+            Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, Soft Serve does
 26+            not currently support access via new SSH RSA keys: only the old SHA-1 ones will work.
 27+            Until we sort this out you’ll either need an SHA-1 RSA key or a key with another
 28+            algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the
 29+            following:
 30+        </p>
 31+        <pre>$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;</pre>
 32+        <p>If you’re curious about the inner workings of this problem have a look at:</p>
 33+        <ul>
 34+            <li><a href="https://github.com/golang/go/issues/37278">golang/go#37278</a></li>
 35+            <li><a href="https://go-review.googlesource.com/c/crypto/+/220037">go-review</a></li>
 36+            <li><a href="https://github.com/golang/crypto/pull/197">golang/crypto#197</a></li>
 37+        </ul>
 38+    </section>
 39+
 40+    <section id="ssh-key">
 41+        <h2 class="text-xl">
 42+            <a href="#ssh-key" rel="nofollow noopener">#</a>
 43+            Generating a new SSH key
 44+        </h2>
 45+        <p>
 46+            <a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent">Github reference</a>
 47+        </p>
 48+        <pre>ssh-keygen -t ed25519 -C "your_email@example.com"</pre>
 49+        <ol>
 50+            <li>When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.</li>
 51+            <li>At the prompt, type a secure passphrase.</li>
 52+        </ol>
 53+    </section>
 54+
 55+    <section id="blog-structure">
 56+        <h2 class="text-xl">
 57+            <a href="#blog-structure" rel="nofollow noopener">#</a>
 58+            What should my blog folder look like?
 59+        </h2>
 60+        <p>
 61+            Currently {{.Site.Domain}} only supports a flat folder structure.  Therefore,
 62+            <code>scp -r</code> is not permitted.  We also only allow <code>.txt</code> files to be
 63+            uploaded.
 64+        </p>
 65+        <p>
 66+            <a href="https://github.com/neurosnap/lists-blog">Here is the source to my blog on this platform</a>
 67+        </p>
 68+        <p>
 69+        Below is an example of what your blog folder should look like:
 70+        </p>
 71+            <pre>blog/
 72+first-post.txt
 73+second-post.txt
 74+third-post.txt</pre>
 75+        </p>
 76+        <p>
 77+            Underscores and hyphens are permitted and will be automatically removed from the title of the list.
 78+        </p>
 79+    </section>
 80+
 81+    <section id="post-update">
 82+        <h2 class="text-xl">
 83+            <a href="#post-update" rel="nofollow noopener">#</a>
 84+            How do I update a list?
 85+        </h2>
 86+        <p>
 87+            Updating a list requires that you update the source document and then run the <code>scp</code>
 88+            command again.  If the filename remains the same, then the list will be updated.
 89+        </p>
 90+    </section>
 91+
 92+    <section id="post-delete">
 93+        <h2 class="text-xl">
 94+            <a href="#post-delete" rel="nofollow noopener">#</a>
 95+            How do I delete a list?
 96+        </h2>
 97+        <p>
 98+            Because <code>scp</code> does not natively support deleting files, I didn't want to bake
 99+            that behavior into my ssh server.
100+        </p>
101+
102+        <p>
103+            However, if a user wants to delete a post they can delete the contents of the file and
104+            then upload it to our server.  If the file contains 0 bytes, we will remove the post.
105+            For example, if you want to delete <code>delete.txt</code> you could:
106+        </p>
107+
108+        <pre>
109+cp /dev/null delete.txt
110+scp ./delete.txt {{.Site.Domain}}:/</pre>
111+
112+        <p>
113+            Alternatively, you can go to <code>ssh {{.Site.Domain}}</code> and select "Manage posts."
114+            Then you can highlight the post you want to delete and then press "X."  It will ask for
115+            confirmation before actually removing the list.
116+        </p>
117+    </section>
118+
119+    <section id="blog-upload-single-file">
120+        <h2 class="text-xl">
121+            <a href="#blog-upload-single-file" rel="nofollow noopener">#</a>
122+            When I want to publish a new post, do I have to upload all posts everytime?
123+        </h2>
124+        <p>
125+            Nope!  Just <code>scp</code> the file you want to publish.  For example, if you created
126+            a new post called <code>taco-tuesday.txt</code> then you would publish it like this:
127+        </p>
128+        <pre>scp ./taco-tuesday.txt {{.Site.Domain}}:</pre>
129+    </section>
130+
131+    <section id="blog-header">
132+        <h2 class="text-xl">
133+            <a href="#blog-header" rel="nofollow noopener">#</a>
134+            How do I change my blog's name?
135+        </h2>
136+        <p>
137+            All you have to do is create a post titled <code>_header.txt</code> and add some
138+            information to the list.
139+        </p>
140+        <pre>=: title My new blog!
141+=: description My blog description!
142+=> https://xyz.com website
143+=> https://twitter.com/xyz twitter</pre>
144+        <ul>
145+            <li><code>title</code> will change your blog name</li>
146+            <li><code>description</code> will add a blurb right under your blog name (and add meta descriptions)</li>
147+            <li>The links will show up next to the <code>rss</code> link to your blog
148+        </ul>
149+    </section>
150+
151+    <section id="blog-readme">
152+        <h2 class="text-xl">
153+            <a href="#blog-readme" rel="nofollow noopener">#</a>
154+            How do I add an introduction to my blog?
155+        </h2>
156+        <p>
157+            All you have to do is create a post titled <code>_readme.txt</code> and add some
158+            information to the list.
159+        </p>
160+        <pre>=: list_type none
161+# Hi my name is Bob!
162+I like to sing. Dance. And I like to have fun fun fun!</pre>
163+        <p>
164+            Whatever is inside the <code>_readme</code> file will get rendered (as a list) right above your
165+            blog posts. Neat!
166+        </p>
167+    </section>
168+
169+    <section id="blog-url">
170+        <h2 class="text-xl">
171+            <a href="#blog-url" rel="nofollow noopener">#</a>
172+            What is my blog URL?
173+        </h2>
174+        <pre>https://{username}.{{.Site.Domain}}</pre>
175+    </section>
176+
177+    <section id="continuous-deployment">
178+        <h2 class="text-xl">
179+            <a href="#continuous-deployment" rel="nofollow noopener">#</a>
180+            How can I automatically publish my post?
181+        </h2>
182+        <p>
183+            There is a github action that we built to make it easy to publish your blog automatically.
184+        </p>
185+        <ul>
186+            <li>
187+                <a href="https://github.com/marketplace/actions/scp-publish-action">github marketplace</a>
188+            </li>
189+            <li>
190+                <a href="https://github.com/neurosnap/lists-official-blog/blob/main/.github/workflows/publish.yml">example workflow</a>
191+            </li>
192+        </ul>
193+        <p>
194+            A user also created a systemd task to automatically publish new posts.  <a href="https://github.com/neurosnap/lists.sh/discussions/24">Check out this github discussion for more details.</a>
195+        </p>
196+    </section>
197+
198+    <section id="multiple-accounts">
199+        <h2 class="text-xl">
200+            <a href="#multiple-accounts" rel="nofollow noopener">#</a>
201+            Can I create multiple accounts?
202+        </h2>
203+        <p>
204+           Yes!  You can either a) create a new keypair and use that for authentication
205+           or b) use the same keypair and ssh into our CMS using our special username
206+           <code>ssh new@{{.Site.Domain}}</code>.
207+        </p>
208+        <p>
209+            Please note that if you use the same keypair for multiple accounts, you will need to
210+            always specify the user when logging into our CMS.
211+        </p>
212+    </section>
213+</main>
214+{{template "marketing-footer" .}}
215+{{end}}
A lists/html/list.partial.tmpl
+35, -0
 1@@ -0,0 +1,35 @@
 2+{{define "list"}}
 3+<ul style="list-style-type: {{.ListType}};">
 4+    {{range .Items}}
 5+        {{if .IsText}}
 6+            {{if .Value}}
 7+            <li>{{.Value}}</li>
 8+            {{end}}
 9+        {{end}}
10+
11+        {{if .IsURL}}
12+        <li><a href="{{.URL}}">{{.Value}}</a></li>
13+        {{end}}
14+
15+        {{if .IsImg}}
16+        <li><img src="{{.URL}}" alt="{{.Value}}" /></li>
17+        {{end}}
18+
19+        {{if .IsBlock}}
20+        <li><blockquote>{{.Value}}</blockquote></li>
21+        {{end}}
22+
23+        {{if .IsHeaderOne}}
24+        </ul><h2 class="text-xl font-bold">{{.Value}}</h2><ul style="list-style-type: {{$.ListType}};">
25+        {{end}}
26+
27+        {{if .IsHeaderTwo}}
28+        </ul><h3 class="text-lg font-bold">{{.Value}}</h3><ul style="list-style-type: {{$.ListType}};">
29+        {{end}}
30+
31+        {{if .IsPre}}
32+        <li><pre>{{.Value}}</pre></li>
33+        {{end}}
34+    {{end}}
35+</ul>
36+{{end}}
A lists/html/marketing-footer.partial.tmpl
+14, -0
 1@@ -0,0 +1,14 @@
 2+{{define "marketing-footer"}}
 3+<footer>
 4+    <hr />
 5+    <p class="font-italic">Built and maintained by <a href="https://pico.sh">pico.sh</a>.</p>
 6+    <div>
 7+        <a href="/">home</a> |
 8+        <a href="/spec">spec</a> |
 9+        <a href="/ops">ops</a> |
10+        <a href="/help">help</a> |
11+        <a href="/rss">rss</a> |
12+        <a href="https://git.sr.ht/~erock/lists.sh">source</a>
13+    </div>
14+</footer>
15+{{end}}
A lists/html/marketing.page.tmpl
+153, -0
  1@@ -0,0 +1,153 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}{{.Site.Domain}} -- a microblog for lists{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="a microblog for lists" />
  8+
  9+<meta property="og:type" content="website">
 10+<meta property="og:site_name" content="{{.Site.Domain}}">
 11+<meta property="og:url" content="https://{{.Site.Domain}}">
 12+<meta property="og:title" content="{{.Site.Domain}}">
 13+<meta property="og:description" content="a microblog for lists">
 14+
 15+<meta name="twitter:card" content="summary" />
 16+<meta property="twitter:url" content="https://{{.Site.Domain}}">
 17+<meta property="twitter:title" content="{{.Site.Domain}}">
 18+<meta property="twitter:description" content="a microblog for lists">
 19+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
 20+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
 21+
 22+<meta property="og:image:width" content="300" />
 23+<meta property="og:image:height" content="300" />
 24+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
 25+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
 26+{{end}}
 27+
 28+{{define "body"}}
 29+<header class="text-center">
 30+    <h1 class="text-2xl font-bold">{{.Site.Domain}}</h1>
 31+    <p class="text-lg">A microblog for lists</p>
 32+    <p class="text-lg"><a href="/read">discover</a> some interesting lists</p>
 33+    <hr />
 34+</header>
 35+
 36+<main>
 37+    <section>
 38+        <h2 class="text-lg font-bold">Examples</h2>
 39+        <p>
 40+            <a href="//hey.{{.Site.Domain}}">official blog</a> |
 41+            <a href="https://git.sr.ht/~erock/lists-official-blog">blog source</a>
 42+        </p>
 43+    </section>
 44+
 45+    <section>
 46+        <h2 class="text-lg font-bold">Create your account with Public-Key Cryptography</h2>
 47+        <p>We don't want your email address.</p>
 48+        <p>To get started, simply ssh into our content management system:</p>
 49+        <pre>ssh new@{{.Site.Domain}}</pre>
 50+        <div class="text-sm font-italic note">
 51+            note: <code>new</code> is a special username that will always send you to account
 52+            creation.
 53+        </div>
 54+        <div class="text-sm font-italic note">
 55+            note: getting permission denied? <a href="/help#permission-denied">read this</a>
 56+        </div>
 57+        <p>
 58+            After that, just set a username and you're ready to start writing! When you SSH
 59+            again, use your username that you set in the CMS.
 60+        </p>
 61+    </section>
 62+
 63+    <section>
 64+        <h2 class="text-lg font-bold">You control the source files</h2>
 65+        <p>Create lists using your favorite editor in plain text files.</p>
 66+        <code>~/blog/days-in-week.txt</code>
 67+        <pre>Sunday
 68+Monday
 69+Tuesday
 70+Wednesday
 71+Thursday
 72+Friday
 73+Saturday</pre>
 74+    </section>
 75+
 76+    <section>
 77+        <h2 class="text-lg font-bold">Publish your posts with one command</h2>
 78+        <p>
 79+            When your post is ready to be published, copy the file to our server with a familiar
 80+            command:
 81+        </p>
 82+        <pre>scp ~/blog/*.txt {{.Site.Domain}}:/</pre>
 83+        <p>We'll either create or update the lists for you.</p>
 84+    </section>
 85+
 86+    <section>
 87+        <h2 class="text-lg font-bold">Terminal workflow without installation</h2>
 88+        <p>
 89+            Since we are leveraging tools you already have on your computer
 90+            (<code>ssh</code> and <code>scp</code>), there is nothing to install.
 91+        </p>
 92+        <p>
 93+            This provides the convenience of a web app, but from inside your terminal!
 94+        </p>
 95+    </section>
 96+
 97+    <section>
 98+        <h2 class="text-lg font-bold">Plain text format</h2>
 99+        <p>A simple specification that is flexible and with no frills.</p>
100+        <p><a href="/spec">specification</a></p>
101+    </section>
102+
103+    <section>
104+        <h2 class="text-lg font-bold">Features</h2>
105+        <ul>
106+            <li>Just lists</li>
107+            <li>Looks great on any device</li>
108+            <li>Bring your own editor</li>
109+            <li>You control the source files</li>
110+            <li>Terminal workflow with no installation</li>
111+            <li>Public-key based authentication</li>
112+            <li>No ads, zero browser-based tracking</li>
113+            <li>No platform lock-in</li>
114+            <li>No javascript</li>
115+            <li>Subscriptions via RSS</li>
116+            <li>Not a platform for todos</li>
117+            <li>Minimalist design</li>
118+            <li>100% open source</li>
119+        </ul>
120+    </section>
121+
122+    <section>
123+        <h2 class="text-lg font-bold">Philosophy</h2>
124+        <p>
125+            I love writing lists.  I think restricting writing to a set of lists can really
126+            help improve clarity in thought.  The goal of this blogging platform is to make it
127+            simple to use the tools you love to write and publish lists.  There is no installation,
128+            signup is as easy as SSH'ing into our CMS, and publishing content is as easy as
129+            copying files to our server.
130+        </p>
131+        <p>
132+            Another goal of this microblog platform is to satisfy my own needs.  I like to
133+            write and share lists with people because I find it's one of the best way to disseminate
134+            knowledge.  Whether it's a list of links or a list of paragraphs, writing in lists is
135+            very satisfying and I welcome you to explore it on this site!
136+        </p>
137+        <p>
138+            Other blogging platforms support writing lists, but they don't
139+            <span class="font-bold">emphasize</span> them.  Writing lists is pretty popular
140+            on Twitter, but discoverability is terrible.  Other blogging platforms focus on prose,
141+            but there really is nothing out there catered specifically for lists ... until now.
142+        </p>
143+    </section>
144+
145+    <section>
146+        <h2 class="text-lg font-bold">Roadmap</h2>
147+        <ol>
148+            <li>Feature complete?</li>
149+        </ol>
150+    </section>
151+</main>
152+
153+{{template "marketing-footer" .}}
154+{{end}}
A lists/html/ops.page.tmpl
+145, -0
  1@@ -0,0 +1,145 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}operations -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="{{.Site.Domain}} operations" />
  8+{{end}}
  9+
 10+{{define "body"}}
 11+<header>
 12+    <h1 class="text-2xl">Operations</h1>
 13+    <ul>
 14+        <li><a href="/privacy">privacy</a></li>
 15+        <li><a href="/transparency">transparency</a></li>
 16+    </ul>
 17+</header>
 18+<main>
 19+    <section>
 20+        <h2 class="text-xl">Purpose</h2>
 21+        <p>
 22+            {{.Site.Domain}} exists to allow people to create and share their lists
 23+            without the need to set up their own server or be part of a platform
 24+            that shows ads or tracks its users.
 25+        </p>
 26+    </section>
 27+    <section>
 28+        <h2 class="text-xl">Ethics</h2>
 29+        <p>We are committed to:</p>
 30+        <ul>
 31+            <li>No browser-based tracking of visitor behavior.</li>
 32+            <li>No attempt to identify users.</li>
 33+            <li>Never sell any user or visitor data.</li>
 34+            <li>No ads — ever.</li>
 35+        </ul>
 36+    </section>
 37+    <section>
 38+        <h2 class="text-xl">Code of Content Publication</h2>
 39+        <p>
 40+            Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any
 41+            combination of words and pixels except for: content of animosity or disparagement of an
 42+            individual or a group on account of a group characteristic such as race, color, national
 43+            origin, sex, disability, religion, or sexual orientation, which will be taken down
 44+            immediately.
 45+        </p>
 46+        <p>
 47+            If one notices something along those lines in a blog please let us know at
 48+            <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>.
 49+        </p>
 50+    </section>
 51+    <section>
 52+        <h2 class="text-xl">Liability</h2>
 53+        <p>
 54+            The user expressly understands and agrees that Eric Bower, the operator of this website
 55+            shall not be liable, in law or in equity, to them or to any third party for any direct,
 56+            indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
 57+        </p>
 58+    </section>
 59+    <section>
 60+        <h2 class="text-xl">Analytics</h2>
 61+        <p>
 62+            We are committed to zero browser-based tracking or trying to identify visitors.  This
 63+            means we do not try to understand the user based on cookies or IP address.  We do not
 64+            store personally identifiable information.
 65+        </p>
 66+        <p>
 67+            However, in order to provide a better service, we do have some analytics on posts.
 68+            List of metrics we track for posts:
 69+        </p>
 70+        <ul>
 71+            <li>anonymous view counts</li>
 72+        </ul>
 73+        <p>
 74+            We might also inspect the headers of HTTP requests to determine some tertiary information
 75+            about the request.  For example we might inspect the <code>User-Agent</code> or
 76+            <code>Referer</code> to filter out requests from bots.
 77+        </p>
 78+    </section>
 79+    <section>
 80+        <h2 class="text-xl">Account Terms</h2>
 81+        <p>
 82+            <ul>
 83+                <li>
 84+                    The user is responsible for all content posted and all actions performed with
 85+                    their account.
 86+                </li>
 87+                <li>
 88+                    We reserve the right to disable or delete a user's account for any reason at
 89+                    any time. We have this clause because, statistically speaking, there will be
 90+                    people trying to do something nefarious.
 91+                </li>
 92+            </ul>
 93+        </p>
 94+    </section>
 95+    <section>
 96+        <h2 class="text-xl">Service Availability</h2>
 97+        <p>
 98+         We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer
 99+         service-level agreements but do take uptime seriously.
100+        </p>
101+    </section>
102+    <section>
103+        <h2 class="text-xl">Contact and Support</h2>
104+        <p>
105+            Email us at <a href="mailto:support@{{.Site.Domain}}">support@{{.Site.Domain}}</a>
106+            with any questions.
107+        </p>
108+    </section>
109+    <section>
110+        <h2 class="text-xl">Acknowledgments</h2>
111+        <p>
112+            {{.Site.Domain}} was inspired by <a href="https://mataroa.blog">Mataroa Blog</a>
113+            and <a href="https://bearblog.dev/">Bear Blog</a>.
114+        </p>
115+        <p>
116+            {{.Site.Domain}} is built with many open source technologies.
117+        </p>
118+        <p>
119+            In particular we would like to thank:
120+        </p>
121+        <ul>
122+            <li>
123+                <span>The </span>
124+                <a href="https://charm.sh">charm.sh</a>
125+                <span> community</span>
126+            </li>
127+            <li>
128+                <span>The </span>
129+                <a href="https://go.dev">golang</a>
130+                <span> community</span>
131+            </li>
132+            <li>
133+                <span>The </span>
134+                <a href="https://www.postgresql.org/">postgresql</a>
135+                <span> community</span>
136+            </li>
137+            <li>
138+                <span>The </span>
139+                <a href="https://github.com/caddyserver/caddy">caddy</a>
140+                <span> community</span>
141+            </li>
142+        </ul>
143+    </section>
144+</main>
145+{{template "marketing-footer" .}}
146+{{end}}
A lists/html/post.page.tmpl
+41, -0
 1@@ -0,0 +1,41 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.Description}}" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="{{.URL}}">
12+<meta property="og:title" content="{{.Title}}">
13+{{if .Description}}<meta property="og:description" content="{{.Description}}">{{end}}
14+<meta property="og:image:width" content="300" />
15+<meta property="og:image:height" content="300" />
16+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18+
19+<meta property="twitter:card" content="summary">
20+<meta property="twitter:url" content="{{.URL}}">
21+<meta property="twitter:title" content="{{.Title}}">
22+{{if .Description}}<meta property="twitter:description" content="{{.Description}}">{{end}}
23+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25+{{end}}
26+
27+{{define "body"}}
28+<header>
29+    <h1 class="text-2xl font-bold">{{.Title}}</h1>
30+    <p class="font-bold m-0">
31+        <time datetime="{{.PublishAtISO}}">{{.PublishAt}}</time>
32+        <span> on </span>
33+        <a href="{{.BlogURL}}">{{.BlogName}}</a></p>
34+    {{if .Description}}<div class="my font-italic">{{.Description}}</div>{{end}}
35+</header>
36+<main>
37+    <article>
38+        {{template "list" .}}
39+    </article>
40+</main>
41+{{template "footer" .}}
42+{{end}}
A lists/html/privacy.page.tmpl
+52, -0
 1@@ -0,0 +1,52 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}privacy -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.Site.Domain}} privacy policy" />
 8+{{end}}
 9+
10+{{define "body"}}
11+<header>
12+    <h1 class="text-2xl">Privacy</h1>
13+    <p>Details on our privacy and security approach.</p>
14+</header>
15+<main>
16+    <section>
17+        <h2 class="text-xl">Account Data</h2>
18+        <p>
19+            In order to have a functional account at {{.Site.Domain}}, we need to store
20+            your public key.  That is the only piece of information we record for a user.
21+        </p>
22+        <p>
23+            Because we use public-key cryptography, our security posture is a battle-tested
24+            and proven technique for authentication.
25+        </p>
26+    </section>
27+
28+    <section>
29+        <h2 class="text-xl">Third parties</h2>
30+        <p>
31+            We have a strong commitment to never share any user data with any third-parties.
32+        </p>
33+    </section>
34+
35+    <section>
36+        <h2 class="text-xl">Service Providers</h2>
37+        <ul>
38+            <li>
39+                <span>We host our server on </span>
40+                <a href="https://digitalocean.com">digital ocean</a>
41+            </li>
42+        </ul>
43+    </section>
44+
45+    <section>
46+        <h2 class="text-xl">Cookies</h2>
47+        <p>
48+            We do not use any cookies, not even account authentication.
49+        </p>
50+    </section>
51+</main>
52+{{template "marketing-footer" .}}
53+{{end}}
A lists/html/read.page.tmpl
+35, -0
 1@@ -0,0 +1,35 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}discover lists -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="discover interesting lists" />
 8+{{end}}
 9+
10+{{define "body"}}
11+<header class="text-center">
12+    <h1 class="text-2xl font-bold">read</h1>
13+    <p class="text-lg">recently updated lists</p>
14+    <hr />
15+</header>
16+<main>
17+    <div class="my">
18+        {{if .PrevPage}}<a href="{{.PrevPage}}">prev</a>{{else}}<span class="text-grey">prev</span>{{end}}
19+        {{if .NextPage}}<a href="{{.NextPage}}">next</a>{{else}}<span class="text-grey">next</span>{{end}}
20+    </div>
21+    {{range .Posts}}
22+    <article>
23+        <div class="flex items-center">
24+            <time datetime="{{.UpdatedAtISO}}" class="font-italic text-sm post-date">{{.UpdatedTimeAgo}}</time>
25+            <div class="flex-1">
26+                <h2 class="inline"><a href="{{.URL}}">{{.Title}}</a></h2>
27+                <address class="text-sm inline">
28+                    <a href="{{.BlogURL}}" class="link-grey">({{.Username}})</a>
29+                </address>
30+            </div>
31+        </div>
32+    </article>
33+    {{end}}
34+</main>
35+{{template "marketing-footer" .}}
36+{{end}}
A lists/html/rss.page.tmpl
+1, -0
1@@ -0,0 +1 @@
2+{{template "list" .}}
A lists/html/spec.page.tmpl
+178, -0
  1@@ -0,0 +1,178 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}specification -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="a specification for lists" />
  8+{{end}}
  9+
 10+{{define "body"}}
 11+<header>
 12+    <h1 class="text-2xl">Plain text list</h1>
 13+    <h2 class="text-xl">Speculative specification</h2>
 14+    <dl>
 15+        <dt>Version</dt>
 16+        <dd>2022.05.02.dev</dd>
 17+
 18+        <dt>Status</dt>
 19+        <dd>Draft</dd>
 20+
 21+        <dt>Author</dt>
 22+        <dd>Eric Bower</dd>
 23+    </dl>
 24+</header>
 25+<main>
 26+    <section id="overview">
 27+        <p>
 28+            The goal of this specification is to understand how we render plain text lists.
 29+            The overall design of this format is to be easy to parse and render.
 30+        </p>
 31+
 32+        <p>
 33+            The format is line-oriented, and a satisfactory rendering can be achieved with a single
 34+            pass of a document, processing each line independently. As per gopher, links can only be
 35+            displayed one per line, encouraging neat, list-like structure.
 36+        </p>
 37+
 38+        <p>
 39+            Feedback on any part of this is extremely welcome, please email
 40+            <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>.
 41+        </p>
 42+
 43+        <p>
 44+            The source code for our parser can be found
 45+            <a href="https://github.com/neurosnap/lists.sh/blob/main/pkg/parser.go">here</a>.
 46+        </p>
 47+
 48+        <p>
 49+            The source code for an example list demonstrating all the features can be found
 50+            <a href="https://github.com/neurosnap/lists-official-blog/blob/main/spec-example.txt">here</a>.
 51+        </p>
 52+    </section>
 53+
 54+    <section id="parameters">
 55+        <p>
 56+            As a subtype of the top-level media type "text", "text/plain" inherits the "charset"
 57+            parameter defined in <a href="https://datatracker.ietf.org/doc/html/rfc2046#section-4.1">RFC 2046</a>.
 58+            The default value of "charset" is "UTF-8" for "text" content.
 59+        </p>
 60+    </section>
 61+
 62+    <section id="line-orientation">
 63+        <p>
 64+            As mentioned, the text format is line-oriented. Each line of a document has a single
 65+            "line type". It is possible to unambiguously determine a line's type purely by
 66+            inspecting its first (3) characters. A line's type determines the manner in which it
 67+            should be presented to the user. Any details of presentation or rendering associated
 68+            with a particular line type are strictly limited in scope to that individual line.
 69+        </p>
 70+    </section>
 71+
 72+    <section id="file-extensions">
 73+        <h2 class="text-xl">File extension</h2>
 74+        <p>
 75+            {{.Site.Domain}} only supports the <code>.txt</code> file extension and will
 76+            ignore all other file extensions.
 77+        </p>
 78+    </section>
 79+
 80+    <section id="list-item">
 81+        <h2 class="text-xl">List item</h2>
 82+        <p>
 83+            List items are separated by newline characters <code>\n</code>.
 84+            Each list item is on its own line.  A list item does not require any special formatting.
 85+            A list item can contain as much text as it wants.  We encourage soft wrapping for readability
 86+            in your editor of choice.  Hard wrapping is not permitted as it will create a new list item.
 87+        </p>
 88+        <p>
 89+            Empty lines will be completely removed and not rendered to the end user.
 90+        </p>
 91+    </section>
 92+
 93+    <section id="hyperlinks">
 94+        <h2 class="text-xl">Hyperlinks</h2>
 95+        <p>
 96+            Hyperlinks are denoted by the prefix <code>=></code>.  The following text should then be
 97+            the hyperlink.
 98+        </p>
 99+        <pre>=> https://{{.Site.Domain}}</pre>
100+        <p>Optionally you can supply the hyperlink text immediately following the link.</p>
101+        <pre>=> https://{{.Site.Domain}} microblog for lists</pre>
102+    </section>
103+
104+    <section id="images">
105+        <h2 class="text-xl">Images</h2>
106+        <p>
107+            List items can be represented as images by prefixing the line with <code>=<</code>.
108+        </p>
109+        <pre>=< https://i.imgur.com/iXMNUN5.jpg</pre>
110+        <p>Optionally you can supply the image alt text immediately following the link.</p>
111+        <pre>=< https://i.imgur.com/iXMNUN5.jpg I use arch, btw</pre>
112+    </section>
113+
114+    <section id="headers">
115+        <h2 class="text-xl">Headers</h2>
116+        <p>
117+            List items can be represented as headers.  We support two headers currently.  Headers
118+            will end the previous list and then create a new one after it.  This allows a single
119+            document to contain multiple lists.
120+        </p>
121+        <pre># Header One
122+## Header Two</pre>
123+    </section>
124+
125+    <section id="blockquotes">
126+        <h2 class="text-xl">Blockquotes</h2>
127+        <p>
128+            List items can be represented as blockquotes.
129+        </p>
130+        <pre>> This is a blockquote.</pre>
131+    </section>
132+
133+    <section id="preformatted">
134+        <h2 class="text-xl">Preformatted</h2>
135+        <p>
136+            List items can be represented as preformatted text where newline characters are not
137+            considered part of new list items.  They can be represented by prefixing the line with
138+            <code>```</code>.
139+        </p>
140+        <pre>```
141+#!/usr/bin/env bash
142+
143+set -x
144+
145+echo "this is a preformatted list item!
146+```</pre>
147+        <p>
148+            You must also close the preformatted text with another <code>```</code> on its own line. The
149+            next example with NOT work.
150+        </p>
151+        <pre>```
152+#!/usr/bin/env bash
153+
154+echo "This will not render properly"```</pre>
155+    </section>
156+
157+    <section id="variables">
158+        <h2 class="text-xl">Variables</h2>
159+        <p>
160+            Variables allow us to store metadata within our system.  Variables are list items with
161+            key value pairs denoted by <code>=:</code> followed by the key, a whitespace character,
162+            and then the value.
163+        </p>
164+        <pre>=: publish_at 2022-04-20</pre>
165+        <p>These variables will not be rendered to the user inside the list.</p>
166+        <h3 class="text-lg">List of available variables:</h3>
167+        <ul>
168+            <li><code>title</code> (custom title not dependent on filename)</li>
169+            <li><code>description</code> (what is the purpose of this list?)</li>
170+            <li><code>publish_at</code> (format must be <code>YYYY-MM-DD</code>)</li>
171+            <li>
172+                <code>list_type</code> (customize bullets; value gets sent directly to css property
173+                <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type">list-style-type</a>)
174+            </li>
175+        </ul>
176+    </section>
177+</main>
178+{{template "marketing-footer" .}}
179+{{end}}
A lists/html/transparency.page.tmpl
+57, -0
 1@@ -0,0 +1,57 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}transparency -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="full transparency of analytics and cost at {{.Site.Domain}}" />
 8+{{end}}
 9+
10+{{define "body"}}
11+<header>
12+    <h1 class="text-2xl">Transparency</h1>
13+    <hr />
14+</header>
15+<main>
16+    <section>
17+        <h2 class="text-xl">Analytics</h2>
18+        <p>
19+            Here are some interesting stats on usage.
20+        </p>
21+
22+        <article>
23+            <h2 class="text-lg">Total users</h2>
24+            <div>{{.Analytics.TotalUsers}}</div>
25+        </article>
26+
27+        <article>
28+            <h2 class="text-lg">New users in the last month</h2>
29+            <div>{{.Analytics.UsersLastMonth}}</div>
30+        </article>
31+
32+        <article>
33+            <h2 class="text-lg">Total posts</h2>
34+            <div>{{.Analytics.TotalPosts}}</div>
35+        </article>
36+
37+        <article>
38+            <h2 class="text-lg">New posts in the last month</h2>
39+            <div>{{.Analytics.PostsLastMonth}}</div>
40+        </article>
41+
42+        <article>
43+            <h2 class="text-lg">Users with at least one post</h2>
44+            <div>{{.Analytics.UsersWithPost}}</div>
45+        </article>
46+    </section>
47+
48+    <section>
49+        <h2 class="text-xl">Service maintenance costs</h2>
50+        <ul>
51+            <li>Server $5.00/mo</li>
52+            <li>Domain name $3.25/mo</li>
53+            <li>Programmer $0.00/mo</li>
54+        </ul>
55+    </section>
56+</main>
57+{{template "marketing-footer" .}}
58+{{end}}
A lists/makefile
+21, -0
 1@@ -0,0 +1,21 @@
 2+DOCKER_TAG?=$(shell git log --format="%H" -n 1)
 3+
 4+bp-setup:
 5+	docker buildx ls | grep pico || docker buildx create --name pico
 6+	docker buildx use pico
 7+.PHONY: bp-setup
 8+
 9+bp-ssh: bp-setup
10+	docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-ssh:$(DOCKER_TAG) --target ssh -f Dockerifle ..
11+.PHONY: bp-ssh
12+
13+bp-web: bp-setup
14+	docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-web:$(DOCKER_TAG) --target web -f Dockerfile ..
15+.PHONY: bp-web
16+
17+bp-gemini: bp-setup
18+	docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/lists-gemini:$(DOCKER_TAG) --target gemini -f Dockerfile ..
19+.PHONY: bp-gemini
20+
21+bp: bp-ssh bp-web bp-gemini
22+.PHONY: bp
A lists/pkg/parser.go
+175, -0
  1@@ -0,0 +1,175 @@
  2+package pkg
  3+
  4+import (
  5+	"fmt"
  6+	"html/template"
  7+	"strings"
  8+	"time"
  9+)
 10+
 11+type ParsedText struct {
 12+	Items    []*ListItem
 13+	MetaData *MetaData
 14+}
 15+
 16+type ListItem struct {
 17+	Value       string
 18+	URL         template.URL
 19+	Variable    string
 20+	IsURL       bool
 21+	IsBlock     bool
 22+	IsText      bool
 23+	IsHeaderOne bool
 24+	IsHeaderTwo bool
 25+	IsImg       bool
 26+	IsPre       bool
 27+}
 28+
 29+type MetaData struct {
 30+	PublishAt   *time.Time
 31+	Title       string
 32+	Description string
 33+	ListType    string // https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type
 34+}
 35+
 36+var urlToken = "=>"
 37+var blockToken = ">"
 38+var varToken = "=:"
 39+var imgToken = "=<"
 40+var headerOneToken = "#"
 41+var headerTwoToken = "##"
 42+var preToken = "```"
 43+
 44+type SplitToken struct {
 45+	Key   string
 46+	Value string
 47+}
 48+
 49+func TextToSplitToken(text string) *SplitToken {
 50+	txt := strings.Trim(text, " ")
 51+	token := &SplitToken{}
 52+	word := ""
 53+	for i, c := range txt {
 54+		if c == ' ' {
 55+			token.Key = strings.Trim(word, " ")
 56+			token.Value = strings.Trim(txt[i:], " ")
 57+			break
 58+		} else {
 59+			word += string(c)
 60+		}
 61+	}
 62+
 63+	if token.Key == "" {
 64+		token.Key = strings.Trim(text, " ")
 65+		token.Value = strings.Trim(text, " ")
 66+	}
 67+
 68+	return token
 69+}
 70+
 71+func SplitByNewline(text string) []string {
 72+	return strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
 73+}
 74+
 75+func PublishAtDate(date string) (*time.Time, error) {
 76+	t, err := time.Parse("2006-01-02", date)
 77+	return &t, err
 78+}
 79+
 80+func TokenToMetaField(meta *MetaData, token *SplitToken) {
 81+	if token.Key == "publish_at" {
 82+		publishAt, err := PublishAtDate(token.Value)
 83+		if err == nil {
 84+			meta.PublishAt = publishAt
 85+		}
 86+	} else if token.Key == "title" {
 87+		meta.Title = token.Value
 88+	} else if token.Key == "description" {
 89+		meta.Description = token.Value
 90+	} else if token.Key == "list_type" {
 91+		meta.ListType = token.Value
 92+	}
 93+}
 94+
 95+func KeyAsValue(token *SplitToken) string {
 96+	if token.Value == "" {
 97+		return token.Key
 98+	}
 99+	return token.Value
100+}
101+
102+func ParseText(text string) *ParsedText {
103+	textItems := SplitByNewline(text)
104+	items := []*ListItem{}
105+	meta := &MetaData{
106+		ListType: "disc",
107+	}
108+	pre := false
109+	skip := false
110+	var prevItem *ListItem
111+
112+	for _, t := range textItems {
113+		skip = false
114+
115+		if len(items) > 0 {
116+			prevItem = items[len(items)-1]
117+		}
118+
119+		li := &ListItem{
120+			Value: strings.Trim(t, " "),
121+		}
122+
123+		if strings.HasPrefix(li.Value, preToken) {
124+			pre = !pre
125+			if pre {
126+				nextValue := strings.Replace(li.Value, preToken, "", 1)
127+				li.IsPre = true
128+				li.Value = nextValue
129+			} else {
130+				skip = true
131+			}
132+		} else if pre {
133+			nextValue := strings.Replace(li.Value, preToken, "", 1)
134+			prevItem.Value = fmt.Sprintf("%s\n%s", prevItem.Value, nextValue)
135+			skip = true
136+		} else if strings.HasPrefix(li.Value, urlToken) {
137+			li.IsURL = true
138+			split := TextToSplitToken(strings.Replace(li.Value, urlToken, "", 1))
139+			li.URL = template.URL(split.Key)
140+			li.Value = KeyAsValue(split)
141+		} else if strings.HasPrefix(li.Value, blockToken) {
142+			li.IsBlock = true
143+			li.Value = strings.Replace(li.Value, blockToken, "", 1)
144+		} else if strings.HasPrefix(li.Value, imgToken) {
145+			li.IsImg = true
146+			split := TextToSplitToken(strings.Replace(li.Value, imgToken, "", 1))
147+			li.URL = template.URL(split.Key)
148+			li.Value = KeyAsValue(split)
149+		} else if strings.HasPrefix(li.Value, varToken) {
150+			split := TextToSplitToken(strings.Replace(li.Value, varToken, "", 1))
151+			TokenToMetaField(meta, split)
152+			continue
153+		} else if strings.HasPrefix(li.Value, headerTwoToken) {
154+			li.IsHeaderTwo = true
155+			li.Value = strings.Replace(li.Value, headerTwoToken, "", 1)
156+		} else if strings.HasPrefix(li.Value, headerOneToken) {
157+			li.IsHeaderOne = true
158+			li.Value = strings.Replace(li.Value, headerOneToken, "", 1)
159+		} else {
160+			li.IsText = true
161+		}
162+
163+		if li.IsText && li.Value == "" {
164+			skip = true
165+		}
166+
167+		if !skip {
168+			items = append(items, li)
169+		}
170+	}
171+
172+	return &ParsedText{
173+		Items:    items,
174+		MetaData: meta,
175+	}
176+}
A lists/public/apple-touch-icon.png
+0, -0
A lists/public/card.png
+0, -0
A lists/public/favicon-16x16.png
+0, -0
A lists/public/favicon.ico
+0, -0
A lists/public/main.css
+293, -0
  1@@ -0,0 +1,293 @@
  2+*, ::before, ::after {
  3+  box-sizing: border-box;
  4+}
  5+
  6+::-moz-focus-inner {
  7+	border-style: none;
  8+	padding: 0;
  9+}
 10+:-moz-focusring { outline: 1px dotted ButtonText; }
 11+:-moz-ui-invalid { box-shadow: none; }
 12+
 13+@media (prefers-color-scheme: light) {
 14+  :root {
 15+    --white: #6a737d;
 16+    --code: rgba(255, 229, 100, 0.2);
 17+    --pre: #f6f8fa;
 18+    --bg-color: #fff;
 19+    --text-color: #24292f;
 20+    --link-color: #005cc5;
 21+    --visited: #6f42c1;
 22+    --blockquote: #785840;
 23+    --blockquote-bg: #fff;
 24+    --hover: #d73a49;
 25+    --grey: #ccc;
 26+  }
 27+}
 28+
 29+@media (prefers-color-scheme: dark) {
 30+  :root {
 31+    --white: #f2f2f2;
 32+    --code: #252525;
 33+    --pre: #252525;
 34+    --bg-color: #282a36;
 35+    --text-color: #f2f2f2;
 36+    --link-color: #8be9fd;
 37+    --visited: #bd93f9;
 38+    --blockquote: #bd93f9;
 39+    --blockquote-bg: #414558;
 40+    --hover: #ff80bf;
 41+    --grey: #414558;
 42+  }
 43+}
 44+
 45+html {
 46+  background-color: var(--bg-color);
 47+  color: var(--text-color);
 48+  line-height: 1.5;
 49+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
 50+    "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
 51+    "Segoe UI Emoji", "Segoe UI Symbol";
 52+	-webkit-text-size-adjust: 100%;
 53+	-moz-tab-size: 4;
 54+	tab-size: 4;
 55+}
 56+
 57+body {
 58+  margin: 0 auto;
 59+  max-width: 35rem;
 60+}
 61+
 62+img {
 63+  max-width: 100%;
 64+  height: auto;
 65+}
 66+
 67+b, strong {
 68+  font-weight: bold;
 69+}
 70+
 71+code, kbd, samp, pre {
 72+	font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
 73+	font-size: 0.8rem;
 74+}
 75+
 76+code, kbd, samp {
 77+  background-color: var(--code);
 78+}
 79+
 80+pre > code {
 81+  background-color: inherit;
 82+  padding: 0;
 83+}
 84+
 85+code {
 86+  border-radius: 0.3rem;
 87+  padding: .15rem .2rem .05rem;
 88+}
 89+
 90+pre {
 91+  border-radius: 5px;
 92+  padding: 1rem;
 93+  overflow-x: auto;
 94+  margin: 0;
 95+  background-color: var(--pre) !important;
 96+}
 97+
 98+small {
 99+  font-size: 0.8rem;
100+}
101+
102+summary {
103+  display: list-item;
104+}
105+
106+h1, h2, h3 {
107+  margin: 0;
108+	padding: 0;
109+	border: 0;
110+  font-style: normal;
111+  font-weight: inherit;
112+  font-size: inherit;
113+}
114+
115+hr {
116+  color: inherit;
117+  border: 0;
118+  margin: 0;
119+  height: 1px;
120+  background: var(--grey);
121+  margin: 2rem auto;
122+  text-align: center;
123+}
124+
125+a {
126+  text-decoration: underline;
127+  color: var(--link-color);
128+}
129+
130+a:hover, a:visited:hover {
131+  color: var(--hover);
132+}
133+
134+a:visited {
135+  color: var(--visited);
136+}
137+
138+a.link-grey {
139+  text-decoration: underline;
140+  color: var(--white);
141+}
142+
143+a.link-grey:visited {
144+  color: var(--white);
145+}
146+
147+section {
148+  margin-bottom: 2rem;
149+}
150+
151+section:last-child {
152+  margin-bottom: 0;
153+}
154+
155+header {
156+  margin: 1rem auto;
157+}
158+
159+p {
160+  margin: 1rem 0;
161+}
162+
163+article {
164+  overflow-wrap: break-word;
165+}
166+
167+blockquote {
168+  border-left: 5px solid var(--blockquote);
169+  background-color: var(--blockquote-bg);
170+  padding: 0.5rem;
171+  margin: 0.5rem 0;
172+}
173+
174+ul, ol {
175+  padding: 0 0 0 2rem;
176+  list-style-position: outside;
177+}
178+
179+ul[style*="list-style-type: none;"] {
180+  padding: 0;
181+}
182+
183+li {
184+  margin: 0.5rem 0;
185+}
186+
187+li > pre {
188+  padding: 0;
189+}
190+
191+footer {
192+  text-align: center;
193+  margin-bottom: 4rem;
194+}
195+
196+dt {
197+  font-weight: bold;
198+}
199+
200+dd {
201+  margin-left: 0;
202+}
203+
204+dd:not(:last-child) {
205+  margin-bottom: .5rem;
206+}
207+
208+.post-date {
209+  width: 130px;
210+}
211+
212+.text-grey {
213+  color: var(--grey);
214+}
215+
216+.text-2xl {
217+  font-size: 1.5rem;
218+  line-height: 1.15;
219+}
220+
221+.text-xl {
222+  font-size: 1.25rem;
223+  line-height: 1.15;
224+}
225+
226+.text-lg {
227+  font-size: 1.125rem;
228+  line-height: 1.15;
229+}
230+
231+.text-sm {
232+  font-size: 0.875rem;
233+}
234+
235+.text-center {
236+  text-align: center;
237+}
238+
239+.font-bold {
240+  font-weight: bold;
241+}
242+
243+.font-italic {
244+  font-style: italic;
245+}
246+
247+.inline {
248+  display: inline;
249+}
250+
251+.flex {
252+  display: flex;
253+}
254+
255+.items-center {
256+  align-items: center;
257+}
258+
259+.m-0 {
260+  margin: 0;
261+}
262+
263+.my {
264+  margin-top: 0.5rem;
265+  margin-bottom: 0.5rem;
266+}
267+
268+.mx {
269+  margin-left: 0.5rem;
270+  margin-right: 0.5rem;
271+}
272+
273+.mx-2 {
274+  margin-left: 1rem;
275+  margin-right: 1rem;
276+}
277+
278+.justify-between {
279+  justify-content: space-between;
280+}
281+
282+.flex-1 {
283+  flex: 1;
284+}
285+
286+@media only screen and (max-width: 600px) {
287+  body {
288+    padding: 1rem;
289+  }
290+
291+  header {
292+    margin: 0;
293+  }
294+}
A lists/public/robots.txt
+2, -0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
A lists/router.go
+97, -0
 1@@ -0,0 +1,97 @@
 2+package internal
 3+
 4+import (
 5+	"context"
 6+	"fmt"
 7+	"net/http"
 8+	"regexp"
 9+	"strings"
10+
11+	"git.sr.ht/~erock/wish/cms/db"
12+	"go.uber.org/zap"
13+)
14+
15+type Route struct {
16+	method  string
17+	regex   *regexp.Regexp
18+	handler http.HandlerFunc
19+}
20+
21+func NewRoute(method, pattern string, handler http.HandlerFunc) Route {
22+	return Route{
23+		method,
24+		regexp.MustCompile("^" + pattern + "$"),
25+		handler,
26+	}
27+}
28+
29+type ServeFn func(http.ResponseWriter, *http.Request)
30+
31+func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpool db.DB, logger *zap.SugaredLogger) ServeFn {
32+	return func(w http.ResponseWriter, r *http.Request) {
33+		var allow []string
34+		curRoutes := routes
35+
36+		hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
37+		appDomain := strings.ToLower(strings.Split(cfg.ConfigCms.Domain, ":")[0])
38+
39+		subdomain := ""
40+		if hostDomain != appDomain && strings.Contains(hostDomain, appDomain) {
41+			subdomain = strings.TrimSuffix(hostDomain, fmt.Sprintf(".%s", appDomain))
42+		}
43+
44+		if cfg.IsSubdomains() && subdomain != "" {
45+			curRoutes = subdomainRoutes
46+		}
47+
48+		for _, route := range curRoutes {
49+			matches := route.regex.FindStringSubmatch(r.URL.Path)
50+			if len(matches) > 0 {
51+				if r.Method != route.method {
52+					allow = append(allow, route.method)
53+					continue
54+				}
55+				loggerCtx := context.WithValue(r.Context(), ctxLoggerKey{}, logger)
56+				subdomainCtx := context.WithValue(loggerCtx, ctxSubdomainKey{}, subdomain)
57+				dbCtx := context.WithValue(subdomainCtx, ctxDBKey{}, dbpool)
58+				cfgCtx := context.WithValue(dbCtx, ctxCfg{}, cfg)
59+				ctx := context.WithValue(cfgCtx, ctxKey{}, matches[1:])
60+				route.handler(w, r.WithContext(ctx))
61+				return
62+			}
63+		}
64+		if len(allow) > 0 {
65+			w.Header().Set("Allow", strings.Join(allow, ", "))
66+			http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
67+			return
68+		}
69+		http.NotFound(w, r)
70+	}
71+}
72+
73+type ctxDBKey struct{}
74+type ctxKey struct{}
75+type ctxLoggerKey struct{}
76+type ctxSubdomainKey struct{}
77+type ctxCfg struct{}
78+
79+func GetCfg(r *http.Request) *ConfigSite {
80+	return r.Context().Value(ctxCfg{}).(*ConfigSite)
81+}
82+
83+func GetLogger(r *http.Request) *zap.SugaredLogger {
84+	return r.Context().Value(ctxLoggerKey{}).(*zap.SugaredLogger)
85+}
86+
87+func GetDB(r *http.Request) db.DB {
88+	return r.Context().Value(ctxDBKey{}).(db.DB)
89+}
90+
91+func GetField(r *http.Request, index int) string {
92+	fields := r.Context().Value(ctxKey{}).([]string)
93+	return fields[index]
94+}
95+
96+func GetSubdomain(r *http.Request) string {
97+	return r.Context().Value(ctxSubdomainKey{}).(string)
98+}
A lists/util.go
+116, -0
  1@@ -0,0 +1,116 @@
  2+package internal
  3+
  4+import (
  5+	"encoding/base64"
  6+	"fmt"
  7+	"math"
  8+	"os"
  9+	pathpkg "path"
 10+	"path/filepath"
 11+	"regexp"
 12+	"strings"
 13+	"time"
 14+	"unicode"
 15+	"unicode/utf8"
 16+
 17+	"github.com/gliderlabs/ssh"
 18+	"golang.org/x/exp/slices"
 19+)
 20+
 21+var fnameRe = regexp.MustCompile(`[-_]+`)
 22+
 23+func FilenameToTitle(filename string, title string) string {
 24+	if filename != title {
 25+		return title
 26+	}
 27+
 28+	pre := fnameRe.ReplaceAllString(title, " ")
 29+	r := []rune(pre)
 30+	r[0] = unicode.ToUpper(r[0])
 31+	return string(r)
 32+}
 33+
 34+func SanitizeFileExt(fname string) string {
 35+	return strings.TrimSuffix(fname, filepath.Ext(fname))
 36+}
 37+
 38+func KeyText(s ssh.Session) (string, error) {
 39+	if s.PublicKey() == nil {
 40+		return "", fmt.Errorf("Session doesn't have public key")
 41+	}
 42+	kb := base64.StdEncoding.EncodeToString(s.PublicKey().Marshal())
 43+	return fmt.Sprintf("%s %s", s.PublicKey().Type(), kb), nil
 44+}
 45+
 46+func GetEnv(key string, defaultVal string) string {
 47+	if value, exists := os.LookupEnv(key); exists {
 48+		return value
 49+	}
 50+
 51+	return defaultVal
 52+}
 53+
 54+// IsText reports whether a significant prefix of s looks like correct UTF-8;
 55+// that is, if it is likely that s is human-readable text.
 56+func IsText(s string) bool {
 57+	const max = 1024 // at least utf8.UTFMax
 58+	if len(s) > max {
 59+		s = s[0:max]
 60+	}
 61+	for i, c := range s {
 62+		if i+utf8.UTFMax > len(s) {
 63+			// last char may be incomplete - ignore
 64+			break
 65+		}
 66+		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
 67+			// decoding error or control character - not a text file
 68+			return false
 69+		}
 70+	}
 71+	return true
 72+}
 73+
 74+var allowedExtensions = []string{".txt"}
 75+
 76+// IsTextFile reports whether the file has a known extension indicating
 77+// a text file, or if a significant chunk of the specified file looks like
 78+// correct UTF-8; that is, if it is likely that the file contains human-
 79+// readable text.
 80+func IsTextFile(text string, filename string) bool {
 81+	ext := pathpkg.Ext(filename)
 82+	if !slices.Contains(allowedExtensions, ext) {
 83+		return false
 84+	}
 85+
 86+	num := math.Min(float64(len(text)), 1024)
 87+	return IsText(text[0:int(num)])
 88+}
 89+
 90+const solarYearSecs = 31556926
 91+
 92+func TimeAgo(t *time.Time) string {
 93+	d := time.Since(*t)
 94+	var metric string
 95+	var amount int
 96+	if d.Seconds() < 60 {
 97+		amount = int(d.Seconds())
 98+		metric = "second"
 99+	} else if d.Minutes() < 60 {
100+		amount = int(d.Minutes())
101+		metric = "minute"
102+	} else if d.Hours() < 24 {
103+		amount = int(d.Hours())
104+		metric = "hour"
105+	} else if d.Seconds() < solarYearSecs {
106+		amount = int(d.Hours()) / 24
107+		metric = "day"
108+	} else {
109+		amount = int(d.Seconds()) / solarYearSecs
110+		metric = "year"
111+	}
112+	if amount == 1 {
113+		return fmt.Sprintf("%d %s ago", amount, metric)
114+	} else {
115+		return fmt.Sprintf("%d %ss ago", amount, metric)
116+	}
117+}
M makefile
+33, -29
 1@@ -3,13 +3,42 @@ PGHOST?="db"
 2 PGUSER?="postgres"
 3 PORT?="5432"
 4 DB_CONTAINER?=pico-services_db_1
 5+DOCKER_TAG?=$(shell git log --format="%H" -n 1)
 6+PROJ?="prose"
 7 
 8 test:
 9 	docker run --rm -v $(shell pwd):/app -w /app golangci/golangci-lint:latest golangci-lint run -E goimports -E godot
10 .PHONY: test
11 
12-build:
13-	go build -o build/migrate ./cmd/migrate
14+bp-setup:
15+	docker buildx ls | grep pico || docker buildx create --name pico
16+	docker buildx use pico
17+.PHONY: bp-setup
18+
19+bp-caddy: bp-setup
20+	docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/cloudflare-caddy:$(DOCKER_TAG) -f Dockerfile.caddy .
21+.PHONY: bp-caddy
22+
23+bp:
24+	$(MAKE) -C $(PROJ) bp
25+.PHONY: bp
26+
27+build-prose:
28+	go build -o build/prose-web ./cmd/prose/web
29+	go build -o build/prose-ssh ./cmd/prose/ssh
30+.PHONY: build-prose
31+
32+build-lists:
33+	go build -o build/lists-web ./cmd/lists/web
34+	go build -o build/lists-ssh ./cmd/lists/ssh
35+.PHONY: build-lists
36+
37+build-pastes:
38+	go build -o build/pastes-web ./cmd/pastes/web
39+	go build -o build/pastes-ssh ./cmd/pastes/ssh
40+.PHONY: build-pastes
41+
42+build: build-prose build-lists build-pastes
43 .PHONY: build
44 
45 format:
46@@ -32,11 +61,10 @@ migrate:
47 	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220523_timestamp_with_tz.sql
48 	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220721_analytics.sql
49 	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220722_post_hidden.sql
50-	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220727_post_change_post_contraints.sql
51 .PHONY: migrate
52 
53 latest:
54-	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220727_post_change_post_contraints.sql
55+	docker exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./db/migrations/20220722_post_hidden.sql
56 .PHONY: latest
57 
58 psql:
59@@ -50,29 +78,5 @@ dump:
60 restore:
61 	docker cp ./backup.sql $(DB_CONTAINER):/backup.sql
62 	docker exec -it $(DB_CONTAINER) /bin/bash
63-	# psql postgres -U postgres < /backup.sql
64+	# psql postgres -U postgres -d pico < /backup.sql
65 .PHONY: restore
66-
67-bp-caddy:
68-	docker build -t neurosnap/prose-caddy -f Dockerfile.caddy .
69-	docker push neurosnap/prose-caddy
70-.PHONY: bp-caddy
71-
72-bp-ssh:
73-	docker build -t neurosnap/prose-ssh --target ssh .
74-	docker push neurosnap/prose-ssh
75-.PHONY: bp-ssh
76-
77-bp-web:
78-	docker build -t neurosnap/prose-web --target web .
79-	docker push neurosnap/prose-web
80-.PHONY: bp-web
81-
82-bp: bp-ssh bp-web bp-caddy
83-.PHONY: bp
84-
85-deploy:
86-	docker system prune -f
87-	docker-compose -f production.yml pull --ignore-pull-failures
88-	docker-compose -f production.yml up --no-deps -d
89-.PHONY: deploy
A pastes/Caddyfile
+27, -0
 1@@ -0,0 +1,27 @@
 2+*.pastes.sh, pastes.sh {
 3+	reverse_proxy web:3000
 4+	tls hello@pastes.sh
 5+	tls {
 6+		dns cloudflare {env.CF_API_TOKEN}
 7+	}
 8+	encode zstd gzip
 9+
10+    header {
11+        # disable FLoC tracking
12+        Permissions-Policy interest-cohort=()
13+
14+        # enable HSTS
15+        Strict-Transport-Security max-age=31536000;
16+
17+        # disable clients from sniffing the media type
18+        X-Content-Type-Options nosniff
19+
20+        # clickjacking protection
21+        X-Frame-Options DENY
22+
23+        # keep referrer data off of HTTP connections
24+        Referrer-Policy no-referrer-when-downgrade
25+
26+        X-XSS-Protection "1; mode=block"
27+    }
28+}
A pastes/Dockerfile
+21, -0
 1@@ -0,0 +1,21 @@
 2+FROM golang:1.18.1-alpine3.15 AS builder
 3+
 4+RUN apk add --no-cache git
 5+
 6+WORKDIR /app
 7+COPY . ./
 8+
 9+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/ssh ./cmd/pastes/ssh
10+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/web ./cmd/pastes/web
11+
12+FROM alpine:3.15 AS ssh
13+WORKDIR /app
14+COPY --from=0 /app/build/ssh ./
15+CMD ["./ssh"]
16+
17+FROM alpine:3.15 AS web
18+WORKDIR /app
19+COPY --from=0 /app/build/web ./
20+COPY --from=0 /app/html ./html
21+COPY --from=0 /app/public ./public
22+CMD ["./web"]
A pastes/Dockerfile.caddy
+8, -0
1@@ -0,0 +1,8 @@
2+FROM caddy:builder-alpine AS builder
3+
4+RUN xcaddy build \
5+    --with github.com/caddy-dns/cloudflare
6+
7+FROM caddy:alpine
8+
9+COPY --from=builder /usr/bin/caddy /usr/bin/caddy
A pastes/api.go
+445, -0
  1@@ -0,0 +1,445 @@
  2+package internal
  3+
  4+import (
  5+	"fmt"
  6+	"html/template"
  7+	"io/ioutil"
  8+	"net/http"
  9+	"net/url"
 10+	"time"
 11+
 12+	"git.sr.ht/~erock/wish/cms/db"
 13+	"git.sr.ht/~erock/wish/cms/db/postgres"
 14+)
 15+
 16+type PageData struct {
 17+	Site SitePageData
 18+}
 19+
 20+type PostItemData struct {
 21+	URL            template.URL
 22+	BlogURL        template.URL
 23+	Username       string
 24+	Title          string
 25+	Description    string
 26+	PublishAtISO   string
 27+	PublishAt      string
 28+	UpdatedAtISO   string
 29+	UpdatedTimeAgo string
 30+	Padding        string
 31+}
 32+
 33+type BlogPageData struct {
 34+	Site      SitePageData
 35+	PageTitle string
 36+	URL       template.URL
 37+	RSSURL    template.URL
 38+	Username  string
 39+	Header    *HeaderTxt
 40+	Posts     []PostItemData
 41+}
 42+
 43+type PostPageData struct {
 44+	Site         SitePageData
 45+	PageTitle    string
 46+	URL          template.URL
 47+	RawURL       template.URL
 48+	BlogURL      template.URL
 49+	Title        string
 50+	Description  string
 51+	Username     string
 52+	BlogName     string
 53+	Contents     template.HTML
 54+	PublishAtISO string
 55+	PublishAt    string
 56+}
 57+
 58+type TransparencyPageData struct {
 59+	Site      SitePageData
 60+	Analytics *db.Analytics
 61+}
 62+
 63+func renderTemplate(templates []string) (*template.Template, error) {
 64+	files := make([]string, len(templates))
 65+	copy(files, templates)
 66+	files = append(
 67+		files,
 68+		"./html/footer.partial.tmpl",
 69+		"./html/marketing-footer.partial.tmpl",
 70+		"./html/base.layout.tmpl",
 71+	)
 72+
 73+	ts, err := template.ParseFiles(files...)
 74+	if err != nil {
 75+		return nil, err
 76+	}
 77+	return ts, nil
 78+}
 79+
 80+func createPageHandler(fname string) http.HandlerFunc {
 81+	return func(w http.ResponseWriter, r *http.Request) {
 82+		logger := GetLogger(r)
 83+		cfg := GetCfg(r)
 84+		ts, err := renderTemplate([]string{fname})
 85+
 86+		if err != nil {
 87+			logger.Error(err)
 88+			http.Error(w, err.Error(), http.StatusInternalServerError)
 89+			return
 90+		}
 91+
 92+		data := PageData{
 93+			Site: *cfg.GetSiteData(),
 94+		}
 95+		err = ts.Execute(w, data)
 96+		if err != nil {
 97+			logger.Error(err)
 98+			http.Error(w, err.Error(), http.StatusInternalServerError)
 99+		}
100+	}
101+}
102+
103+type Link struct {
104+	URL  string
105+	Text string
106+}
107+
108+type HeaderTxt struct {
109+	Title    string
110+	Bio      string
111+	Nav      []Link
112+	HasLinks bool
113+}
114+
115+func GetUsernameFromRequest(r *http.Request) string {
116+	subdomain := GetSubdomain(r)
117+	cfg := GetCfg(r)
118+
119+	if !cfg.IsSubdomains() || subdomain == "" {
120+		return GetField(r, 0)
121+	}
122+	return subdomain
123+}
124+
125+func blogHandler(w http.ResponseWriter, r *http.Request) {
126+	username := GetUsernameFromRequest(r)
127+	dbpool := GetDB(r)
128+	logger := GetLogger(r)
129+	cfg := GetCfg(r)
130+
131+	user, err := dbpool.FindUserForName(username)
132+	if err != nil {
133+		logger.Infof("blog not found: %s", username)
134+		http.Error(w, "blog not found", http.StatusNotFound)
135+		return
136+	}
137+	posts, err := dbpool.FindPostsForUser(user.ID, cfg.Space)
138+	if err != nil {
139+		logger.Error(err)
140+		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
141+		return
142+	}
143+
144+	ts, err := renderTemplate([]string{
145+		"./html/blog.page.tmpl",
146+	})
147+
148+	if err != nil {
149+		logger.Error(err)
150+		http.Error(w, err.Error(), http.StatusInternalServerError)
151+		return
152+	}
153+
154+	headerTxt := &HeaderTxt{
155+		Title: GetBlogName(username),
156+		Bio:   "",
157+	}
158+
159+	postCollection := make([]PostItemData, 0, len(posts))
160+	for _, post := range posts {
161+		p := PostItemData{
162+			URL:            template.URL(cfg.PostURL(post.Username, post.Filename)),
163+			BlogURL:        template.URL(cfg.BlogURL(post.Username)),
164+			Title:          FilenameToTitle(post.Filename, post.Title),
165+			PublishAt:      post.PublishAt.Format("02 Jan, 2006"),
166+			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
167+			UpdatedTimeAgo: TimeAgo(post.UpdatedAt),
168+			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
169+		}
170+		postCollection = append(postCollection, p)
171+	}
172+
173+	data := BlogPageData{
174+		Site:      *cfg.GetSiteData(),
175+		PageTitle: headerTxt.Title,
176+		URL:       template.URL(cfg.BlogURL(username)),
177+		RSSURL:    template.URL(cfg.RssBlogURL(username)),
178+		Header:    headerTxt,
179+		Username:  username,
180+		Posts:     postCollection,
181+	}
182+
183+	err = ts.Execute(w, data)
184+	if err != nil {
185+		logger.Error(err)
186+		http.Error(w, err.Error(), http.StatusInternalServerError)
187+	}
188+}
189+
190+func GetPostTitle(post *db.Post) string {
191+	if post.Description == "" {
192+		return post.Title
193+	}
194+
195+	return fmt.Sprintf("%s: %s", post.Title, post.Description)
196+}
197+
198+func GetBlogName(username string) string {
199+	return fmt.Sprintf("%s's pastes", username)
200+}
201+
202+func postHandler(w http.ResponseWriter, r *http.Request) {
203+	username := GetUsernameFromRequest(r)
204+	subdomain := GetSubdomain(r)
205+	cfg := GetCfg(r)
206+
207+	var filename string
208+	if !cfg.IsSubdomains() || subdomain == "" {
209+		filename, _ = url.PathUnescape(GetField(r, 1))
210+	} else {
211+		filename, _ = url.PathUnescape(GetField(r, 0))
212+	}
213+
214+	dbpool := GetDB(r)
215+	logger := GetLogger(r)
216+
217+	user, err := dbpool.FindUserForName(username)
218+	if err != nil {
219+		logger.Infof("blog not found: %s", username)
220+		http.Error(w, "blog not found", http.StatusNotFound)
221+		return
222+	}
223+
224+	blogName := GetBlogName(username)
225+
226+	var data PostPageData
227+	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
228+	if err == nil {
229+		parsedText, err := ParseText(post.Filename, post.Text)
230+		if err != nil {
231+			logger.Error(err)
232+		}
233+
234+		data = PostPageData{
235+			Site:         *cfg.GetSiteData(),
236+			PageTitle:    GetPostTitle(post),
237+			URL:          template.URL(cfg.PostURL(post.Username, post.Filename)),
238+			RawURL:       template.URL(cfg.RawPostURL(post.Username, post.Filename)),
239+			BlogURL:      template.URL(cfg.BlogURL(username)),
240+			Description:  post.Description,
241+			Title:        FilenameToTitle(post.Filename, post.Title),
242+			PublishAt:    post.PublishAt.Format("02 Jan, 2006"),
243+			PublishAtISO: post.PublishAt.Format(time.RFC3339),
244+			Username:     username,
245+			BlogName:     blogName,
246+			Contents:     template.HTML(parsedText),
247+		}
248+	} else {
249+		logger.Infof("post not found %s/%s", username, filename)
250+		data = PostPageData{
251+			Site:         *cfg.GetSiteData(),
252+			PageTitle:    "Paste not found",
253+			Description:  "Paste not found",
254+			Title:        "Paste not found",
255+			BlogURL:      template.URL(cfg.BlogURL(username)),
256+			PublishAt:    time.Now().Format("02 Jan, 2006"),
257+			PublishAtISO: time.Now().Format(time.RFC3339),
258+			Username:     username,
259+			BlogName:     blogName,
260+			Contents:     "oops!  we can't seem to find this post.",
261+		}
262+	}
263+
264+	ts, err := renderTemplate([]string{
265+		"./html/post.page.tmpl",
266+	})
267+
268+	if err != nil {
269+		http.Error(w, err.Error(), http.StatusInternalServerError)
270+	}
271+
272+	err = ts.Execute(w, data)
273+	if err != nil {
274+		logger.Error(err)
275+		http.Error(w, err.Error(), http.StatusInternalServerError)
276+	}
277+}
278+
279+func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
280+	username := GetUsernameFromRequest(r)
281+	subdomain := GetSubdomain(r)
282+	cfg := GetCfg(r)
283+
284+	var filename string
285+	if !cfg.IsSubdomains() || subdomain == "" {
286+		filename, _ = url.PathUnescape(GetField(r, 1))
287+	} else {
288+		filename, _ = url.PathUnescape(GetField(r, 0))
289+	}
290+
291+	dbpool := GetDB(r)
292+	logger := GetLogger(r)
293+
294+	user, err := dbpool.FindUserForName(username)
295+	if err != nil {
296+		logger.Infof("blog not found: %s", username)
297+		http.Error(w, "blog not found", http.StatusNotFound)
298+		return
299+	}
300+
301+	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
302+	if err != nil {
303+		logger.Infof("post not found %s/%s", username, filename)
304+		http.Error(w, "post not found", http.StatusNotFound)
305+		return
306+	}
307+
308+	w.Header().Set("Content-Type", "text/plain")
309+	w.Write([]byte(post.Text))
310+}
311+
312+func transparencyHandler(w http.ResponseWriter, r *http.Request) {
313+	dbpool := GetDB(r)
314+	logger := GetLogger(r)
315+	cfg := GetCfg(r)
316+
317+	analytics, err := dbpool.FindSiteAnalytics(cfg.Space)
318+	if err != nil {
319+		logger.Error(err)
320+		http.Error(w, err.Error(), http.StatusInternalServerError)
321+		return
322+	}
323+
324+	ts, err := template.ParseFiles(
325+		"./html/transparency.page.tmpl",
326+		"./html/footer.partial.tmpl",
327+		"./html/marketing-footer.partial.tmpl",
328+		"./html/base.layout.tmpl",
329+	)
330+
331+	if err != nil {
332+		http.Error(w, err.Error(), http.StatusInternalServerError)
333+	}
334+
335+	data := TransparencyPageData{
336+		Site:      *cfg.GetSiteData(),
337+		Analytics: analytics,
338+	}
339+	err = ts.Execute(w, data)
340+	if err != nil {
341+		logger.Error(err)
342+		http.Error(w, err.Error(), http.StatusInternalServerError)
343+	}
344+}
345+
346+func serveFile(file string, contentType string) http.HandlerFunc {
347+	return func(w http.ResponseWriter, r *http.Request) {
348+		logger := GetLogger(r)
349+
350+		contents, err := ioutil.ReadFile(fmt.Sprintf("./public/%s", file))
351+		if err != nil {
352+			logger.Error(err)
353+			http.Error(w, "file not found", 404)
354+		}
355+		w.Header().Add("Content-Type", contentType)
356+
357+		_, err = w.Write(contents)
358+		if err != nil {
359+			logger.Error(err)
360+			http.Error(w, "server error", 500)
361+		}
362+	}
363+}
364+
365+func createStaticRoutes() []Route {
366+	return []Route{
367+		NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
368+		NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
369+		NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
370+		NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
371+		NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
372+		NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
373+		NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
374+		NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
375+	}
376+}
377+
378+func createMainRoutes(staticRoutes []Route) []Route {
379+	routes := []Route{
380+		NewRoute("GET", "/", createPageHandler("./html/marketing.page.tmpl")),
381+		NewRoute("GET", "/spec", createPageHandler("./html/spec.page.tmpl")),
382+		NewRoute("GET", "/ops", createPageHandler("./html/ops.page.tmpl")),
383+		NewRoute("GET", "/privacy", createPageHandler("./html/privacy.page.tmpl")),
384+		NewRoute("GET", "/help", createPageHandler("./html/help.page.tmpl")),
385+		NewRoute("GET", "/transparency", transparencyHandler),
386+	}
387+
388+	routes = append(
389+		routes,
390+		staticRoutes...,
391+	)
392+
393+	routes = append(
394+		routes,
395+		NewRoute("GET", "/([^/]+)", blogHandler),
396+		NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
397+		NewRoute("GET", "/([^/]+)/([^/]+)/raw", postHandlerRaw),
398+		NewRoute("GET", "/raw/([^/]+)/([^/]+)", postHandlerRaw),
399+	)
400+
401+	return routes
402+}
403+
404+func createSubdomainRoutes(staticRoutes []Route) []Route {
405+	routes := []Route{
406+		NewRoute("GET", "/", blogHandler),
407+	}
408+
409+	routes = append(
410+		routes,
411+		staticRoutes...,
412+	)
413+
414+	routes = append(
415+		routes,
416+		NewRoute("GET", "/([^/]+)", postHandler),
417+		NewRoute("GET", "/([^/]+)/raw", postHandlerRaw),
418+		NewRoute("GET", "/raw/([^/]+)", postHandlerRaw),
419+	)
420+
421+	return routes
422+}
423+
424+func StartApiServer() {
425+	cfg := NewConfigSite()
426+	db := postgres.NewDB(&cfg.ConfigCms)
427+	defer db.Close()
428+	logger := cfg.Logger
429+
430+	go CronDeleteExpiredPosts(cfg, db)
431+
432+	staticRoutes := createStaticRoutes()
433+	mainRoutes := createMainRoutes(staticRoutes)
434+	subdomainRoutes := createSubdomainRoutes(staticRoutes)
435+
436+	handler := CreateServe(mainRoutes, subdomainRoutes, cfg, db, logger)
437+	router := http.HandlerFunc(handler)
438+
439+	portStr := fmt.Sprintf(":%s", cfg.Port)
440+	logger.Infof("Starting server on port %s", cfg.Port)
441+	logger.Infof("Subdomains enabled: %t", cfg.SubdomainsEnabled)
442+	logger.Infof("Domain: %s", cfg.Domain)
443+	logger.Infof("Email: %s", cfg.Email)
444+
445+	logger.Fatal(http.ListenAndServe(portStr, router))
446+}
A pastes/config.go
+128, -0
  1@@ -0,0 +1,128 @@
  2+package internal
  3+
  4+import (
  5+	"fmt"
  6+	"html/template"
  7+	"log"
  8+	"net/url"
  9+
 10+	"git.sr.ht/~erock/wish/cms/config"
 11+	"go.uber.org/zap"
 12+)
 13+
 14+type SitePageData struct {
 15+	Domain  template.URL
 16+	HomeURL template.URL
 17+	Email   string
 18+}
 19+
 20+type ConfigSite struct {
 21+	config.ConfigCms
 22+	config.ConfigURL
 23+	SubdomainsEnabled bool
 24+}
 25+
 26+func NewConfigSite() *ConfigSite {
 27+	domain := GetEnv("PASTES_DOMAIN", "pastes.sh")
 28+	email := GetEnv("PASTES_EMAIL", "hello@pastes.sh")
 29+	subdomains := GetEnv("PASTES_SUBDOMAINS", "0")
 30+	port := GetEnv("PASTES_WEB_PORT", "3000")
 31+	dbURL := GetEnv("DATABASE_URL", "")
 32+	protocol := GetEnv("PASTES_PROTOCOL", "https")
 33+	subdomainsEnabled := false
 34+	if subdomains == "1" {
 35+		subdomainsEnabled = true
 36+	}
 37+
 38+	intro := "To get started, enter a username.\n"
 39+	intro += "Then create a folder locally (e.g. ~/pastes).\n"
 40+	intro += "Then write your paste post (e.g. feature.patch).\n"
 41+	intro += "Finally, send your files to us:\n\n"
 42+	intro += fmt.Sprintf("scp ~/pastes/* %s:/", domain)
 43+
 44+	return &ConfigSite{
 45+		SubdomainsEnabled: subdomainsEnabled,
 46+		ConfigCms: config.ConfigCms{
 47+			Domain:      domain,
 48+			Port:        port,
 49+			Protocol:    protocol,
 50+			Email:       email,
 51+			DbURL:       dbURL,
 52+			Description: "a pastebin for hackers.",
 53+			IntroText:   intro,
 54+			Space:       "pastes",
 55+			Logger:      CreateLogger(),
 56+		},
 57+	}
 58+}
 59+
 60+func (c *ConfigSite) GetSiteData() *SitePageData {
 61+	return &SitePageData{
 62+		Domain:  template.URL(c.Domain),
 63+		HomeURL: template.URL(c.HomeURL()),
 64+		Email:   c.Email,
 65+	}
 66+}
 67+
 68+func (c *ConfigSite) BlogURL(username string) string {
 69+	if c.IsSubdomains() {
 70+		return fmt.Sprintf("%s://%s.%s", c.Protocol, username, c.Domain)
 71+	}
 72+
 73+	return fmt.Sprintf("/%s", username)
 74+}
 75+
 76+func (c *ConfigSite) PostURL(username, filename string) string {
 77+	fname := url.PathEscape(filename)
 78+	if c.IsSubdomains() {
 79+		return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
 80+	}
 81+
 82+	return fmt.Sprintf("/%s/%s", username, fname)
 83+}
 84+
 85+func (c *ConfigSite) RawPostURL(username, filename string) string {
 86+	fname := url.PathEscape(filename)
 87+	if c.IsSubdomains() {
 88+		return fmt.Sprintf("%s://%s.%s/raw/%s", c.Protocol, username, c.Domain, fname)
 89+	}
 90+
 91+	return fmt.Sprintf("/raw/%s/%s", username, fname)
 92+}
 93+
 94+func (c *ConfigSite) IsSubdomains() bool {
 95+	return c.SubdomainsEnabled
 96+}
 97+
 98+func (c *ConfigSite) RssBlogURL(username string) string {
 99+	if c.IsSubdomains() {
100+		return fmt.Sprintf("%s://%s.%s/rss", c.Protocol, username, c.Domain)
101+	}
102+
103+	return fmt.Sprintf("/%s/rss", username)
104+}
105+
106+func (c *ConfigSite) HomeURL() string {
107+	if c.IsSubdomains() {
108+		return fmt.Sprintf("//%s", c.Domain)
109+	}
110+
111+	return "/"
112+}
113+
114+func (c *ConfigSite) ReadURL() string {
115+	if c.IsSubdomains() {
116+		return fmt.Sprintf("%s://%s/read", c.Protocol, c.Domain)
117+	}
118+
119+	return "/read"
120+}
121+
122+func CreateLogger() *zap.SugaredLogger {
123+	logger, err := zap.NewProduction()
124+	if err != nil {
125+		log.Fatal(err)
126+	}
127+
128+	return logger.Sugar()
129+}
A pastes/cron.go
+38, -0
 1@@ -0,0 +1,38 @@
 2+package internal
 3+
 4+import (
 5+	"time"
 6+
 7+	"git.sr.ht/~erock/wish/cms/db"
 8+)
 9+
10+func deleteExpiredPosts(cfg *ConfigSite, dbpool db.DB) error {
11+	cfg.Logger.Infof("checking for expired posts")
12+	now := time.Now()
13+	// delete posts that are older than three days
14+	expired := now.AddDate(0, 0, -3)
15+	posts, err := dbpool.FindPostsBeforeDate(&expired, cfg.Space)
16+	if err != nil {
17+		return err
18+	}
19+
20+	postIds := []string{}
21+	for _, post := range posts {
22+		postIds = append(postIds, post.ID)
23+	}
24+
25+	cfg.Logger.Infof("deleteing (%d) expired posts", len(postIds))
26+	err = dbpool.RemovePosts(postIds)
27+	if err != nil {
28+		return err
29+	}
30+
31+	return nil
32+}
33+
34+func CronDeleteExpiredPosts(cfg *ConfigSite, dbpool db.DB) {
35+	for {
36+		deleteExpiredPosts(cfg, dbpool)
37+		time.Sleep(1 * time.Hour)
38+	}
39+}
A pastes/db_handler.go
+119, -0
  1@@ -0,0 +1,119 @@
  2+package internal
  3+
  4+import (
  5+	"fmt"
  6+	"io"
  7+	"time"
  8+
  9+	"git.sr.ht/~erock/wish/cms/db"
 10+	"git.sr.ht/~erock/wish/cms/util"
 11+	"git.sr.ht/~erock/wish/send/utils"
 12+	"github.com/gliderlabs/ssh"
 13+)
 14+
 15+type Opener struct {
 16+	entry *utils.FileEntry
 17+}
 18+
 19+func (o *Opener) Open(name string) (io.Reader, error) {
 20+	return o.entry.Reader, nil
 21+}
 22+
 23+type DbHandler struct {
 24+	User   *db.User
 25+	DBPool db.DB
 26+	Cfg    *ConfigSite
 27+}
 28+
 29+func NewDbHandler(dbpool db.DB, cfg *ConfigSite) *DbHandler {
 30+	return &DbHandler{
 31+		DBPool: dbpool,
 32+		Cfg:    cfg,
 33+	}
 34+}
 35+
 36+func (h *DbHandler) Validate(s ssh.Session) error {
 37+	var err error
 38+	key, err := util.KeyText(s)
 39+	if err != nil {
 40+		return fmt.Errorf("key not found")
 41+	}
 42+
 43+	user, err := h.DBPool.FindUserForKey(s.User(), key)
 44+	if err != nil {
 45+		return err
 46+	}
 47+
 48+	if user.Name == "" {
 49+		return fmt.Errorf("must have username set")
 50+	}
 51+
 52+	h.User = user
 53+	return nil
 54+}
 55+
 56+func (h *DbHandler) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
 57+	logger := h.Cfg.Logger
 58+	userID := h.User.ID
 59+	filename := entry.Name
 60+	title := filename
 61+	var err error
 62+	post, err := h.DBPool.FindPostWithFilename(filename, userID, h.Cfg.Space)
 63+	if err != nil {
 64+		logger.Debug("unable to load post, continuing:", err)
 65+	}
 66+
 67+	user, err := h.DBPool.FindUser(userID)
 68+	if err != nil {
 69+		return "", fmt.Errorf("error for %s: %v", filename, err)
 70+	}
 71+
 72+	var text string
 73+	if b, err := io.ReadAll(entry.Reader); err == nil {
 74+		text = string(b)
 75+	}
 76+
 77+	if !IsTextFile(text, entry.Filepath) {
 78+		logger.Errorf("WARNING: (%s) invalid file, the contents must be plain text, skipping", entry.Name)
 79+		return "", fmt.Errorf("WARNING: (%s) invalid file, the contents must be plain text, skipping", entry.Name)
 80+	}
 81+
 82+	// if the file is empty we remove it from our database
 83+	if len(text) == 0 {
 84+		// skip empty files from being added to db
 85+		if post == nil {
 86+			logger.Infof("(%s) is empty, skipping record", filename)
 87+			return "", nil
 88+		}
 89+
 90+		err := h.DBPool.RemovePosts([]string{post.ID})
 91+		logger.Infof("(%s) is empty, removing record", filename)
 92+		if err != nil {
 93+			logger.Errorf("error for %s: %v", filename, err)
 94+			return "", fmt.Errorf("error for %s: %v", filename, err)
 95+		}
 96+	} else if post == nil {
 97+		publishAt := time.Now()
 98+		logger.Infof("(%s) not found, adding record", filename)
 99+		_, err = h.DBPool.InsertPost(userID, filename, title, text, "", &publishAt, false, h.Cfg.Space)
100+		if err != nil {
101+			logger.Errorf("error for %s: %v", filename, err)
102+			return "", fmt.Errorf("error for %s: %v", filename, err)
103+		}
104+	} else {
105+		publishAt := post.PublishAt
106+		if text == post.Text {
107+			logger.Infof("(%s) found, but text is identical, skipping", filename)
108+			return h.Cfg.PostURL(user.Name, filename), nil
109+		}
110+
111+		logger.Infof("(%s) found, updating record", filename)
112+		_, err = h.DBPool.UpdatePost(post.ID, title, text, "", publishAt)
113+		if err != nil {
114+			logger.Errorf("error for %s: %v", filename, err)
115+			return "", fmt.Errorf("error for %s: %v", filename, err)
116+		}
117+	}
118+
119+	return h.Cfg.PostURL(user.Name, filename), nil
120+}
A pastes/html/base.layout.tmpl
+18, -0
 1@@ -0,0 +1,18 @@
 2+{{define "base"}}
 3+<!doctype html>
 4+<html lang="en">
 5+    <head>
 6+        <meta charset='utf-8'>
 7+        <meta name="viewport" content="width=device-width, initial-scale=1" />
 8+        <title>{{template "title" .}}</title>
 9+
10+        <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
11+
12+        <meta name="keywords" content="pastebin, paste, copy, snippets" />
13+        {{template "meta" .}}
14+
15+        <link rel="stylesheet" href="/main.css" />
16+    </head>
17+    <body {{template "attrs" .}}>{{template "body" .}}</body>
18+</html>
19+{{end}}
A pastes/html/blog.page.tmpl
+46, -0
 1@@ -0,0 +1,46 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{if .Header.Bio}}{{.Header.Bio}}{{else}}{{.Header.Title}}{{end}}" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="{{.URL}}">
12+<meta property="og:title" content="{{.Header.Title}}">
13+{{if .Header.Bio}}<meta property="og:description" content="{{.Header.Bio}}">{{end}}
14+<meta property="og:image:width" content="300" />
15+<meta property="og:image:height" content="300" />
16+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18+
19+<meta property="twitter:card" content="summary">
20+<meta property="twitter:url" content="{{.URL}}">
21+<meta property="twitter:title" content="{{.Header.Title}}">
22+{{if .Header.Bio}}<meta property="twitter:description" content="{{.Header.Bio}}">{{end}}
23+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25+{{end}}
26+
27+{{define "attrs"}}{{end}}
28+
29+{{define "body"}}
30+<header class="text-center">
31+    <h1 class="text-2xl font-bold">{{.Header.Title}}</h1>
32+    <hr />
33+</header>
34+<main>
35+    <section class="posts">
36+        {{range .Posts}}
37+        <article>
38+            <div class="flex items-center">
39+                <time datetime="{{.PublishAtISO}}" class="font-italic text-sm post-date">{{.PublishAt}}</time>
40+                <h2 class="font-bold flex-1"><a href="{{.URL}}">{{.Title}}</a></h2>
41+            </div>
42+        </article>
43+        {{end}}
44+    </section>
45+</main>
46+{{template "footer" .}}
47+{{end}}
A pastes/html/footer.partial.tmpl
+6, -0
1@@ -0,0 +1,6 @@
2+{{define "footer"}}
3+<footer>
4+    <hr />
5+    published with <a href={{.Site.HomeURL}}>{{.Site.Domain}}</a>
6+</footer>
7+{{end}}
A pastes/html/help.page.tmpl
+137, -0
  1@@ -0,0 +1,137 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}help -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="questions and answers" />
  8+{{end}}
  9+
 10+{{define "attrs"}}{{end}}
 11+
 12+{{define "body"}}
 13+<header>
 14+    <h1 class="text-2xl">Need help?</h1>
 15+    <p>Here are some common questions on using this platform that we would like to answer.</p>
 16+</header>
 17+<main>
 18+    <section id="permission-denied">
 19+        <h2 class="text-xl">
 20+            <a href="#permission-denied" rel="nofollow noopener">#</a>
 21+            I get a permission denied when trying to SSH
 22+        </h2>
 23+        <p>
 24+            Unfortunately SHA-2 RSA keys are <strong>not</strong> currently supported.
 25+        </p>
 26+        <p>
 27+            Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, we do
 28+            not currently support access via new SSH RSA keys: only the old SHA-1 ones will work.
 29+            Until we sort this out you’ll either need an SHA-1 RSA key or a key with another
 30+            algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the
 31+            following:
 32+        </p>
 33+        <pre>$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;</pre>
 34+        <p>If you’re curious about the inner workings of this problem have a look at:</p>
 35+        <ul>
 36+            <li><a href="https://github.com/golang/go/issues/37278">golang/go#37278</a></li>
 37+            <li><a href="https://go-review.googlesource.com/c/crypto/+/220037">go-review</a></li>
 38+            <li><a href="https://github.com/golang/crypto/pull/197">golang/crypto#197</a></li>
 39+        </ul>
 40+    </section>
 41+
 42+    <section id="ssh-key">
 43+        <h2 class="text-xl">
 44+            <a href="#ssh-key" rel="nofollow noopener">#</a>
 45+            Generating a new SSH key
 46+        </h2>
 47+        <p>
 48+            <a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent">Github reference</a>
 49+        </p>
 50+        <pre>ssh-keygen -t ed25519 -C "your_email@example.com"</pre>
 51+        <ol>
 52+            <li>When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.</li>
 53+            <li>At the prompt, type a secure passphrase.</li>
 54+        </ol>
 55+    </section>
 56+
 57+    <section id="post-update">
 58+        <h2 class="text-xl">
 59+            <a href="#post-update" rel="nofollow noopener">#</a>
 60+            How do I update a post?
 61+        </h2>
 62+        <p>
 63+            Updating a post requires that you update the source document and then run the <code>scp</code>
 64+            command again.  If the filename remains the same, then the post will be updated.
 65+        </p>
 66+    </section>
 67+
 68+    <section id="post-delete">
 69+        <h2 class="text-xl">
 70+            <a href="#post-delete" rel="nofollow noopener">#</a>
 71+            How do I delete a post?
 72+        </h2>
 73+        <p>
 74+            Because <code>scp</code> does not natively support deleting files, I didn't want to bake
 75+            that behavior into my ssh server.
 76+        </p>
 77+
 78+        <p>
 79+            However, if a user wants to delete a post they can delete the contents of the file and
 80+            then upload it to our server.  If the file contains 0 bytes, we will remove the post.
 81+            For example, if you want to delete <code>delete.txt</code> you could:
 82+        </p>
 83+
 84+        <pre>
 85+cp /dev/null delete.txt
 86+scp ./delete.txt {{.Site.Domain}}:/</pre>
 87+
 88+        <p>
 89+            Alternatively, you can go to <code>ssh {{.Site.Domain}}</code> and select "Manage posts."
 90+            Then you can highlight the post you want to delete and then press "X."  It will ask for
 91+            confirmation before actually removing the post.
 92+        </p>
 93+    </section>
 94+
 95+    <section id="post-upload-single-file">
 96+        <h2 class="text-xl">
 97+            <a href="#post-upload-single-file" rel="nofollow noopener">#</a>
 98+            When I want to publish a new post, do I have to upload all posts everytime?
 99+        </h2>
100+        <p>
101+            Nope!  Just <code>scp</code> the file you want to publish.  For example, if you created
102+            a new post called <code>taco-tuesday.md</code> then you would publish it like this:
103+        </p>
104+        <pre>scp ./taco-tuesday.md {{.Site.Domain}}:</pre>
105+    </section>
106+
107+    <section id="multiple-accounts">
108+        <h2 class="text-xl">
109+            <a href="#multiple-accounts" rel="nofollow noopener">#</a>
110+            Can I create multiple accounts?
111+        </h2>
112+        <p>
113+           Yes!  You can either a) create a new keypair and use that for authentication
114+           or b) use the same keypair and ssh into our CMS using our special username
115+           <code>ssh new@{{.Site.Domain}}</code>.
116+        </p>
117+        <p>
118+            Please note that if you use the same keypair for multiple accounts, you will need to
119+            always specify the user when logging into our CMS.
120+        </p>
121+    </section>
122+
123+    <section id="pipe">
124+        <h2 class="text-xl">
125+            <a href="#pipe" rel="nofollow noopener">#</a>
126+            Can I pipe my paste?
127+        </h2>
128+        <p>
129+           Yes!
130+        </p>
131+        <pre>echo "foobar" | ssh pastes.sh</pre>
132+        <pre>echo "foobar" | ssh pastes.sh FILENAME</pre>
133+        <pre># if the tty warning annoys you
134+echo "foobar" | ssh -T pastes.sh</pre>
135+    </section>
136+</main>
137+{{template "marketing-footer" .}}
138+{{end}}
A pastes/html/marketing-footer.partial.tmpl
+12, -0
 1@@ -0,0 +1,12 @@
 2+{{define "marketing-footer"}}
 3+<footer>
 4+    <hr />
 5+    <p class="font-italic">Built and maintained by <a href="https://pico.sh">pico.sh</a>.</p>
 6+    <div>
 7+        <a href="/">home</a> |
 8+        <a href="/ops">ops</a> |
 9+        <a href="/help">help</a> |
10+        <a href="https://git.sr.ht/~erock/pastes.sh">source</a>
11+    </div>
12+</footer>
13+{{end}}
A pastes/html/marketing.page.tmpl
+101, -0
  1@@ -0,0 +1,101 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}{{.Site.Domain}} -- a pastebin for hackers{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="a pastebin for hackers" />
  8+
  9+<meta property="og:type" content="website">
 10+<meta property="og:site_name" content="{{.Site.Domain}}">
 11+<meta property="og:url" content="https://{{.Site.Domain}}">
 12+<meta property="og:title" content="{{.Site.Domain}}">
 13+<meta property="og:description" content="a pastebin for hackers">
 14+
 15+<meta name="twitter:card" content="summary" />
 16+<meta property="twitter:url" content="https://{{.Site.Domain}}">
 17+<meta property="twitter:title" content="{{.Site.Domain}}">
 18+<meta property="twitter:description" content="a pastebin platform for hackers">
 19+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
 20+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
 21+
 22+<meta property="og:image:width" content="300" />
 23+<meta property="og:image:height" content="300" />
 24+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
 25+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
 26+{{end}}
 27+
 28+{{define "attrs"}}{{end}}
 29+
 30+{{define "body"}}
 31+<header class="text-center">
 32+    <h1 class="text-2xl font-bold">{{.Site.Domain}}</h1>
 33+    <p class="text-lg">a pastebin for hackers</p>
 34+    <hr />
 35+</header>
 36+
 37+<main>
 38+    <section>
 39+        <h2 class="text-lg font-bold">Create your account with Public-Key Cryptography</h2>
 40+        <p>We don't want your email address.</p>
 41+        <p>To get started, simply ssh into our content management system:</p>
 42+        <pre>ssh new@{{.Site.Domain}}</pre>
 43+        <div class="text-sm font-italic note">
 44+            note: <code>new</code> is a special username that will always send you to account
 45+            creation, even with multiple accounts associated with your key-pair.
 46+        </div>
 47+        <div class="text-sm font-italic note">
 48+            note: getting permission denied? <a href="/help#permission-denied">read this</a>
 49+        </div>
 50+        <p>
 51+            After that, just set a username and you're ready to start writing! When you SSH
 52+            again, use your username that you set in the CMS.
 53+        </p>
 54+    </section>
 55+
 56+    <section>
 57+        <h2 class="text-lg font-bold">Publish your pastes with one command</h2>
 58+        <p>
 59+            When your post is ready to be published, copy the file to our server with a familiar
 60+            command:
 61+        </p>
 62+        <pre>scp ./changes.patch {{.Site.Domain}}:/</pre>
 63+        <p>We'll return the URL back to you so you never have to leave the terminal!</p>
 64+    </section>
 65+
 66+    <section>
 67+        <h2 class="text-lg font-bold">Terminal workflow without installation</h2>
 68+        <p>
 69+            Since we are leveraging tools you already have on your computer
 70+            (<code>ssh</code> and <code>scp</code>), there is nothing to install.
 71+        </p>
 72+        <p>
 73+            This provides the convenience of a web app, but from inside your terminal!
 74+        </p>
 75+    </section>
 76+
 77+    <section>
 78+        <h2 class="text-lg font-bold">Features</h2>
 79+        <ul>
 80+            <li>Pastes last 3 days</li>
 81+            <li>Bring your own editor</li>
 82+            <li>You control the source files</li>
 83+            <li>Terminal workflow with no installation</li>
 84+            <li>Public-key based authentication</li>
 85+            <li>No ads, zero tracking</li>
 86+            <li>No platform lock-in</li>
 87+            <li>No javascript</li>
 88+            <li>Minimalist design</li>
 89+            <li>100% open source</li>
 90+        </ul>
 91+    </section>
 92+
 93+    <section>
 94+        <h2 class="text-lg font-bold">Roadmap</h2>
 95+        <ol>
 96+          <li>Ability to customize expiration</li>
 97+        </ol>
 98+    </section>
 99+</main>
100+
101+{{template "marketing-footer" .}}
102+{{end}}
A pastes/html/ops.page.tmpl
+126, -0
  1@@ -0,0 +1,126 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}operations -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="{{.Site.Domain}} operations" />
  8+{{end}}
  9+
 10+{{define "attrs"}}{{end}}
 11+
 12+{{define "body"}}
 13+<header>
 14+    <h1 class="text-2xl">Operations</h1>
 15+    <ul>
 16+        <li><a href="/privacy">privacy</a></li>
 17+        <li><a href="/transparency">transparency</a></li>
 18+    </ul>
 19+</header>
 20+<main>
 21+    <section>
 22+        <h2 class="text-xl">Purpose</h2>
 23+        <p>
 24+            {{.Site.Domain}} exists to allow people to create and share their thoughts
 25+            without the need to set up their own server or be part of a platform
 26+            that shows ads or tracks its users.
 27+        </p>
 28+    </section>
 29+    <section>
 30+        <h2 class="text-xl">Ethics</h2>
 31+        <p>We are committed to:</p>
 32+        <ul>
 33+            <li>No tracking of user or visitor behaviour.</li>
 34+            <li>Never sell any user or visitor data.</li>
 35+            <li>No ads — ever.</li>
 36+        </ul>
 37+    </section>
 38+    <section>
 39+        <h2 class="text-xl">Code of Content Publication</h2>
 40+        <p>
 41+            Content in {{.Site.Domain}} pastes is unfiltered and unmonitored. Users are free to publish any
 42+            combination of words and pixels except for: content of animosity or disparagement of an
 43+            individual or a group on account of a group characteristic such as race, color, national
 44+            origin, sex, disability, religion, or sexual orientation, which will be taken down
 45+            immediately.
 46+        </p>
 47+        <p>
 48+            If one notices something along those lines in a paste post please let us know at
 49+            <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>.
 50+        </p>
 51+    </section>
 52+    <section>
 53+        <h2 class="text-xl">Liability</h2>
 54+        <p>
 55+            The user expressly understands and agrees that Eric Bower and Antonio Mika, the operator of this website
 56+            shall not be liable, in law or in equity, to them or to any third party for any direct,
 57+            indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
 58+        </p>
 59+    </section>
 60+    <section>
 61+        <h2 class="text-xl">Account Terms</h2>
 62+        <p>
 63+            <ul>
 64+                <li>
 65+                    The user is responsible for all content posted and all actions performed with
 66+                    their account.
 67+                </li>
 68+                <li>
 69+                    We reserve the right to disable or delete a user's account for any reason at
 70+                    any time. We have this clause because, statistically speaking, there will be
 71+                    people trying to do something nefarious.
 72+                </li>
 73+            </ul>
 74+        </p>
 75+    </section>
 76+    <section>
 77+        <h2 class="text-xl">Service Availability</h2>
 78+        <p>
 79+         We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer
 80+         service-level agreements but do take uptime seriously.
 81+        </p>
 82+    </section>
 83+    <section>
 84+        <h2 class="text-xl">Contact and Support</h2>
 85+        <p>
 86+            Email us at <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>
 87+            with any questions.
 88+        </p>
 89+    </section>
 90+    <section>
 91+        <h2 class="text-xl">Acknowledgments</h2>
 92+        <p>
 93+            {{.Site.Domain}} was inspired by <a href="https://mataroa.blog">Mataroa Blog</a>
 94+            and <a href="https://bearblog.dev/">Bear Blog</a>.
 95+        </p>
 96+        <p>
 97+            {{.Site.Domain}} is built with many open source technologies.
 98+        </p>
 99+        <p>
100+            In particular we would like to thank:
101+        </p>
102+        <ul>
103+            <li>
104+                <span>The </span>
105+                <a href="https://charm.sh">charm.sh</a>
106+                <span> community</span>
107+            </li>
108+            <li>
109+                <span>The </span>
110+                <a href="https://go.dev">golang</a>
111+                <span> community</span>
112+            </li>
113+            <li>
114+                <span>The </span>
115+                <a href="https://www.postgresql.org/">postgresql</a>
116+                <span> community</span>
117+            </li>
118+            <li>
119+                <span>The </span>
120+                <a href="https://github.com/caddyserver/caddy">caddy</a>
121+                <span> community</span>
122+            </li>
123+        </ul>
124+    </section>
125+</main>
126+{{template "marketing-footer" .}}
127+{{end}}
A pastes/html/post.page.tmpl
+43, -0
 1@@ -0,0 +1,43 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta property="og:type" content="website">
 8+<meta property="og:site_name" content="{{.Site.Domain}}">
 9+<meta property="og:url" content="{{.URL}}">
10+<meta property="og:title" content="{{.Title}}">
11+<meta property="og:image:width" content="300" />
12+<meta property="og:image:height" content="300" />
13+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
14+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
15+
16+<meta property="twitter:card" content="summary">
17+<meta property="twitter:url" content="{{.URL}}">
18+<meta property="twitter:title" content="{{.Title}}">
19+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
20+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
21+
22+<link rel="stylesheet" href="/syntax.css" />
23+{{end}}
24+
25+{{define "attrs"}}id="post"{{end}}
26+
27+{{define "body"}}
28+<header>
29+    <h1 class="text-2xl font-bold">{{.Title}}</h1>
30+    <p class="font-bold m-0">
31+        <time datetime="{{.PublishAtISO}}">{{.PublishAt}}</time>
32+        <span> on </span>
33+        <a href="{{.BlogURL}}">{{.BlogName}}</a>
34+        <span> | </span>
35+        <a href="{{.RawURL}}">raw</a>
36+    </p>
37+</header>
38+<main>
39+    <article>
40+        {{.Contents}}
41+    </article>
42+</main>
43+{{template "footer" .}}
44+{{end}}
A pastes/html/privacy.page.tmpl
+54, -0
 1@@ -0,0 +1,54 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}privacy -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.Site.Domain}} privacy policy" />
 8+{{end}}
 9+
10+{{define "attrs"}}{{end}}
11+
12+{{define "body"}}
13+<header>
14+    <h1 class="text-2xl">Privacy</h1>
15+    <p>Details on our privacy and security approach.</p>
16+</header>
17+<main>
18+    <section>
19+        <h2 class="text-xl">Account Data</h2>
20+        <p>
21+            In order to have a functional account at {{.Site.Domain}}, we need to store
22+            your public key.  That is the only piece of information we record for a user.
23+        </p>
24+        <p>
25+            Because we use public-key cryptography, our security posture is a battle-tested
26+            and proven technique for authentication.
27+        </p>
28+    </section>
29+
30+    <section>
31+        <h2 class="text-xl">Third parties</h2>
32+        <p>
33+            We have a strong commitment to never share any user data with any third-parties.
34+        </p>
35+    </section>
36+
37+    <section>
38+        <h2 class="text-xl">Service Providers</h2>
39+        <ul>
40+            <li>
41+                <span>We host our server on </span>
42+                <a href="https://digitalocean.com">digital ocean</a>
43+            </li>
44+        </ul>
45+    </section>
46+
47+    <section>
48+        <h2 class="text-xl">Cookies</h2>
49+        <p>
50+            We do not use any cookies, not even account authentication.
51+        </p>
52+    </section>
53+</main>
54+{{template "marketing-footer" .}}
55+{{end}}
A pastes/html/transparency.page.tmpl
+59, -0
 1@@ -0,0 +1,59 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}transparency -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="full transparency of analytics and cost at {{.Site.Domain}}" />
 8+{{end}}
 9+
10+{{define "attrs"}}{{end}}
11+
12+{{define "body"}}
13+<header>
14+    <h1 class="text-2xl">Transparency</h1>
15+    <hr />
16+</header>
17+<main>
18+    <section>
19+        <h2 class="text-xl">Analytics</h2>
20+        <p>
21+            Here are some interesting stats on usage.
22+        </p>
23+
24+        <article>
25+            <h2 class="text-lg">Total users</h2>
26+            <div>{{.Analytics.TotalUsers}}</div>
27+        </article>
28+
29+        <article>
30+            <h2 class="text-lg">New users in the last month</h2>
31+            <div>{{.Analytics.UsersLastMonth}}</div>
32+        </article>
33+
34+        <article>
35+            <h2 class="text-lg">Total pastes</h2>
36+            <div>{{.Analytics.TotalPosts}}</div>
37+        </article>
38+
39+        <article>
40+            <h2 class="text-lg">New pastes in the last month</h2>
41+            <div>{{.Analytics.PostsLastMonth}}</div>
42+        </article>
43+
44+        <article>
45+            <h2 class="text-lg">Users with at least one paste</h2>
46+            <div>{{.Analytics.UsersWithPost}}</div>
47+        </article>
48+    </section>
49+
50+    <section>
51+        <h2 class="text-xl">Service maintenance costs</h2>
52+        <ul>
53+            <li>Server $5.00/mo</li>
54+            <li>Domain name $3.25/mo</li>
55+            <li>Programmer $0.00/mo</li>
56+        </ul>
57+    </section>
58+</main>
59+{{template "marketing-footer" .}}
60+{{end}}
A pastes/makefile
+17, -0
 1@@ -0,0 +1,17 @@
 2+DOCKER_TAG?=$(shell git log --format="%H" -n 1)
 3+
 4+bp-setup:
 5+	docker buildx ls | grep pico || docker buildx create --name pico
 6+	docker buildx use pico
 7+.PHONY: bp-setup
 8+
 9+bp-ssh: bp-setup
10+	docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/pastes-ssh:$(DOCKER_TAG) --target ssh -f Dockerfile ..
11+.PHONY: bp-ssh
12+
13+bp-web: bp-setup
14+	docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/pastes-web:$(DOCKER_TAG) --target web -f Dockerfile ..
15+.PHONY: bp-web
16+
17+bp: bp-ssh bp-web
18+.PHONY: bp
A pastes/parser.go
+33, -0
 1@@ -0,0 +1,33 @@
 2+package internal
 3+
 4+import (
 5+	"bytes"
 6+	"github.com/alecthomas/chroma/formatters/html"
 7+	"github.com/alecthomas/chroma/lexers"
 8+	"github.com/alecthomas/chroma/styles"
 9+)
10+
11+func ParseText(filename string, text string) (string, error) {
12+	formatter := html.New(
13+		html.WithLineNumbers(true),
14+		html.LinkableLineNumbers(true, ""),
15+		html.WithClasses(true),
16+	)
17+	lexer := lexers.Match(filename)
18+	if lexer == nil {
19+		lexer = lexers.Analyse(text)
20+	}
21+	if lexer == nil {
22+		lexer = lexers.Get("plaintext")
23+	}
24+	iterator, err := lexer.Tokenise(nil, text)
25+	if err != nil {
26+		return text, err
27+	}
28+	var buf bytes.Buffer
29+	err = formatter.Format(&buf, styles.Dracula, iterator)
30+	if err != nil {
31+		return text, err
32+	}
33+	return buf.String(), nil
34+}
A pastes/public/apple-touch-icon.png
+0, -0
A pastes/public/card.png
+0, -0
A pastes/public/favicon-16x16.png
+0, -0
A pastes/public/favicon.ico
+0, -0
A pastes/public/main.css
+320, -0
  1@@ -0,0 +1,320 @@
  2+*, ::before, ::after {
  3+  box-sizing: border-box;
  4+}
  5+
  6+::-moz-focus-inner {
  7+	border-style: none;
  8+	padding: 0;
  9+}
 10+:-moz-focusring { outline: 1px dotted ButtonText; }
 11+:-moz-ui-invalid { box-shadow: none; }
 12+
 13+@media (prefers-color-scheme: light) {
 14+  :root {
 15+    --white: #6a737d;
 16+    --code: rgba(255, 229, 100, 0.2);
 17+    --pre: #f6f8fa;
 18+    --bg-color: #fff;
 19+    --text-color: #24292f;
 20+    --link-color: #005cc5;
 21+    --visited: #6f42c1;
 22+    --blockquote: #785840;
 23+    --blockquote-bg: #fff;
 24+    --hover: #d73a49;
 25+    --grey: #ccc;
 26+  }
 27+}
 28+
 29+@media (prefers-color-scheme: dark) {
 30+  :root {
 31+    --white: #f2f2f2;
 32+    --code: #252525;
 33+    --pre: #252525;
 34+    --bg-color: #282a36;
 35+    --text-color: #f2f2f2;
 36+    --link-color: #8be9fd;
 37+    --visited: #bd93f9;
 38+    --blockquote: #bd93f9;
 39+    --blockquote-bg: #414558;
 40+    --hover: #ff80bf;
 41+    --grey: #414558;
 42+  }
 43+}
 44+
 45+html {
 46+  background-color: var(--bg-color);
 47+  color: var(--text-color);
 48+  line-height: 1.5;
 49+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
 50+    "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
 51+    "Segoe UI Emoji", "Segoe UI Symbol";
 52+	-webkit-text-size-adjust: 100%;
 53+	-moz-tab-size: 4;
 54+	tab-size: 4;
 55+}
 56+
 57+body {
 58+  margin: 0 auto;
 59+  max-width: 42rem;
 60+}
 61+
 62+img {
 63+  max-width: 100%;
 64+  height: auto;
 65+}
 66+
 67+b, strong {
 68+  font-weight: bold;
 69+}
 70+
 71+code, kbd, samp, pre {
 72+	font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
 73+	font-size: 0.8rem;
 74+}
 75+
 76+code, kbd, samp {
 77+  background-color: var(--code);
 78+}
 79+
 80+pre > code {
 81+  background-color: inherit;
 82+  padding: 0;
 83+}
 84+
 85+code {
 86+  border-radius: 0.3rem;
 87+  padding: .15rem .2rem .05rem;
 88+}
 89+
 90+pre {
 91+  border-radius: 5px;
 92+  padding: 1rem;
 93+  overflow-x: auto;
 94+  margin: 0;
 95+  background-color: var(--pre) !important;
 96+}
 97+
 98+small {
 99+  font-size: 0.8rem;
100+}
101+
102+summary {
103+  display: list-item;
104+}
105+
106+h1, h2, h3 {
107+  margin: 0;
108+	padding: 0;
109+	border: 0;
110+  font-style: normal;
111+  font-weight: inherit;
112+  font-size: inherit;
113+}
114+
115+hr {
116+  color: inherit;
117+  border: 0;
118+  margin: 0;
119+  height: 1px;
120+  background: var(--grey);
121+  margin: 2rem auto;
122+  text-align: center;
123+}
124+
125+a {
126+  text-decoration: underline;
127+  color: var(--link-color);
128+}
129+
130+a:hover, a:visited:hover {
131+  color: var(--hover);
132+}
133+
134+a:visited {
135+  color: var(--visited);
136+}
137+
138+a.link-grey {
139+  text-decoration: underline;
140+  color: var(--white);
141+}
142+
143+a.link-grey:visited {
144+  color: var(--white);
145+}
146+
147+section {
148+  margin-bottom: 2rem;
149+}
150+
151+section:last-child {
152+  margin-bottom: 0;
153+}
154+
155+header {
156+  margin: 1rem auto;
157+}
158+
159+p {
160+  margin: 1rem 0;
161+}
162+
163+article {
164+  overflow-wrap: break-word;
165+}
166+
167+blockquote {
168+  border-left: 5px solid var(--blockquote);
169+  background-color: var(--blockquote-bg);
170+  padding: 0.5rem;
171+  margin: 0.5rem 0;
172+}
173+
174+ul, ol {
175+  padding: 0 0 0 2rem;
176+  list-style-position: outside;
177+}
178+
179+ul[style*="list-style-type: none;"] {
180+  padding: 0;
181+}
182+
183+li {
184+  margin: 0.5rem 0;
185+}
186+
187+li > pre {
188+  padding: 0;
189+}
190+
191+footer {
192+  text-align: center;
193+  margin-bottom: 4rem;
194+}
195+
196+dt {
197+  font-weight: bold;
198+}
199+
200+dd {
201+  margin-left: 0;
202+}
203+
204+dd:not(:last-child) {
205+  margin-bottom: .5rem;
206+}
207+
208+.md h1 {
209+  font-size: 1.25rem;
210+  line-height: 1.15;
211+  font-weight: bold;
212+  padding: 0.5rem 0;
213+}
214+
215+.md h2 {
216+  font-size: 1.125rem;
217+  line-height: 1.15;
218+  font-weight: bold;
219+  padding: 0.5rem 0;
220+}
221+
222+.md h3 {
223+  font-weight: bold;
224+}
225+
226+.md h4 {
227+  font-size: 0.875rem;
228+  font-weight: bold;
229+}
230+
231+.post-date {
232+  width: 130px;
233+}
234+
235+.text-grey {
236+  color: var(--grey);
237+}
238+
239+.text-2xl {
240+  font-size: 1.5rem;
241+  line-height: 1.15;
242+}
243+
244+.text-xl {
245+  font-size: 1.25rem;
246+  line-height: 1.15;
247+}
248+
249+.text-lg {
250+  font-size: 1.125rem;
251+  line-height: 1.15;
252+}
253+
254+.text-sm {
255+  font-size: 0.875rem;
256+}
257+
258+.text-center {
259+  text-align: center;
260+}
261+
262+.font-bold {
263+  font-weight: bold;
264+}
265+
266+.font-italic {
267+  font-style: italic;
268+}
269+
270+.inline {
271+  display: inline;
272+}
273+
274+.flex {
275+  display: flex;
276+}
277+
278+.items-center {
279+  align-items: center;
280+}
281+
282+.m-0 {
283+  margin: 0;
284+}
285+
286+.my {
287+  margin-top: 0.5rem;
288+  margin-bottom: 0.5rem;
289+}
290+
291+.mx {
292+  margin-left: 0.5rem;
293+  margin-right: 0.5rem;
294+}
295+
296+.mx-2 {
297+  margin-left: 1rem;
298+  margin-right: 1rem;
299+}
300+
301+.justify-between {
302+  justify-content: space-between;
303+}
304+
305+.flex-1 {
306+  flex: 1;
307+}
308+
309+@media only screen and (max-width: 600px) {
310+  body {
311+    padding: 1rem;
312+  }
313+
314+  header {
315+    margin: 0;
316+  }
317+}
318+
319+#post {
320+  max-width: 90%;
321+}
A pastes/public/robots.txt
+2, -0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
A pastes/public/syntax.css
+175, -0
  1@@ -0,0 +1,175 @@
  2+@media (prefers-color-scheme: light) {
  3+  /* Background */ .bg { background-color: #ffffff; }
  4+  /* PreWrapper */ .chroma { background-color: #ffffff; }
  5+  /* Other */ .chroma .x {  }
  6+  /* Error */ .chroma .err { background-color: #a848a8 }
  7+  /* CodeLine */ .chroma .cl {  }
  8+  /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
  9+  /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
 10+  /* LineHighlight */ .chroma .hl { background-color: #ffffcc }
 11+  /* LineNumbersTable */ .chroma .lnt { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
 12+  /* LineNumbers */ .chroma .ln { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
 13+  /* Line */ .chroma .line { display: flex; }
 14+  /* Keyword */ .chroma .k { color: #2838b0 }
 15+  /* KeywordConstant */ .chroma .kc { color: #444444; font-style: italic }
 16+  /* KeywordDeclaration */ .chroma .kd { color: #2838b0; font-style: italic }
 17+  /* KeywordNamespace */ .chroma .kn { color: #2838b0 }
 18+  /* KeywordPseudo */ .chroma .kp { color: #2838b0 }
 19+  /* KeywordReserved */ .chroma .kr { color: #2838b0 }
 20+  /* KeywordType */ .chroma .kt { color: #2838b0; font-style: italic }
 21+  /* Name */ .chroma .n {  }
 22+  /* NameAttribute */ .chroma .na { color: #388038 }
 23+  /* NameBuiltin */ .chroma .nb { color: #388038 }
 24+  /* NameBuiltinPseudo */ .chroma .bp { font-style: italic }
 25+  /* NameClass */ .chroma .nc { color: #287088 }
 26+  /* NameConstant */ .chroma .no { color: #b85820 }
 27+  /* NameDecorator */ .chroma .nd { color: #287088 }
 28+  /* NameEntity */ .chroma .ni { color: #709030 }
 29+  /* NameException */ .chroma .ne { color: #908828 }
 30+  /* NameFunction */ .chroma .nf { color: #785840 }
 31+  /* NameFunctionMagic */ .chroma .fm { color: #b85820 }
 32+  /* NameLabel */ .chroma .nl { color: #289870 }
 33+  /* NameNamespace */ .chroma .nn { color: #289870 }
 34+  /* NameOther */ .chroma .nx {  }
 35+  /* NameProperty */ .chroma .py {  }
 36+  /* NameTag */ .chroma .nt { color: #2838b0 }
 37+  /* NameVariable */ .chroma .nv { color: #b04040 }
 38+  /* NameVariableClass */ .chroma .vc {  }
 39+  /* NameVariableGlobal */ .chroma .vg { color: #908828 }
 40+  /* NameVariableInstance */ .chroma .vi {  }
 41+  /* NameVariableMagic */ .chroma .vm { color: #b85820 }
 42+  /* Literal */ .chroma .l {  }
 43+  /* LiteralDate */ .chroma .ld {  }
 44+  /* LiteralString */ .chroma .s { color: #b83838 }
 45+  /* LiteralStringAffix */ .chroma .sa { color: #444444 }
 46+  /* LiteralStringBacktick */ .chroma .sb { color: #b83838 }
 47+  /* LiteralStringChar */ .chroma .sc { color: #a848a8 }
 48+  /* LiteralStringDelimiter */ .chroma .dl { color: #b85820 }
 49+  /* LiteralStringDoc */ .chroma .sd { color: #b85820; font-style: italic }
 50+  /* LiteralStringDouble */ .chroma .s2 { color: #b83838 }
 51+  /* LiteralStringEscape */ .chroma .se { color: #709030 }
 52+  /* LiteralStringHeredoc */ .chroma .sh { color: #b83838 }
 53+  /* LiteralStringInterpol */ .chroma .si { color: #b83838; text-decoration: underline }
 54+  /* LiteralStringOther */ .chroma .sx { color: #a848a8 }
 55+  /* LiteralStringRegex */ .chroma .sr { color: #a848a8 }
 56+  /* LiteralStringSingle */ .chroma .s1 { color: #b83838 }
 57+  /* LiteralStringSymbol */ .chroma .ss { color: #b83838 }
 58+  /* LiteralNumber */ .chroma .m { color: #444444 }
 59+  /* LiteralNumberBin */ .chroma .mb { color: #444444 }
 60+  /* LiteralNumberFloat */ .chroma .mf { color: #444444 }
 61+  /* LiteralNumberHex */ .chroma .mh { color: #444444 }
 62+  /* LiteralNumberInteger */ .chroma .mi { color: #444444 }
 63+  /* LiteralNumberIntegerLong */ .chroma .il { color: #444444 }
 64+  /* LiteralNumberOct */ .chroma .mo { color: #444444 }
 65+  /* Operator */ .chroma .o { color: #666666 }
 66+  /* OperatorWord */ .chroma .ow { color: #a848a8 }
 67+  /* Punctuation */ .chroma .p { color: #888888 }
 68+  /* Comment */ .chroma .c { color: #888888; font-style: italic }
 69+  /* CommentHashbang */ .chroma .ch { color: #287088; font-style: italic }
 70+  /* CommentMultiline */ .chroma .cm { color: #888888; font-style: italic }
 71+  /* CommentSingle */ .chroma .c1 { color: #888888; font-style: italic }
 72+  /* CommentSpecial */ .chroma .cs { color: #888888; font-style: italic }
 73+  /* CommentPreproc */ .chroma .cp { color: #289870 }
 74+  /* CommentPreprocFile */ .chroma .cpf { color: #289870 }
 75+  /* Generic */ .chroma .g {  }
 76+  /* GenericDeleted */ .chroma .gd { color: #c02828 }
 77+  /* GenericEmph */ .chroma .ge { font-style: italic }
 78+  /* GenericError */ .chroma .gr { color: #c02828 }
 79+  /* GenericHeading */ .chroma .gh { color: #666666 }
 80+  /* GenericInserted */ .chroma .gi { color: #388038 }
 81+  /* GenericOutput */ .chroma .go { color: #666666 }
 82+  /* GenericPrompt */ .chroma .gp { color: #444444 }
 83+  /* GenericStrong */ .chroma .gs { font-weight: bold }
 84+  /* GenericSubheading */ .chroma .gu { color: #444444 }
 85+  /* GenericTraceback */ .chroma .gt { color: #2838b0 }
 86+  /* GenericUnderline */ .chroma .gl { text-decoration: underline }
 87+  /* TextWhitespace */ .chroma .w { color: #a89028 }
 88+}
 89+
 90+@media (prefers-color-scheme: dark) {
 91+  /* Background */ .bg { color: #f8f8f2; background-color: #282a36; }
 92+  /* PreWrapper */ .chroma { color: #f8f8f2; background-color: #282a36; }
 93+  /* Other */ .chroma .x {  }
 94+  /* Error */ .chroma .err {  }
 95+  /* CodeLine */ .chroma .cl {  }
 96+  /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
 97+  /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
 98+  /* LineHighlight */ .chroma .hl { background-color: #ffffcc }
 99+  /* LineNumbersTable */ .chroma .lnt { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
100+  /* LineNumbers */ .chroma .ln { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
101+  /* Line */ .chroma .line { display: flex; }
102+  /* Keyword */ .chroma .k { color: #ff79c6 }
103+  /* KeywordConstant */ .chroma .kc { color: #ff79c6 }
104+  /* KeywordDeclaration */ .chroma .kd { color: #8be9fd; font-style: italic }
105+  /* KeywordNamespace */ .chroma .kn { color: #ff79c6 }
106+  /* KeywordPseudo */ .chroma .kp { color: #ff79c6 }
107+  /* KeywordReserved */ .chroma .kr { color: #ff79c6 }
108+  /* KeywordType */ .chroma .kt { color: #8be9fd }
109+  /* Name */ .chroma .n {  }
110+  /* NameAttribute */ .chroma .na { color: #50fa7b }
111+  /* NameBuiltin */ .chroma .nb { color: #8be9fd; font-style: italic }
112+  /* NameBuiltinPseudo */ .chroma .bp {  }
113+  /* NameClass */ .chroma .nc { color: #50fa7b }
114+  /* NameConstant */ .chroma .no {  }
115+  /* NameDecorator */ .chroma .nd {  }
116+  /* NameEntity */ .chroma .ni {  }
117+  /* NameException */ .chroma .ne {  }
118+  /* NameFunction */ .chroma .nf { color: #50fa7b }
119+  /* NameFunctionMagic */ .chroma .fm {  }
120+  /* NameLabel */ .chroma .nl { color: #8be9fd; font-style: italic }
121+  /* NameNamespace */ .chroma .nn {  }
122+  /* NameOther */ .chroma .nx {  }
123+  /* NameProperty */ .chroma .py {  }
124+  /* NameTag */ .chroma .nt { color: #ff79c6 }
125+  /* NameVariable */ .chroma .nv { color: #8be9fd; font-style: italic }
126+  /* NameVariableClass */ .chroma .vc { color: #8be9fd; font-style: italic }
127+  /* NameVariableGlobal */ .chroma .vg { color: #8be9fd; font-style: italic }
128+  /* NameVariableInstance */ .chroma .vi { color: #8be9fd; font-style: italic }
129+  /* NameVariableMagic */ .chroma .vm {  }
130+  /* Literal */ .chroma .l {  }
131+  /* LiteralDate */ .chroma .ld {  }
132+  /* LiteralString */ .chroma .s { color: #f1fa8c }
133+  /* LiteralStringAffix */ .chroma .sa { color: #f1fa8c }
134+  /* LiteralStringBacktick */ .chroma .sb { color: #f1fa8c }
135+  /* LiteralStringChar */ .chroma .sc { color: #f1fa8c }
136+  /* LiteralStringDelimiter */ .chroma .dl { color: #f1fa8c }
137+  /* LiteralStringDoc */ .chroma .sd { color: #f1fa8c }
138+  /* LiteralStringDouble */ .chroma .s2 { color: #f1fa8c }
139+  /* LiteralStringEscape */ .chroma .se { color: #f1fa8c }
140+  /* LiteralStringHeredoc */ .chroma .sh { color: #f1fa8c }
141+  /* LiteralStringInterpol */ .chroma .si { color: #f1fa8c }
142+  /* LiteralStringOther */ .chroma .sx { color: #f1fa8c }
143+  /* LiteralStringRegex */ .chroma .sr { color: #f1fa8c }
144+  /* LiteralStringSingle */ .chroma .s1 { color: #f1fa8c }
145+  /* LiteralStringSymbol */ .chroma .ss { color: #f1fa8c }
146+  /* LiteralNumber */ .chroma .m { color: #bd93f9 }
147+  /* LiteralNumberBin */ .chroma .mb { color: #bd93f9 }
148+  /* LiteralNumberFloat */ .chroma .mf { color: #bd93f9 }
149+  /* LiteralNumberHex */ .chroma .mh { color: #bd93f9 }
150+  /* LiteralNumberInteger */ .chroma .mi { color: #bd93f9 }
151+  /* LiteralNumberIntegerLong */ .chroma .il { color: #bd93f9 }
152+  /* LiteralNumberOct */ .chroma .mo { color: #bd93f9 }
153+  /* Operator */ .chroma .o { color: #ff79c6 }
154+  /* OperatorWord */ .chroma .ow { color: #ff79c6 }
155+  /* Punctuation */ .chroma .p {  }
156+  /* Comment */ .chroma .c { color: #6272a4 }
157+  /* CommentHashbang */ .chroma .ch { color: #6272a4 }
158+  /* CommentMultiline */ .chroma .cm { color: #6272a4 }
159+  /* CommentSingle */ .chroma .c1 { color: #6272a4 }
160+  /* CommentSpecial */ .chroma .cs { color: #6272a4 }
161+  /* CommentPreproc */ .chroma .cp { color: #ff79c6 }
162+  /* CommentPreprocFile */ .chroma .cpf { color: #ff79c6 }
163+  /* Generic */ .chroma .g {  }
164+  /* GenericDeleted */ .chroma .gd { color: #ff5555 }
165+  /* GenericEmph */ .chroma .ge { text-decoration: underline }
166+  /* GenericError */ .chroma .gr {  }
167+  /* GenericHeading */ .chroma .gh { font-weight: bold }
168+  /* GenericInserted */ .chroma .gi { color: #50fa7b; font-weight: bold }
169+  /* GenericOutput */ .chroma .go { color: #44475a }
170+  /* GenericPrompt */ .chroma .gp {  }
171+  /* GenericStrong */ .chroma .gs {  }
172+  /* GenericSubheading */ .chroma .gu { font-weight: bold }
173+  /* GenericTraceback */ .chroma .gt {  }
174+  /* GenericUnderline */ .chroma .gl { text-decoration: underline }
175+  /* TextWhitespace */ .chroma .w {  }
176+}
A pastes/router.go
+97, -0
 1@@ -0,0 +1,97 @@
 2+package internal
 3+
 4+import (
 5+	"context"
 6+	"fmt"
 7+	"net/http"
 8+	"regexp"
 9+	"strings"
10+
11+	"git.sr.ht/~erock/wish/cms/db"
12+	"go.uber.org/zap"
13+)
14+
15+type Route struct {
16+	method  string
17+	regex   *regexp.Regexp
18+	handler http.HandlerFunc
19+}
20+
21+func NewRoute(method, pattern string, handler http.HandlerFunc) Route {
22+	return Route{
23+		method,
24+		regexp.MustCompile("^" + pattern + "$"),
25+		handler,
26+	}
27+}
28+
29+type ServeFn func(http.ResponseWriter, *http.Request)
30+
31+func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpool db.DB, logger *zap.SugaredLogger) ServeFn {
32+	return func(w http.ResponseWriter, r *http.Request) {
33+		var allow []string
34+		curRoutes := routes
35+
36+		hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
37+		appDomain := strings.ToLower(strings.Split(cfg.ConfigCms.Domain, ":")[0])
38+
39+		subdomain := ""
40+		if hostDomain != appDomain && strings.Contains(hostDomain, appDomain) {
41+			subdomain = strings.TrimSuffix(hostDomain, fmt.Sprintf(".%s", appDomain))
42+		}
43+
44+		if cfg.IsSubdomains() && subdomain != "" {
45+			curRoutes = subdomainRoutes
46+		}
47+
48+		for _, route := range curRoutes {
49+			matches := route.regex.FindStringSubmatch(r.URL.Path)
50+			if len(matches) > 0 {
51+				if r.Method != route.method {
52+					allow = append(allow, route.method)
53+					continue
54+				}
55+				loggerCtx := context.WithValue(r.Context(), ctxLoggerKey{}, logger)
56+				subdomainCtx := context.WithValue(loggerCtx, ctxSubdomainKey{}, subdomain)
57+				dbCtx := context.WithValue(subdomainCtx, ctxDBKey{}, dbpool)
58+				cfgCtx := context.WithValue(dbCtx, ctxCfg{}, cfg)
59+				ctx := context.WithValue(cfgCtx, ctxKey{}, matches[1:])
60+				route.handler(w, r.WithContext(ctx))
61+				return
62+			}
63+		}
64+		if len(allow) > 0 {
65+			w.Header().Set("Allow", strings.Join(allow, ", "))
66+			http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
67+			return
68+		}
69+		http.NotFound(w, r)
70+	}
71+}
72+
73+type ctxDBKey struct{}
74+type ctxKey struct{}
75+type ctxLoggerKey struct{}
76+type ctxSubdomainKey struct{}
77+type ctxCfg struct{}
78+
79+func GetCfg(r *http.Request) *ConfigSite {
80+	return r.Context().Value(ctxCfg{}).(*ConfigSite)
81+}
82+
83+func GetLogger(r *http.Request) *zap.SugaredLogger {
84+	return r.Context().Value(ctxLoggerKey{}).(*zap.SugaredLogger)
85+}
86+
87+func GetDB(r *http.Request) db.DB {
88+	return r.Context().Value(ctxDBKey{}).(db.DB)
89+}
90+
91+func GetField(r *http.Request, index int) string {
92+	fields := r.Context().Value(ctxKey{}).([]string)
93+	return fields[index]
94+}
95+
96+func GetSubdomain(r *http.Request) string {
97+	return r.Context().Value(ctxSubdomainKey{}).(string)
98+}
A pastes/util.go
+107, -0
  1@@ -0,0 +1,107 @@
  2+package internal
  3+
  4+import (
  5+	"encoding/base64"
  6+	"fmt"
  7+	"math"
  8+	"os"
  9+	"path/filepath"
 10+	"regexp"
 11+	"strings"
 12+	"time"
 13+	"unicode"
 14+	"unicode/utf8"
 15+
 16+	"github.com/gliderlabs/ssh"
 17+)
 18+
 19+var fnameRe = regexp.MustCompile(`[-_]+`)
 20+
 21+func FilenameToTitle(filename string, title string) string {
 22+	if filename != title {
 23+		return title
 24+	}
 25+
 26+	pre := fnameRe.ReplaceAllString(title, " ")
 27+	r := []rune(pre)
 28+	r[0] = unicode.ToUpper(r[0])
 29+	return string(r)
 30+}
 31+
 32+func SanitizeFileExt(fname string) string {
 33+	return strings.TrimSuffix(fname, filepath.Ext(fname))
 34+}
 35+
 36+func KeyText(s ssh.Session) (string, error) {
 37+	if s.PublicKey() == nil {
 38+		return "", fmt.Errorf("Session doesn't have public key")
 39+	}
 40+	kb := base64.StdEncoding.EncodeToString(s.PublicKey().Marshal())
 41+	return fmt.Sprintf("%s %s", s.PublicKey().Type(), kb), nil
 42+}
 43+
 44+func GetEnv(key string, defaultVal string) string {
 45+	if value, exists := os.LookupEnv(key); exists {
 46+		return value
 47+	}
 48+
 49+	return defaultVal
 50+}
 51+
 52+// IsText reports whether a significant prefix of s looks like correct UTF-8;
 53+// that is, if it is likely that s is human-readable text.
 54+func IsText(s string) bool {
 55+	const max = 1024 // at least utf8.UTFMax
 56+	if len(s) > max {
 57+		s = s[0:max]
 58+	}
 59+	for i, c := range s {
 60+		if i+utf8.UTFMax > len(s) {
 61+			// last char may be incomplete - ignore
 62+			break
 63+		}
 64+		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
 65+			// decoding error or control character - not a text file
 66+			return false
 67+		}
 68+	}
 69+	return true
 70+}
 71+
 72+// IsTextFile reports whether the file has a known extension indicating
 73+// a text file, or if a significant chunk of the specified file looks like
 74+// correct UTF-8; that is, if it is likely that the file contains human-
 75+// readable text.
 76+func IsTextFile(text string, filename string) bool {
 77+	num := math.Min(float64(len(text)), 1024)
 78+	return IsText(text[0:int(num)])
 79+}
 80+
 81+const solarYearSecs = 31556926
 82+
 83+func TimeAgo(t *time.Time) string {
 84+	d := time.Since(*t)
 85+	var metric string
 86+	var amount int
 87+	if d.Seconds() < 60 {
 88+		amount = int(d.Seconds())
 89+		metric = "second"
 90+	} else if d.Minutes() < 60 {
 91+		amount = int(d.Minutes())
 92+		metric = "minute"
 93+	} else if d.Hours() < 24 {
 94+		amount = int(d.Hours())
 95+		metric = "hour"
 96+	} else if d.Seconds() < solarYearSecs {
 97+		amount = int(d.Hours()) / 24
 98+		metric = "day"
 99+	} else {
100+		amount = int(d.Seconds()) / solarYearSecs
101+		metric = "year"
102+	}
103+	if amount == 1 {
104+		return fmt.Sprintf("%d %s ago", amount, metric)
105+	} else {
106+		return fmt.Sprintf("%d %ss ago", amount, metric)
107+	}
108+}
A prose/Caddyfile
+44, -0
 1@@ -0,0 +1,44 @@
 2+{
 3+	on_demand_tls {
 4+		ask http://web:3000/check
 5+		interval 1m
 6+		burst 10
 7+	}
 8+}
 9+
10+*.prose.sh, prose.sh {
11+	reverse_proxy web:3000
12+	tls hello@prose.sh {
13+		dns cloudflare {env.CF_API_TOKEN}
14+	}
15+	encode zstd gzip
16+
17+    header {
18+        # disable FLoC tracking
19+        Permissions-Policy interest-cohort=()
20+
21+        # enable HSTS
22+        Strict-Transport-Security max-age=31536000;
23+
24+        # disable clients from sniffing the media type
25+        X-Content-Type-Options nosniff
26+
27+        # clickjacking protection
28+        X-Frame-Options DENY
29+
30+        # keep referrer data off of HTTP connections
31+        Referrer-Policy no-referrer-when-downgrade
32+
33+        Content-Security-Policy "default-src 'self'; img-src * 'unsafe-inline'; style-src * 'unsafe-inline'"
34+
35+        X-XSS-Protection "1; mode=block"
36+    }
37+}
38+
39+:443 {
40+	reverse_proxy web:3000
41+	tls hello@prose.sh {
42+		on_demand
43+	}
44+	encode zstd gzip
45+}
A prose/Dockerfile
+21, -0
 1@@ -0,0 +1,21 @@
 2+FROM golang:1.18.1-alpine3.15 AS builder
 3+
 4+RUN apk add --no-cache git
 5+
 6+WORKDIR /app
 7+COPY . ./
 8+
 9+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/ssh ./cmd/prose/ssh
10+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ./build/web ./cmd/prose/web
11+
12+FROM alpine:3.15 AS ssh
13+WORKDIR /app
14+COPY --from=0 /app/build/ssh ./
15+CMD ["./ssh"]
16+
17+FROM alpine:3.15 AS web
18+WORKDIR /app
19+COPY --from=0 /app/build/web ./
20+COPY --from=0 /app/html ./html
21+COPY --from=0 /app/public ./public
22+CMD ["./web"]
A prose/api.go
+852, -0
  1@@ -0,0 +1,852 @@
  2+package internal
  3+
  4+import (
  5+	"bytes"
  6+	"fmt"
  7+	"html/template"
  8+	"io/ioutil"
  9+	"net/http"
 10+	"net/url"
 11+	"strconv"
 12+	"strings"
 13+	"time"
 14+
 15+	"git.sr.ht/~erock/wish/cms/db"
 16+	"git.sr.ht/~erock/wish/cms/db/postgres"
 17+	"github.com/gorilla/feeds"
 18+	"golang.org/x/exp/slices"
 19+)
 20+
 21+type PageData struct {
 22+	Site SitePageData
 23+}
 24+
 25+type PostItemData struct {
 26+	URL            template.URL
 27+	BlogURL        template.URL
 28+	Username       string
 29+	Title          string
 30+	Description    string
 31+	PublishAtISO   string
 32+	PublishAt      string
 33+	UpdatedAtISO   string
 34+	UpdatedTimeAgo string
 35+	Padding        string
 36+	Score          string
 37+}
 38+
 39+type BlogPageData struct {
 40+	Site      SitePageData
 41+	PageTitle string
 42+	URL       template.URL
 43+	RSSURL    template.URL
 44+	Username  string
 45+	Readme    *ReadmeTxt
 46+	Header    *HeaderTxt
 47+	Posts     []PostItemData
 48+	HasCSS    bool
 49+	CssURL    template.URL
 50+}
 51+
 52+type ReadPageData struct {
 53+	Site     SitePageData
 54+	NextPage string
 55+	PrevPage string
 56+	Posts    []PostItemData
 57+}
 58+
 59+type PostPageData struct {
 60+	Site         SitePageData
 61+	PageTitle    string
 62+	URL          template.URL
 63+	BlogURL      template.URL
 64+	Title        string
 65+	Description  string
 66+	Username     string
 67+	BlogName     string
 68+	Contents     template.HTML
 69+	PublishAtISO string
 70+	PublishAt    string
 71+	HasCSS       bool
 72+	CssURL       template.URL
 73+}
 74+
 75+type TransparencyPageData struct {
 76+	Site      SitePageData
 77+	Analytics *db.Analytics
 78+}
 79+
 80+func isRequestTrackable(r *http.Request) bool {
 81+	return true
 82+}
 83+
 84+func renderTemplate(templates []string) (*template.Template, error) {
 85+	files := make([]string, len(templates))
 86+	copy(files, templates)
 87+	files = append(
 88+		files,
 89+		"./html/footer.partial.tmpl",
 90+		"./html/marketing-footer.partial.tmpl",
 91+		"./html/base.layout.tmpl",
 92+	)
 93+
 94+	ts, err := template.ParseFiles(files...)
 95+	if err != nil {
 96+		return nil, err
 97+	}
 98+	return ts, nil
 99+}
100+
101+func createPageHandler(fname string) http.HandlerFunc {
102+	return func(w http.ResponseWriter, r *http.Request) {
103+		logger := GetLogger(r)
104+		cfg := GetCfg(r)
105+		ts, err := renderTemplate([]string{fname})
106+
107+		if err != nil {
108+			logger.Error(err)
109+			http.Error(w, err.Error(), http.StatusInternalServerError)
110+			return
111+		}
112+
113+		data := PageData{
114+			Site: *cfg.GetSiteData(),
115+		}
116+		err = ts.Execute(w, data)
117+		if err != nil {
118+			logger.Error(err)
119+			http.Error(w, err.Error(), http.StatusInternalServerError)
120+		}
121+	}
122+}
123+
124+type Link struct {
125+	URL  string
126+	Text string
127+}
128+
129+type HeaderTxt struct {
130+	Title    string
131+	Bio      string
132+	Nav      []Link
133+	HasLinks bool
134+}
135+
136+type ReadmeTxt struct {
137+	HasText  bool
138+	Contents template.HTML
139+}
140+
141+func GetUsernameFromRequest(r *http.Request) string {
142+	subdomain := GetSubdomain(r)
143+	cfg := GetCfg(r)
144+
145+	if !cfg.IsSubdomains() || subdomain == "" {
146+		return GetField(r, 0)
147+	}
148+	return subdomain
149+}
150+
151+func blogStyleHandler(w http.ResponseWriter, r *http.Request) {
152+	username := GetUsernameFromRequest(r)
153+	dbpool := GetDB(r)
154+	logger := GetLogger(r)
155+	cfg := GetCfg(r)
156+
157+	user, err := dbpool.FindUserForName(username)
158+	if err != nil {
159+		logger.Infof("blog not found: %s", username)
160+		http.Error(w, "blog not found", http.StatusNotFound)
161+		return
162+	}
163+	styles, err := dbpool.FindPostWithFilename("_styles", user.ID, cfg.Space)
164+	if err != nil {
165+		logger.Infof("css not found for: %s", username)
166+		http.Error(w, "css not found", http.StatusNotFound)
167+		return
168+	}
169+
170+	w.Header().Add("Content-Type", "text/css")
171+
172+	_, err = w.Write([]byte(styles.Text))
173+	if err != nil {
174+		logger.Error(err)
175+		http.Error(w, "server error", 500)
176+	}
177+}
178+
179+func blogHandler(w http.ResponseWriter, r *http.Request) {
180+	username := GetUsernameFromRequest(r)
181+	dbpool := GetDB(r)
182+	logger := GetLogger(r)
183+	cfg := GetCfg(r)
184+
185+	user, err := dbpool.FindUserForName(username)
186+	if err != nil {
187+		logger.Infof("blog not found: %s", username)
188+		http.Error(w, "blog not found", http.StatusNotFound)
189+		return
190+	}
191+	posts, err := dbpool.FindPostsForUser(user.ID, cfg.Space)
192+	if err != nil {
193+		logger.Error(err)
194+		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
195+		return
196+	}
197+
198+	hostDomain := strings.Split(r.Host, ":")[0]
199+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
200+
201+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
202+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
203+
204+	ts, err := renderTemplate([]string{
205+		"./html/blog.page.tmpl",
206+	})
207+
208+	if err != nil {
209+		logger.Error(err)
210+		http.Error(w, err.Error(), http.StatusInternalServerError)
211+		return
212+	}
213+
214+	headerTxt := &HeaderTxt{
215+		Title: GetBlogName(username),
216+		Bio:   "",
217+	}
218+	readmeTxt := &ReadmeTxt{}
219+
220+	hasCSS := false
221+	postCollection := make([]PostItemData, 0, len(posts))
222+	for _, post := range posts {
223+		if post.Filename == "_styles" && len(post.Text) > 0 {
224+			hasCSS = true
225+		} else if post.Filename == "_readme" {
226+			parsedText, err := ParseText(post.Text)
227+			if err != nil {
228+				logger.Error(err)
229+			}
230+			headerTxt.Bio = parsedText.Description
231+			if parsedText.Title != "" {
232+				headerTxt.Title = parsedText.Title
233+			}
234+			headerTxt.Nav = parsedText.Nav
235+			readmeTxt.Contents = template.HTML(parsedText.Html)
236+			if len(readmeTxt.Contents) > 0 {
237+				readmeTxt.HasText = true
238+			}
239+		} else {
240+			p := PostItemData{
241+				URL:            template.URL(cfg.FullPostURL(post.Username, post.Filename, onSubdomain, withUserName)),
242+				BlogURL:        template.URL(cfg.FullBlogURL(post.Username, onSubdomain, withUserName)),
243+				Title:          FilenameToTitle(post.Filename, post.Title),
244+				PublishAt:      post.PublishAt.Format("02 Jan, 2006"),
245+				PublishAtISO:   post.PublishAt.Format(time.RFC3339),
246+				UpdatedTimeAgo: TimeAgo(post.UpdatedAt),
247+				UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
248+			}
249+			postCollection = append(postCollection, p)
250+		}
251+	}
252+
253+	data := BlogPageData{
254+		Site:      *cfg.GetSiteData(),
255+		PageTitle: headerTxt.Title,
256+		URL:       template.URL(cfg.FullBlogURL(username, onSubdomain, withUserName)),
257+		RSSURL:    template.URL(cfg.RssBlogURL(username, onSubdomain, withUserName)),
258+		Readme:    readmeTxt,
259+		Header:    headerTxt,
260+		Username:  username,
261+		Posts:     postCollection,
262+		HasCSS:    hasCSS,
263+		CssURL:    template.URL(cfg.CssURL(username)),
264+	}
265+
266+	err = ts.Execute(w, data)
267+	if err != nil {
268+		logger.Error(err)
269+		http.Error(w, err.Error(), http.StatusInternalServerError)
270+	}
271+}
272+
273+func GetPostTitle(post *db.Post) string {
274+	if post.Description == "" {
275+		return post.Title
276+	}
277+
278+	return fmt.Sprintf("%s: %s", post.Title, post.Description)
279+}
280+
281+func GetBlogName(username string) string {
282+	return fmt.Sprintf("%s's blog", username)
283+}
284+
285+func postRawHandler(w http.ResponseWriter, r *http.Request) {
286+	username := GetUsernameFromRequest(r)
287+	subdomain := GetSubdomain(r)
288+	cfg := GetCfg(r)
289+
290+	var filename string
291+	if !cfg.IsSubdomains() || subdomain == "" {
292+		filename, _ = url.PathUnescape(GetField(r, 1))
293+	} else {
294+		filename, _ = url.PathUnescape(GetField(r, 0))
295+	}
296+
297+	dbpool := GetDB(r)
298+	logger := GetLogger(r)
299+
300+	user, err := dbpool.FindUserForName(username)
301+	if err != nil {
302+		logger.Infof("blog not found: %s", username)
303+		http.Error(w, "blog not found", http.StatusNotFound)
304+		return
305+	}
306+
307+	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
308+	if err != nil {
309+		logger.Infof("post not found")
310+		http.Error(w, "post not found", http.StatusNotFound)
311+		return
312+	}
313+
314+	w.Header().Add("Content-Type", "text/plain")
315+
316+	_, err = w.Write([]byte(post.Text))
317+	if err != nil {
318+		logger.Error(err)
319+		http.Error(w, "server error", 500)
320+	}
321+}
322+
323+func postHandler(w http.ResponseWriter, r *http.Request) {
324+	username := GetUsernameFromRequest(r)
325+	subdomain := GetSubdomain(r)
326+	cfg := GetCfg(r)
327+
328+	var filename string
329+	if !cfg.IsSubdomains() || subdomain == "" {
330+		filename, _ = url.PathUnescape(GetField(r, 1))
331+	} else {
332+		filename, _ = url.PathUnescape(GetField(r, 0))
333+	}
334+
335+	dbpool := GetDB(r)
336+	logger := GetLogger(r)
337+
338+	user, err := dbpool.FindUserForName(username)
339+	if err != nil {
340+		logger.Infof("blog not found: %s", username)
341+		http.Error(w, "blog not found", http.StatusNotFound)
342+		return
343+	}
344+
345+	blogName := GetBlogName(username)
346+	hostDomain := strings.Split(r.Host, ":")[0]
347+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
348+
349+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
350+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
351+
352+	hasCSS := false
353+	var data PostPageData
354+	post, err := dbpool.FindPostWithFilename(filename, user.ID, cfg.Space)
355+	if err == nil {
356+		parsedText, err := ParseText(post.Text)
357+		if err != nil {
358+			logger.Error(err)
359+		}
360+
361+		// we need the blog name from the readme unfortunately
362+		readme, err := dbpool.FindPostWithFilename("_readme", user.ID, cfg.Space)
363+		if err == nil {
364+			readmeParsed, err := ParseText(readme.Text)
365+			if err != nil {
366+				logger.Error(err)
367+			}
368+			if readmeParsed.MetaData.Title != "" {
369+				blogName = readmeParsed.MetaData.Title
370+			}
371+		}
372+
373+		// we need the blog name from the readme unfortunately
374+		css, err := dbpool.FindPostWithFilename("_styles", user.ID, cfg.Space)
375+		if err == nil {
376+			if len(css.Text) > 0 {
377+				hasCSS = true
378+			}
379+		}
380+
381+		// validate and fire off analytic event
382+		if isRequestTrackable(r) {
383+			_, err := dbpool.AddViewCount(post.ID)
384+			if err != nil {
385+				logger.Error(err)
386+			}
387+		}
388+
389+		data = PostPageData{
390+			Site:         *cfg.GetSiteData(),
391+			PageTitle:    GetPostTitle(post),
392+			URL:          template.URL(cfg.FullPostURL(post.Username, post.Filename, onSubdomain, withUserName)),
393+			BlogURL:      template.URL(cfg.FullBlogURL(username, onSubdomain, withUserName)),
394+			Description:  post.Description,
395+			Title:        FilenameToTitle(post.Filename, post.Title),
396+			PublishAt:    post.PublishAt.Format("02 Jan, 2006"),
397+			PublishAtISO: post.PublishAt.Format(time.RFC3339),
398+			Username:     username,
399+			BlogName:     blogName,
400+			Contents:     template.HTML(parsedText.Html),
401+			HasCSS:       hasCSS,
402+			CssURL:       template.URL(cfg.CssURL(username)),
403+		}
404+	} else {
405+		data = PostPageData{
406+			Site:         *cfg.GetSiteData(),
407+			BlogURL:      template.URL(cfg.FullBlogURL(username, onSubdomain, withUserName)),
408+			PageTitle:    "Post not found",
409+			Description:  "Post not found",
410+			Title:        "Post not found",
411+			PublishAt:    time.Now().Format("02 Jan, 2006"),
412+			PublishAtISO: time.Now().Format(time.RFC3339),
413+			Username:     username,
414+			BlogName:     blogName,
415+			Contents:     "Oops!  we can't seem to find this post.",
416+		}
417+		logger.Infof("post not found %s/%s", username, filename)
418+	}
419+
420+	ts, err := renderTemplate([]string{
421+		"./html/post.page.tmpl",
422+	})
423+
424+	if err != nil {
425+		http.Error(w, err.Error(), http.StatusInternalServerError)
426+	}
427+
428+	err = ts.Execute(w, data)
429+	if err != nil {
430+		logger.Error(err)
431+		http.Error(w, err.Error(), http.StatusInternalServerError)
432+	}
433+}
434+
435+func transparencyHandler(w http.ResponseWriter, r *http.Request) {
436+	dbpool := GetDB(r)
437+	logger := GetLogger(r)
438+	cfg := GetCfg(r)
439+
440+	analytics, err := dbpool.FindSiteAnalytics(cfg.Space)
441+	if err != nil {
442+		logger.Error(err)
443+		http.Error(w, err.Error(), http.StatusInternalServerError)
444+		return
445+	}
446+
447+	ts, err := template.ParseFiles(
448+		"./html/transparency.page.tmpl",
449+		"./html/footer.partial.tmpl",
450+		"./html/marketing-footer.partial.tmpl",
451+		"./html/base.layout.tmpl",
452+	)
453+
454+	if err != nil {
455+		http.Error(w, err.Error(), http.StatusInternalServerError)
456+	}
457+
458+	data := TransparencyPageData{
459+		Site:      *cfg.GetSiteData(),
460+		Analytics: analytics,
461+	}
462+	err = ts.Execute(w, data)
463+	if err != nil {
464+		logger.Error(err)
465+		http.Error(w, err.Error(), http.StatusInternalServerError)
466+	}
467+}
468+
469+func checkHandler(w http.ResponseWriter, r *http.Request) {
470+	dbpool := GetDB(r)
471+	cfg := GetCfg(r)
472+
473+	if cfg.IsCustomdomains() {
474+		hostDomain := r.URL.Query().Get("domain")
475+		appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
476+
477+		if !strings.Contains(hostDomain, appDomain) {
478+			subdomain := GetCustomDomain(hostDomain)
479+			if subdomain != "" {
480+				u, err := dbpool.FindUserForName(subdomain)
481+				if u != nil && err == nil {
482+					w.WriteHeader(http.StatusOK)
483+					return
484+				}
485+			}
486+		}
487+	}
488+
489+	w.WriteHeader(http.StatusNotFound)
490+}
491+
492+func readHandler(w http.ResponseWriter, r *http.Request) {
493+	dbpool := GetDB(r)
494+	logger := GetLogger(r)
495+	cfg := GetCfg(r)
496+
497+	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
498+	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 30, Page: page}, cfg.Space)
499+	if err != nil {
500+		logger.Error(err)
501+		http.Error(w, err.Error(), http.StatusInternalServerError)
502+		return
503+	}
504+
505+	ts, err := renderTemplate([]string{
506+		"./html/read.page.tmpl",
507+	})
508+
509+	if err != nil {
510+		http.Error(w, err.Error(), http.StatusInternalServerError)
511+	}
512+
513+	nextPage := ""
514+	if page < pager.Total-1 {
515+		nextPage = fmt.Sprintf("/read?page=%d", page+1)
516+	}
517+
518+	prevPage := ""
519+	if page > 0 {
520+		prevPage = fmt.Sprintf("/read?page=%d", page-1)
521+	}
522+
523+	data := ReadPageData{
524+		Site:     *cfg.GetSiteData(),
525+		NextPage: nextPage,
526+		PrevPage: prevPage,
527+	}
528+	for _, post := range pager.Data {
529+		item := PostItemData{
530+			URL:            template.URL(cfg.FullPostURL(post.Username, post.Filename, true, true)),
531+			BlogURL:        template.URL(cfg.FullBlogURL(post.Username, true, true)),
532+			Title:          FilenameToTitle(post.Filename, post.Title),
533+			Description:    post.Description,
534+			Username:       post.Username,
535+			PublishAt:      post.PublishAt.Format("02 Jan, 2006"),
536+			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
537+			UpdatedTimeAgo: TimeAgo(post.UpdatedAt),
538+			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
539+			Score:          post.Score,
540+		}
541+		data.Posts = append(data.Posts, item)
542+	}
543+
544+	err = ts.Execute(w, data)
545+	if err != nil {
546+		logger.Error(err)
547+		http.Error(w, err.Error(), http.StatusInternalServerError)
548+	}
549+}
550+
551+func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
552+	username := GetUsernameFromRequest(r)
553+	dbpool := GetDB(r)
554+	logger := GetLogger(r)
555+	cfg := GetCfg(r)
556+
557+	user, err := dbpool.FindUserForName(username)
558+	if err != nil {
559+		logger.Infof("rss feed not found: %s", username)
560+		http.Error(w, "rss feed not found", http.StatusNotFound)
561+		return
562+	}
563+	posts, err := dbpool.FindPostsForUser(user.ID, cfg.Space)
564+	if err != nil {
565+		logger.Error(err)
566+		http.Error(w, err.Error(), http.StatusInternalServerError)
567+		return
568+	}
569+
570+	ts, err := template.ParseFiles("./html/rss.page.tmpl")
571+	if err != nil {
572+		logger.Error(err)
573+		http.Error(w, err.Error(), http.StatusInternalServerError)
574+		return
575+	}
576+
577+	headerTxt := &HeaderTxt{
578+		Title: GetBlogName(username),
579+	}
580+
581+	for _, post := range posts {
582+		if post.Filename == "_readme" {
583+			parsedText, err := ParseText(post.Text)
584+			if err != nil {
585+				logger.Error(err)
586+			}
587+			if parsedText.Title != "" {
588+				headerTxt.Title = parsedText.Title
589+			}
590+
591+			if parsedText.Description != "" {
592+				headerTxt.Bio = parsedText.Description
593+			}
594+
595+			break
596+		}
597+	}
598+
599+	hostDomain := strings.Split(r.Host, ":")[0]
600+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
601+
602+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
603+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
604+
605+	feed := &feeds.Feed{
606+		Title:       headerTxt.Title,
607+		Link:        &feeds.Link{Href: cfg.FullBlogURL(username, onSubdomain, withUserName)},
608+		Description: headerTxt.Bio,
609+		Author:      &feeds.Author{Name: username},
610+		Created:     time.Now(),
611+	}
612+
613+	var feedItems []*feeds.Item
614+	for _, post := range posts {
615+		if slices.Contains(hiddenPosts, post.Filename) {
616+			continue
617+		}
618+		parsed, err := ParseText(post.Text)
619+		if err != nil {
620+			logger.Error(err)
621+		}
622+		var tpl bytes.Buffer
623+		data := &PostPageData{
624+			Contents: template.HTML(parsed.Html),
625+		}
626+		if err := ts.Execute(&tpl, data); err != nil {
627+			continue
628+		}
629+
630+		realUrl := cfg.FullPostURL(post.Username, post.Filename, onSubdomain, withUserName)
631+		if !onSubdomain && !withUserName {
632+			realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
633+		}
634+
635+		item := &feeds.Item{
636+			Id:      realUrl,
637+			Title:   FilenameToTitle(post.Filename, post.Title),
638+			Link:    &feeds.Link{Href: realUrl},
639+			Content: tpl.String(),
640+			Created: *post.PublishAt,
641+		}
642+
643+		if post.Description != "" {
644+			item.Description = post.Description
645+		}
646+
647+		feedItems = append(feedItems, item)
648+	}
649+	feed.Items = feedItems
650+
651+	rss, err := feed.ToAtom()
652+	if err != nil {
653+		logger.Fatal(err)
654+		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
655+	}
656+
657+	w.Header().Add("Content-Type", "application/atom+xml")
658+	_, err = w.Write([]byte(rss))
659+	if err != nil {
660+		logger.Error(err)
661+	}
662+}
663+
664+func rssHandler(w http.ResponseWriter, r *http.Request) {
665+	dbpool := GetDB(r)
666+	logger := GetLogger(r)
667+	cfg := GetCfg(r)
668+
669+	pager, err := dbpool.FindAllPosts(&db.Pager{Num: 25, Page: 0}, cfg.Space)
670+	if err != nil {
671+		logger.Error(err)
672+		http.Error(w, err.Error(), http.StatusInternalServerError)
673+		return
674+	}
675+
676+	ts, err := template.ParseFiles("./html/rss.page.tmpl")
677+	if err != nil {
678+		logger.Error(err)
679+		http.Error(w, err.Error(), http.StatusInternalServerError)
680+		return
681+	}
682+
683+	feed := &feeds.Feed{
684+		Title:       fmt.Sprintf("%s discovery feed", cfg.Domain),
685+		Link:        &feeds.Link{Href: cfg.ReadURL()},
686+		Description: fmt.Sprintf("%s latest posts", cfg.Domain),
687+		Author:      &feeds.Author{Name: cfg.Domain},
688+		Created:     time.Now(),
689+	}
690+
691+	hostDomain := strings.Split(r.Host, ":")[0]
692+	appDomain := strings.Split(cfg.ConfigCms.Domain, ":")[0]
693+
694+	onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
695+	withUserName := (!onSubdomain && hostDomain == appDomain) || !cfg.IsCustomdomains()
696+
697+	var feedItems []*feeds.Item
698+	for _, post := range pager.Data {
699+		parsed, err := ParseText(post.Text)
700+		if err != nil {
701+			logger.Error(err)
702+		}
703+
704+		var tpl bytes.Buffer
705+		data := &PostPageData{
706+			Contents: template.HTML(parsed.Html),
707+		}
708+		if err := ts.Execute(&tpl, data); err != nil {
709+			continue
710+		}
711+
712+		realUrl := cfg.FullPostURL(post.Username, post.Filename, onSubdomain, withUserName)
713+		if !onSubdomain && !withUserName {
714+			realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
715+		}
716+
717+		item := &feeds.Item{
718+			Id:      realUrl,
719+			Title:   post.Title,
720+			Link:    &feeds.Link{Href: realUrl},
721+			Content: tpl.String(),
722+			Created: *post.PublishAt,
723+		}
724+
725+		if post.Description != "" {
726+			item.Description = post.Description
727+		}
728+
729+		feedItems = append(feedItems, item)
730+	}
731+	feed.Items = feedItems
732+
733+	rss, err := feed.ToAtom()
734+	if err != nil {
735+		logger.Fatal(err)
736+		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
737+	}
738+
739+	w.Header().Add("Content-Type", "application/atom+xml")
740+	_, err = w.Write([]byte(rss))
741+	if err != nil {
742+		logger.Error(err)
743+	}
744+}
745+
746+func serveFile(file string, contentType string) http.HandlerFunc {
747+	return func(w http.ResponseWriter, r *http.Request) {
748+		logger := GetLogger(r)
749+
750+		contents, err := ioutil.ReadFile(fmt.Sprintf("./public/%s", file))
751+		if err != nil {
752+			logger.Error(err)
753+			http.Error(w, "file not found", 404)
754+		}
755+		w.Header().Add("Content-Type", contentType)
756+
757+		_, err = w.Write(contents)
758+		if err != nil {
759+			logger.Error(err)
760+			http.Error(w, "server error", 500)
761+		}
762+	}
763+}
764+
765+func createStaticRoutes() []Route {
766+	return []Route{
767+		NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
768+		NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
769+		NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
770+		NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
771+		NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
772+		NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
773+		NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
774+		NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
775+	}
776+}
777+
778+func createMainRoutes(staticRoutes []Route) []Route {
779+	routes := []Route{
780+		NewRoute("GET", "/", createPageHandler("./html/marketing.page.tmpl")),
781+		NewRoute("GET", "/spec", createPageHandler("./html/spec.page.tmpl")),
782+		NewRoute("GET", "/ops", createPageHandler("./html/ops.page.tmpl")),
783+		NewRoute("GET", "/privacy", createPageHandler("./html/privacy.page.tmpl")),
784+		NewRoute("GET", "/help", createPageHandler("./html/help.page.tmpl")),
785+		NewRoute("GET", "/transparency", transparencyHandler),
786+		NewRoute("GET", "/read", readHandler),
787+		NewRoute("GET", "/check", checkHandler),
788+	}
789+
790+	routes = append(
791+		routes,
792+		staticRoutes...,
793+	)
794+
795+	routes = append(
796+		routes,
797+		NewRoute("GET", "/rss", rssHandler),
798+		NewRoute("GET", "/rss.xml", rssHandler),
799+		NewRoute("GET", "/atom.xml", rssHandler),
800+		NewRoute("GET", "/feed.xml", rssHandler),
801+
802+		NewRoute("GET", "/([^/]+)", blogHandler),
803+		NewRoute("GET", "/([^/]+)/rss", rssBlogHandler),
804+		NewRoute("GET", "/([^/]+)/styles.css", blogStyleHandler),
805+		NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
806+		NewRoute("GET", "/raw/([^/]+)/([^/]+)", postRawHandler),
807+	)
808+
809+	return routes
810+}
811+
812+func createSubdomainRoutes(staticRoutes []Route) []Route {
813+	routes := []Route{
814+		NewRoute("GET", "/", blogHandler),
815+		NewRoute("GET", "/_styles.css", blogStyleHandler),
816+		NewRoute("GET", "/rss", rssBlogHandler),
817+	}
818+
819+	routes = append(
820+		routes,
821+		staticRoutes...,
822+	)
823+
824+	routes = append(
825+		routes,
826+		NewRoute("GET", "/([^/]+)", postHandler),
827+		NewRoute("GET", "/raw/([^/]+)", postRawHandler),
828+	)
829+
830+	return routes
831+}
832+
833+func StartApiServer() {
834+	cfg := NewConfigSite()
835+	db := postgres.NewDB(&cfg.ConfigCms)
836+	defer db.Close()
837+	logger := cfg.Logger
838+
839+	staticRoutes := createStaticRoutes()
840+	mainRoutes := createMainRoutes(staticRoutes)
841+	subdomainRoutes := createSubdomainRoutes(staticRoutes)
842+
843+	handler := CreateServe(mainRoutes, subdomainRoutes, cfg, db, logger)
844+	router := http.HandlerFunc(handler)
845+
846+	portStr := fmt.Sprintf(":%s", cfg.Port)
847+	logger.Infof("Starting server on port %s", cfg.Port)
848+	logger.Infof("Subdomains enabled: %t", cfg.SubdomainsEnabled)
849+	logger.Infof("Domain: %s", cfg.Domain)
850+	logger.Infof("Email: %s", cfg.Email)
851+
852+	logger.Fatal(http.ListenAndServe(portStr, router))
853+}
A prose/config.go
+169, -0
  1@@ -0,0 +1,169 @@
  2+package internal
  3+
  4+import (
  5+	"fmt"
  6+	"html/template"
  7+	"log"
  8+	"net/url"
  9+
 10+	"git.sr.ht/~erock/wish/cms/config"
 11+	"go.uber.org/zap"
 12+)
 13+
 14+type SitePageData struct {
 15+	Domain  template.URL
 16+	HomeURL template.URL
 17+	Email   string
 18+}
 19+
 20+type ConfigSite struct {
 21+	config.ConfigCms
 22+	config.ConfigURL
 23+	SubdomainsEnabled    bool
 24+	CustomdomainsEnabled bool
 25+}
 26+
 27+func NewConfigSite() *ConfigSite {
 28+	domain := GetEnv("PROSE_DOMAIN", "prose.sh")
 29+	email := GetEnv("PROSE_EMAIL", "hello@prose.sh")
 30+	subdomains := GetEnv("PROSE_SUBDOMAINS", "0")
 31+	customdomains := GetEnv("PROSE_CUSTOMDOMAINS", "0")
 32+	port := GetEnv("PROSE_WEB_PORT", "3000")
 33+	protocol := GetEnv("PROSE_PROTOCOL", "https")
 34+	dbURL := GetEnv("DATABASE_URL", "")
 35+	subdomainsEnabled := false
 36+	if subdomains == "1" {
 37+		subdomainsEnabled = true
 38+	}
 39+
 40+	customdomainsEnabled := false
 41+	if customdomains == "1" {
 42+		customdomainsEnabled = true
 43+	}
 44+
 45+	intro := "To get started, enter a username.\n"
 46+	intro += "Then create a folder locally (e.g. ~/blog).\n"
 47+	intro += "Then write your post in markdown files (e.g. hello-world.md).\n"
 48+	intro += "Finally, send your files to us:\n\n"
 49+	intro += fmt.Sprintf("scp ~/blog/*.md %s:/", domain)
 50+
 51+	return &ConfigSite{
 52+		SubdomainsEnabled:    subdomainsEnabled,
 53+		CustomdomainsEnabled: customdomainsEnabled,
 54+		ConfigCms: config.ConfigCms{
 55+			Domain:      domain,
 56+			Email:       email,
 57+			Port:        port,
 58+			Protocol:    protocol,
 59+			DbURL:       dbURL,
 60+			Description: "a blog platform for hackers.",
 61+			IntroText:   intro,
 62+			Space:       "prose",
 63+			Logger:      CreateLogger(),
 64+		},
 65+	}
 66+}
 67+
 68+func (c *ConfigSite) GetSiteData() *SitePageData {
 69+	return &SitePageData{
 70+		Domain:  template.URL(c.Domain),
 71+		HomeURL: template.URL(c.HomeURL()),
 72+		Email:   c.Email,
 73+	}
 74+}
 75+
 76+func (c *ConfigSite) BlogURL(username string) string {
 77+	if c.IsSubdomains() {
 78+		return fmt.Sprintf("%s://%s.%s", c.Protocol, username, c.Domain)
 79+	}
 80+
 81+	return fmt.Sprintf("/%s", username)
 82+}
 83+
 84+func (c *ConfigSite) FullBlogURL(username string, onSubdomain bool, withUserName bool) string {
 85+	if c.IsSubdomains() && onSubdomain {
 86+		return fmt.Sprintf("%s://%s.%s", c.Protocol, username, c.Domain)
 87+	}
 88+
 89+	if withUserName {
 90+		return fmt.Sprintf("/%s", username)
 91+	}
 92+
 93+	return "/"
 94+}
 95+
 96+func (c *ConfigSite) PostURL(username, filename string) string {
 97+	fname := url.PathEscape(filename)
 98+	if c.IsSubdomains() {
 99+		return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
100+	}
101+
102+	return fmt.Sprintf("/%s/%s", username, fname)
103+
104+}
105+
106+func (c *ConfigSite) FullPostURL(username, filename string, onSubdomain bool, withUserName bool) string {
107+	fname := url.PathEscape(filename)
108+	if c.IsSubdomains() && onSubdomain {
109+		return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
110+	}
111+
112+	if withUserName {
113+		return fmt.Sprintf("/%s/%s", username, fname)
114+	}
115+
116+	return fmt.Sprintf("/%s", fname)
117+}
118+
119+func (c *ConfigSite) IsSubdomains() bool {
120+	return c.SubdomainsEnabled
121+}
122+
123+func (c *ConfigSite) IsCustomdomains() bool {
124+	return c.CustomdomainsEnabled
125+}
126+
127+func (c *ConfigSite) RssBlogURL(username string, onSubdomain bool, withUserName bool) string {
128+	if c.IsSubdomains() && onSubdomain {
129+		return fmt.Sprintf("%s://%s.%s/rss", c.Protocol, username, c.Domain)
130+	}
131+
132+	if withUserName {
133+		return fmt.Sprintf("/%s/rss", username)
134+	}
135+
136+	return "/rss"
137+}
138+
139+func (c *ConfigSite) HomeURL() string {
140+	if c.IsSubdomains() || c.IsCustomdomains() {
141+		return fmt.Sprintf("//%s", c.Domain)
142+	}
143+
144+	return "/"
145+}
146+
147+func (c *ConfigSite) ReadURL() string {
148+	if c.IsSubdomains() || c.IsCustomdomains() {
149+		return fmt.Sprintf("%s://%s/read", c.Protocol, c.Domain)
150+	}
151+
152+	return "/read"
153+}
154+
155+func (c *ConfigSite) CssURL(username string) string {
156+	if c.IsSubdomains() || c.IsCustomdomains() {
157+		return fmt.Sprintf("%s://%s.%s/_styles.css", c.Protocol, username, c.Domain)
158+	}
159+
160+	return fmt.Sprintf("/%s/styles.css", username)
161+}
162+
163+func CreateLogger() *zap.SugaredLogger {
164+	logger, err := zap.NewProduction()
165+	if err != nil {
166+		log.Fatal(err)
167+	}
168+
169+	return logger.Sugar()
170+}
A prose/db_handler.go
+143, -0
  1@@ -0,0 +1,143 @@
  2+package internal
  3+
  4+import (
  5+	"fmt"
  6+	"io"
  7+	"strings"
  8+	"time"
  9+
 10+	"git.sr.ht/~erock/wish/cms/db"
 11+	"git.sr.ht/~erock/wish/cms/util"
 12+	"git.sr.ht/~erock/wish/send/utils"
 13+	"github.com/gliderlabs/ssh"
 14+	"golang.org/x/exp/slices"
 15+)
 16+
 17+var hiddenPosts = []string{"_readme.md", "_styles.css"}
 18+
 19+type Opener struct {
 20+	entry *utils.FileEntry
 21+}
 22+
 23+func (o *Opener) Open(name string) (io.Reader, error) {
 24+	return o.entry.Reader, nil
 25+}
 26+
 27+type DbHandler struct {
 28+	User   *db.User
 29+	DBPool db.DB
 30+	Cfg    *ConfigSite
 31+}
 32+
 33+func NewDbHandler(dbpool db.DB, cfg *ConfigSite) *DbHandler {
 34+	return &DbHandler{
 35+		DBPool: dbpool,
 36+		Cfg:    cfg,
 37+	}
 38+}
 39+
 40+func (h *DbHandler) Validate(s ssh.Session) error {
 41+	var err error
 42+	key, err := util.KeyText(s)
 43+	if err != nil {
 44+		return fmt.Errorf("key not found")
 45+	}
 46+
 47+	user, err := h.DBPool.FindUserForKey(s.User(), key)
 48+	if err != nil {
 49+		return err
 50+	}
 51+
 52+	if user.Name == "" {
 53+		return fmt.Errorf("must have username set")
 54+	}
 55+
 56+	h.User = user
 57+	return nil
 58+}
 59+
 60+func (h *DbHandler) Write(s ssh.Session, entry *utils.FileEntry) (string, error) {
 61+	logger := h.Cfg.Logger
 62+	userID := h.User.ID
 63+	filename := SanitizeFileExt(entry.Name)
 64+	title := filename
 65+	var err error
 66+	post, err := h.DBPool.FindPostWithFilename(filename, userID, h.Cfg.Space)
 67+	if err != nil {
 68+		logger.Debug("unable to load post, continuing:", err)
 69+	}
 70+
 71+	user, err := h.DBPool.FindUser(userID)
 72+	if err != nil {
 73+		return "", fmt.Errorf("error for %s: %v", filename, err)
 74+	}
 75+
 76+	var text string
 77+	if b, err := io.ReadAll(entry.Reader); err == nil {
 78+		text = string(b)
 79+	}
 80+
 81+	if !IsTextFile(text, entry.Filepath) {
 82+		extStr := strings.Join(allowedExtensions, ",")
 83+		logger.Errorf("WARNING: (%s) invalid file, format must be (%s) and the contents must be plain text, skipping", entry.Name, extStr)
 84+		return "", fmt.Errorf("WARNING: (%s) invalid file, format must be (%s) and the contents must be plain text, skipping", entry.Name, extStr)
 85+	}
 86+
 87+	parsedText, err := ParseText(text)
 88+	if err != nil {
 89+		logger.Errorf("error for %s: %v", filename, err)
 90+		return "", fmt.Errorf("error for %s: %v", filename, err)
 91+	}
 92+
 93+	if parsedText.MetaData.Title != "" {
 94+		title = parsedText.MetaData.Title
 95+	}
 96+	description := parsedText.MetaData.Description
 97+
 98+	// if the file is empty we remove it from our database
 99+	if len(text) == 0 {
100+		// skip empty files from being added to db
101+		if post == nil {
102+			logger.Infof("(%s) is empty, skipping record", filename)
103+			return "", nil
104+		}
105+
106+		err := h.DBPool.RemovePosts([]string{post.ID})
107+		logger.Infof("(%s) is empty, removing record", filename)
108+		if err != nil {
109+			logger.Errorf("error for %s: %v", filename, err)
110+			return "", fmt.Errorf("error for %s: %v", filename, err)
111+		}
112+	} else if post == nil {
113+		publishAt := time.Now()
114+		if parsedText.MetaData.PublishAt != nil && !parsedText.MetaData.PublishAt.IsZero() {
115+			publishAt = *parsedText.MetaData.PublishAt
116+		}
117+		hidden := slices.Contains(hiddenPosts, entry.Name)
118+
119+		logger.Infof("(%s) not found, adding record", filename)
120+		_, err = h.DBPool.InsertPost(userID, filename, title, text, description, &publishAt, hidden, h.Cfg.Space)
121+		if err != nil {
122+			logger.Errorf("error for %s: %v", filename, err)
123+			return "", fmt.Errorf("error for %s: %v", filename, err)
124+		}
125+	} else {
126+		publishAt := post.PublishAt
127+		if parsedText.MetaData.PublishAt != nil {
128+			publishAt = parsedText.MetaData.PublishAt
129+		}
130+		if text == post.Text {
131+			logger.Infof("(%s) found, but text is identical, skipping", filename)
132+			return h.Cfg.FullPostURL(user.Name, filename, h.Cfg.IsSubdomains(), true), nil
133+		}
134+
135+		logger.Infof("(%s) found, updating record", filename)
136+		_, err = h.DBPool.UpdatePost(post.ID, title, text, description, publishAt)
137+		if err != nil {
138+			logger.Errorf("error for %s: %v", filename, err)
139+			return "", fmt.Errorf("error for %s: %v", filename, err)
140+		}
141+	}
142+
143+	return h.Cfg.FullPostURL(user.Name, filename, h.Cfg.IsSubdomains(), true), nil
144+}
A prose/html/base.layout.tmpl
+18, -0
 1@@ -0,0 +1,18 @@
 2+{{define "base"}}
 3+<!doctype html>
 4+<html lang="en">
 5+    <head>
 6+        <meta charset='utf-8'>
 7+        <meta name="viewport" content="width=device-width, initial-scale=1" />
 8+        <title>{{template "title" .}}</title>
 9+
10+        <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
11+
12+        <meta name="keywords" content="blog, blogging, write, writing" />
13+        {{template "meta" .}}
14+
15+        <link rel="stylesheet" href="/main.css" />
16+    </head>
17+    <body {{template "attrs" .}}>{{template "body" .}}</body>
18+</html>
19+{{end}}
A prose/html/blog.page.tmpl
+65, -0
 1@@ -0,0 +1,65 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{if .Header.Bio}}{{.Header.Bio}}{{else}}{{.Header.Title}}{{end}}" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="{{.URL}}">
12+<meta property="og:title" content="{{.Header.Title}}">
13+{{if .Header.Bio}}<meta property="og:description" content="{{.Header.Bio}}">{{end}}
14+<meta property="og:image:width" content="300" />
15+<meta property="og:image:height" content="300" />
16+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18+
19+<meta property="twitter:card" content="summary">
20+<meta property="twitter:url" content="{{.URL}}">
21+<meta property="twitter:title" content="{{.Header.Title}}">
22+{{if .Header.Bio}}<meta property="twitter:description" content="{{.Header.Bio}}">{{end}}
23+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25+
26+<link rel="stylesheet" href="/syntax.css" />
27+{{if .HasCSS}}<link rel="stylesheet" href="{{.CssURL}}" />{{end}}
28+{{end}}
29+
30+{{define "attrs"}}id="blog"{{end}}
31+
32+{{define "body"}}
33+<header class="text-center">
34+    <h1 class="text-2xl font-bold">{{.Header.Title}}</h1>
35+    {{if .Header.Bio}}<p class="text-lg">{{.Header.Bio}}</p>{{end}}
36+    <nav>
37+        {{range .Header.Nav}}
38+        <a href="{{.URL}}" class="text-lg">{{.Text}}</a> |
39+        {{end}}
40+        <a href="{{.RSSURL}}" class="text-lg">rss</a>
41+    </nav>
42+    <hr />
43+</header>
44+<main>
45+    {{if .Readme.HasText}}
46+    <section>
47+        <article class="md">
48+            {{.Readme.Contents}}
49+        </article>
50+        <hr />
51+    </section>
52+    {{end}}
53+
54+    <section class="posts">
55+        {{range .Posts}}
56+        <article>
57+            <div class="flex items-center">
58+                <time datetime="{{.PublishAtISO}}" class="font-italic text-sm post-date">{{.PublishAt}}</time>
59+                <h2 class="font-bold flex-1"><a href="{{.URL}}">{{.Title}}</a></h2>
60+            </div>
61+        </article>
62+        {{end}}
63+    </section>
64+</main>
65+{{template "footer" .}}
66+{{end}}
A prose/html/footer.partial.tmpl
+6, -0
1@@ -0,0 +1,6 @@
2+{{define "footer"}}
3+<footer>
4+    <hr />
5+    published with <a href={{.Site.HomeURL}}>{{.Site.Domain}}</a>
6+</footer>
7+{{end}}
A prose/html/help.page.tmpl
+277, -0
  1@@ -0,0 +1,277 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}help -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="questions and answers" />
  8+{{end}}
  9+
 10+{{define "attrs"}}{{end}}
 11+
 12+{{define "body"}}
 13+<header>
 14+    <h1 class="text-2xl">Need help?</h1>
 15+    <p>Here are some common questions on using this platform that we would like to answer.</p>
 16+</header>
 17+<main>
 18+    <section id="permission-denied">
 19+        <h2 class="text-xl">
 20+            <a href="#permission-denied" rel="nofollow noopener">#</a>
 21+            I get a permission denied when trying to SSH
 22+        </h2>
 23+        <p>
 24+            Unfortunately SHA-2 RSA keys are <strong>not</strong> currently supported.
 25+        </p>
 26+        <p>
 27+            Unfortunately, due to a shortcoming in Go’s x/crypto/ssh package, we
 28+            not currently support access via new SSH RSA keys: only the old SHA-1 ones will work.
 29+            Until we sort this out you’ll either need an SHA-1 RSA key or a key with another
 30+            algorithm, e.g. Ed25519. Not sure what type of keys you have? You can check with the
 31+            following:
 32+        </p>
 33+        <pre>$ find ~/.ssh/id_*.pub -exec ssh-keygen -l -f {} \;</pre>
 34+        <p>If you’re curious about the inner workings of this problem have a look at:</p>
 35+        <ul>
 36+            <li><a href="https://github.com/golang/go/issues/37278">golang/go#37278</a></li>
 37+            <li><a href="https://go-review.googlesource.com/c/crypto/+/220037">go-review</a></li>
 38+            <li><a href="https://github.com/golang/crypto/pull/197">golang/crypto#197</a></li>
 39+        </ul>
 40+    </section>
 41+
 42+    <section id="blog-ssh-key">
 43+        <h2 class="text-xl">
 44+            <a href="#blog-ssh-key" rel="nofollow noopener">#</a>
 45+            Generating a new SSH key
 46+        </h2>
 47+        <p>
 48+            <a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent">Github reference</a>
 49+        </p>
 50+        <pre>ssh-keygen -t ed25519 -C "your_email@example.com"</pre>
 51+        <ol>
 52+            <li>When you're prompted to "Enter a file in which to save the key," press Enter. This accepts the default file location.</li>
 53+            <li>At the prompt, type a secure passphrase.</li>
 54+        </ol>
 55+    </section>
 56+
 57+    <section id="blog-structure">
 58+        <h2 class="text-xl">
 59+            <a href="#blog-structure" rel="nofollow noopener">#</a>
 60+            What should my blog folder look like?
 61+        </h2>
 62+        <p>
 63+            Currently {{.Site.Domain}} only supports a flat folder structure.  Therefore,
 64+            <code>scp -r</code> is not permitted.  We also only allow <code>.md</code> files to be
 65+            uploaded.
 66+        </p>
 67+        <p>
 68+            <a href="https://github.com/neurosnap/prose-blog">Here is the source to my blog on this platform</a>
 69+        </p>
 70+        <p>
 71+        Below is an example of what your blog folder should look like:
 72+        </p>
 73+            <pre>blog/
 74+first-post.md
 75+second-post.md
 76+third-post.md</pre>
 77+        </p>
 78+        <p>
 79+            Underscores and hyphens are permitted and will be automatically removed from the title of the post.
 80+        </p>
 81+    </section>
 82+
 83+    <section id="post-metadata">
 84+        <h2 class="text-xl">
 85+            <a href="#post-metadata" rel="nofollow noopener">#</a>
 86+            How do I update metadata like publish date and title?
 87+        </h2>
 88+        <p>
 89+        We support adding frontmatter to the top of your markdown posts.  A frontmatter looks like the following:
 90+        <pre>---
 91+title: some title!
 92+description: this is a great description
 93+date: 2022-06-28
 94+---</pre>
 95+        </p>
 96+        <p>
 97+            List of available variables:
 98+            <ul>
 99+                <li>title (custom title not dependent on filename)</li>
100+                <li>description (what is the purpose of this post?  It's also added to meta tag)</li>
101+                <li>date (format must be YYYY-MM-DD)</li>
102+            </ul>
103+        </p>
104+    </section>
105+
106+    <section id="post-update">
107+        <h2 class="text-xl">
108+            <a href="#post-update" rel="nofollow noopener">#</a>
109+            How do I update a post?
110+        </h2>
111+        <p>
112+            Updating a post requires that you update the source document and then run the <code>scp</code>
113+            command again.  If the filename remains the same, then the post will be updated.
114+        </p>
115+    </section>
116+
117+    <section id="post-delete">
118+        <h2 class="text-xl">
119+            <a href="#post-delete" rel="nofollow noopener">#</a>
120+            How do I delete a post?
121+        </h2>
122+        <p>
123+            Because <code>scp</code> does not natively support deleting files, I didn't want to bake
124+            that behavior into my ssh server.
125+        </p>
126+
127+        <p>
128+            However, if a user wants to delete a post they can delete the contents of the file and
129+            then upload it to our server.  If the file contains 0 bytes, we will remove the post.
130+            For example, if you want to delete <code>delete.md</code> you could:
131+        </p>
132+
133+        <pre>
134+cp /dev/null delete.md
135+scp ./delete.md {{.Site.Domain}}:/</pre>
136+
137+        <p>
138+            Alternatively, you can go to <code>ssh {{.Site.Domain}}</code> and select "Manage posts."
139+            Then you can highlight the post you want to delete and then press "X."  It will ask for
140+            confirmation before actually removing the post.
141+        </p>
142+    </section>
143+
144+    <section id="blog-upload-single-file">
145+        <h2 class="text-xl">
146+            <a href="#blog-upload-single-file" rel="nofollow noopener">#</a>
147+            When I want to publish a new post, do I have to upload all posts everytime?
148+        </h2>
149+        <p>
150+            Nope!  Just <code>scp</code> the file you want to publish.  For example, if you created
151+            a new post called <code>taco-tuesday.md</code> then you would publish it like this:
152+        </p>
153+        <pre>scp ./taco-tuesday.md {{.Site.Domain}}:</pre>
154+    </section>
155+
156+    <section id="blog-readme">
157+        <h2 class="text-xl">
158+            <a href="#blog-readme" rel="nofollow noopener">#</a>
159+            How can I customize my blog page?
160+        </h2>
161+        <p>
162+        There's a special file you can upload `_readme.md` which will allow
163+        users to add a bio and links to their blog landing page.
164+        <pre>---
165+title: some title!
166+description: this is a great description
167+nav:
168+    - google: https://google.com
169+    - site: https://some.site
170+---
171+
172+Here is a quick intro to my personal blog!
173+This will show up on the blog landing page.
174+</pre>
175+        </p>
176+        <p>
177+            List of available variables:
178+            <ul>
179+                <li>title (name of the blog, default: "X's blog")</li>
180+                <li>description (description of blog)</li>
181+                <li>nav (key=value pair that corresponds to text=href in html)</li>
182+            </ul>
183+        </p>
184+    </section>
185+
186+    <section id="blog-style">
187+        <h2 class="text-xl">
188+            <a href="#blog-style" rel="nofollow noopener">#</a>
189+            How can I change the theme of my blog?
190+        </h2>
191+        <p>
192+            There's a special file you can upload `_styles.css` which will allow
193+            users to add a CSS file to their page.  It will be the final CSS file
194+            loaded on the page so it will overwrite whatever styles have previously
195+            been added.  We've also added a couple of convenience id's attached to the
196+            body element for the blog and post pages.
197+        </p>
198+        <pre>/* _styles.css */
199+#post {
200+    color: green;
201+}
202+
203+#blog {
204+    color: tomato;
205+}</pre>
206+        <p>Then just upload the file:</p>
207+        <pre>scp _styles.css <username>@prose.sh:/</pre>
208+    </section>
209+
210+    <section id="blog-url">
211+        <h2 class="text-xl">
212+            <a href="#blog-url" rel="nofollow noopener">#</a>
213+            What is my blog URL?
214+        </h2>
215+        <pre>https://{username}.{{.Site.Domain}}</pre>
216+    </section>
217+
218+    <section id="continuous-deployment">
219+        <h2 class="text-xl">
220+            <a href="#continuous-deployment" rel="nofollow noopener">#</a>
221+            How can I automatically publish my post?
222+        </h2>
223+        <p>
224+            There is a github action that we built to make it easy to publish your blog automatically.
225+        </p>
226+        <ul>
227+            <li>
228+                <a href="https://github.com/marketplace/actions/scp-publish-action">github marketplace</a>
229+            </li>
230+            <li>
231+                <a href="https://github.com/neurosnap/lists-official-blog/blob/main/.github/workflows/publish.yml">example workflow</a>
232+            </li>
233+        </ul>
234+        <p>
235+            A user also created a systemd task to automatically publish new posts.  <a href="https://github.com/neurosnap/lists.sh/discussions/24">Check out this github discussion for more details.</a>
236+        </p>
237+    </section>
238+
239+    <section id="multiple-accounts">
240+        <h2 class="text-xl">
241+            <a href="#multiple-accounts" rel="nofollow noopener">#</a>
242+            Can I create multiple accounts?
243+        </h2>
244+        <p>
245+           Yes!  You can either a) create a new keypair and use that for authentication
246+           or b) use the same keypair and ssh into our CMS using our special username
247+           <code>ssh new@{{.Site.Domain}}</code>.
248+        </p>
249+        <p>
250+            Please note that if you use the same keypair for multiple accounts, you will need to
251+            always specify the user when logging into our CMS.
252+        </p>
253+    </section>
254+
255+    <section id="custom-domain">
256+        <h2 class="text-xl">
257+            <a href="#custom-domain" rel="nofollow noopener">#</a>
258+            Setup a custom domain
259+        </h2>
260+        <p>
261+            A blog can be accessed from a custom domain.
262+            HTTPS will be automatically enabled and a certificate will be retrieved
263+            from <a href="https://letsencrypt.org/">Let's Encrypt</a>. In order for this to work,
264+            2 DNS records need to be created:
265+        </p>
266+
267+        <p>CNAME for the domain to prose (subdomains or DNS hosting with CNAME flattening) or A record</p>
268+        <pre>CNAME subdomain.yourcustomdomain.com -> prose.sh</pre>
269+        <p>Resulting in:</p>
270+        <pre>subdomain.yourcustomdomain.com.         300     IN      CNAME   prose.sh.</pre>
271+        <p>And a TXT record to tell Prose what blog is hosted on that domain at the subdomain entry _prose</p>
272+        <pre>TXT _prose.subdomain.yourcustomdomain.com -> yourproseusername</pre>
273+        <p>Resulting in:</p>
274+        <pre>_prose.subdomain.yourcustomdomain.com.         300     IN      TXT     "hey"</pre>
275+    </section>
276+</main>
277+{{template "marketing-footer" .}}
278+{{end}}
A prose/html/marketing-footer.partial.tmpl
+13, -0
 1@@ -0,0 +1,13 @@
 2+{{define "marketing-footer"}}
 3+<footer>
 4+    <hr />
 5+    <p class="font-italic">Built and maintained by <a href="https://pico.sh">pico.sh</a>.</p>
 6+    <div>
 7+        <a href="/">home</a> |
 8+        <a href="/ops">ops</a> |
 9+        <a href="/help">help</a> |
10+        <a href="/rss">rss</a> |
11+        <a href="https://git.sr.ht/~erock/prose.sh">source</a>
12+    </div>
13+</footer>
14+{{end}}
A prose/html/marketing.page.tmpl
+147, -0
  1@@ -0,0 +1,147 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}{{.Site.Domain}} -- a blog platform for hackers{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="a blog platform for hackers" />
  8+
  9+<meta property="og:type" content="website">
 10+<meta property="og:site_name" content="{{.Site.Domain}}">
 11+<meta property="og:url" content="https://{{.Site.Domain}}">
 12+<meta property="og:title" content="{{.Site.Domain}}">
 13+<meta property="og:description" content="a blog platform for hackers">
 14+
 15+<meta name="twitter:card" content="summary" />
 16+<meta property="twitter:url" content="https://{{.Site.Domain}}">
 17+<meta property="twitter:title" content="{{.Site.Domain}}">
 18+<meta property="twitter:description" content="a blog platform for hackers">
 19+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
 20+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
 21+
 22+<meta property="og:image:width" content="300" />
 23+<meta property="og:image:height" content="300" />
 24+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
 25+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
 26+{{end}}
 27+
 28+{{define "attrs"}}{{end}}
 29+
 30+{{define "body"}}
 31+<header class="text-center">
 32+    <h1 class="text-2xl font-bold">{{.Site.Domain}}</h1>
 33+    <p class="text-lg">a blog platform for hackers</p>
 34+    <p class="text-lg"><a href="/read">discover</a> some interesting posts</p>
 35+    <hr />
 36+</header>
 37+
 38+<main>
 39+    <section>
 40+        <h2 class="text-lg font-bold">Examples</h2>
 41+        <p>
 42+            <a href="//hey.{{.Site.Domain}}">official blog</a> |
 43+            <a href="https://git.sr.ht/~erock/prose-official-blog">blog source</a>
 44+        </p>
 45+    </section>
 46+
 47+    <section>
 48+        <h2 class="text-lg font-bold">Create your account with Public-Key Cryptography</h2>
 49+        <p>We don't want your email address.</p>
 50+        <p>To get started, simply ssh into our content management system:</p>
 51+        <pre>ssh new@{{.Site.Domain}}</pre>
 52+        <div class="text-sm font-italic note">
 53+            note: <code>new</code> is a special username that will always send you to account
 54+            creation, even with multiple accounts associated with your key-pair.
 55+        </div>
 56+        <div class="text-sm font-italic note">
 57+            note: getting permission denied? <a href="/help#permission-denied">read this</a>
 58+        </div>
 59+        <p>
 60+            After that, just set a username and you're ready to start writing! When you SSH
 61+            again, use your username that you set in the CMS.
 62+        </p>
 63+    </section>
 64+
 65+    <section>
 66+        <h2 class="text-lg font-bold">You control the source files</h2>
 67+        <p>Create posts using your favorite editor in plain text files.</p>
 68+        <code>~/blog/hello-world.md</code>
 69+        <pre># hello world!
 70+
 71+This is my first blog post.
 72+
 73+Check out some resources:
 74+
 75+- [pico.sh](https://pico.sh)
 76+- [lists.sh](https://lists.sh)
 77+- [antoniomika](https://antoniomika.me)
 78+- [erock.io](https://erock.io)
 79+
 80+Cya!
 81+</pre>
 82+    </section>
 83+
 84+    <section>
 85+        <h2 class="text-lg font-bold">Publish your posts with one command</h2>
 86+        <p>
 87+            When your post is ready to be published, copy the file to our server with a familiar
 88+            command:
 89+        </p>
 90+        <pre>scp ~/blog/*.md {{.Site.Domain}}:/</pre>
 91+        <p>We'll either create or update the posts for you.</p>
 92+    </section>
 93+
 94+    <section>
 95+        <h2 class="text-lg font-bold">Terminal workflow without installation</h2>
 96+        <p>
 97+            Since we are leveraging tools you already have on your computer
 98+            (<code>ssh</code> and <code>scp</code>), there is nothing to install.
 99+        </p>
100+        <p>
101+            This provides the convenience of a web app, but from inside your terminal!
102+        </p>
103+    </section>
104+
105+    <section>
106+        <h2 class="text-lg font-bold">Features</h2>
107+        <ul>
108+            <li>Github flavor markdown</li>
109+            <li><a href="/help#custom-domain">Custom domains</a></li>
110+            <li>Looks great on any device</li>
111+            <li>Bring your own editor</li>
112+            <li>You control the source files</li>
113+            <li>Terminal workflow with no installation</li>
114+            <li>Public-key based authentication</li>
115+            <li>No ads, zero browser-based tracking</li>
116+            <li>No attempt to identify users</li>
117+            <li>No platform lock-in</li>
118+            <li>No javascript</li>
119+            <li>Subscriptions via RSS</li>
120+            <li>Minimalist design</li>
121+            <li>100% open source</li>
122+        </ul>
123+    </section>
124+
125+    <section>
126+        <h2 class="text-lg font-bold">Philosophy</h2>
127+        <p>
128+            The goal of this blogging platform is to make it simple to use the tools you love to
129+            write and publish your thoughts.  There is no installation, signup is as easy as SSH'ing
130+            into our CMS, and publishing content is as easy as copying files to our server.
131+        </p>
132+
133+        <p>
134+            If you'd like to read more about our group, please read our profile at <a href="https://pico.sh">pico.sh</a>.
135+        </p>
136+    </section>
137+
138+    <section>
139+        <h2 class="text-lg font-bold">Roadmap</h2>
140+        <ol>
141+            <li>Ability to upload images</li>
142+            <li>Limited compatibility with <a href="https://gohugo.io">hugo</a></li>
143+        </ol>
144+    </section>
145+</main>
146+
147+{{template "marketing-footer" .}}
148+{{end}}
A prose/html/ops.page.tmpl
+147, -0
  1@@ -0,0 +1,147 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}operations -- {{.Site.Domain}}{{end}}
  5+
  6+{{define "meta"}}
  7+<meta name="description" content="{{.Site.Domain}} operations" />
  8+{{end}}
  9+
 10+{{define "attrs"}}{{end}}
 11+
 12+{{define "body"}}
 13+<header>
 14+    <h1 class="text-2xl">Operations</h1>
 15+    <ul>
 16+        <li><a href="/privacy">privacy</a></li>
 17+        <li><a href="/transparency">transparency</a></li>
 18+    </ul>
 19+</header>
 20+<main>
 21+    <section>
 22+        <h2 class="text-xl">Purpose</h2>
 23+        <p>
 24+            {{.Site.Domain}} exists to allow people to create and share their thoughts
 25+            without the need to set up their own server or be part of a platform
 26+            that shows ads or tracks its users.
 27+        </p>
 28+    </section>
 29+    <section>
 30+        <h2 class="text-xl">Ethics</h2>
 31+        <p>We are committed to:</p>
 32+        <ul>
 33+            <li>No browser-based tracking of visitor behavior.</li>
 34+            <li>No attempt to identify users.</li>
 35+            <li>Never sell any user or visitor data.</li>
 36+            <li>No ads — ever.</li>
 37+        </ul>
 38+    </section>
 39+    <section>
 40+        <h2 class="text-xl">Code of Content Publication</h2>
 41+        <p>
 42+            Content in {{.Site.Domain}} blogs is unfiltered and unmonitored. Users are free to publish any
 43+            combination of words and pixels except for: content of animosity or disparagement of an
 44+            individual or a group on account of a group characteristic such as race, color, national
 45+            origin, sex, disability, religion, or sexual orientation, which will be taken down
 46+            immediately.
 47+        </p>
 48+        <p>
 49+            If one notices something along those lines in a blog please let us know at
 50+            <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>.
 51+        </p>
 52+    </section>
 53+    <section>
 54+        <h2 class="text-xl">Liability</h2>
 55+        <p>
 56+            The user expressly understands and agrees that Eric Bower and Antonio Mika, the operator of this website
 57+            shall not be liable, in law or in equity, to them or to any third party for any direct,
 58+            indirect, incidental, lost profits, special, consequential, punitive or exemplary damages.
 59+        </p>
 60+    </section>
 61+    <section>
 62+        <h2 class="text-xl">Analytics</h2>
 63+        <p>
 64+            We are committed to zero browser-based tracking or trying to identify visitors.  This
 65+            means we do not try to understand the user based on cookies or IP address.  We do not
 66+            store personally identifiable information.
 67+        </p>
 68+        <p>
 69+            However, in order to provide a better service, we do have some analytics on posts.
 70+            List of metrics we track for posts:
 71+        </p>
 72+        <ul>
 73+            <li>anonymous view counts</li>
 74+        </ul>
 75+        <p>
 76+            We might also inspect the headers of HTTP requests to determine some tertiary information
 77+            about the request.  For example we might inspect the <code>User-Agent</code> or
 78+            <code>Referer</code> to filter out requests from bots.
 79+        </p>
 80+    </section>
 81+    <section>
 82+        <h2 class="text-xl">Account Terms</h2>
 83+        <p>
 84+            <ul>
 85+                <li>
 86+                    The user is responsible for all content posted and all actions performed with
 87+                    their account.
 88+                </li>
 89+                <li>
 90+                    We reserve the right to disable or delete a user's account for any reason at
 91+                    any time. We have this clause because, statistically speaking, there will be
 92+                    people trying to do something nefarious.
 93+                </li>
 94+            </ul>
 95+        </p>
 96+    </section>
 97+    <section>
 98+        <h2 class="text-xl">Service Availability</h2>
 99+        <p>
100+         We provide the {{.Site.Domain}} service on an "as is" and "as available" basis. We do not offer
101+         service-level agreements but do take uptime seriously.
102+        </p>
103+    </section>
104+    <section>
105+        <h2 class="text-xl">Contact and Support</h2>
106+        <p>
107+            Email us at <a href="mailto:{{.Site.Email}}">{{.Site.Email}}</a>
108+            with any questions.
109+        </p>
110+    </section>
111+    <section>
112+        <h2 class="text-xl">Acknowledgments</h2>
113+        <p>
114+            {{.Site.Domain}} was inspired by <a href="https://mataroa.blog">Mataroa Blog</a>
115+            and <a href="https://bearblog.dev/">Bear Blog</a>.
116+        </p>
117+        <p>
118+            {{.Site.Domain}} is built with many open source technologies.
119+        </p>
120+        <p>
121+            In particular we would like to thank:
122+        </p>
123+        <ul>
124+            <li>
125+                <span>The </span>
126+                <a href="https://charm.sh">charm.sh</a>
127+                <span> community</span>
128+            </li>
129+            <li>
130+                <span>The </span>
131+                <a href="https://go.dev">golang</a>
132+                <span> community</span>
133+            </li>
134+            <li>
135+                <span>The </span>
136+                <a href="https://www.postgresql.org/">postgresql</a>
137+                <span> community</span>
138+            </li>
139+            <li>
140+                <span>The </span>
141+                <a href="https://github.com/caddyserver/caddy">caddy</a>
142+                <span> community</span>
143+            </li>
144+        </ul>
145+    </section>
146+</main>
147+{{template "marketing-footer" .}}
148+{{end}}
A prose/html/post.page.tmpl
+46, -0
 1@@ -0,0 +1,46 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}{{.PageTitle}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.Description}}" />
 8+
 9+<meta property="og:type" content="website">
10+<meta property="og:site_name" content="{{.Site.Domain}}">
11+<meta property="og:url" content="{{.URL}}">
12+<meta property="og:title" content="{{.Title}}">
13+{{if .Description}}<meta property="og:description" content="{{.Description}}">{{end}}
14+<meta property="og:image:width" content="300" />
15+<meta property="og:image:height" content="300" />
16+<meta itemprop="image" content="https://{{.Site.Domain}}/card.png" />
17+<meta property="og:image" content="https://{{.Site.Domain}}/card.png" />
18+
19+<meta property="twitter:card" content="summary">
20+<meta property="twitter:url" content="{{.URL}}">
21+<meta property="twitter:title" content="{{.Title}}">
22+{{if .Description}}<meta property="twitter:description" content="{{.Description}}">{{end}}
23+<meta name="twitter:image" content="https://{{.Site.Domain}}/card.png" />
24+<meta name="twitter:image:src" content="https://{{.Site.Domain}}/card.png" />
25+
26+<link rel="stylesheet" href="/syntax.css" />
27+{{if .HasCSS}}<link rel="stylesheet" href="{{.CssURL}}" />{{end}}
28+{{end}}
29+
30+{{define "attrs"}}id="post"{{end}}
31+
32+{{define "body"}}
33+<header>
34+    <h1 class="text-2xl font-bold">{{.Title}}</h1>
35+    <p class="font-bold m-0">
36+        <time datetime="{{.PublishAtISO}}">{{.PublishAt}}</time>
37+        <span> on </span>
38+        <a href="{{.BlogURL}}">{{.BlogName}}</a></p>
39+    {{if .Description}}<div class="my font-italic">{{.Description}}</div>{{end}}
40+</header>
41+<main>
42+    <article class="md">
43+        {{.Contents}}
44+    </article>
45+</main>
46+{{template "footer" .}}
47+{{end}}
A prose/html/privacy.page.tmpl
+54, -0
 1@@ -0,0 +1,54 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}privacy -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="{{.Site.Domain}} privacy policy" />
 8+{{end}}
 9+
10+{{define "attrs"}}{{end}}
11+
12+{{define "body"}}
13+<header>
14+    <h1 class="text-2xl">Privacy</h1>
15+    <p>Details on our privacy and security approach.</p>
16+</header>
17+<main>
18+    <section>
19+        <h2 class="text-xl">Account Data</h2>
20+        <p>
21+            In order to have a functional account at {{.Site.Domain}}, we need to store
22+            your public key.  That is the only piece of information we record for a user.
23+        </p>
24+        <p>
25+            Because we use public-key cryptography, our security posture is a battle-tested
26+            and proven technique for authentication.
27+        </p>
28+    </section>
29+
30+    <section>
31+        <h2 class="text-xl">Third parties</h2>
32+        <p>
33+            We have a strong commitment to never share any user data with any third-parties.
34+        </p>
35+    </section>
36+
37+    <section>
38+        <h2 class="text-xl">Service Providers</h2>
39+        <ul>
40+            <li>
41+                <span>We host our server on </span>
42+                <a href="https://digitalocean.com">digital ocean</a>
43+            </li>
44+        </ul>
45+    </section>
46+
47+    <section>
48+        <h2 class="text-xl">Cookies</h2>
49+        <p>
50+            We do not use any cookies, not even account authentication.
51+        </p>
52+    </section>
53+</main>
54+{{template "marketing-footer" .}}
55+{{end}}
A prose/html/read.page.tmpl
+38, -0
 1@@ -0,0 +1,38 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}discover prose -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="discover interesting posts" />
 8+{{end}}
 9+
10+{{define "attrs"}}{{end}}
11+
12+{{define "body"}}
13+<header class="text-center">
14+    <h1 class="text-2xl font-bold">read</h1>
15+    <p class="text-lg">recent posts</p>
16+    <p class="text-lg"><a href="/rss">rss</a></p>
17+    <hr />
18+</header>
19+<main>
20+    <div class="my">
21+        {{if .PrevPage}}<a href="{{.PrevPage}}">prev</a>{{else}}<span class="text-grey">prev</span>{{end}}
22+        {{if .NextPage}}<a href="{{.NextPage}}">next</a>{{else}}<span class="text-grey">next</span>{{end}}
23+    </div>
24+    {{range .Posts}}
25+    <article>
26+        <div class="flex items-center">
27+            <time datetime="{{.PublishAtISO}}" class="font-italic text-sm post-date">{{.PublishAt}}</time>
28+            <div class="flex-1">
29+                <h2 class="inline"><a href="{{.URL}}">{{.Title}}</a></h2>
30+                <address class="text-sm inline">
31+                    <a href="{{.BlogURL}}" class="link-grey">({{.Username}})</a>
32+                </address>
33+            </div>
34+        </div>
35+    </article>
36+    {{end}}
37+</main>
38+{{template "marketing-footer" .}}
39+{{end}}
A prose/html/rss.page.tmpl
+1, -0
1@@ -0,0 +1 @@
2+{{.Contents}}
A prose/html/transparency.page.tmpl
+59, -0
 1@@ -0,0 +1,59 @@
 2+{{template "base" .}}
 3+
 4+{{define "title"}}transparency -- {{.Site.Domain}}{{end}}
 5+
 6+{{define "meta"}}
 7+<meta name="description" content="full transparency of analytics and cost at {{.Site.Domain}}" />
 8+{{end}}
 9+
10+{{define "attrs"}}{{end}}
11+
12+{{define "body"}}
13+<header>
14+    <h1 class="text-2xl">Transparency</h1>
15+    <hr />
16+</header>
17+<main>
18+    <section>
19+        <h2 class="text-xl">Analytics</h2>
20+        <p>
21+            Here are some interesting stats on usage.
22+        </p>
23+
24+        <article>
25+            <h2 class="text-lg">Total users</h2>
26+            <div>{{.Analytics.TotalUsers}}</div>
27+        </article>
28+
29+        <article>
30+            <h2 class="text-lg">New users in the last month</h2>
31+            <div>{{.Analytics.UsersLastMonth}}</div>
32+        </article>
33+
34+        <article>
35+            <h2 class="text-lg">Total posts</h2>
36+            <div>{{.Analytics.TotalPosts}}</div>
37+        </article>
38+
39+        <article>
40+            <h2 class="text-lg">New posts in the last month</h2>
41+            <div>{{.Analytics.PostsLastMonth}}</div>
42+        </article>
43+
44+        <article>
45+            <h2 class="text-lg">Users with at least one post</h2>
46+            <div>{{.Analytics.UsersWithPost}}</div>
47+        </article>
48+    </section>
49+
50+    <section>
51+        <h2 class="text-xl">Service maintenance costs</h2>
52+        <ul>
53+            <li>Server $5.00/mo</li>
54+            <li>Domain name $3.25/mo</li>
55+            <li>Programmer $0.00/mo</li>
56+        </ul>
57+    </section>
58+</main>
59+{{template "marketing-footer" .}}
60+{{end}}
A prose/makefile
+17, -0
 1@@ -0,0 +1,17 @@
 2+DOCKER_TAG?=$(shell git log --format="%H" -n 1)
 3+
 4+bp-setup:
 5+	docker buildx ls | grep pico || docker buildx create --name pico
 6+	docker buildx use pico
 7+.PHONY: bp-setup
 8+
 9+bp-ssh: bp-setup
10+	docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/prose-ssh:$(DOCKER_TAG) --target ssh -f Dockerfile ..
11+.PHONY: bp-ssh
12+
13+bp-web: bp-setup
14+	docker buildx build --push --platform linux/amd64,linux/arm64 -t neurosnap/prose-web:$(DOCKER_TAG) --target web -f Dockerfile ..
15+.PHONY: bp-web
16+
17+bp: bp-ssh bp-web
18+.PHONY: bp
A prose/parser.go
+123, -0
  1@@ -0,0 +1,123 @@
  2+package internal
  3+
  4+import (
  5+	"bytes"
  6+	"fmt"
  7+	"regexp"
  8+	"strings"
  9+	"time"
 10+
 11+	"github.com/alecthomas/chroma/formatters/html"
 12+	"github.com/yuin/goldmark"
 13+	highlighting "github.com/yuin/goldmark-highlighting"
 14+	meta "github.com/yuin/goldmark-meta"
 15+	"github.com/yuin/goldmark/extension"
 16+	"github.com/yuin/goldmark/parser"
 17+)
 18+
 19+type MetaData struct {
 20+	PublishAt   *time.Time
 21+	Title       string
 22+	Description string
 23+	Nav         []Link
 24+}
 25+
 26+type ParsedText struct {
 27+	Html string
 28+	*MetaData
 29+}
 30+
 31+func toString(obj interface{}) string {
 32+	if obj == nil {
 33+		return ""
 34+	}
 35+	return obj.(string)
 36+}
 37+
 38+func toLinks(obj interface{}) ([]Link, error) {
 39+	links := []Link{}
 40+	if obj == nil {
 41+		return links, nil
 42+	}
 43+
 44+	addLinks := func(raw map[interface{}]interface{}) {
 45+		for k, v := range raw {
 46+			links = append(links, Link{
 47+				Text: k.(string),
 48+				URL:  v.(string),
 49+			})
 50+		}
 51+	}
 52+
 53+	switch raw := obj.(type) {
 54+	case map[interface{}]interface{}:
 55+		addLinks(raw)
 56+	case []interface{}:
 57+		for _, v := range raw {
 58+			switch linkRaw := v.(type) {
 59+			case map[interface{}]interface{}:
 60+				addLinks(v.(map[interface{}]interface{}))
 61+			default:
 62+				return links, fmt.Errorf("unsupported type for `nav` link item (%T), looking for map (`text: href`)", linkRaw)
 63+			}
 64+		}
 65+	default:
 66+		return links, fmt.Errorf("unsupported type for `nav` variable: %T", raw)
 67+	}
 68+
 69+	return links, nil
 70+}
 71+
 72+var reTimestamp = regexp.MustCompile(`T.+`)
 73+
 74+func ParseText(text string) (*ParsedText, error) {
 75+	var buf bytes.Buffer
 76+	hili := highlighting.NewHighlighting(
 77+		highlighting.WithFormatOptions(
 78+			html.WithLineNumbers(true),
 79+			html.WithClasses(true),
 80+		),
 81+	)
 82+	md := goldmark.New(
 83+		goldmark.WithExtensions(
 84+			extension.GFM,
 85+			meta.Meta,
 86+			hili,
 87+		),
 88+	)
 89+	context := parser.NewContext()
 90+	if err := md.Convert([]byte(text), &buf, parser.WithContext(context)); err != nil {
 91+		return &ParsedText{}, err
 92+	}
 93+	metaData := meta.Get(context)
 94+
 95+	var publishAt *time.Time = nil
 96+	var err error
 97+	date := toString(metaData["date"])
 98+	if date != "" {
 99+		if strings.Contains(date, "T") {
100+			date = reTimestamp.ReplaceAllString(date, "")
101+		}
102+
103+		nextDate, err := time.Parse("2006-01-02", date)
104+		if err != nil {
105+			return &ParsedText{}, err
106+		}
107+		publishAt = &nextDate
108+	}
109+
110+	nav, err := toLinks(metaData["nav"])
111+	if err != nil {
112+		return &ParsedText{}, err
113+	}
114+
115+	return &ParsedText{
116+		Html: buf.String(),
117+		MetaData: &MetaData{
118+			PublishAt:   publishAt,
119+			Title:       toString(metaData["title"]),
120+			Description: toString(metaData["description"]),
121+			Nav:         nav,
122+		},
123+	}, nil
124+}
A prose/public/apple-touch-icon.png
+0, -0
A prose/public/card.png
+0, -0
A prose/public/favicon-16x16.png
+0, -0
A prose/public/favicon.ico
+0, -0
A prose/public/main.css
+326, -0
  1@@ -0,0 +1,326 @@
  2+*, ::before, ::after {
  3+  box-sizing: border-box;
  4+}
  5+
  6+::-moz-focus-inner {
  7+	border-style: none;
  8+	padding: 0;
  9+}
 10+:-moz-focusring { outline: 1px dotted ButtonText; }
 11+:-moz-ui-invalid { box-shadow: none; }
 12+
 13+@media (prefers-color-scheme: light) {
 14+  :root {
 15+    --white: #6a737d;
 16+    --code: rgba(255, 229, 100, 0.2);
 17+    --pre: #f6f8fa;
 18+    --bg-color: #fff;
 19+    --text-color: #24292f;
 20+    --link-color: #005cc5;
 21+    --visited: #6f42c1;
 22+    --blockquote: #785840;
 23+    --blockquote-bg: #fff;
 24+    --hover: #d73a49;
 25+    --grey: #ccc;
 26+  }
 27+}
 28+
 29+@media (prefers-color-scheme: dark) {
 30+  :root {
 31+    --white: #f2f2f2;
 32+    --code: #252525;
 33+    --pre: #252525;
 34+    --bg-color: #282a36;
 35+    --text-color: #f2f2f2;
 36+    --link-color: #8be9fd;
 37+    --visited: #bd93f9;
 38+    --blockquote: #bd93f9;
 39+    --blockquote-bg: #414558;
 40+    --hover: #ff80bf;
 41+    --grey: #414558;
 42+  }
 43+}
 44+
 45+html {
 46+  background-color: var(--bg-color);
 47+  color: var(--text-color);
 48+  line-height: 1.5;
 49+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
 50+    "Fira Sans", "Droid Sans", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
 51+    "Segoe UI Emoji", "Segoe UI Symbol";
 52+	-webkit-text-size-adjust: 100%;
 53+	-moz-tab-size: 4;
 54+	tab-size: 4;
 55+}
 56+
 57+body {
 58+  margin: 0 auto;
 59+  max-width: 42rem;
 60+}
 61+
 62+img {
 63+  max-width: 100%;
 64+  height: auto;
 65+}
 66+
 67+b, strong {
 68+  font-weight: bold;
 69+}
 70+
 71+code, kbd, samp, pre {
 72+	font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
 73+	font-size: 1rem;
 74+}
 75+
 76+code, kbd, samp {
 77+  background-color: var(--code);
 78+}
 79+
 80+pre > code {
 81+  background-color: inherit;
 82+  padding: 0;
 83+}
 84+
 85+code {
 86+  border-radius: 0.3rem;
 87+  padding: .15rem .2rem .05rem;
 88+}
 89+
 90+pre {
 91+  border-radius: 5px;
 92+  padding: 1rem;
 93+  overflow-x: auto;
 94+  margin: 0;
 95+  background-color: var(--pre) !important;
 96+}
 97+
 98+small {
 99+  font-size: 0.8rem;
100+}
101+
102+summary {
103+  display: list-item;
104+}
105+
106+h1, h2, h3 {
107+  margin: 0;
108+	padding: 0;
109+	border: 0;
110+  font-style: normal;
111+  font-weight: inherit;
112+  font-size: inherit;
113+}
114+
115+hr {
116+  color: inherit;
117+  border: 0;
118+  margin: 0;
119+  height: 1px;
120+  background: var(--grey);
121+  margin: 2rem auto;
122+  text-align: center;
123+}
124+
125+a {
126+  text-decoration: underline;
127+  color: var(--link-color);
128+}
129+
130+a:hover, a:visited:hover {
131+  color: var(--hover);
132+}
133+
134+a:visited {
135+  color: var(--visited);
136+}
137+
138+a.link-grey {
139+  text-decoration: underline;
140+  color: var(--white);
141+}
142+
143+a.link-grey:visited {
144+  color: var(--white);
145+}
146+
147+section {
148+  margin-bottom: 2rem;
149+}
150+
151+section:last-child {
152+  margin-bottom: 0;
153+}
154+
155+header {
156+  margin: 1rem auto;
157+}
158+
159+p {
160+  margin: 1rem 0;
161+}
162+
163+article {
164+  overflow-wrap: break-word;
165+}
166+
167+blockquote {
168+  border-left: 5px solid var(--blockquote);
169+  background-color: var(--blockquote-bg);
170+  padding: 0.5rem;
171+  margin: 0.5rem 0;
172+}
173+
174+table {
175+  display: block;
176+  max-width: fit-content;
177+  margin: 0 auto;
178+  overflow-x: auto;
179+  white-space: nowrap;
180+  border-spacing: 10px;
181+  border-collapse: separate;
182+}
183+
184+ul, ol {
185+  padding: 0 0 0 2rem;
186+  list-style-position: outside;
187+}
188+
189+ul[style*="list-style-type: none;"] {
190+  padding: 0;
191+}
192+
193+li {
194+  margin: 0.5rem 0;
195+}
196+
197+li > pre {
198+  padding: 0;
199+}
200+
201+footer {
202+  text-align: center;
203+  margin-bottom: 4rem;
204+}
205+
206+dt {
207+  font-weight: bold;
208+}
209+
210+dd {
211+  margin-left: 0;
212+}
213+
214+dd:not(:last-child) {
215+  margin-bottom: .5rem;
216+}
217+
218+.md h1 {
219+  font-size: 1.25rem;
220+  line-height: 1.15;
221+  font-weight: bold;
222+  padding: 0.5rem 0;
223+}
224+
225+.md h2 {
226+  font-size: 1.125rem;
227+  line-height: 1.15;
228+  font-weight: bold;
229+  padding: 0.5rem 0;
230+}
231+
232+.md h3 {
233+  font-weight: bold;
234+}
235+
236+.md h4 {
237+  font-size: 0.875rem;
238+  font-weight: bold;
239+}
240+
241+.post-date {
242+  width: 130px;
243+}
244+
245+.text-grey {
246+  color: var(--grey);
247+}
248+
249+.text-2xl {
250+  font-size: 1.5rem;
251+  line-height: 1.15;
252+}
253+
254+.text-xl {
255+  font-size: 1.25rem;
256+  line-height: 1.15;
257+}
258+
259+.text-lg {
260+  font-size: 1.125rem;
261+  line-height: 1.15;
262+}
263+
264+.text-sm {
265+  font-size: 0.875rem;
266+}
267+
268+.text-center {
269+  text-align: center;
270+}
271+
272+.font-bold {
273+  font-weight: bold;
274+}
275+
276+.font-italic {
277+  font-style: italic;
278+}
279+
280+.inline {
281+  display: inline;
282+}
283+
284+.flex {
285+  display: flex;
286+}
287+
288+.items-center {
289+  align-items: center;
290+}
291+
292+.m-0 {
293+  margin: 0;
294+}
295+
296+.my {
297+  margin-top: 0.5rem;
298+  margin-bottom: 0.5rem;
299+}
300+
301+.mx {
302+  margin-left: 0.5rem;
303+  margin-right: 0.5rem;
304+}
305+
306+.mx-2 {
307+  margin-left: 1rem;
308+  margin-right: 1rem;
309+}
310+
311+.justify-between {
312+  justify-content: space-between;
313+}
314+
315+.flex-1 {
316+  flex: 1;
317+}
318+
319+@media only screen and (max-width: 600px) {
320+  body {
321+    padding: 1rem;
322+  }
323+
324+  header {
325+    margin: 0;
326+  }
327+}
A prose/public/robots.txt
+2, -0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
A prose/public/syntax.css
+175, -0
  1@@ -0,0 +1,175 @@
  2+@media (prefers-color-scheme: light) {
  3+  /* Background */ .bg { background-color: #ffffff; }
  4+  /* PreWrapper */ .chroma { background-color: #ffffff; }
  5+  /* Other */ .chroma .x {  }
  6+  /* Error */ .chroma .err { background-color: #a848a8 }
  7+  /* CodeLine */ .chroma .cl {  }
  8+  /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
  9+  /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
 10+  /* LineHighlight */ .chroma .hl { background-color: #ffffcc }
 11+  /* LineNumbersTable */ .chroma .lnt { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
 12+  /* LineNumbers */ .chroma .ln { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
 13+  /* Line */ .chroma .line { display: flex; }
 14+  /* Keyword */ .chroma .k { color: #2838b0 }
 15+  /* KeywordConstant */ .chroma .kc { color: #444444; font-style: italic }
 16+  /* KeywordDeclaration */ .chroma .kd { color: #2838b0; font-style: italic }
 17+  /* KeywordNamespace */ .chroma .kn { color: #2838b0 }
 18+  /* KeywordPseudo */ .chroma .kp { color: #2838b0 }
 19+  /* KeywordReserved */ .chroma .kr { color: #2838b0 }
 20+  /* KeywordType */ .chroma .kt { color: #2838b0; font-style: italic }
 21+  /* Name */ .chroma .n {  }
 22+  /* NameAttribute */ .chroma .na { color: #388038 }
 23+  /* NameBuiltin */ .chroma .nb { color: #388038 }
 24+  /* NameBuiltinPseudo */ .chroma .bp { font-style: italic }
 25+  /* NameClass */ .chroma .nc { color: #287088 }
 26+  /* NameConstant */ .chroma .no { color: #b85820 }
 27+  /* NameDecorator */ .chroma .nd { color: #287088 }
 28+  /* NameEntity */ .chroma .ni { color: #709030 }
 29+  /* NameException */ .chroma .ne { color: #908828 }
 30+  /* NameFunction */ .chroma .nf { color: #785840 }
 31+  /* NameFunctionMagic */ .chroma .fm { color: #b85820 }
 32+  /* NameLabel */ .chroma .nl { color: #289870 }
 33+  /* NameNamespace */ .chroma .nn { color: #289870 }
 34+  /* NameOther */ .chroma .nx {  }
 35+  /* NameProperty */ .chroma .py {  }
 36+  /* NameTag */ .chroma .nt { color: #2838b0 }
 37+  /* NameVariable */ .chroma .nv { color: #b04040 }
 38+  /* NameVariableClass */ .chroma .vc {  }
 39+  /* NameVariableGlobal */ .chroma .vg { color: #908828 }
 40+  /* NameVariableInstance */ .chroma .vi {  }
 41+  /* NameVariableMagic */ .chroma .vm { color: #b85820 }
 42+  /* Literal */ .chroma .l {  }
 43+  /* LiteralDate */ .chroma .ld {  }
 44+  /* LiteralString */ .chroma .s { color: #b83838 }
 45+  /* LiteralStringAffix */ .chroma .sa { color: #444444 }
 46+  /* LiteralStringBacktick */ .chroma .sb { color: #b83838 }
 47+  /* LiteralStringChar */ .chroma .sc { color: #a848a8 }
 48+  /* LiteralStringDelimiter */ .chroma .dl { color: #b85820 }
 49+  /* LiteralStringDoc */ .chroma .sd { color: #b85820; font-style: italic }
 50+  /* LiteralStringDouble */ .chroma .s2 { color: #b83838 }
 51+  /* LiteralStringEscape */ .chroma .se { color: #709030 }
 52+  /* LiteralStringHeredoc */ .chroma .sh { color: #b83838 }
 53+  /* LiteralStringInterpol */ .chroma .si { color: #b83838; text-decoration: underline }
 54+  /* LiteralStringOther */ .chroma .sx { color: #a848a8 }
 55+  /* LiteralStringRegex */ .chroma .sr { color: #a848a8 }
 56+  /* LiteralStringSingle */ .chroma .s1 { color: #b83838 }
 57+  /* LiteralStringSymbol */ .chroma .ss { color: #b83838 }
 58+  /* LiteralNumber */ .chroma .m { color: #444444 }
 59+  /* LiteralNumberBin */ .chroma .mb { color: #444444 }
 60+  /* LiteralNumberFloat */ .chroma .mf { color: #444444 }
 61+  /* LiteralNumberHex */ .chroma .mh { color: #444444 }
 62+  /* LiteralNumberInteger */ .chroma .mi { color: #444444 }
 63+  /* LiteralNumberIntegerLong */ .chroma .il { color: #444444 }
 64+  /* LiteralNumberOct */ .chroma .mo { color: #444444 }
 65+  /* Operator */ .chroma .o { color: #666666 }
 66+  /* OperatorWord */ .chroma .ow { color: #a848a8 }
 67+  /* Punctuation */ .chroma .p { color: #888888 }
 68+  /* Comment */ .chroma .c { color: #888888; font-style: italic }
 69+  /* CommentHashbang */ .chroma .ch { color: #287088; font-style: italic }
 70+  /* CommentMultiline */ .chroma .cm { color: #888888; font-style: italic }
 71+  /* CommentSingle */ .chroma .c1 { color: #888888; font-style: italic }
 72+  /* CommentSpecial */ .chroma .cs { color: #888888; font-style: italic }
 73+  /* CommentPreproc */ .chroma .cp { color: #289870 }
 74+  /* CommentPreprocFile */ .chroma .cpf { color: #289870 }
 75+  /* Generic */ .chroma .g {  }
 76+  /* GenericDeleted */ .chroma .gd { color: #c02828 }
 77+  /* GenericEmph */ .chroma .ge { font-style: italic }
 78+  /* GenericError */ .chroma .gr { color: #c02828 }
 79+  /* GenericHeading */ .chroma .gh { color: #666666 }
 80+  /* GenericInserted */ .chroma .gi { color: #388038 }
 81+  /* GenericOutput */ .chroma .go { color: #666666 }
 82+  /* GenericPrompt */ .chroma .gp { color: #444444 }
 83+  /* GenericStrong */ .chroma .gs { font-weight: bold }
 84+  /* GenericSubheading */ .chroma .gu { color: #444444 }
 85+  /* GenericTraceback */ .chroma .gt { color: #2838b0 }
 86+  /* GenericUnderline */ .chroma .gl { text-decoration: underline }
 87+  /* TextWhitespace */ .chroma .w { color: #a89028 }
 88+}
 89+
 90+@media (prefers-color-scheme: dark) {
 91+  /* Background */ .bg { color: #f8f8f2; background-color: #282a36; }
 92+  /* PreWrapper */ .chroma { color: #f8f8f2; background-color: #282a36; }
 93+  /* Other */ .chroma .x {  }
 94+  /* Error */ .chroma .err {  }
 95+  /* CodeLine */ .chroma .cl {  }
 96+  /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
 97+  /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
 98+  /* LineHighlight */ .chroma .hl { background-color: #ffffcc }
 99+  /* LineNumbersTable */ .chroma .lnt { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
100+  /* LineNumbers */ .chroma .ln { white-space: pre; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
101+  /* Line */ .chroma .line { display: flex; }
102+  /* Keyword */ .chroma .k { color: #ff79c6 }
103+  /* KeywordConstant */ .chroma .kc { color: #ff79c6 }
104+  /* KeywordDeclaration */ .chroma .kd { color: #8be9fd; font-style: italic }
105+  /* KeywordNamespace */ .chroma .kn { color: #ff79c6 }
106+  /* KeywordPseudo */ .chroma .kp { color: #ff79c6 }
107+  /* KeywordReserved */ .chroma .kr { color: #ff79c6 }
108+  /* KeywordType */ .chroma .kt { color: #8be9fd }
109+  /* Name */ .chroma .n {  }
110+  /* NameAttribute */ .chroma .na { color: #50fa7b }
111+  /* NameBuiltin */ .chroma .nb { color: #8be9fd; font-style: italic }
112+  /* NameBuiltinPseudo */ .chroma .bp {  }
113+  /* NameClass */ .chroma .nc { color: #50fa7b }
114+  /* NameConstant */ .chroma .no {  }
115+  /* NameDecorator */ .chroma .nd {  }
116+  /* NameEntity */ .chroma .ni {  }
117+  /* NameException */ .chroma .ne {  }
118+  /* NameFunction */ .chroma .nf { color: #50fa7b }
119+  /* NameFunctionMagic */ .chroma .fm {  }
120+  /* NameLabel */ .chroma .nl { color: #8be9fd; font-style: italic }
121+  /* NameNamespace */ .chroma .nn {  }
122+  /* NameOther */ .chroma .nx {  }
123+  /* NameProperty */ .chroma .py {  }
124+  /* NameTag */ .chroma .nt { color: #ff79c6 }
125+  /* NameVariable */ .chroma .nv { color: #8be9fd; font-style: italic }
126+  /* NameVariableClass */ .chroma .vc { color: #8be9fd; font-style: italic }
127+  /* NameVariableGlobal */ .chroma .vg { color: #8be9fd; font-style: italic }
128+  /* NameVariableInstance */ .chroma .vi { color: #8be9fd; font-style: italic }
129+  /* NameVariableMagic */ .chroma .vm {  }
130+  /* Literal */ .chroma .l {  }
131+  /* LiteralDate */ .chroma .ld {  }
132+  /* LiteralString */ .chroma .s { color: #f1fa8c }
133+  /* LiteralStringAffix */ .chroma .sa { color: #f1fa8c }
134+  /* LiteralStringBacktick */ .chroma .sb { color: #f1fa8c }
135+  /* LiteralStringChar */ .chroma .sc { color: #f1fa8c }
136+  /* LiteralStringDelimiter */ .chroma .dl { color: #f1fa8c }
137+  /* LiteralStringDoc */ .chroma .sd { color: #f1fa8c }
138+  /* LiteralStringDouble */ .chroma .s2 { color: #f1fa8c }
139+  /* LiteralStringEscape */ .chroma .se { color: #f1fa8c }
140+  /* LiteralStringHeredoc */ .chroma .sh { color: #f1fa8c }
141+  /* LiteralStringInterpol */ .chroma .si { color: #f1fa8c }
142+  /* LiteralStringOther */ .chroma .sx { color: #f1fa8c }
143+  /* LiteralStringRegex */ .chroma .sr { color: #f1fa8c }
144+  /* LiteralStringSingle */ .chroma .s1 { color: #f1fa8c }
145+  /* LiteralStringSymbol */ .chroma .ss { color: #f1fa8c }
146+  /* LiteralNumber */ .chroma .m { color: #bd93f9 }
147+  /* LiteralNumberBin */ .chroma .mb { color: #bd93f9 }
148+  /* LiteralNumberFloat */ .chroma .mf { color: #bd93f9 }
149+  /* LiteralNumberHex */ .chroma .mh { color: #bd93f9 }
150+  /* LiteralNumberInteger */ .chroma .mi { color: #bd93f9 }
151+  /* LiteralNumberIntegerLong */ .chroma .il { color: #bd93f9 }
152+  /* LiteralNumberOct */ .chroma .mo { color: #bd93f9 }
153+  /* Operator */ .chroma .o { color: #ff79c6 }
154+  /* OperatorWord */ .chroma .ow { color: #ff79c6 }
155+  /* Punctuation */ .chroma .p {  }
156+  /* Comment */ .chroma .c { color: #6272a4 }
157+  /* CommentHashbang */ .chroma .ch { color: #6272a4 }
158+  /* CommentMultiline */ .chroma .cm { color: #6272a4 }
159+  /* CommentSingle */ .chroma .c1 { color: #6272a4 }
160+  /* CommentSpecial */ .chroma .cs { color: #6272a4 }
161+  /* CommentPreproc */ .chroma .cp { color: #ff79c6 }
162+  /* CommentPreprocFile */ .chroma .cpf { color: #ff79c6 }
163+  /* Generic */ .chroma .g {  }
164+  /* GenericDeleted */ .chroma .gd { color: #ff5555 }
165+  /* GenericEmph */ .chroma .ge { text-decoration: underline }
166+  /* GenericError */ .chroma .gr {  }
167+  /* GenericHeading */ .chroma .gh { font-weight: bold }
168+  /* GenericInserted */ .chroma .gi { color: #50fa7b; font-weight: bold }
169+  /* GenericOutput */ .chroma .go { color: #44475a }
170+  /* GenericPrompt */ .chroma .gp {  }
171+  /* GenericStrong */ .chroma .gs {  }
172+  /* GenericSubheading */ .chroma .gu { font-weight: bold }
173+  /* GenericTraceback */ .chroma .gt {  }
174+  /* GenericUnderline */ .chroma .gl { text-decoration: underline }
175+  /* TextWhitespace */ .chroma .w {  }
176+}
A prose/router.go
+117, -0
  1@@ -0,0 +1,117 @@
  2+package internal
  3+
  4+import (
  5+	"context"
  6+	"fmt"
  7+	"net"
  8+	"net/http"
  9+	"regexp"
 10+	"strings"
 11+
 12+	"git.sr.ht/~erock/wish/cms/db"
 13+	"go.uber.org/zap"
 14+)
 15+
 16+type Route struct {
 17+	method  string
 18+	regex   *regexp.Regexp
 19+	handler http.HandlerFunc
 20+}
 21+
 22+func NewRoute(method, pattern string, handler http.HandlerFunc) Route {
 23+	return Route{
 24+		method,
 25+		regexp.MustCompile("^" + pattern + "$"),
 26+		handler,
 27+	}
 28+}
 29+
 30+type ServeFn func(http.ResponseWriter, *http.Request)
 31+
 32+func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpool db.DB, logger *zap.SugaredLogger) ServeFn {
 33+	return func(w http.ResponseWriter, r *http.Request) {
 34+		var allow []string
 35+		var subdomain string
 36+		curRoutes := routes
 37+
 38+		if cfg.IsCustomdomains() || cfg.IsSubdomains() {
 39+			hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
 40+			appDomain := strings.ToLower(strings.Split(cfg.ConfigCms.Domain, ":")[0])
 41+
 42+			if hostDomain != appDomain && strings.Contains(hostDomain, appDomain) {
 43+				subdomain = strings.TrimSuffix(hostDomain, fmt.Sprintf(".%s", appDomain))
 44+				if subdomain != "" {
 45+					curRoutes = subdomainRoutes
 46+				}
 47+			} else {
 48+				subdomain = GetCustomDomain(hostDomain)
 49+				if subdomain != "" {
 50+					curRoutes = subdomainRoutes
 51+				}
 52+			}
 53+		}
 54+
 55+		for _, route := range curRoutes {
 56+			matches := route.regex.FindStringSubmatch(r.URL.Path)
 57+			if len(matches) > 0 {
 58+				if r.Method != route.method {
 59+					allow = append(allow, route.method)
 60+					continue
 61+				}
 62+				loggerCtx := context.WithValue(r.Context(), ctxLoggerKey{}, logger)
 63+				subdomainCtx := context.WithValue(loggerCtx, ctxSubdomainKey{}, subdomain)
 64+				dbCtx := context.WithValue(subdomainCtx, ctxDBKey{}, dbpool)
 65+				cfgCtx := context.WithValue(dbCtx, ctxCfg{}, cfg)
 66+				ctx := context.WithValue(cfgCtx, ctxKey{}, matches[1:])
 67+				route.handler(w, r.WithContext(ctx))
 68+				return
 69+			}
 70+		}
 71+		if len(allow) > 0 {
 72+			w.Header().Set("Allow", strings.Join(allow, ", "))
 73+			http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
 74+			return
 75+		}
 76+		http.NotFound(w, r)
 77+	}
 78+}
 79+
 80+type ctxDBKey struct{}
 81+type ctxKey struct{}
 82+type ctxLoggerKey struct{}
 83+type ctxSubdomainKey struct{}
 84+type ctxCfg struct{}
 85+
 86+func GetCfg(r *http.Request) *ConfigSite {
 87+	return r.Context().Value(ctxCfg{}).(*ConfigSite)
 88+}
 89+
 90+func GetLogger(r *http.Request) *zap.SugaredLogger {
 91+	return r.Context().Value(ctxLoggerKey{}).(*zap.SugaredLogger)
 92+}
 93+
 94+func GetDB(r *http.Request) db.DB {
 95+	return r.Context().Value(ctxDBKey{}).(db.DB)
 96+}
 97+
 98+func GetField(r *http.Request, index int) string {
 99+	fields := r.Context().Value(ctxKey{}).([]string)
100+	return fields[index]
101+}
102+
103+func GetSubdomain(r *http.Request) string {
104+	return r.Context().Value(ctxSubdomainKey{}).(string)
105+}
106+
107+func GetCustomDomain(host string) string {
108+	records, err := net.LookupTXT(fmt.Sprintf("_prose.%s", host))
109+	if err != nil {
110+		return ""
111+	}
112+
113+	for _, v := range records {
114+		return strings.TrimSpace(v)
115+	}
116+
117+	return ""
118+}
A prose/util.go
+116, -0
  1@@ -0,0 +1,116 @@
  2+package internal
  3+
  4+import (
  5+	"encoding/base64"
  6+	"fmt"
  7+	"math"
  8+	"os"
  9+	pathpkg "path"
 10+	"path/filepath"
 11+	"regexp"
 12+	"strings"
 13+	"time"
 14+	"unicode"
 15+	"unicode/utf8"
 16+
 17+	"github.com/gliderlabs/ssh"
 18+	"golang.org/x/exp/slices"
 19+)
 20+
 21+var fnameRe = regexp.MustCompile(`[-_]+`)
 22+
 23+func FilenameToTitle(filename string, title string) string {
 24+	if filename != title {
 25+		return title
 26+	}
 27+
 28+	pre := fnameRe.ReplaceAllString(title, " ")
 29+	r := []rune(pre)
 30+	r[0] = unicode.ToUpper(r[0])
 31+	return string(r)
 32+}
 33+
 34+func SanitizeFileExt(fname string) string {
 35+	return strings.TrimSuffix(fname, filepath.Ext(fname))
 36+}
 37+
 38+func KeyText(s ssh.Session) (string, error) {
 39+	if s.PublicKey() == nil {
 40+		return "", fmt.Errorf("Session doesn't have public key")
 41+	}
 42+	kb := base64.StdEncoding.EncodeToString(s.PublicKey().Marshal())
 43+	return fmt.Sprintf("%s %s", s.PublicKey().Type(), kb), nil
 44+}
 45+
 46+func GetEnv(key string, defaultVal string) string {
 47+	if value, exists := os.LookupEnv(key); exists {
 48+		return value
 49+	}
 50+
 51+	return defaultVal
 52+}
 53+
 54+// IsText reports whether a significant prefix of s looks like correct UTF-8;
 55+// that is, if it is likely that s is human-readable text.
 56+func IsText(s string) bool {
 57+	const max = 1024 // at least utf8.UTFMax
 58+	if len(s) > max {
 59+		s = s[0:max]
 60+	}
 61+	for i, c := range s {
 62+		if i+utf8.UTFMax > len(s) {
 63+			// last char may be incomplete - ignore
 64+			break
 65+		}
 66+		if c == 0xFFFD || c < ' ' && c != '\n' && c != '\t' && c != '\f' && c != '\r' {
 67+			// decoding error or control character - not a text file
 68+			return false
 69+		}
 70+	}
 71+	return true
 72+}
 73+
 74+var allowedExtensions = []string{".md", ".css"}
 75+
 76+// IsTextFile reports whether the file has a known extension indicating
 77+// a text file, or if a significant chunk of the specified file looks like
 78+// correct UTF-8; that is, if it is likely that the file contains human-
 79+// readable text.
 80+func IsTextFile(text string, filename string) bool {
 81+	ext := pathpkg.Ext(filename)
 82+	if !slices.Contains(allowedExtensions, ext) {
 83+		return false
 84+	}
 85+
 86+	num := math.Min(float64(len(text)), 1024)
 87+	return IsText(text[0:int(num)])
 88+}
 89+
 90+const solarYearSecs = 31556926
 91+
 92+func TimeAgo(t *time.Time) string {
 93+	d := time.Since(*t)
 94+	var metric string
 95+	var amount int
 96+	if d.Seconds() < 60 {
 97+		amount = int(d.Seconds())
 98+		metric = "second"
 99+	} else if d.Minutes() < 60 {
100+		amount = int(d.Minutes())
101+		metric = "minute"
102+	} else if d.Hours() < 24 {
103+		amount = int(d.Hours())
104+		metric = "hour"
105+	} else if d.Seconds() < solarYearSecs {
106+		amount = int(d.Hours()) / 24
107+		metric = "day"
108+	} else {
109+		amount = int(d.Seconds()) / solarYearSecs
110+		metric = "year"
111+	}
112+	if amount == 1 {
113+		return fmt.Sprintf("%d %s ago", amount, metric)
114+	} else {
115+		return fmt.Sprintf("%d %ss ago", amount, metric)
116+	}
117+}