- commit
- e908a4c
- parent
- 403635e
- author
- Eric Bower
- date
- 2022-07-29 03:34:04 +0000 UTC
refactor: merge all services code
+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"
+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
+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+}
+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+}
+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+}
+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+}
+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+}
+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+}
+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=
+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"]
+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
+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+}
+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+}
+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+}
+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+}
+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+}
+3,
-0
1@@ -0,0 +1,3 @@
2+{{define "base"}}
3+{{template "body" .}}
4+{{end}}
+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}}
1@@ -0,0 +1,5 @@
2+{{define "footer"}}
3+---
4+
5+=> / published with {{.Site.Domain}}
6+{{end}}
+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}}
+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}}
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}}
+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}}
+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}}
+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}}
+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}}
+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}}
+1,
-0
1@@ -0,0 +1 @@
2+{{template "list" .}}
+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}}
+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}}
+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}}
+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}}
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}}
+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}}
+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}}
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}}
+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}}
+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}}
+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}}
+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}}
+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}}
+1,
-0
1@@ -0,0 +1 @@
2+{{template "list" .}}
+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}}
+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}}
+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
+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+}
+0,
-0
+0,
-0
+0,
-0
+0,
-0
+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+}
+2,
-0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
+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+}
+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
+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+}
+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"]
+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
+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+}
+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+}
+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+}
+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+}
+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}}
+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}}
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}}
+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}}
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}}
+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}}
+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}}
+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}}
+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}}
+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}}
+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
+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+}
+0,
-0
+0,
-0
+0,
-0
+0,
-0
+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+}
+2,
-0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
+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+}
+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+}
+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+}
+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+}
+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"]
+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+}
+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+}
+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+}
+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}}
+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}}
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}}
+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}}
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}}
+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}}
+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}}
+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}}
+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}}
+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}}
+1,
-0
1@@ -0,0 +1 @@
2+{{.Contents}}
+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}}
+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
+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+}
+0,
-0
+0,
-0
+0,
-0
+0,
-0
+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+}
+2,
-0
1@@ -0,0 +1,2 @@
2+User-agent: *
3+Allow: /
+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+}
+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+}
+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+}