- commit
- e594875
- parent
- 701e0e7
- author
- Eric Bower
- date
- 2022-07-29 12:54:45 +0000 UTC
chore: added wish
+5,
-5
1@@ -9,11 +9,11 @@ import (
2 "time"
3
4 "git.sr.ht/~erock/pico/lists"
5- "git.sr.ht/~erock/wish/cms"
6- "git.sr.ht/~erock/wish/cms/db/postgres"
7- "git.sr.ht/~erock/wish/proxy"
8- "git.sr.ht/~erock/wish/send/scp"
9- "git.sr.ht/~erock/wish/send/sftp"
10+ "git.sr.ht/~erock/pico/wish/cms"
11+ "git.sr.ht/~erock/pico/wish/cms/db/postgres"
12+ "git.sr.ht/~erock/pico/wish/proxy"
13+ "git.sr.ht/~erock/pico/wish/send/scp"
14+ "git.sr.ht/~erock/pico/wish/send/sftp"
15 "github.com/charmbracelet/wish"
16 bm "github.com/charmbracelet/wish/bubbletea"
17 lm "github.com/charmbracelet/wish/logging"
+3,
-3
1@@ -7,9 +7,9 @@ import (
2 "log"
3 "os"
4
5- "git.sr.ht/~erock/wish/cms/config"
6- "git.sr.ht/~erock/wish/cms/db"
7- "git.sr.ht/~erock/wish/cms/db/postgres"
8+ "git.sr.ht/~erock/pico/wish/cms/config"
9+ "git.sr.ht/~erock/pico/wish/cms/db"
10+ "git.sr.ht/~erock/pico/wish/cms/db/postgres"
11 "go.uber.org/zap"
12 )
13
+5,
-5
1@@ -9,11 +9,11 @@ import (
2 "time"
3
4 "git.sr.ht/~erock/pico/pastes"
5- "git.sr.ht/~erock/wish/cms"
6- "git.sr.ht/~erock/wish/cms/db/postgres"
7- "git.sr.ht/~erock/wish/proxy"
8- "git.sr.ht/~erock/wish/send/scp"
9- "git.sr.ht/~erock/wish/send/sftp"
10+ "git.sr.ht/~erock/pico/wish/cms"
11+ "git.sr.ht/~erock/pico/wish/cms/db/postgres"
12+ "git.sr.ht/~erock/pico/wish/proxy"
13+ "git.sr.ht/~erock/pico/wish/send/scp"
14+ "git.sr.ht/~erock/pico/wish/send/sftp"
15 "github.com/charmbracelet/wish"
16 bm "github.com/charmbracelet/wish/bubbletea"
17 lm "github.com/charmbracelet/wish/logging"
+5,
-5
1@@ -9,11 +9,11 @@ import (
2 "time"
3
4 "git.sr.ht/~erock/pico/prose"
5- "git.sr.ht/~erock/wish/cms"
6- "git.sr.ht/~erock/wish/cms/db/postgres"
7- "git.sr.ht/~erock/wish/proxy"
8- "git.sr.ht/~erock/wish/send/scp"
9- "git.sr.ht/~erock/wish/send/sftp"
10+ "git.sr.ht/~erock/pico/wish/cms"
11+ "git.sr.ht/~erock/pico/wish/cms/db/postgres"
12+ "git.sr.ht/~erock/pico/wish/proxy"
13+ "git.sr.ht/~erock/pico/wish/send/scp"
14+ "git.sr.ht/~erock/pico/wish/send/sftp"
15 "github.com/charmbracelet/wish"
16 bm "github.com/charmbracelet/wish/bubbletea"
17 lm "github.com/charmbracelet/wish/logging"
M
go.mod
+10,
-11
1@@ -2,51 +2,50 @@ module git.sr.ht/~erock/pico
2
3 go 1.18
4
5-// replace git.sr.ht/~erock/wish => /home/erock/pico/wish
6+// replace git.sr.ht/~erock/pico/wish => /home/erock/pico/wish
7
8 require (
9 git.sr.ht/~adnano/go-gemini v0.2.3
10 git.sr.ht/~aw/gorilla-feeds v1.1.4
11 git.sr.ht/~erock/lists.sh v0.0.0-20220729004305-bc7f4fd43f42
12- git.sr.ht/~erock/pastes.sh v0.0.0-20220728142200-14b39ac0d571
13- git.sr.ht/~erock/prose.sh v0.0.0-20220729004314-0162e0d18c80
14- git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120
15 github.com/alecthomas/chroma v0.10.0
16+ github.com/charmbracelet/bubbles v0.13.0
17+ github.com/charmbracelet/bubbletea v0.22.0
18+ github.com/charmbracelet/lipgloss v0.5.0
19 github.com/charmbracelet/wish v0.5.0
20 github.com/gliderlabs/ssh v0.3.4
21 github.com/gorilla/feeds v1.1.1
22+ github.com/lib/pq v1.10.6
23+ github.com/matryer/is v1.4.0
24+ github.com/muesli/reflow v0.3.0
25+ github.com/pkg/sftp v1.13.5
26 github.com/yuin/goldmark v1.4.12
27 github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
28 github.com/yuin/goldmark-meta v1.1.0
29 go.uber.org/zap v1.21.0
30+ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
31 golang.org/x/exp v0.0.0-20220713135740-79cabaa25d75
32 )
33
34 require (
35+ git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120 // indirect
36 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
37 github.com/atotto/clipboard v0.1.4 // indirect
38 github.com/caarlos0/sshmarshal v0.1.0 // indirect
39- github.com/charmbracelet/bubbles v0.13.0 // indirect
40- github.com/charmbracelet/bubbletea v0.22.0 // indirect
41 github.com/charmbracelet/keygen v0.3.0 // indirect
42- github.com/charmbracelet/lipgloss v0.5.0 // indirect
43 github.com/containerd/console v1.0.3 // indirect
44 github.com/dlclark/regexp2 v1.7.0 // indirect
45 github.com/kr/fs v0.1.0 // indirect
46- github.com/lib/pq v1.10.6 // indirect
47 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
48 github.com/mattn/go-isatty v0.0.14 // indirect
49 github.com/mattn/go-runewidth v0.0.13 // indirect
50 github.com/mitchellh/go-homedir v1.1.0 // indirect
51 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
52 github.com/muesli/cancelreader v0.2.2 // indirect
53- github.com/muesli/reflow v0.3.0 // indirect
54 github.com/muesli/termenv v0.12.0 // indirect
55- github.com/pkg/sftp v1.13.5 // indirect
56 github.com/rivo/uniseg v0.2.0 // indirect
57 go.uber.org/atomic v1.9.0 // indirect
58 go.uber.org/multierr v1.8.0 // indirect
59- golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect
60 golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect
61 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
62 golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
M
go.sum
+1,
-4
1@@ -4,10 +4,6 @@ git.sr.ht/~aw/gorilla-feeds v1.1.4 h1:bL78pZ1DtHEhumHK0iWQi30uwEkWtetMfnyt9TFcdl
2 git.sr.ht/~aw/gorilla-feeds v1.1.4/go.mod h1:VLpbtNDEWoaJKU41Crj6r3ChvlqYvBm56c0O6IM457g=
3 git.sr.ht/~erock/lists.sh v0.0.0-20220729004305-bc7f4fd43f42 h1:ZT0cN4f8dXOq5zvXVMfqC8IYjNBVzrmNI1rZMLotAYs=
4 git.sr.ht/~erock/lists.sh v0.0.0-20220729004305-bc7f4fd43f42/go.mod h1:dnJtCor8uSE2ZciYWTOqfhcm5VtzP0uLAaDQw0rfzOk=
5-git.sr.ht/~erock/pastes.sh v0.0.0-20220728142200-14b39ac0d571 h1:L7oqAflvoaLIdJsbikOEzA9gw49D/d3HRtC7PVo36AQ=
6-git.sr.ht/~erock/pastes.sh v0.0.0-20220728142200-14b39ac0d571/go.mod h1:vzhDghntGRBVsWsV/sIC06A4/W+etmLp8y5ThNRR19A=
7-git.sr.ht/~erock/prose.sh v0.0.0-20220729004314-0162e0d18c80 h1:e1bpAu44UjEbeObVfUC+oKoyXR+rJ25vz/n0rlcPAJE=
8-git.sr.ht/~erock/prose.sh v0.0.0-20220729004314-0162e0d18c80/go.mod h1:WYeWQcd9FWzofSswFSlmQWWEHFWClWj9CGFwA3t+Gi4=
9 git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120 h1:9O4PKFF8JGvK9g3aVHr2wgozHK0s6BaVISPRl8MAovs=
10 git.sr.ht/~erock/wish v0.0.0-20220729004215-0881364c2120/go.mod h1:QZKk7m9jc9iXah90daPGhQkSfNfxSVvpb6nfVeI+MM0=
11 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
12@@ -57,6 +53,7 @@ github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
13 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
14 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
15 github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
16+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
17 github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
18 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
19 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+3,
-3
1@@ -10,9 +10,9 @@ import (
2 "strconv"
3 "time"
4
5- "git.sr.ht/~erock/lists.sh/pkg"
6- "git.sr.ht/~erock/wish/cms/db"
7- "git.sr.ht/~erock/wish/cms/db/postgres"
8+ "git.sr.ht/~erock/pico/lists.sh/pkg"
9+ "git.sr.ht/~erock/pico/wish/cms/db"
10+ "git.sr.ht/~erock/pico/wish/cms/db/postgres"
11 "github.com/gorilla/feeds"
12 "golang.org/x/exp/slices"
13 )
+1,
-1
1@@ -6,7 +6,7 @@ import (
2 "log"
3 "net/url"
4
5- "git.sr.ht/~erock/wish/cms/config"
6+ "git.sr.ht/~erock/pico/wish/cms/config"
7 "go.uber.org/zap"
8 )
9
+3,
-3
1@@ -6,9 +6,9 @@ import (
2 "time"
3
4 "git.sr.ht/~erock/lists.sh/pkg"
5- "git.sr.ht/~erock/wish/cms/db"
6- "git.sr.ht/~erock/wish/cms/util"
7- sendutils "git.sr.ht/~erock/wish/send/utils"
8+ "git.sr.ht/~erock/pico/wish/cms/db"
9+ "git.sr.ht/~erock/pico/wish/cms/util"
10+ sendutils "git.sr.ht/~erock/pico/wish/send/utils"
11 "github.com/gliderlabs/ssh"
12 "golang.org/x/exp/slices"
13 )
+2,
-2
1@@ -18,8 +18,8 @@ import (
2 feeds "git.sr.ht/~aw/gorilla-feeds"
3 "git.sr.ht/~erock/lists.sh/internal"
4 "git.sr.ht/~erock/lists.sh/pkg"
5- "git.sr.ht/~erock/wish/cms/db"
6- "git.sr.ht/~erock/wish/cms/db/postgres"
7+ "git.sr.ht/~erock/pico/wish/cms/db"
8+ "git.sr.ht/~erock/pico/wish/cms/db/postgres"
9 "golang.org/x/exp/slices"
10 )
11
+1,
-1
1@@ -6,7 +6,7 @@ import (
2
3 "git.sr.ht/~adnano/go-gemini"
4 "git.sr.ht/~erock/lists.sh/internal"
5- "git.sr.ht/~erock/wish/cms/db"
6+ "git.sr.ht/~erock/pico/wish/cms/db"
7 "go.uber.org/zap"
8 )
9
+1,
-1
1@@ -7,7 +7,7 @@ import (
2 "regexp"
3 "strings"
4
5- "git.sr.ht/~erock/wish/cms/db"
6+ "git.sr.ht/~erock/pico/wish/cms/db"
7 "go.uber.org/zap"
8 )
9
+2,
-2
1@@ -8,8 +8,8 @@ import (
2 "net/url"
3 "time"
4
5- "git.sr.ht/~erock/wish/cms/db"
6- "git.sr.ht/~erock/wish/cms/db/postgres"
7+ "git.sr.ht/~erock/pico/wish/cms/db"
8+ "git.sr.ht/~erock/pico/wish/cms/db/postgres"
9 )
10
11 type PageData struct {
+1,
-1
1@@ -6,7 +6,7 @@ import (
2 "log"
3 "net/url"
4
5- "git.sr.ht/~erock/wish/cms/config"
6+ "git.sr.ht/~erock/pico/wish/cms/config"
7 "go.uber.org/zap"
8 )
9
+1,
-1
1@@ -3,7 +3,7 @@ package internal
2 import (
3 "time"
4
5- "git.sr.ht/~erock/wish/cms/db"
6+ "git.sr.ht/~erock/pico/wish/cms/db"
7 )
8
9 func deleteExpiredPosts(cfg *ConfigSite, dbpool db.DB) error {
+3,
-3
1@@ -5,9 +5,9 @@ import (
2 "io"
3 "time"
4
5- "git.sr.ht/~erock/wish/cms/db"
6- "git.sr.ht/~erock/wish/cms/util"
7- "git.sr.ht/~erock/wish/send/utils"
8+ "git.sr.ht/~erock/pico/wish/cms/db"
9+ "git.sr.ht/~erock/pico/wish/cms/util"
10+ "git.sr.ht/~erock/pico/wish/send/utils"
11 "github.com/gliderlabs/ssh"
12 )
13
+1,
-1
1@@ -7,7 +7,7 @@ import (
2 "regexp"
3 "strings"
4
5- "git.sr.ht/~erock/wish/cms/db"
6+ "git.sr.ht/~erock/pico/wish/cms/db"
7 "go.uber.org/zap"
8 )
9
+2,
-2
1@@ -11,8 +11,8 @@ import (
2 "strings"
3 "time"
4
5- "git.sr.ht/~erock/wish/cms/db"
6- "git.sr.ht/~erock/wish/cms/db/postgres"
7+ "git.sr.ht/~erock/pico/wish/cms/db"
8+ "git.sr.ht/~erock/pico/wish/cms/db/postgres"
9 "github.com/gorilla/feeds"
10 "golang.org/x/exp/slices"
11 )
+1,
-1
1@@ -6,7 +6,7 @@ import (
2 "log"
3 "net/url"
4
5- "git.sr.ht/~erock/wish/cms/config"
6+ "git.sr.ht/~erock/pico/wish/cms/config"
7 "go.uber.org/zap"
8 )
9
+3,
-3
1@@ -6,9 +6,9 @@ import (
2 "strings"
3 "time"
4
5- "git.sr.ht/~erock/wish/cms/db"
6- "git.sr.ht/~erock/wish/cms/util"
7- "git.sr.ht/~erock/wish/send/utils"
8+ "git.sr.ht/~erock/pico/wish/cms/db"
9+ "git.sr.ht/~erock/pico/wish/cms/util"
10+ "git.sr.ht/~erock/pico/wish/send/utils"
11 "github.com/gliderlabs/ssh"
12 "golang.org/x/exp/slices"
13 )
+1,
-1
1@@ -8,7 +8,7 @@ import (
2 "regexp"
3 "strings"
4
5- "git.sr.ht/~erock/wish/cms/db"
6+ "git.sr.ht/~erock/pico/wish/cms/db"
7 "go.uber.org/zap"
8 )
9
+169,
-0
1@@ -0,0 +1,169 @@
2+# wish middleware
3+
4+This repo contains a collection of wish middleware we've built for our
5+services.
6+
7+- [cms](#cms)
8+- [send](#send)
9+- [proxy](#proxy)
10+
11+## comms
12+
13+- [website](https://pico.sh)
14+- [irc #pico.sh](irc://irc.libera.chat/#pico.sh)
15+- [mailing list](https://lists.sr.ht/~erock/pico.sh)
16+- [ticket tracker](https://todo.sr.ht/~erock/pico.sh)
17+- [email](mailto:hello@pico.sh)
18+
19+## cms
20+
21+A content management system wish ssh app. The goal of this library is to
22+provide a wish middleware that lets users ssh into this app to manage their
23+account as well as their posts.
24+
25+### setup
26+
27+You are responsible for creating your own sql tables. A copy of the schema is
28+in this repo.
29+
30+### example
31+
32+```go
33+import (
34+ "github.com/charmbracelet/wish"
35+ bm "github.com/charmbracelet/wish/bubbletea"
36+ "github.com/gliderlabs/ssh"
37+ "git.sr.ht/~erock/pico/wish/cms"
38+ "git.sr.ht/~erock/pico/wish/cms/config"
39+)
40+
41+type SSHServer struct{}
42+func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
43+ return true
44+}
45+
46+func main() {
47+ cfg := config.NewConfigCms()
48+ handler := cms.Middleware(cfg)
49+
50+ sshServer := &SSHServer{}
51+ s, err := wish.NewServer(
52+ wish.WithAddress(fmt.Sprintf("%s:%s", "localhost", "2222")),
53+ wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
54+ wish.WithPublicKeyAuth(sshServer.authHandler),
55+ wish.WithMiddleware(bm.Middleware(handler)),
56+ )
57+
58+ // ... the rest of the wish initialization
59+}
60+```
61+
62+## send
63+
64+wish middleware to allow secure file transfers with scp or sftp
65+
66+### example
67+
68+```go
69+package main
70+
71+import (
72+ "github.com/charmbracelet/wish"
73+ "github.com/neurosnap/lists.sh/internal/db/postgres"
74+ "git.sr.ht/~erock/pico/wish/send"
75+)
76+
77+type SSHServer struct{}
78+
79+func (me *SSHServer) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
80+ return true
81+}
82+
83+func main() {
84+ host := "0.0.0.0"
85+ port := "2222"
86+
87+ handler := &send.DbHandler{}
88+
89+ dbh := postgres.NewDB()
90+ defer dbh.Close()
91+
92+ sshServer := &SSHServer{}
93+
94+ s, err := wish.NewServer(
95+ wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
96+ wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
97+ wish.WithPublicKeyAuth(sshServer.authHandler),
98+ wish.WithMiddleware(send.Middleware(handler)),
99+ )
100+ if err != nil {
101+ panic(err)
102+ }
103+
104+ done := make(chan os.Signal, 1)
105+ signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
106+
107+ fmt.Printf("Starting SSH server on %s:%s\n", host, port)
108+
109+ go func() {
110+ if err = s.ListenAndServe(); err != nil {
111+ panic(err)
112+ }
113+ }()
114+
115+ <-done
116+
117+ fmt.Println("Stopping SSH server")
118+
119+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
120+ defer func() { cancel() }()
121+
122+ if err := s.Shutdown(ctx); err != nil {
123+ panic(err)
124+ }
125+}
126+```
127+
128+## proxy
129+
130+A command-based proxy middleware for your wish ssh apps. If you have ssh apps that only run on
131+certain ssh commands, this middleware will help you.
132+
133+### example
134+
135+```go
136+package example
137+
138+import (
139+ "github.com/charmbracelet/wish"
140+ "github.com/gliderlabs/ssh"
141+ wp "git.sr.ht/~erock/pico/wish/proxy"
142+)
143+
144+func router(sh ssh.Handler, s ssh.Session) []wish.Middleware {
145+ cmd := s.Command()
146+ mdw := []wish.Middleware{}
147+
148+ if len(cmd) == 0 {
149+ mdw = append(mdw, lm.Middleware())
150+ } else if cmd[0] == "scp" {
151+ mdw = append(mdw, scp.Middleware())
152+ }
153+
154+ return mdw
155+}
156+
157+func main() {
158+ s, err := wish.NewServer(
159+ wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
160+ wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
161+ wp.WithProxy(router),
162+ )
163+}
164+```
165+
166+## attribution
167+
168+- [Wish (middleware and SCP support)](https://github.com/charmbracelet/wish)
169+- [UI inspiration](https://github.com/charmbracelet/charm)
170+- [Go SFTP (SFTP support)](https://github.com/pkg/sftp)
+44,
-0
1@@ -0,0 +1,44 @@
2+package main
3+
4+import (
5+ "fmt"
6+ "log"
7+
8+ "git.sr.ht/~erock/pico/wish/send"
9+ "git.sr.ht/~erock/pico/wish/send/utils"
10+ "github.com/charmbracelet/wish"
11+ "github.com/gliderlabs/ssh"
12+)
13+
14+type handler struct {
15+}
16+
17+func (h *handler) Write(session ssh.Session, file *utils.FileEntry) (string, error) {
18+ str := fmt.Sprintf("Received file: %+v from session: %+v", file, session)
19+ log.Print(str)
20+ return str, nil
21+}
22+
23+func (h *handler) Validate(session ssh.Session) error {
24+ log.Printf("Received validate from session: %+v", session)
25+
26+ return nil
27+}
28+
29+func main() {
30+ h := &handler{}
31+
32+ s, err := wish.NewServer(
33+ wish.WithAddress(":9000"),
34+ wish.WithHostKeyPath("ssh_data/term_info_ed25519"),
35+ send.Middleware(h),
36+ )
37+
38+ if err != nil {
39+ log.Fatal(err)
40+ }
41+
42+ log.Println("Starting ssh server on 9000")
43+
44+ log.Fatal(s.ListenAndServe())
45+}
+387,
-0
1@@ -0,0 +1,387 @@
2+package cms
3+
4+import (
5+ "errors"
6+ "fmt"
7+
8+ "git.sr.ht/~erock/pico/wish/cms/config"
9+ "git.sr.ht/~erock/pico/wish/cms/db"
10+ "git.sr.ht/~erock/pico/wish/cms/db/postgres"
11+ "git.sr.ht/~erock/pico/wish/cms/ui/account"
12+ "git.sr.ht/~erock/pico/wish/cms/ui/common"
13+ "git.sr.ht/~erock/pico/wish/cms/ui/info"
14+ "git.sr.ht/~erock/pico/wish/cms/ui/keys"
15+ "git.sr.ht/~erock/pico/wish/cms/ui/posts"
16+ "git.sr.ht/~erock/pico/wish/cms/ui/username"
17+ "git.sr.ht/~erock/pico/wish/cms/util"
18+ "github.com/charmbracelet/bubbles/spinner"
19+ tea "github.com/charmbracelet/bubbletea"
20+ "github.com/charmbracelet/lipgloss"
21+ bm "github.com/charmbracelet/wish/bubbletea"
22+ "github.com/gliderlabs/ssh"
23+ "github.com/muesli/reflow/indent"
24+ "github.com/muesli/reflow/wordwrap"
25+ "github.com/muesli/reflow/wrap"
26+)
27+
28+type status int
29+
30+const (
31+ statusInit status = iota
32+ statusReady
33+ statusNoAccount
34+ statusBrowsingPosts
35+ statusBrowsingKeys
36+ statusSettingUsername
37+ statusQuitting
38+)
39+
40+func (s status) String() string {
41+ return [...]string{
42+ "initializing",
43+ "ready",
44+ "setting username",
45+ "browsing posts",
46+ "browsing keys",
47+ "quitting",
48+ "error",
49+ }[s]
50+}
51+
52+// menuChoice represents a chosen menu item.
53+type menuChoice int
54+
55+// menu choices.
56+const (
57+ setUserChoice menuChoice = iota
58+ postsChoice
59+ keysChoice
60+ exitChoice
61+ unsetChoice // set when no choice has been made
62+)
63+
64+// menu text corresponding to menu choices. these are presented to the user.
65+var menuChoices = map[menuChoice]string{
66+ setUserChoice: "Set username",
67+ keysChoice: "Manage keys",
68+ postsChoice: "Manage posts",
69+ exitChoice: "Exit",
70+}
71+
72+var (
73+ spinnerStyle = lipgloss.NewStyle().
74+ Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
75+)
76+
77+func NewSpinner() spinner.Model {
78+ s := spinner.NewModel()
79+ s.Spinner = spinner.Dot
80+ s.Style = spinnerStyle
81+ return s
82+}
83+
84+type GotDBMsg db.DB
85+
86+func Middleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
87+ return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
88+ logger := cfg.Logger
89+
90+ _, _, active := s.Pty()
91+ if !active {
92+ logger.Error("no active terminal, skipping")
93+ return nil, nil
94+ }
95+ key, err := util.KeyText(s)
96+ if err != nil {
97+ logger.Error(err)
98+ }
99+
100+ sshUser := s.User()
101+
102+ dbpool := postgres.NewDB(cfg)
103+
104+ m := model{
105+ cfg: cfg,
106+ urls: urls,
107+ publicKey: key,
108+ dbpool: dbpool,
109+ sshUser: sshUser,
110+ status: statusInit,
111+ menuChoice: unsetChoice,
112+ styles: common.DefaultStyles(),
113+ spinner: common.NewSpinner(),
114+ }
115+
116+ user, err := m.findUser()
117+ if err != nil {
118+ _, _ = fmt.Fprintln(s.Stderr(), err)
119+ return nil, nil
120+ }
121+ m.user = user
122+
123+ return m, []tea.ProgramOption{tea.WithAltScreen()}
124+ }
125+}
126+
127+// Just a generic tea.Model to demo terminal information of ssh.
128+type model struct {
129+ cfg *config.ConfigCms
130+ urls config.ConfigURL
131+ publicKey string
132+ dbpool db.DB
133+ user *db.User
134+ err error
135+ sshUser string
136+ status status
137+ menuIndex int
138+ menuChoice menuChoice
139+ terminalWidth int
140+ styles common.Styles
141+ info info.Model
142+ spinner spinner.Model
143+ username username.Model
144+ posts posts.Model
145+ keys keys.Model
146+ createAccount account.CreateModel
147+}
148+
149+func (m model) Init() tea.Cmd {
150+ return spinner.Tick
151+}
152+
153+func (m model) findUser() (*db.User, error) {
154+ logger := m.cfg.Logger
155+ var user *db.User
156+
157+ if m.sshUser == "new" {
158+ logger.Infof("User requesting to register account")
159+ return nil, nil
160+ }
161+
162+ user, err := m.dbpool.FindUserForKey(m.sshUser, m.publicKey)
163+
164+ if err != nil {
165+ logger.Error(err)
166+ // we only want to throw an error for specific cases
167+ if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
168+ return nil, err
169+ }
170+ return nil, nil
171+ }
172+
173+ return user, nil
174+}
175+
176+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
177+ var (
178+ cmds []tea.Cmd
179+ cmd tea.Cmd
180+ )
181+
182+ switch msg := msg.(type) {
183+ case tea.WindowSizeMsg:
184+ m.terminalWidth = msg.Width
185+ case tea.KeyMsg:
186+ switch msg.Type {
187+ case tea.KeyCtrlC:
188+ m.dbpool.Close()
189+ return m, tea.Quit
190+ }
191+
192+ if m.status == statusReady { // Process keys for the menu
193+ switch msg.String() {
194+ // Quit
195+ case "q", "esc":
196+ m.status = statusQuitting
197+ m.dbpool.Close()
198+ return m, tea.Quit
199+
200+ // Prev menu item
201+ case "up", "k":
202+ m.menuIndex--
203+ if m.menuIndex < 0 {
204+ m.menuIndex = len(menuChoices) - 1
205+ }
206+
207+ // Select menu item
208+ case "enter":
209+ m.menuChoice = menuChoice(m.menuIndex)
210+
211+ // Next menu item
212+ case "down", "j":
213+ m.menuIndex++
214+ if m.menuIndex >= len(menuChoices) {
215+ m.menuIndex = 0
216+ }
217+ }
218+ }
219+ case username.NameSetMsg:
220+ m.status = statusReady
221+ m.info.User.Name = string(msg)
222+ m.user = m.info.User
223+ m.username = username.NewModel(m.dbpool, m.user, m.sshUser) // reset the state
224+ case account.CreateAccountMsg:
225+ m.status = statusReady
226+ m.info.User = msg
227+ m.user = msg
228+ m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
229+ m.info = info.NewModel(m.cfg, m.urls, m.user)
230+ m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user)
231+ m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
232+ m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
233+ }
234+
235+ switch m.status {
236+ case statusInit:
237+ m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
238+ m.info = info.NewModel(m.cfg, m.urls, m.user)
239+ m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user)
240+ m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
241+ m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
242+ if m.user == nil {
243+ m.status = statusNoAccount
244+ } else {
245+ m.status = statusReady
246+ }
247+ }
248+
249+ m, cmd = updateChildren(msg, m)
250+ if cmd != nil {
251+ cmds = append(cmds, cmd)
252+ }
253+
254+ return m, tea.Batch(cmds...)
255+}
256+
257+func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
258+ var cmd tea.Cmd
259+
260+ switch m.status {
261+ case statusBrowsingPosts:
262+ newModel, newCmd := m.posts.Update(msg)
263+ postsModel, ok := newModel.(posts.Model)
264+ if !ok {
265+ panic("could not perform assertion on posts model")
266+ }
267+ m.posts = postsModel
268+ cmd = newCmd
269+
270+ if m.posts.Exit {
271+ m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user)
272+ m.status = statusReady
273+ } else if m.posts.Quit {
274+ m.status = statusQuitting
275+ return m, tea.Quit
276+ }
277+ case statusBrowsingKeys:
278+ newModel, newCmd := m.keys.Update(msg)
279+ keysModel, ok := newModel.(keys.Model)
280+ if !ok {
281+ panic("could not perform assertion on posts model")
282+ }
283+ m.keys = keysModel
284+ cmd = newCmd
285+
286+ if m.keys.Exit {
287+ m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
288+ m.status = statusReady
289+ } else if m.keys.Quit {
290+ m.status = statusQuitting
291+ return m, tea.Quit
292+ }
293+ case statusSettingUsername:
294+ m.username, cmd = username.Update(msg, m.username)
295+ if m.username.Done {
296+ m.username = username.NewModel(m.dbpool, m.user, m.sshUser) // reset the state
297+ m.status = statusReady
298+ } else if m.username.Quit {
299+ m.status = statusQuitting
300+ return m, tea.Quit
301+ }
302+ case statusNoAccount:
303+ m.createAccount, cmd = account.Update(msg, m.createAccount)
304+ if m.createAccount.Done {
305+ m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey) // reset the state
306+ m.status = statusReady
307+ } else if m.createAccount.Quit {
308+ m.status = statusQuitting
309+ return m, tea.Quit
310+ }
311+ }
312+
313+ // Handle the menu
314+ switch m.menuChoice {
315+ case setUserChoice:
316+ m.status = statusSettingUsername
317+ m.menuChoice = unsetChoice
318+ cmd = username.InitialCmd()
319+ case postsChoice:
320+ m.status = statusBrowsingPosts
321+ m.menuChoice = unsetChoice
322+ cmd = posts.LoadPosts(m.posts)
323+ case keysChoice:
324+ m.status = statusBrowsingKeys
325+ m.menuChoice = unsetChoice
326+ cmd = keys.LoadKeys(m.keys)
327+ case exitChoice:
328+ m.status = statusQuitting
329+ m.dbpool.Close()
330+ cmd = tea.Quit
331+ }
332+
333+ return m, cmd
334+}
335+
336+func (m model) menuView() string {
337+ var s string
338+ for i := 0; i < len(menuChoices); i++ {
339+ e := " "
340+ menuItem := menuChoices[menuChoice(i)]
341+ if i == m.menuIndex {
342+ e = m.styles.SelectionMarker.String() +
343+ m.styles.SelectedMenuItem.Render(menuItem)
344+ } else {
345+ e += menuItem
346+ }
347+ if i < len(menuChoices)-1 {
348+ e += "\n"
349+ }
350+ s += e
351+ }
352+
353+ return s
354+}
355+
356+func footerView(m model) string {
357+ if m.err != nil {
358+ return m.errorView(m.err)
359+ }
360+ return "\n\n" + common.HelpView("j/k, ↑/↓: choose", "enter: select")
361+}
362+
363+func (m model) errorView(err error) string {
364+ head := m.styles.Error.Render("Error: ")
365+ body := m.styles.Subtle.Render(err.Error())
366+ msg := m.styles.Wrap.Render(head + body)
367+ return "\n\n" + indent.String(msg, 2)
368+}
369+
370+func (m model) View() string {
371+ w := m.terminalWidth - m.styles.App.GetHorizontalFrameSize()
372+ s := m.styles.Logo.SetString(m.cfg.Domain).String() + "\n\n"
373+ switch m.status {
374+ case statusNoAccount:
375+ s += account.View(m.createAccount)
376+ case statusReady:
377+ s += m.info.View()
378+ s += "\n\n" + m.menuView()
379+ s += footerView(m)
380+ case statusSettingUsername:
381+ s += username.View(m.username)
382+ case statusBrowsingPosts:
383+ s += m.posts.View()
384+ case statusBrowsingKeys:
385+ s += m.keys.View()
386+ }
387+ return m.styles.App.Render(wrap.String(wordwrap.String(s, w), w))
388+}
+26,
-0
1@@ -0,0 +1,26 @@
2+package config
3+
4+import (
5+ "go.uber.org/zap"
6+)
7+
8+type ConfigURL interface {
9+ BlogURL(username string) string
10+ PostURL(username string, filename string) string
11+}
12+
13+type ConfigCms struct {
14+ Domain string
15+ Port string
16+ Email string
17+ Protocol string
18+ DbURL string
19+ Description string
20+ IntroText string
21+ Space string
22+ Logger *zap.SugaredLogger
23+}
24+
25+func NewConfigCms() *ConfigCms {
26+ return &ConfigCms{}
27+}
+120,
-0
1@@ -0,0 +1,120 @@
2+package db
3+
4+import (
5+ "errors"
6+ "regexp"
7+ "time"
8+)
9+
10+var ErrNameTaken = errors.New("name taken")
11+
12+type PublicKey struct {
13+ ID string `json:"id"`
14+ UserID string `json:"user_id"`
15+ Key string `json:"key"`
16+ CreatedAt *time.Time `json:"created_at"`
17+}
18+
19+type User struct {
20+ ID string `json:"id"`
21+ Name string `json:"name"`
22+ PublicKey *PublicKey `json:"public_key,omitempty"`
23+ CreatedAt *time.Time `json:"created_at"`
24+}
25+
26+type Post struct {
27+ ID string `json:"id"`
28+ UserID string `json:"user_id"`
29+ Filename string `json:"filename"`
30+ Title string `json:"title"`
31+ Text string `json:"text"`
32+ Description string `json:"description"`
33+ CreatedAt *time.Time `json:"created_at"`
34+ PublishAt *time.Time `json:"publish_at"`
35+ Username string `json:"username"`
36+ UpdatedAt *time.Time `json:"updated_at"`
37+ Hidden bool `json:"hidden"`
38+ Views int `json:"views"`
39+ Space string `json:"space"`
40+ Score string `json:"score"`
41+}
42+
43+type Paginate[T any] struct {
44+ Data []T
45+ Total int
46+}
47+
48+type Analytics struct {
49+ TotalUsers int
50+ UsersLastMonth int
51+ TotalPosts int
52+ PostsLastMonth int
53+ UsersWithPost int
54+}
55+
56+type PostAnalytics struct {
57+ ID string
58+ PostID string
59+ Views int
60+ UpdateAt *time.Time
61+}
62+
63+type Pager struct {
64+ Num int
65+ Page int
66+}
67+
68+type ErrMultiplePublicKeys struct{}
69+
70+func (m *ErrMultiplePublicKeys) Error() string {
71+ return "there are multiple users with this public key, you must provide the username when using SSH: `ssh <user>@<domain>`\n"
72+}
73+
74+var NameValidator = regexp.MustCompile("^[a-zA-Z0-9]{1,50}$")
75+var DenyList = []string{
76+ "admin",
77+ "abuse",
78+ "cgi",
79+ "ops",
80+ "help",
81+ "spec",
82+ "root",
83+ "new",
84+ "create",
85+ "www",
86+}
87+
88+type DB interface {
89+ AddUser() (string, error)
90+ RemoveUsers(userIDs []string) error
91+ LinkUserKey(userID string, key string) error
92+ FindPublicKeyForKey(key string) (*PublicKey, error)
93+ FindKeysForUser(user *User) ([]*PublicKey, error)
94+ RemoveKeys(keyIDs []string) error
95+
96+ FindSiteAnalytics(space string) (*Analytics, error)
97+
98+ FindUsers() ([]*User, error)
99+ FindUserForName(name string) (*User, error)
100+ FindUserForNameAndKey(name string, key string) (*User, error)
101+ FindUserForKey(name string, key string) (*User, error)
102+ FindUser(userID string) (*User, error)
103+ ValidateName(name string) bool
104+ SetUserName(userID string, name string) error
105+
106+ FindPosts() ([]*Post, error)
107+ FindPost(postID string) (*Post, error)
108+ FindPostsForUser(userID string, space string) ([]*Post, error)
109+ FindPostsBeforeDate(date *time.Time, space string) ([]*Post, error)
110+ FindUpdatedPostsForUser(userID string, space string) ([]*Post, error)
111+ FindPostWithFilename(filename string, userID string, space string) (*Post, error)
112+ FindAllPosts(pager *Pager, space string) (*Paginate[*Post], error)
113+ FindAllUpdatedPosts(pager *Pager, space string) (*Paginate[*Post], error)
114+ InsertPost(userID string, filename string, title string, text string, description string, publishAt *time.Time, hidden bool, space string) (*Post, error)
115+ UpdatePost(postID string, title string, text string, description string, publishAt *time.Time) (*Post, error)
116+ RemovePosts(postIDs []string) error
117+
118+ AddViewCount(postID string) (int, error)
119+
120+ Close() error
121+}
+600,
-0
1@@ -0,0 +1,600 @@
2+package postgres
3+
4+import (
5+ "database/sql"
6+ "errors"
7+ "math"
8+ "strings"
9+ "time"
10+
11+ "git.sr.ht/~erock/pico/wish/cms/config"
12+ "git.sr.ht/~erock/pico/wish/cms/db"
13+ _ "github.com/lib/pq"
14+ "go.uber.org/zap"
15+ "golang.org/x/exp/slices"
16+)
17+
18+var PAGER_SIZE = 15
19+
20+const (
21+ sqlSelectPublicKey = `SELECT id, user_id, public_key, created_at FROM public_keys WHERE public_key = $1`
22+ sqlSelectPublicKeys = `SELECT id, user_id, public_key, created_at FROM public_keys WHERE user_id = $1`
23+ sqlSelectUser = `SELECT id, name, created_at FROM app_users WHERE id = $1`
24+ sqlSelectUserForName = `SELECT id, name, created_at FROM app_users WHERE name = $1`
25+ sqlSelectUserForNameAndKey = `SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT OUTER JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`
26+ sqlSelectUsers = `SELECT id, name, created_at FROM app_users ORDER BY name ASC`
27+
28+ sqlSelectTotalUsers = `SELECT count(id) FROM app_users`
29+ sqlSelectUsersAfterDate = `SELECT count(id) FROM app_users WHERE created_at >= $1`
30+ sqlSelectTotalPosts = `SELECT count(id) FROM posts WHERE cur_space = $1`
31+ sqlSelectTotalPostsAfterDate = `SELECT count(id) FROM posts WHERE created_at >= $1 AND cur_space = $2`
32+ sqlSelectUsersWithPost = `SELECT count(app_users.id) FROM app_users WHERE EXISTS (SELECT 1 FROM posts WHERE user_id = app_users.id AND cur_space = $1);`
33+
34+ sqlSelectPosts = `SELECT id, user_id, filename, title, text, description, created_at, publish_at, updated_at, hidden FROM posts`
35+ sqlSelectPostsBeforeDate = `SELECT posts.id, user_id, filename, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE publish_at::date <= $1 AND cur_space = $2`
36+ sqlSelectPostWithFilename = `SELECT posts.id, user_id, filename, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE filename = $1 AND user_id = $2 AND cur_space = $3`
37+ sqlSelectPost = `SELECT posts.id, user_id, filename, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE posts.id = $1`
38+ sqlSelectPostsForUser = `SELECT posts.id, user_id, filename, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE user_id = $1 AND publish_at::date <= CURRENT_DATE AND cur_space = $2 ORDER BY publish_at DESC`
39+ sqlSelectUpdatedPostsForUser = `SELECT posts.id, user_id, filename, title, text, description, publish_at, app_users.name as username, posts.updated_at FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE user_id = $1 AND publish_at::date <= CURRENT_DATE AND cur_space = $2 ORDER BY updated_at DESC`
40+ sqlSelectAllUpdatedPosts = `SELECT posts.id, user_id, filename, title, text, description, publish_at, app_users.name as username, posts.updated_at, 0 as score FROM posts LEFT OUTER JOIN app_users ON app_users.id = posts.user_id WHERE hidden = FALSE AND publish_at::date <= CURRENT_DATE AND cur_space = $3 ORDER BY updated_at DESC LIMIT $1 OFFSET $2`
41+ sqlSelectPostCount = `SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`
42+ sqlSelectPostsByRank = `
43+ SELECT
44+ posts.id,
45+ user_id,
46+ filename,
47+ title,
48+ text,
49+ description,
50+ publish_at,
51+ app_users.name as username,
52+ posts.updated_at,
53+ (
54+ LOG(2.0, COALESCE(NULLIF(posts.views, 0), 1)) / (
55+ EXTRACT(
56+ EPOCH FROM (STATEMENT_TIMESTAMP() - posts.publish_at)
57+ ) / (14 * 8600)
58+ )
59+ ) AS "score"
60+ FROM posts
61+ LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
62+ WHERE
63+ hidden = FALSE AND
64+ publish_at::date <= CURRENT_DATE AND
65+ cur_space = $3
66+ ORDER BY score DESC
67+ LIMIT $1 OFFSET $2`
68+
69+ sqlInsertPublicKey = `INSERT INTO public_keys (user_id, public_key) VALUES ($1, $2)`
70+ sqlInsertPost = `INSERT INTO posts (user_id, filename, title, text, description, publish_at, hidden, cur_space) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`
71+ sqlInsertUser = `INSERT INTO app_users DEFAULT VALUES returning id`
72+
73+ sqlUpdatePost = `UPDATE posts SET title = $1, text = $2, description = $3, updated_at = $4, publish_at = $5 WHERE id = $6`
74+ sqlUpdateUserName = `UPDATE app_users SET name = $1 WHERE id = $2`
75+ sqlIncrementViews = `UPDATE posts SET views = views + 1 WHERE id = $1 RETURNING views`
76+
77+ sqlRemovePosts = `DELETE FROM posts WHERE id = ANY($1::uuid[])`
78+ sqlRemoveKeys = `DELETE FROM public_keys WHERE id = ANY($1::uuid[])`
79+ sqlRemoveUsers = `DELETE FROM app_users WHERE id = ANY($1::uuid[])`
80+)
81+
82+type PsqlDB struct {
83+ Logger *zap.SugaredLogger
84+ Db *sql.DB
85+}
86+
87+func NewDB(cfg *config.ConfigCms) *PsqlDB {
88+ databaseUrl := cfg.DbURL
89+ var err error
90+ d := &PsqlDB{
91+ Logger: cfg.Logger,
92+ }
93+ d.Logger.Infof("Connecting to postgres: %s", databaseUrl)
94+
95+ db, err := sql.Open("postgres", databaseUrl)
96+ if err != nil {
97+ d.Logger.Fatal(err)
98+ }
99+ d.Db = db
100+ return d
101+}
102+
103+func (me *PsqlDB) AddUser() (string, error) {
104+ var id string
105+ err := me.Db.QueryRow(sqlInsertUser).Scan(&id)
106+ if err != nil {
107+ return "", err
108+ }
109+ return id, nil
110+}
111+
112+func (me *PsqlDB) RemoveUsers(userIDs []string) error {
113+ param := "{" + strings.Join(userIDs, ",") + "}"
114+ _, err := me.Db.Exec(sqlRemoveUsers, param)
115+ return err
116+}
117+
118+func (me *PsqlDB) LinkUserKey(userID string, key string) error {
119+ _, err := me.Db.Exec(sqlInsertPublicKey, userID, key)
120+ return err
121+}
122+
123+func (me *PsqlDB) FindPublicKeyForKey(key string) (*db.PublicKey, error) {
124+ var keys []*db.PublicKey
125+ rs, err := me.Db.Query(sqlSelectPublicKey, key)
126+ if err != nil {
127+ return nil, err
128+ }
129+
130+ for rs.Next() {
131+ pk := &db.PublicKey{}
132+ err := rs.Scan(&pk.ID, &pk.UserID, &pk.Key, &pk.CreatedAt)
133+ if err != nil {
134+ return nil, err
135+ }
136+
137+ keys = append(keys, pk)
138+ }
139+
140+ if rs.Err() != nil {
141+ return nil, rs.Err()
142+ }
143+
144+ if len(keys) == 0 {
145+ return nil, errors.New("no public keys found for key provided")
146+ }
147+
148+ // When we run PublicKeyForKey and there are multiple public keys returned from the database
149+ // that should mean that we don't have the correct username for this public key.
150+ // When that happens we need to reject the authentication and ask the user to provide the correct
151+ // username when using ssh. So instead of `ssh <domain>` it should be `ssh user@<domain>`
152+ if len(keys) > 1 {
153+ return nil, &db.ErrMultiplePublicKeys{}
154+ }
155+
156+ return keys[0], nil
157+}
158+
159+func (me *PsqlDB) FindKeysForUser(user *db.User) ([]*db.PublicKey, error) {
160+ var keys []*db.PublicKey
161+ rs, err := me.Db.Query(sqlSelectPublicKeys, user.ID)
162+ if err != nil {
163+ return keys, err
164+ }
165+ for rs.Next() {
166+ pk := &db.PublicKey{}
167+ err := rs.Scan(&pk.ID, &pk.UserID, &pk.Key, &pk.CreatedAt)
168+ if err != nil {
169+ return keys, err
170+ }
171+
172+ keys = append(keys, pk)
173+ }
174+ if rs.Err() != nil {
175+ return keys, rs.Err()
176+ }
177+ return keys, nil
178+}
179+
180+func (me *PsqlDB) RemoveKeys(keyIDs []string) error {
181+ param := "{" + strings.Join(keyIDs, ",") + "}"
182+ _, err := me.Db.Exec(sqlRemoveKeys, param)
183+ return err
184+}
185+
186+func (me *PsqlDB) FindSiteAnalytics(space string) (*db.Analytics, error) {
187+ analytics := &db.Analytics{}
188+ r := me.Db.QueryRow(sqlSelectTotalUsers)
189+ err := r.Scan(&analytics.TotalUsers)
190+ if err != nil {
191+ return nil, err
192+ }
193+
194+ r = me.Db.QueryRow(sqlSelectTotalPosts, space)
195+ err = r.Scan(&analytics.TotalPosts)
196+ if err != nil {
197+ return nil, err
198+ }
199+
200+ now := time.Now()
201+ year, month, _ := now.Date()
202+ begMonth := time.Date(year, month, 1, 0, 0, 0, 0, now.Location())
203+
204+ r = me.Db.QueryRow(sqlSelectTotalPostsAfterDate, begMonth, space)
205+ err = r.Scan(&analytics.PostsLastMonth)
206+ if err != nil {
207+ return nil, err
208+ }
209+
210+ r = me.Db.QueryRow(sqlSelectUsersAfterDate, begMonth)
211+ err = r.Scan(&analytics.UsersLastMonth)
212+ if err != nil {
213+ return nil, err
214+ }
215+
216+ r = me.Db.QueryRow(sqlSelectUsersWithPost, space)
217+ err = r.Scan(&analytics.UsersWithPost)
218+ if err != nil {
219+ return nil, err
220+ }
221+
222+ return analytics, nil
223+}
224+
225+func (me *PsqlDB) FindPostsBeforeDate(date *time.Time, space string) ([]*db.Post, error) {
226+ // now := time.Now()
227+ // expired := now.AddDate(0, 0, -3)
228+ var posts []*db.Post
229+ rs, err := me.Db.Query(sqlSelectPostsBeforeDate, date, space)
230+ if err != nil {
231+ return posts, err
232+ }
233+ for rs.Next() {
234+ post := &db.Post{}
235+ err := rs.Scan(
236+ &post.ID,
237+ &post.UserID,
238+ &post.Filename,
239+ &post.Title,
240+ &post.Text,
241+ &post.Description,
242+ &post.PublishAt,
243+ &post.Username,
244+ &post.UpdatedAt,
245+ )
246+ if err != nil {
247+ return posts, err
248+ }
249+
250+ posts = append(posts, post)
251+ }
252+ if rs.Err() != nil {
253+ return posts, rs.Err()
254+ }
255+ return posts, nil
256+}
257+
258+func (me *PsqlDB) FindUserForKey(username string, key string) (*db.User, error) {
259+ me.Logger.Infof("Attempting to find user with only public key (%s)", key)
260+ pk, err := me.FindPublicKeyForKey(key)
261+ if err == nil {
262+ user, err := me.FindUser(pk.UserID)
263+ if err != nil {
264+ return nil, err
265+ }
266+ user.PublicKey = pk
267+ return user, nil
268+ }
269+
270+ if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
271+ me.Logger.Infof("Detected multiple users with same public key, using ssh username (%s) to find correct one", username)
272+ user, err := me.FindUserForNameAndKey(username, key)
273+ if err != nil {
274+ me.Logger.Infof("Could not find user by username (%s) and public key (%s)", username, key)
275+ // this is a little hacky but if we cannot find a user by name and public key
276+ // then we return the multiple keys detected error so the user knows to specify their
277+ // when logging in
278+ return nil, &db.ErrMultiplePublicKeys{}
279+ }
280+ return user, nil
281+ }
282+
283+ return nil, err
284+}
285+
286+func (me *PsqlDB) FindUser(userID string) (*db.User, error) {
287+ user := &db.User{}
288+ var un sql.NullString
289+ r := me.Db.QueryRow(sqlSelectUser, userID)
290+ err := r.Scan(&user.ID, &un, &user.CreatedAt)
291+ if err != nil {
292+ return nil, err
293+ }
294+ if un.Valid {
295+ user.Name = un.String
296+ }
297+ return user, nil
298+}
299+
300+func (me *PsqlDB) ValidateName(name string) bool {
301+ lower := strings.ToLower(name)
302+ if slices.Contains(db.DenyList, lower) {
303+ return false
304+ }
305+ v := db.NameValidator.MatchString(lower)
306+ if !v {
307+ return false
308+ }
309+ user, _ := me.FindUserForName(lower)
310+ return user == nil
311+}
312+
313+func (me *PsqlDB) FindUserForName(name string) (*db.User, error) {
314+ user := &db.User{}
315+ r := me.Db.QueryRow(sqlSelectUserForName, strings.ToLower(name))
316+ err := r.Scan(&user.ID, &user.Name, &user.CreatedAt)
317+ if err != nil {
318+ return nil, err
319+ }
320+ return user, nil
321+}
322+
323+func (me *PsqlDB) FindUserForNameAndKey(name string, key string) (*db.User, error) {
324+ user := &db.User{}
325+ pk := &db.PublicKey{}
326+
327+ r := me.Db.QueryRow(sqlSelectUserForNameAndKey, strings.ToLower(name), key)
328+ err := r.Scan(&user.ID, &user.Name, &user.CreatedAt, &pk.ID, &pk.Key, &pk.CreatedAt)
329+ if err != nil {
330+ return nil, err
331+ }
332+
333+ user.PublicKey = pk
334+ return user, nil
335+}
336+
337+func (me *PsqlDB) SetUserName(userID string, name string) error {
338+ lowerName := strings.ToLower(name)
339+ if !me.ValidateName(lowerName) {
340+ return errors.New("name is already taken")
341+ }
342+
343+ _, err := me.Db.Exec(sqlUpdateUserName, lowerName, userID)
344+ return err
345+}
346+
347+func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space string) (*db.Post, error) {
348+ post := &db.Post{}
349+ r := me.Db.QueryRow(sqlSelectPostWithFilename, filename, persona_id, space)
350+ err := r.Scan(
351+ &post.ID,
352+ &post.UserID,
353+ &post.Filename,
354+ &post.Title,
355+ &post.Text,
356+ &post.Description,
357+ &post.PublishAt,
358+ &post.Username,
359+ &post.UpdatedAt,
360+ )
361+ if err != nil {
362+ return nil, err
363+ }
364+
365+ return post, nil
366+}
367+
368+func (me *PsqlDB) FindPost(postID string) (*db.Post, error) {
369+ post := &db.Post{}
370+ r := me.Db.QueryRow(sqlSelectPost, postID)
371+ err := r.Scan(
372+ &post.ID,
373+ &post.UserID,
374+ &post.Filename,
375+ &post.Title,
376+ &post.Text,
377+ &post.Description,
378+ &post.PublishAt,
379+ &post.Username,
380+ &post.UpdatedAt,
381+ )
382+ if err != nil {
383+ return nil, err
384+ }
385+
386+ return post, nil
387+}
388+
389+func (me *PsqlDB) postPager(rs *sql.Rows, pageNum int, space string) (*db.Paginate[*db.Post], error) {
390+ var posts []*db.Post
391+ for rs.Next() {
392+ post := &db.Post{}
393+ err := rs.Scan(
394+ &post.ID,
395+ &post.UserID,
396+ &post.Filename,
397+ &post.Title,
398+ &post.Text,
399+ &post.Description,
400+ &post.PublishAt,
401+ &post.Username,
402+ &post.UpdatedAt,
403+ &post.Score,
404+ )
405+ if err != nil {
406+ return nil, err
407+ }
408+
409+ posts = append(posts, post)
410+ }
411+ if rs.Err() != nil {
412+ return nil, rs.Err()
413+ }
414+
415+ var count int
416+ err := me.Db.QueryRow(sqlSelectPostCount, space).Scan(&count)
417+ if err != nil {
418+ return nil, err
419+ }
420+
421+ pager := &db.Paginate[*db.Post]{
422+ Data: posts,
423+ Total: int(math.Ceil(float64(count) / float64(pageNum))),
424+ }
425+
426+ return pager, nil
427+}
428+
429+func (me *PsqlDB) FindAllPosts(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
430+ rs, err := me.Db.Query(sqlSelectPostsByRank, page.Num, page.Num*page.Page, space)
431+ if err != nil {
432+ return nil, err
433+ }
434+ return me.postPager(rs, page.Num, space)
435+}
436+
437+func (me *PsqlDB) FindAllUpdatedPosts(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
438+ rs, err := me.Db.Query(sqlSelectAllUpdatedPosts, page.Num, page.Num*page.Page, space)
439+ if err != nil {
440+ return nil, err
441+ }
442+ return me.postPager(rs, page.Num, space)
443+}
444+
445+func (me *PsqlDB) InsertPost(userID string, filename string, title string, text string, description string, publishAt *time.Time, hidden bool, space string) (*db.Post, error) {
446+ var id string
447+ err := me.Db.QueryRow(sqlInsertPost, userID, filename, title, text, description, publishAt, hidden, space).Scan(&id)
448+ if err != nil {
449+ return nil, err
450+ }
451+
452+ return me.FindPost(id)
453+}
454+
455+func (me *PsqlDB) UpdatePost(postID string, title string, text string, description string, publishAt *time.Time) (*db.Post, error) {
456+ _, err := me.Db.Exec(sqlUpdatePost, title, text, description, time.Now(), publishAt, postID)
457+ if err != nil {
458+ return nil, err
459+ }
460+
461+ return me.FindPost(postID)
462+}
463+
464+func (me *PsqlDB) RemovePosts(postIDs []string) error {
465+ param := "{" + strings.Join(postIDs, ",") + "}"
466+ _, err := me.Db.Exec(sqlRemovePosts, param)
467+ return err
468+}
469+
470+func (me *PsqlDB) FindPostsForUser(userID string, space string) ([]*db.Post, error) {
471+ var posts []*db.Post
472+ rs, err := me.Db.Query(sqlSelectPostsForUser, userID, space)
473+ if err != nil {
474+ return posts, err
475+ }
476+ for rs.Next() {
477+ post := &db.Post{}
478+ err := rs.Scan(
479+ &post.ID,
480+ &post.UserID,
481+ &post.Filename,
482+ &post.Title,
483+ &post.Text,
484+ &post.Description,
485+ &post.PublishAt,
486+ &post.Username,
487+ &post.UpdatedAt,
488+ )
489+ if err != nil {
490+ return posts, err
491+ }
492+
493+ posts = append(posts, post)
494+ }
495+ if rs.Err() != nil {
496+ return posts, rs.Err()
497+ }
498+ return posts, nil
499+}
500+
501+func (me *PsqlDB) FindPosts() ([]*db.Post, error) {
502+ var posts []*db.Post
503+ rs, err := me.Db.Query(sqlSelectPosts)
504+ if err != nil {
505+ return posts, err
506+ }
507+ for rs.Next() {
508+ post := &db.Post{}
509+ err := rs.Scan(
510+ &post.ID,
511+ &post.UserID,
512+ &post.Filename,
513+ &post.Title,
514+ &post.Text,
515+ &post.Description,
516+ &post.CreatedAt,
517+ &post.PublishAt,
518+ &post.UpdatedAt,
519+ &post.Hidden,
520+ )
521+ if err != nil {
522+ return posts, err
523+ }
524+
525+ posts = append(posts, post)
526+ }
527+ if rs.Err() != nil {
528+ return posts, rs.Err()
529+ }
530+ return posts, nil
531+}
532+
533+func (me *PsqlDB) FindUpdatedPostsForUser(userID string, space string) ([]*db.Post, error) {
534+ var posts []*db.Post
535+ rs, err := me.Db.Query(sqlSelectUpdatedPostsForUser, userID, space)
536+ if err != nil {
537+ return posts, err
538+ }
539+ for rs.Next() {
540+ post := &db.Post{}
541+ err := rs.Scan(
542+ &post.ID,
543+ &post.UserID,
544+ &post.Filename,
545+ &post.Title,
546+ &post.Text,
547+ &post.Description,
548+ &post.PublishAt,
549+ &post.Username,
550+ &post.UpdatedAt,
551+ )
552+ if err != nil {
553+ return posts, err
554+ }
555+
556+ posts = append(posts, post)
557+ }
558+ if rs.Err() != nil {
559+ return posts, rs.Err()
560+ }
561+ return posts, nil
562+}
563+
564+func (me *PsqlDB) Close() error {
565+ me.Logger.Info("Closing db")
566+ return me.Db.Close()
567+}
568+
569+func (me *PsqlDB) AddViewCount(postID string) (int, error) {
570+ views := 0
571+ err := me.Db.QueryRow(sqlIncrementViews, postID).Scan(&views)
572+ if err != nil {
573+ return views, err
574+ }
575+ return views, nil
576+}
577+
578+func (me *PsqlDB) FindUsers() ([]*db.User, error) {
579+ var users []*db.User
580+ rs, err := me.Db.Query(sqlSelectUsers)
581+ if err != nil {
582+ return users, err
583+ }
584+ for rs.Next() {
585+ user := &db.User{}
586+ err := rs.Scan(
587+ &user.ID,
588+ &user.Name,
589+ &user.CreatedAt,
590+ )
591+ if err != nil {
592+ return users, err
593+ }
594+
595+ users = append(users, user)
596+ }
597+ if rs.Err() != nil {
598+ return users, rs.Err()
599+ }
600+ return users, nil
601+}
+14,
-0
1@@ -0,0 +1,14 @@
2+package db
3+
4+import "strings"
5+
6+func FilterMetaFiles(posts []*Post) []*Post {
7+ filtered := []*Post{}
8+ for _, post := range posts {
9+ if strings.HasPrefix(post.Filename, "_") {
10+ continue
11+ }
12+ filtered = append(filtered, post)
13+ }
14+ return filtered
15+}
+316,
-0
1@@ -0,0 +1,316 @@
2+package account
3+
4+import (
5+ "fmt"
6+ "strings"
7+
8+ "git.sr.ht/~erock/pico/wish/cms/config"
9+ "git.sr.ht/~erock/pico/wish/cms/db"
10+ "git.sr.ht/~erock/pico/wish/cms/ui/common"
11+ "github.com/charmbracelet/bubbles/spinner"
12+ input "github.com/charmbracelet/bubbles/textinput"
13+ tea "github.com/charmbracelet/bubbletea"
14+)
15+
16+type state int
17+
18+const (
19+ ready state = iota
20+ submitting
21+)
22+
23+// index specifies the UI element that's in focus.
24+type index int
25+
26+const (
27+ textInput index = iota
28+ okButton
29+ cancelButton
30+)
31+
32+type CreateAccountMsg *db.User
33+
34+// NameTakenMsg is sent when the requested username has already been taken.
35+type NameTakenMsg struct{}
36+
37+// NameInvalidMsg is sent when the requested username has failed validation.
38+type NameInvalidMsg struct{}
39+
40+type errMsg struct{ err error }
41+
42+func (e errMsg) Error() string { return e.err.Error() }
43+
44+// Model holds the state of the username UI.
45+type CreateModel struct {
46+ Done bool // true when it's time to exit this view
47+ Quit bool // true when the user wants to quit the whole program
48+
49+ cfg *config.ConfigCms
50+ dbpool db.DB
51+ publicKey string
52+ styles common.Styles
53+ state state
54+ newName string
55+ index index
56+ errMsg string
57+ input input.Model
58+ spinner spinner.Model
59+}
60+
61+// updateFocus updates the focused states in the model based on the current
62+// focus index.
63+func (m *CreateModel) updateFocus() {
64+ if m.index == textInput && !m.input.Focused() {
65+ m.input.Focus()
66+ m.input.Prompt = m.styles.FocusedPrompt.String()
67+ } else if m.index != textInput && m.input.Focused() {
68+ m.input.Blur()
69+ m.input.Prompt = m.styles.Prompt.String()
70+ }
71+}
72+
73+// Move the focus index one unit forward.
74+func (m *CreateModel) indexForward() {
75+ m.index++
76+ if m.index > cancelButton {
77+ m.index = textInput
78+ }
79+
80+ m.updateFocus()
81+}
82+
83+// Move the focus index one unit backwards.
84+func (m *CreateModel) indexBackward() {
85+ m.index--
86+ if m.index < textInput {
87+ m.index = cancelButton
88+ }
89+
90+ m.updateFocus()
91+}
92+
93+// NewModel returns a new username model in its initial state.
94+func NewCreateModel(cfg *config.ConfigCms, dbpool db.DB, publicKey string) CreateModel {
95+ st := common.DefaultStyles()
96+
97+ im := input.NewModel()
98+ im.CursorStyle = st.Cursor
99+ im.Placeholder = "erock"
100+ im.Prompt = st.FocusedPrompt.String()
101+ im.CharLimit = 50
102+ im.Focus()
103+
104+ return CreateModel{
105+ cfg: cfg,
106+ Done: false,
107+ Quit: false,
108+ dbpool: dbpool,
109+ styles: st,
110+ state: ready,
111+ newName: "",
112+ index: textInput,
113+ errMsg: "",
114+ input: im,
115+ spinner: common.NewSpinner(),
116+ publicKey: publicKey,
117+ }
118+}
119+
120+// Init is the Bubble Tea initialization function.
121+func Init(cfg *config.ConfigCms, dbpool db.DB, publicKey string) func() (CreateModel, tea.Cmd) {
122+ return func() (CreateModel, tea.Cmd) {
123+ m := NewCreateModel(cfg, dbpool, publicKey)
124+ return m, InitialCmd()
125+ }
126+}
127+
128+// InitialCmd returns the initial command.
129+func InitialCmd() tea.Cmd {
130+ return input.Blink
131+}
132+
133+// Update is the Bubble Tea update loop.
134+func Update(msg tea.Msg, m CreateModel) (CreateModel, tea.Cmd) {
135+ switch msg := msg.(type) {
136+ case tea.KeyMsg:
137+ switch msg.Type {
138+ case tea.KeyCtrlC: // quit
139+ m.Quit = true
140+ return m, nil
141+ case tea.KeyEscape: // exit this mini-app
142+ m.Done = true
143+ return m, nil
144+
145+ default:
146+ // Ignore keys if we're submitting
147+ if m.state == submitting {
148+ return m, nil
149+ }
150+
151+ switch msg.String() {
152+ case "tab":
153+ m.indexForward()
154+ case "shift+tab":
155+ m.indexBackward()
156+ case "l", "k", "right":
157+ if m.index != textInput {
158+ m.indexForward()
159+ }
160+ case "h", "j", "left":
161+ if m.index != textInput {
162+ m.indexBackward()
163+ }
164+ case "up", "down":
165+ if m.index == textInput {
166+ m.indexForward()
167+ } else {
168+ m.index = textInput
169+ m.updateFocus()
170+ }
171+ case "enter":
172+ switch m.index {
173+ case textInput:
174+ fallthrough
175+ case okButton: // Submit the form
176+ m.state = submitting
177+ m.errMsg = ""
178+ m.newName = strings.TrimSpace(m.input.Value())
179+
180+ return m, tea.Batch(
181+ createAccount(m), // fire off the command, too
182+ spinner.Tick,
183+ )
184+ case cancelButton: // Exit
185+ m.Quit = true
186+ return m, nil
187+ }
188+ }
189+
190+ // Pass messages through to the input element if that's the element
191+ // in focus
192+ if m.index == textInput {
193+ var cmd tea.Cmd
194+ m.input, cmd = m.input.Update(msg)
195+
196+ return m, cmd
197+ }
198+
199+ return m, nil
200+ }
201+
202+ case NameTakenMsg:
203+ m.state = ready
204+ m.errMsg = m.styles.Subtle.Render("Sorry, ") +
205+ m.styles.Error.Render(m.newName) +
206+ m.styles.Subtle.Render(" is taken.")
207+
208+ return m, nil
209+
210+ case NameInvalidMsg:
211+ m.state = ready
212+ head := m.styles.Error.Render("Invalid name. ")
213+ deny := strings.Join(db.DenyList, ", ")
214+ helpMsg := fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
215+ body := m.styles.Subtle.Render(helpMsg)
216+ m.errMsg = m.styles.Wrap.Render(head + body)
217+
218+ return m, nil
219+
220+ case errMsg:
221+ m.state = ready
222+ head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
223+ body := m.styles.Subtle.Render(msg.Error())
224+ m.errMsg = m.styles.Wrap.Render(head + body)
225+
226+ return m, nil
227+
228+ case spinner.TickMsg:
229+ var cmd tea.Cmd
230+ m.spinner, cmd = m.spinner.Update(msg)
231+
232+ return m, cmd
233+
234+ default:
235+ var cmd tea.Cmd
236+ m.input, cmd = m.input.Update(msg) // Do we still need this?
237+
238+ return m, cmd
239+ }
240+}
241+
242+// View renders current view from the model.
243+func View(m CreateModel) string {
244+ s := fmt.Sprintf("%s\n\n%s\n\n", m.cfg.Description, m.cfg.IntroText)
245+ s += "Enter a username\n\n"
246+ s += m.input.View() + "\n\n"
247+
248+ if m.state == submitting {
249+ s += spinnerView(m)
250+ } else {
251+ s += common.OKButtonView(m.index == 1, true)
252+ s += " " + common.CancelButtonView(m.index == 2, false)
253+ if m.errMsg != "" {
254+ s += "\n\n" + m.errMsg
255+ }
256+ }
257+
258+ return s
259+}
260+
261+func spinnerView(m CreateModel) string {
262+ return m.spinner.View() + " Creating account..."
263+}
264+
265+func registerUser(m CreateModel) (*db.User, error) {
266+ userID, err := m.dbpool.AddUser()
267+ if err != nil {
268+ return nil, err
269+ }
270+
271+ err = m.dbpool.LinkUserKey(userID, m.publicKey)
272+ if err != nil {
273+ return nil, err
274+ }
275+
276+ user, err := m.dbpool.FindUser(userID)
277+ if err != nil {
278+ return nil, err
279+ }
280+
281+ return user, nil
282+
283+}
284+
285+// Attempt to update the username on the server.
286+func createAccount(m CreateModel) tea.Cmd {
287+ return func() tea.Msg {
288+ if m.newName == "" {
289+ return NameInvalidMsg{}
290+ }
291+
292+ // Validate before resetting the session to potentially save some
293+ // network traffic and keep things feeling speedy.
294+ if !m.dbpool.ValidateName(m.newName) {
295+ return NameInvalidMsg{}
296+ }
297+
298+ user, err := registerUser(m)
299+ if err != nil {
300+ return errMsg{err}
301+ }
302+
303+ err = m.dbpool.SetUserName(user.ID, m.newName)
304+ if err == db.ErrNameTaken {
305+ return NameTakenMsg{}
306+ } else if err != nil {
307+ return errMsg{err}
308+ }
309+
310+ user, err = m.dbpool.FindUserForKey(m.newName, m.publicKey)
311+ if err != nil {
312+ return errMsg{err}
313+ }
314+
315+ return CreateAccountMsg(user)
316+ }
317+}
+86,
-0
1@@ -0,0 +1,86 @@
2+package common
3+
4+import (
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+// Color definitions.
9+var (
10+ indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
11+ subtleIndigo = lipgloss.AdaptiveColor{Light: "#7D79F6", Dark: "#514DC1"}
12+ cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
13+ fuschia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
14+ green = lipgloss.Color("#04B575")
15+ red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
16+ faintRed = lipgloss.AdaptiveColor{Light: "#FF6F91", Dark: "#C74665"}
17+)
18+
19+type Styles struct {
20+ Cursor,
21+ Wrap,
22+ Paragraph,
23+ Keyword,
24+ Code,
25+ Subtle,
26+ Error,
27+ Prompt,
28+ FocusedPrompt,
29+ Note,
30+ NoteDim,
31+ Delete,
32+ DeleteDim,
33+ Label,
34+ LabelDim,
35+ ListKey,
36+ ListDim,
37+ InactivePagination,
38+ SelectionMarker,
39+ SelectedMenuItem,
40+ Checkmark,
41+ Logo,
42+ App lipgloss.Style
43+}
44+
45+func DefaultStyles() Styles {
46+ s := Styles{}
47+
48+ s.Cursor = lipgloss.NewStyle().Foreground(fuschia)
49+ s.Wrap = lipgloss.NewStyle().Width(58)
50+ s.Keyword = lipgloss.NewStyle().Foreground(green)
51+ s.Paragraph = s.Wrap.Copy().Margin(1, 0, 0, 2)
52+ s.Code = lipgloss.NewStyle().
53+ Foreground(lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}).
54+ Background(lipgloss.AdaptiveColor{Light: "#EBE5EC", Dark: "#2B2A2A"}).
55+ Padding(0, 1)
56+ s.Subtle = lipgloss.NewStyle().
57+ Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
58+ s.Error = lipgloss.NewStyle().Foreground(red)
59+ s.Prompt = lipgloss.NewStyle().MarginRight(1).SetString(">")
60+ s.FocusedPrompt = s.Prompt.Copy().Foreground(fuschia)
61+ s.Note = lipgloss.NewStyle().Foreground(green)
62+ s.NoteDim = lipgloss.NewStyle().
63+ Foreground(lipgloss.AdaptiveColor{Light: "#ABE5D1", Dark: "#2B4A3F"})
64+ s.Delete = s.Error.Copy()
65+ s.DeleteDim = lipgloss.NewStyle().Foreground(faintRed)
66+ s.Label = lipgloss.NewStyle().Foreground(fuschia)
67+ s.LabelDim = lipgloss.NewStyle().Foreground(indigo)
68+ s.ListKey = lipgloss.NewStyle().Foreground(indigo)
69+ s.ListDim = lipgloss.NewStyle().Foreground(subtleIndigo)
70+ s.InactivePagination = lipgloss.NewStyle().
71+ Foreground(lipgloss.AdaptiveColor{Light: "#CACACA", Dark: "#4F4F4F"})
72+ s.SelectionMarker = lipgloss.NewStyle().
73+ Foreground(fuschia).
74+ PaddingRight(1).
75+ SetString(">")
76+ s.Checkmark = lipgloss.NewStyle().
77+ SetString("✔").
78+ Foreground(green)
79+ s.SelectedMenuItem = lipgloss.NewStyle().Foreground(fuschia)
80+ s.Logo = lipgloss.NewStyle().
81+ Foreground(cream).
82+ Background(lipgloss.Color("#5A56E0")).
83+ Padding(0, 1)
84+ s.App = lipgloss.NewStyle().Margin(1, 0, 1, 2)
85+
86+ return s
87+}
+135,
-0
1@@ -0,0 +1,135 @@
2+package common
3+
4+import (
5+ "fmt"
6+ "strings"
7+
8+ "github.com/charmbracelet/bubbles/spinner"
9+ "github.com/charmbracelet/lipgloss"
10+)
11+
12+// State is a general UI state used to help style components.
13+type State int
14+
15+// UI states.
16+const (
17+ StateNormal State = iota
18+ StateSelected
19+ StateActive
20+ StateSpecial
21+ StateDeleting
22+)
23+
24+var lineColors = map[State]lipgloss.TerminalColor{
25+ StateNormal: lipgloss.AdaptiveColor{Light: "#BCBCBC", Dark: "#646464"},
26+ StateSelected: lipgloss.Color("#F684FF"),
27+ StateDeleting: lipgloss.AdaptiveColor{Light: "#FF8BA7", Dark: "#893D4E"},
28+ StateSpecial: lipgloss.Color("#04B575"),
29+}
30+
31+// VerticalLine return a vertical line colored according to the given state.
32+func VerticalLine(state State) string {
33+ return lipgloss.NewStyle().
34+ SetString("│").
35+ Foreground(lineColors[state]).
36+ String()
37+}
38+
39+var valStyle = lipgloss.NewStyle().Foreground(indigo)
40+
41+var (
42+ spinnerStyle = lipgloss.NewStyle().
43+ Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
44+
45+ blurredButtonStyle = lipgloss.NewStyle().
46+ Foreground(cream).
47+ Background(lipgloss.AdaptiveColor{Light: "#BDB0BE", Dark: "#827983"}).
48+ Padding(0, 3)
49+
50+ focusedButtonStyle = blurredButtonStyle.Copy().
51+ Background(fuschia)
52+)
53+
54+// KeyValueView renders key-value pairs.
55+func KeyValueView(stuff ...string) string {
56+ if len(stuff) == 0 {
57+ return ""
58+ }
59+
60+ var (
61+ s string
62+ index int
63+ )
64+ for i := 0; i < len(stuff); i++ {
65+ if i%2 == 0 {
66+ // even: key
67+ s += fmt.Sprintf("%s %s: ", VerticalLine(StateNormal), stuff[i])
68+ continue
69+ }
70+ // odd: value
71+ s += valStyle.Render(stuff[i])
72+ s += "\n"
73+ index++
74+ }
75+
76+ return strings.TrimSpace(s)
77+}
78+
79+// NewSpinner returns a spinner model.
80+func NewSpinner() spinner.Model {
81+ s := spinner.NewModel()
82+ s.Spinner = spinner.Dot
83+ s.Style = spinnerStyle
84+ return s
85+}
86+
87+// OKButtonView returns a button reading "OK".
88+func OKButtonView(focused bool, defaultButton bool) string {
89+ return styledButton("OK", defaultButton, focused)
90+}
91+
92+// CancelButtonView returns a button reading "Cancel.".
93+func CancelButtonView(focused bool, defaultButton bool) string {
94+ return styledButton("Cancel", defaultButton, focused)
95+}
96+
97+func styledButton(str string, underlined, focused bool) string {
98+ var st lipgloss.Style
99+ if focused {
100+ st = focusedButtonStyle.Copy()
101+ } else {
102+ st = blurredButtonStyle.Copy()
103+ }
104+ if underlined {
105+ st = st.Underline(true)
106+ }
107+ return st.Render(str)
108+}
109+
110+var (
111+ helpDivider = lipgloss.NewStyle().
112+ Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}).
113+ Padding(0, 1).
114+ Render("•")
115+
116+ helpSection = lipgloss.NewStyle().
117+ Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
118+)
119+
120+// HelpView renders text intended to display at help text, often at the
121+// bottom of a view.
122+func HelpView(sections ...string) string {
123+ var s string
124+ if len(sections) == 0 {
125+ return s
126+ }
127+
128+ for i := 0; i < len(sections); i++ {
129+ s += helpSection.Render(sections[i])
130+ if i < len(sections)-1 {
131+ s += helpDivider
132+ }
133+ }
134+
135+ return s
136+}
+278,
-0
1@@ -0,0 +1,278 @@
2+package createkey
3+
4+import (
5+ "strings"
6+
7+ "git.sr.ht/~erock/pico/wish/cms/config"
8+ "git.sr.ht/~erock/pico/wish/cms/db"
9+ "git.sr.ht/~erock/pico/wish/cms/ui/common"
10+ "github.com/charmbracelet/bubbles/spinner"
11+ input "github.com/charmbracelet/bubbles/textinput"
12+ tea "github.com/charmbracelet/bubbletea"
13+ "golang.org/x/crypto/ssh"
14+)
15+
16+type state int
17+
18+const (
19+ ready state = iota
20+ submitting
21+)
22+
23+type index int
24+
25+const (
26+ textInput index = iota
27+ okButton
28+ cancelButton
29+)
30+
31+type KeySetMsg string
32+
33+type KeyInvalidMsg struct{}
34+
35+type errMsg struct {
36+ err error
37+}
38+
39+func (e errMsg) Error() string { return e.err.Error() }
40+
41+type Model struct {
42+ Done bool
43+ Quit bool
44+
45+ dbpool db.DB
46+ user *db.User
47+ styles common.Styles
48+ state state
49+ newKey string
50+ index index
51+ errMsg string
52+ input input.Model
53+ spinner spinner.Model
54+}
55+
56+// updateFocus updates the focused states in the model based on the current
57+// focus index.
58+func (m *Model) updateFocus() {
59+ if m.index == textInput && !m.input.Focused() {
60+ m.input.Focus()
61+ m.input.Prompt = m.styles.FocusedPrompt.String()
62+ } else if m.index != textInput && m.input.Focused() {
63+ m.input.Blur()
64+ m.input.Prompt = m.styles.Prompt.String()
65+ }
66+}
67+
68+// Move the focus index one unit forward.
69+func (m *Model) indexForward() {
70+ m.index++
71+ if m.index > cancelButton {
72+ m.index = textInput
73+ }
74+
75+ m.updateFocus()
76+}
77+
78+// Move the focus index one unit backwards.
79+func (m *Model) indexBackward() {
80+ m.index--
81+ if m.index < textInput {
82+ m.index = cancelButton
83+ }
84+
85+ m.updateFocus()
86+}
87+
88+// NewModel returns a new username model in its initial state.
89+func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
90+ st := common.DefaultStyles()
91+
92+ im := input.NewModel()
93+ im.CursorStyle = st.Cursor
94+ im.Placeholder = "ssh-ed25519 AAAA..."
95+ im.Prompt = st.FocusedPrompt.String()
96+ im.CharLimit = 500
97+ im.Focus()
98+
99+ return Model{
100+ Done: false,
101+ Quit: false,
102+ dbpool: dbpool,
103+ user: user,
104+ styles: st,
105+ state: ready,
106+ newKey: "",
107+ index: textInput,
108+ errMsg: "",
109+ input: im,
110+ spinner: common.NewSpinner(),
111+ }
112+}
113+
114+// Init is the Bubble Tea initialization function.
115+func (m Model) Init() tea.Cmd {
116+ return input.Blink
117+}
118+
119+// Update is the Bubble Tea update loop.
120+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121+ switch msg := msg.(type) {
122+ case tea.KeyMsg:
123+ switch msg.Type {
124+ case tea.KeyCtrlC: // quit
125+ m.Quit = true
126+ return m, nil
127+ case tea.KeyEscape: // exit this mini-app
128+ m.Done = true
129+ return m, nil
130+
131+ default:
132+ // Ignore keys if we're submitting
133+ if m.state == submitting {
134+ return m, nil
135+ }
136+
137+ switch msg.String() {
138+ case "tab":
139+ m.indexForward()
140+ case "shift+tab":
141+ m.indexBackward()
142+ case "l", "k", "right":
143+ if m.index != textInput {
144+ m.indexForward()
145+ }
146+ case "h", "j", "left":
147+ if m.index != textInput {
148+ m.indexBackward()
149+ }
150+ case "up", "down":
151+ if m.index == textInput {
152+ m.indexForward()
153+ } else {
154+ m.index = textInput
155+ m.updateFocus()
156+ }
157+ case "enter":
158+ switch m.index {
159+ case textInput:
160+ fallthrough
161+ case okButton: // Submit the form
162+ m.state = submitting
163+ m.errMsg = ""
164+ m.newKey = strings.TrimSpace(m.input.Value())
165+
166+ return m, tea.Batch(
167+ addPublicKey(m), // fire off the command, too
168+ spinner.Tick,
169+ )
170+ case cancelButton: // Exit this mini-app
171+ m.Done = true
172+ return m, nil
173+ }
174+ }
175+
176+ // Pass messages through to the input element if that's the element
177+ // in focus
178+ if m.index == textInput {
179+ var cmd tea.Cmd
180+ m.input, cmd = m.input.Update(msg)
181+
182+ return m, cmd
183+ }
184+
185+ return m, nil
186+ }
187+
188+ case KeyInvalidMsg:
189+ m.state = ready
190+ head := m.styles.Error.Render("Invalid public key. ")
191+ helpMsg := "Public keys must but in the correct format"
192+ body := m.styles.Subtle.Render(helpMsg)
193+ m.errMsg = m.styles.Wrap.Render(head + body)
194+
195+ return m, nil
196+
197+ case errMsg:
198+ m.state = ready
199+ head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
200+ body := m.styles.Subtle.Render(msg.Error())
201+ m.errMsg = m.styles.Wrap.Render(head + body)
202+
203+ return m, nil
204+
205+ case spinner.TickMsg:
206+ var cmd tea.Cmd
207+ m.spinner, cmd = m.spinner.Update(msg)
208+
209+ return m, cmd
210+
211+ default:
212+ var cmd tea.Cmd
213+ m.input, cmd = m.input.Update(msg) // Do we still need this?
214+
215+ return m, cmd
216+ }
217+}
218+
219+// View renders current view from the model.
220+func (m Model) View() string {
221+ s := "Enter a new public key\n\n"
222+ s += m.input.View() + "\n\n"
223+
224+ if m.state == submitting {
225+ s += spinnerView(m)
226+ } else {
227+ s += common.OKButtonView(m.index == 1, true)
228+ s += " " + common.CancelButtonView(m.index == 2, false)
229+ if m.errMsg != "" {
230+ s += "\n\n" + m.errMsg
231+ }
232+ }
233+
234+ return s
235+}
236+
237+func spinnerView(m Model) string {
238+ return m.spinner.View() + " Submitting..."
239+}
240+
241+func IsPublicKeyValid(key string) bool {
242+ if len(key) == 0 {
243+ return false
244+ }
245+
246+ _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
247+ return err == nil
248+}
249+
250+func sanitizeKey(key string) string {
251+ // comments are removed when using our ssh app so
252+ // we need to be sure to remove them from the public key
253+ parts := strings.Split(key, " ")
254+ keep := []string{}
255+ for i, part := range parts {
256+ if i == 2 {
257+ break
258+ }
259+ keep = append(keep, strings.Trim(part, " "))
260+ }
261+
262+ return strings.Join(keep, " ")
263+}
264+
265+func addPublicKey(m Model) tea.Cmd {
266+ return func() tea.Msg {
267+ if !IsPublicKeyValid(m.newKey) {
268+ return KeyInvalidMsg{}
269+ }
270+
271+ key := sanitizeKey(m.newKey)
272+ err := m.dbpool.LinkUserKey(m.user.ID, key)
273+ if err != nil {
274+ return errMsg{err}
275+ }
276+
277+ return KeySetMsg(m.newKey)
278+ }
279+}
+84,
-0
1@@ -0,0 +1,84 @@
2+package info
3+
4+import (
5+ "git.sr.ht/~erock/pico/wish/cms/config"
6+ "git.sr.ht/~erock/pico/wish/cms/db"
7+ "git.sr.ht/~erock/pico/wish/cms/ui/common"
8+ tea "github.com/charmbracelet/bubbletea"
9+)
10+
11+type errMsg struct {
12+ err error
13+}
14+
15+// Error satisfies the error interface.
16+func (e errMsg) Error() string {
17+ return e.err.Error()
18+}
19+
20+// Model stores the state of the info user interface.
21+type Model struct {
22+ cfg *config.ConfigCms
23+ urls config.ConfigURL
24+ Quit bool // signals it's time to exit the whole application
25+ Err error
26+ User *db.User
27+ styles common.Styles
28+}
29+
30+// NewModel returns a new Model in its initial state.
31+func NewModel(cfg *config.ConfigCms, urls config.ConfigURL, user *db.User) Model {
32+ return Model{
33+ Quit: false,
34+ User: user,
35+ styles: common.DefaultStyles(),
36+ cfg: cfg,
37+ urls: urls,
38+ }
39+}
40+
41+// Update is the Bubble Tea update loop.
42+func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
43+ var cmd tea.Cmd
44+
45+ switch msg := msg.(type) {
46+ case tea.KeyMsg:
47+ switch msg.String() {
48+ case "ctrl+c", "esc", "q":
49+ m.Quit = true
50+ return m, nil
51+ }
52+ case errMsg:
53+ // If there's an error we print the error and exit
54+ m.Err = msg
55+ m.Quit = true
56+ return m, nil
57+ }
58+
59+ return m, cmd
60+}
61+
62+// View renders the current view from the model.
63+func (m Model) View() string {
64+ if m.Err != nil {
65+ return "error: " + m.Err.Error()
66+ } else if m.User == nil {
67+ return " Authenticating..."
68+ }
69+ return m.bioView()
70+}
71+
72+func (m Model) bioView() string {
73+ var username string
74+ if m.User.Name != "" {
75+ username = m.User.Name
76+ } else {
77+ username = m.styles.Subtle.Render("(none set)")
78+ }
79+ return common.KeyValueView(
80+ "Username", username,
81+ "Blog URL", m.urls.BlogURL(username),
82+ "Public key", m.User.PublicKey.Key,
83+ "Joined", m.User.CreatedAt.Format("02 Jan 2006"),
84+ )
85+}
+436,
-0
1@@ -0,0 +1,436 @@
2+package keys
3+
4+import (
5+ "fmt"
6+
7+ "git.sr.ht/~erock/pico/wish/cms/config"
8+ "git.sr.ht/~erock/pico/wish/cms/db"
9+ "git.sr.ht/~erock/pico/wish/cms/ui/common"
10+ "git.sr.ht/~erock/pico/wish/cms/ui/createkey"
11+ pager "github.com/charmbracelet/bubbles/paginator"
12+ "github.com/charmbracelet/bubbles/spinner"
13+ tea "github.com/charmbracelet/bubbletea"
14+)
15+
16+const keysPerPage = 4
17+
18+type state int
19+
20+const (
21+ stateLoading state = iota
22+ stateNormal
23+ stateDeletingKey
24+ stateDeletingActiveKey
25+ stateDeletingAccount
26+ stateCreateKey
27+ stateQuitting
28+)
29+
30+type keyState int
31+
32+const (
33+ keyNormal keyState = iota
34+ keySelected
35+ keyDeleting
36+)
37+
38+type errMsg struct {
39+ err error
40+}
41+
42+func (e errMsg) Error() string { return e.err.Error() }
43+
44+type (
45+ keysLoadedMsg []*db.PublicKey
46+ unlinkedKeyMsg int
47+)
48+
49+// Model is the Tea state model for this user interface.
50+type Model struct {
51+ cfg *config.ConfigCms
52+ dbpool db.DB
53+ user *db.User
54+ styles common.Styles
55+ pager pager.Model
56+ state state
57+ err error
58+ activeKeyIndex int // index of the key in the below slice which is currently in use
59+ keys []*db.PublicKey // keys linked to user's account
60+ index int // index of selected key in relation to the current page
61+ Exit bool
62+ Quit bool
63+ spinner spinner.Model
64+ createKey createkey.Model
65+}
66+
67+// getSelectedIndex returns the index of the cursor in relation to the total
68+// number of items.
69+func (m *Model) getSelectedIndex() int {
70+ return m.index + m.pager.Page*m.pager.PerPage
71+}
72+
73+// UpdatePaging runs an update against the underlying pagination model as well
74+// as performing some related tasks on this model.
75+func (m *Model) UpdatePaging(msg tea.Msg) {
76+ // Handle paging
77+ m.pager.SetTotalPages(len(m.keys))
78+ m.pager, _ = m.pager.Update(msg)
79+
80+ // If selected item is out of bounds, put it in bounds
81+ numItems := m.pager.ItemsOnPage(len(m.keys))
82+ m.index = min(m.index, numItems-1)
83+}
84+
85+// NewModel creates a new model with defaults.
86+func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
87+ st := common.DefaultStyles()
88+
89+ p := pager.NewModel()
90+ p.PerPage = keysPerPage
91+ p.Type = pager.Dots
92+ p.InactiveDot = st.InactivePagination.Render("•")
93+
94+ return Model{
95+ cfg: cfg,
96+ dbpool: dbpool,
97+ user: user,
98+ styles: st,
99+ pager: p,
100+ state: stateLoading,
101+ err: nil,
102+ activeKeyIndex: -1,
103+ keys: []*db.PublicKey{},
104+ index: 0,
105+ spinner: common.NewSpinner(),
106+ Exit: false,
107+ Quit: false,
108+ }
109+}
110+
111+// Init is the Tea initialization function.
112+func (m Model) Init() tea.Cmd {
113+ return tea.Batch(
114+ spinner.Tick,
115+ )
116+}
117+
118+// Update is the tea update function which handles incoming messages.
119+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
120+ var (
121+ cmds []tea.Cmd
122+ cmd tea.Cmd
123+ )
124+
125+ switch msg := msg.(type) {
126+ case tea.KeyMsg:
127+ switch msg.Type {
128+ case tea.KeyCtrlC:
129+ m.Exit = true
130+ return m, nil
131+ }
132+
133+ if m.state != stateCreateKey {
134+ switch msg.String() {
135+ case "q", "esc":
136+ m.Exit = true
137+ return m, nil
138+ case "up", "k":
139+ m.index--
140+ if m.index < 0 && m.pager.Page > 0 {
141+ m.index = m.pager.PerPage - 1
142+ m.pager.PrevPage()
143+ }
144+ m.index = max(0, m.index)
145+ case "down", "j":
146+ itemsOnPage := m.pager.ItemsOnPage(len(m.keys))
147+ m.index++
148+ if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
149+ m.index = 0
150+ m.pager.NextPage()
151+ }
152+ m.index = min(itemsOnPage-1, m.index)
153+
154+ case "n":
155+ m.state = stateCreateKey
156+ return m, nil
157+
158+ // Delete
159+ case "x":
160+ m.state = stateDeletingKey
161+ m.UpdatePaging(msg)
162+ return m, nil
163+
164+ // Confirm Delete
165+ case "y":
166+ switch m.state {
167+ case stateDeletingKey:
168+ if len(m.keys) == 1 {
169+ // The user is about to delete her account. Double confirm.
170+ m.state = stateDeletingAccount
171+ return m, nil
172+ }
173+ if m.getSelectedIndex() == m.activeKeyIndex {
174+ // The user is going to delete her active key. Double confirm.
175+ m.state = stateDeletingActiveKey
176+ return m, nil
177+ }
178+ m.state = stateNormal
179+ return m, unlinkKey(m)
180+ case stateDeletingActiveKey:
181+ // Active key will be deleted. Remove the key and exit.
182+ fallthrough
183+ case stateDeletingAccount:
184+ // Account will be deleted. Remove the key and exit.
185+ m.state = stateQuitting
186+ return m, deleteAccount(m)
187+ }
188+ }
189+ }
190+
191+ case errMsg:
192+ m.err = msg.err
193+ return m, nil
194+
195+ case keysLoadedMsg:
196+ m.state = stateNormal
197+ m.index = 0
198+ m.keys = msg
199+ for i, key := range m.keys {
200+ if key.Key == m.user.PublicKey.Key {
201+ m.activeKeyIndex = i
202+ }
203+ }
204+
205+ case unlinkedKeyMsg:
206+ if m.state == stateQuitting {
207+ return m, tea.Quit
208+ }
209+ i := m.getSelectedIndex()
210+
211+ // Remove key from array
212+ m.keys = append(m.keys[:i], m.keys[i+1:]...)
213+
214+ // Update pagination
215+ m.pager.SetTotalPages(len(m.keys))
216+ m.pager.Page = min(m.pager.Page, m.pager.TotalPages-1)
217+
218+ // Update cursor
219+ m.index = min(m.index, m.pager.ItemsOnPage(len(m.keys)-1))
220+ for i, key := range m.keys {
221+ if key.Key == m.user.PublicKey.Key {
222+ m.activeKeyIndex = i
223+ }
224+ }
225+
226+ return m, nil
227+
228+ case createkey.KeySetMsg:
229+ m.state = stateNormal
230+ return m, fetchKeys(m.dbpool, m.user)
231+
232+ case spinner.TickMsg:
233+ var cmd tea.Cmd
234+ if m.state < stateNormal {
235+ m.spinner, cmd = m.spinner.Update(msg)
236+ }
237+ return m, cmd
238+ }
239+
240+ switch m.state {
241+ case stateNormal:
242+ m.createKey = createkey.NewModel(m.cfg, m.dbpool, m.user)
243+ case stateDeletingKey:
244+ // If an item is being confirmed for delete, any key (other than the key
245+ // used for confirmation above) cancels the deletion
246+ k, ok := msg.(tea.KeyMsg)
247+ if ok && k.String() != "y" {
248+ m.state = stateNormal
249+ }
250+ }
251+
252+ m.UpdatePaging(msg)
253+
254+ m, cmd = updateChildren(msg, m)
255+ if cmd != nil {
256+ cmds = append(cmds, cmd)
257+ }
258+
259+ return m, tea.Batch(cmds...)
260+}
261+
262+func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
263+ var cmd tea.Cmd
264+
265+ switch m.state {
266+ case stateCreateKey:
267+ newModel, newCmd := m.createKey.Update(msg)
268+ createKeyModel, ok := newModel.(createkey.Model)
269+ if !ok {
270+ panic("could not perform assertion on posts model")
271+ }
272+ m.createKey = createKeyModel
273+ cmd = newCmd
274+ if m.createKey.Done {
275+ m.createKey = createkey.NewModel(m.cfg, m.dbpool, m.user) // reset the state
276+ m.state = stateNormal
277+ } else if m.createKey.Quit {
278+ m.state = stateQuitting
279+ return m, tea.Quit
280+ }
281+
282+ }
283+
284+ return m, cmd
285+}
286+
287+// View renders the current UI into a string.
288+func (m Model) View() string {
289+ if m.err != nil {
290+ return m.err.Error()
291+ }
292+
293+ var s string
294+
295+ switch m.state {
296+ case stateLoading:
297+ s = m.spinner.View() + " Loading...\n\n"
298+ case stateQuitting:
299+ s = fmt.Sprintf("Thanks for using %s!\n", m.cfg.Domain)
300+ case stateCreateKey:
301+ s = m.createKey.View()
302+ default:
303+ s = fmt.Sprintf("Here are the keys linked to your %s account.\n\n", m.cfg.Domain)
304+
305+ // Keys
306+ s += keysView(m)
307+ if m.pager.TotalPages > 1 {
308+ s += m.pager.View()
309+ }
310+
311+ // Footer
312+ switch m.state {
313+ case stateDeletingKey:
314+ s += m.promptView("Delete this key?")
315+ case stateDeletingActiveKey:
316+ s += m.promptView("This is the key currently in use. Are you, like, for-sure-for-sure?")
317+ case stateDeletingAccount:
318+ s += m.promptView("Sure? This will delete your account. Are you absolutely positive?")
319+ default:
320+ s += "\n\n" + helpView(m)
321+ }
322+ }
323+
324+ return s
325+}
326+
327+func keysView(m Model) string {
328+ var (
329+ s string
330+ state keyState
331+ start, end = m.pager.GetSliceBounds(len(m.keys))
332+ slice = m.keys[start:end]
333+ )
334+
335+ destructiveState :=
336+ (m.state == stateDeletingKey ||
337+ m.state == stateDeletingActiveKey ||
338+ m.state == stateDeletingAccount)
339+
340+ // Render key info
341+ for i, key := range slice {
342+ if destructiveState && m.index == i {
343+ state = keyDeleting
344+ } else if m.index == i {
345+ state = keySelected
346+ } else {
347+ state = keyNormal
348+ }
349+ s += m.newStyledKey(m.styles, key, i+start == m.activeKeyIndex).render(state)
350+ }
351+
352+ // If there aren't enough keys to fill the view, fill the missing parts
353+ // with whitespace
354+ if len(slice) < m.pager.PerPage {
355+ for i := len(slice); i < keysPerPage; i++ {
356+ s += "\n\n\n"
357+ }
358+ }
359+
360+ return s
361+}
362+
363+func helpView(m Model) string {
364+ var items []string
365+ if len(m.keys) > 1 {
366+ items = append(items, "j/k, ↑/↓: choose")
367+ }
368+ if m.pager.TotalPages > 1 {
369+ items = append(items, "h/l, ←/→: page")
370+ }
371+ items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
372+ return common.HelpView(items...)
373+}
374+
375+func (m Model) promptView(prompt string) string {
376+ st := m.styles.Delete.Copy().MarginTop(2).MarginRight(1)
377+ return st.Render(prompt) +
378+ m.styles.DeleteDim.Render("(y/N)")
379+}
380+
381+// LoadKeys returns the command necessary for loading the keys.
382+func LoadKeys(m Model) tea.Cmd {
383+ return tea.Batch(
384+ fetchKeys(m.dbpool, m.user),
385+ spinner.Tick,
386+ )
387+}
388+
389+// fetchKeys loads the current set of keys via the charm client.
390+func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
391+ return func() tea.Msg {
392+ ak, err := dbpool.FindKeysForUser(user)
393+ if err != nil {
394+ return errMsg{err}
395+ }
396+ return keysLoadedMsg(ak)
397+ }
398+}
399+
400+// unlinkKey deletes the selected key.
401+func unlinkKey(m Model) tea.Cmd {
402+ return func() tea.Msg {
403+ id := m.keys[m.getSelectedIndex()].ID
404+ err := m.dbpool.RemoveKeys([]string{id})
405+ if err != nil {
406+ return errMsg{err}
407+ }
408+ return unlinkedKeyMsg(m.index)
409+ }
410+}
411+
412+func deleteAccount(m Model) tea.Cmd {
413+ return func() tea.Msg {
414+ id := m.keys[m.getSelectedIndex()].UserID
415+ err := m.dbpool.RemoveUsers([]string{id})
416+ if err != nil {
417+ return errMsg{err}
418+ }
419+ return unlinkedKeyMsg(m.index)
420+ }
421+}
422+
423+// Utils
424+
425+func min(a, b int) int {
426+ if a < b {
427+ return a
428+ }
429+ return b
430+}
431+
432+func max(a, b int) int {
433+ if a > b {
434+ return a
435+ }
436+ return b
437+}
+137,
-0
1@@ -0,0 +1,137 @@
2+package keys
3+
4+import (
5+ "fmt"
6+ "strings"
7+
8+ "git.sr.ht/~erock/pico/wish/cms/db"
9+ "git.sr.ht/~erock/pico/wish/cms/ui/common"
10+ "golang.org/x/crypto/ssh"
11+)
12+
13+var styles = common.DefaultStyles()
14+
15+func algo(keyType string) string {
16+ if idx := strings.Index(keyType, "@"); idx > 0 {
17+ return algo(keyType[0:idx])
18+ }
19+ parts := strings.Split(keyType, "-")
20+ if len(parts) == 2 {
21+ return parts[1]
22+ }
23+ if parts[0] == "sk" {
24+ return algo(strings.TrimPrefix(keyType, "sk-"))
25+ }
26+ return parts[0]
27+}
28+
29+type Fingerprint struct {
30+ Type string
31+ Value string
32+ Algorithm string
33+}
34+
35+// String outputs a string representation of the fingerprint.
36+func (f Fingerprint) String() string {
37+ return fmt.Sprintf(
38+ "%s %s",
39+ styles.ListDim.Render(strings.ToUpper(f.Algorithm)),
40+ styles.ListKey.Render(f.Type+":"+f.Value),
41+ )
42+}
43+
44+// FingerprintSHA256 returns the algorithm and SHA256 fingerprint for the given
45+// key.
46+func FingerprintSHA256(k *db.PublicKey) (Fingerprint, error) {
47+ key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.Key))
48+ if err != nil {
49+ return Fingerprint{}, fmt.Errorf("failed to parse public key: %w", err)
50+ }
51+
52+ return Fingerprint{
53+ Algorithm: algo(key.Type()),
54+ Type: "SHA256",
55+ Value: strings.TrimPrefix(ssh.FingerprintSHA256(key), "SHA256:"),
56+ }, nil
57+}
58+
59+// wrap fingerprint to support additional states.
60+type fingerprint struct {
61+ Fingerprint
62+}
63+
64+func (f fingerprint) state(s keyState, styles common.Styles) string {
65+ if s == keyDeleting {
66+ return fmt.Sprintf(
67+ "%s %s",
68+ styles.DeleteDim.Render(strings.ToUpper(f.Algorithm)),
69+ styles.Delete.Render(f.Type+":"+f.Value),
70+ )
71+ }
72+ return f.String()
73+}
74+
75+type styledKey struct {
76+ styles common.Styles
77+ date string
78+ fingerprint fingerprint
79+ gutter string
80+ keyLabel string
81+ dateLabel string
82+ dateVal string
83+ note string
84+}
85+
86+func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool) styledKey {
87+ date := key.CreatedAt.Format("02 Jan 2006 15:04:05 MST")
88+ fp, err := FingerprintSHA256(key)
89+ if err != nil {
90+ fp = Fingerprint{Value: "[error generating fingerprint]"}
91+ }
92+
93+ var note string
94+ if active {
95+ note = m.styles.NoteDim.Render("• ") + m.styles.Note.Render("Current Key")
96+ }
97+
98+ // Default state
99+ return styledKey{
100+ styles: styles,
101+ date: date,
102+ fingerprint: fingerprint{fp},
103+ gutter: " ",
104+ keyLabel: "Key:",
105+ dateLabel: "Added:",
106+ dateVal: styles.LabelDim.Render(date),
107+ note: note,
108+ }
109+}
110+
111+// Selected state.
112+func (k *styledKey) selected() {
113+ k.gutter = common.VerticalLine(common.StateSelected)
114+ k.keyLabel = k.styles.Label.Render("Key:")
115+ k.dateLabel = k.styles.Label.Render("Added:")
116+}
117+
118+// Deleting state.
119+func (k *styledKey) deleting() {
120+ k.gutter = common.VerticalLine(common.StateDeleting)
121+ k.keyLabel = k.styles.Delete.Render("Key:")
122+ k.dateLabel = k.styles.Delete.Render("Added:")
123+ k.dateVal = k.styles.DeleteDim.Render(k.date)
124+}
125+
126+func (k styledKey) render(state keyState) string {
127+ switch state {
128+ case keySelected:
129+ k.selected()
130+ case keyDeleting:
131+ k.deleting()
132+ }
133+ return fmt.Sprintf(
134+ "%s %s %s\n%s %s %s %s\n\n",
135+ k.gutter, k.keyLabel, k.fingerprint.state(state, k.styles),
136+ k.gutter, k.dateLabel, k.dateVal, k.note,
137+ )
138+}
+69,
-0
1@@ -0,0 +1,69 @@
2+package posts
3+
4+import (
5+ "fmt"
6+
7+ "git.sr.ht/~erock/pico/wish/cms/config"
8+ "git.sr.ht/~erock/pico/wish/cms/db"
9+ "git.sr.ht/~erock/pico/wish/cms/ui/common"
10+)
11+
12+type styledKey struct {
13+ styles common.Styles
14+ date string
15+ gutter string
16+ postLabel string
17+ dateLabel string
18+ dateVal string
19+ title string
20+ urlLabel string
21+ url string
22+}
23+
24+func (m Model) newStyledKey(styles common.Styles, post *db.Post, urls config.ConfigURL) styledKey {
25+ publishAt := post.PublishAt
26+ // Default state
27+ return styledKey{
28+ styles: styles,
29+ gutter: " ",
30+ postLabel: "post:",
31+ date: publishAt.String(),
32+ dateLabel: "publish_at:",
33+ dateVal: styles.LabelDim.Render(publishAt.Format("02 Jan, 2006")),
34+ title: post.Title,
35+ urlLabel: "url:",
36+ url: urls.PostURL(post.Username, post.Filename),
37+ }
38+}
39+
40+// Selected state.
41+func (k *styledKey) selected() {
42+ k.gutter = common.VerticalLine(common.StateSelected)
43+ k.postLabel = k.styles.Label.Render("post:")
44+ k.dateLabel = k.styles.Label.Render("publish_at:")
45+ k.urlLabel = k.styles.Label.Render("url:")
46+}
47+
48+// Deleting state.
49+func (k *styledKey) deleting() {
50+ k.gutter = common.VerticalLine(common.StateDeleting)
51+ k.postLabel = k.styles.Delete.Render("post:")
52+ k.dateLabel = k.styles.Delete.Render("publish_at:")
53+ k.urlLabel = k.styles.Delete.Render("url:")
54+ k.title = k.styles.DeleteDim.Render(k.title)
55+}
56+
57+func (k styledKey) render(state postState) string {
58+ switch state {
59+ case postSelected:
60+ k.selected()
61+ case postDeleting:
62+ k.deleting()
63+ }
64+ return fmt.Sprintf(
65+ "%s %s %s\n%s %s %s\n%s %s %s\n\n",
66+ k.gutter, k.postLabel, k.title,
67+ k.gutter, k.dateLabel, k.dateVal,
68+ k.gutter, k.urlLabel, k.url,
69+ )
70+}
+353,
-0
1@@ -0,0 +1,353 @@
2+package posts
3+
4+import (
5+ "errors"
6+
7+ pager "github.com/charmbracelet/bubbles/paginator"
8+ "github.com/charmbracelet/bubbles/spinner"
9+ tea "github.com/charmbracelet/bubbletea"
10+
11+ "git.sr.ht/~erock/pico/wish/cms/config"
12+ "git.sr.ht/~erock/pico/wish/cms/db"
13+ "git.sr.ht/~erock/pico/wish/cms/ui/common"
14+
15+ "go.uber.org/zap"
16+)
17+
18+const keysPerPage = 4
19+
20+type state int
21+
22+const (
23+ stateLoading state = iota
24+ stateNormal
25+ stateDeletingPost
26+ stateQuitting
27+)
28+
29+type postState int
30+
31+const (
32+ postNormal postState = iota
33+ postSelected
34+ postDeleting
35+)
36+
37+type PostLoader struct {
38+ Posts []*db.Post
39+}
40+
41+type (
42+ postsLoadedMsg PostLoader
43+ removePostMsg int
44+ errMsg struct {
45+ err error
46+ }
47+)
48+
49+// Model is the Tea state model for this user interface.
50+type Model struct {
51+ cfg *config.ConfigCms
52+ urls config.ConfigURL
53+ dbpool db.DB
54+ user *db.User
55+ posts []*db.Post
56+ styles common.Styles
57+ pager pager.Model
58+ state state
59+ err error
60+ index int // index of selected key in relation to the current page
61+ Exit bool
62+ Quit bool
63+ spinner spinner.Model
64+ logger *zap.SugaredLogger
65+}
66+
67+// getSelectedIndex returns the index of the cursor in relation to the total
68+// number of items.
69+func (m *Model) getSelectedIndex() int {
70+ return m.index + m.pager.Page*m.pager.PerPage
71+}
72+
73+// UpdatePaging runs an update against the underlying pagination model as well
74+// as performing some related tasks on this model.
75+func (m *Model) UpdatePaging(msg tea.Msg) {
76+ // Handle paging
77+ m.pager.SetTotalPages(len(m.posts))
78+ m.pager, _ = m.pager.Update(msg)
79+
80+ // If selected item is out of bounds, put it in bounds
81+ numItems := m.pager.ItemsOnPage(len(m.posts))
82+ m.index = min(m.index, numItems-1)
83+}
84+
85+// NewModel creates a new model with defaults.
86+func NewModel(cfg *config.ConfigCms, urls config.ConfigURL, dbpool db.DB, user *db.User) Model {
87+ logger := cfg.Logger
88+ st := common.DefaultStyles()
89+
90+ p := pager.NewModel()
91+ p.PerPage = keysPerPage
92+ p.Type = pager.Dots
93+ p.InactiveDot = st.InactivePagination.Render("•")
94+
95+ return Model{
96+ cfg: cfg,
97+ dbpool: dbpool,
98+ user: user,
99+ styles: st,
100+ pager: p,
101+ state: stateLoading,
102+ err: nil,
103+ posts: []*db.Post{},
104+ index: 0,
105+ spinner: common.NewSpinner(),
106+ Exit: false,
107+ Quit: false,
108+ logger: logger,
109+ urls: urls,
110+ }
111+}
112+
113+// Init is the Tea initialization function.
114+func (m Model) Init() tea.Cmd {
115+ return tea.Batch(
116+ spinner.Tick,
117+ )
118+}
119+
120+// Update is the tea update function which handles incoming messages.
121+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
122+ switch msg := msg.(type) {
123+ case tea.KeyMsg:
124+ switch msg.String() {
125+ case "ctrl+c", "q", "esc":
126+ m.Exit = true
127+ return m, nil
128+
129+ // Select individual items
130+ case "up", "k":
131+ // Move up
132+ m.index--
133+ if m.index < 0 && m.pager.Page > 0 {
134+ m.index = m.pager.PerPage - 1
135+ m.pager.PrevPage()
136+ }
137+ m.index = max(0, m.index)
138+ case "down", "j":
139+ // Move down
140+ itemsOnPage := m.pager.ItemsOnPage(len(m.posts))
141+ m.index++
142+ if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
143+ m.index = 0
144+ m.pager.NextPage()
145+ }
146+ m.index = min(itemsOnPage-1, m.index)
147+
148+ // Delete
149+ case "x":
150+ if len(m.posts) > 0 {
151+ m.state = stateDeletingPost
152+ m.UpdatePaging(msg)
153+ }
154+
155+ return m, nil
156+
157+ // Confirm Delete
158+ case "y":
159+ switch m.state {
160+ case stateDeletingPost:
161+ m.state = stateNormal
162+ return m, removePost(m)
163+ }
164+ }
165+
166+ case errMsg:
167+ m.err = msg.err
168+ return m, nil
169+
170+ case postsLoadedMsg:
171+ m.state = stateNormal
172+ m.index = 0
173+ m.posts = msg.Posts
174+
175+ case removePostMsg:
176+ if m.state == stateQuitting {
177+ return m, tea.Quit
178+ }
179+ i := m.getSelectedIndex()
180+
181+ // Remove key from array
182+ m.posts = append(m.posts[:i], m.posts[i+1:]...)
183+
184+ // Update pagination
185+ m.pager.SetTotalPages(len(m.posts))
186+ m.pager.Page = min(m.pager.Page, m.pager.TotalPages-1)
187+
188+ // Update cursor
189+ m.index = min(m.index, m.pager.ItemsOnPage(len(m.posts)-1))
190+
191+ return m, nil
192+
193+ case spinner.TickMsg:
194+ var cmd tea.Cmd
195+ if m.state < stateNormal {
196+ m.spinner, cmd = m.spinner.Update(msg)
197+ }
198+ return m, cmd
199+ }
200+
201+ m.UpdatePaging(msg)
202+
203+ // If an item is being confirmed for delete, any key (other than the key
204+ // used for confirmation above) cancels the deletion
205+ k, ok := msg.(tea.KeyMsg)
206+ if ok && k.String() != "x" {
207+ m.state = stateNormal
208+ }
209+
210+ return m, nil
211+}
212+
213+// View renders the current UI into a string.
214+func (m Model) View() string {
215+ if m.err != nil {
216+ return m.err.Error()
217+ }
218+
219+ var s string
220+
221+ switch m.state {
222+ case stateLoading:
223+ s = m.spinner.View() + " Loading...\n\n"
224+ case stateQuitting:
225+ s = "Thanks for using lists!\n"
226+ default:
227+ s = "Here are the posts linked to your account.\n\n"
228+
229+ s += postsView(m)
230+ if m.pager.TotalPages > 1 {
231+ s += m.pager.View()
232+ }
233+
234+ // Footer
235+ switch m.state {
236+ case stateDeletingPost:
237+ s += m.promptView("Delete this post?")
238+ default:
239+ s += "\n\n" + helpView(m)
240+ }
241+ }
242+
243+ return s
244+}
245+
246+func postsView(m Model) string {
247+ var (
248+ s string
249+ state postState
250+ start, end = m.pager.GetSliceBounds(len(m.posts))
251+ slice = m.posts[start:end]
252+ )
253+
254+ destructiveState := m.state == stateDeletingPost
255+
256+ if len(m.posts) == 0 {
257+ s += "You don't have any posts yet."
258+ return s
259+ }
260+
261+ // Render key info
262+ for i, post := range slice {
263+ if destructiveState && m.index == i {
264+ state = postDeleting
265+ } else if m.index == i {
266+ state = postSelected
267+ } else {
268+ state = postNormal
269+ }
270+ s += m.newStyledKey(m.styles, post, m.urls).render(state)
271+ }
272+
273+ // If there aren't enough keys to fill the view, fill the missing parts
274+ // with whitespace
275+ if len(slice) < m.pager.PerPage {
276+ for i := len(slice); i < keysPerPage; i++ {
277+ s += "\n\n\n"
278+ }
279+ }
280+
281+ return s
282+}
283+
284+func helpView(m Model) string {
285+ var items []string
286+ if len(m.posts) > 1 {
287+ items = append(items, "j/k, ↑/↓: choose")
288+ }
289+ if m.pager.TotalPages > 1 {
290+ items = append(items, "h/l, ←/→: page")
291+ }
292+ if len(m.posts) > 0 {
293+ items = append(items, "x: delete")
294+ }
295+ items = append(items, "esc: exit")
296+ return common.HelpView(items...)
297+}
298+
299+func (m Model) promptView(prompt string) string {
300+ st := m.styles.Delete.Copy().MarginTop(2).MarginRight(1)
301+ return st.Render(prompt) +
302+ m.styles.DeleteDim.Render("(y/N)")
303+}
304+
305+func LoadPosts(m Model) tea.Cmd {
306+ if m.user == nil {
307+ m.logger.Info("user not found!")
308+ err := errors.New("user not found")
309+ return func() tea.Msg {
310+ return errMsg{err}
311+ }
312+ }
313+
314+ return tea.Batch(
315+ m.fetchPosts(m.user.ID),
316+ spinner.Tick,
317+ )
318+}
319+
320+func (m Model) fetchPosts(userID string) tea.Cmd {
321+ return func() tea.Msg {
322+ posts, _ := m.dbpool.FindPostsForUser(userID, m.cfg.Space)
323+ loader := PostLoader{
324+ Posts: posts,
325+ }
326+ return postsLoadedMsg(loader)
327+ }
328+}
329+
330+func removePost(m Model) tea.Cmd {
331+ return func() tea.Msg {
332+ err := m.dbpool.RemovePosts([]string{m.posts[m.getSelectedIndex()].ID})
333+ if err != nil {
334+ return errMsg{err}
335+ }
336+ return removePostMsg(m.index)
337+ }
338+}
339+
340+// Utils
341+
342+func min(a, b int) int {
343+ if a < b {
344+ return a
345+ }
346+ return b
347+}
348+
349+func max(a, b int) int {
350+ if a > b {
351+ return a
352+ }
353+ return b
354+}
+280,
-0
1@@ -0,0 +1,280 @@
2+package username
3+
4+import (
5+ "fmt"
6+ "strings"
7+
8+ "git.sr.ht/~erock/pico/wish/cms/db"
9+ "git.sr.ht/~erock/pico/wish/cms/ui/common"
10+ "github.com/charmbracelet/bubbles/spinner"
11+ input "github.com/charmbracelet/bubbles/textinput"
12+ tea "github.com/charmbracelet/bubbletea"
13+)
14+
15+type state int
16+
17+const (
18+ ready state = iota
19+ submitting
20+)
21+
22+// index specifies the UI element that's in focus.
23+type index int
24+
25+const (
26+ textInput index = iota
27+ okButton
28+ cancelButton
29+)
30+
31+// NameSetMsg is sent when a new name has been set successfully. It contains
32+// the new name.
33+type NameSetMsg string
34+
35+// NameTakenMsg is sent when the requested username has already been taken.
36+type NameTakenMsg struct{}
37+
38+// NameInvalidMsg is sent when the requested username has failed validation.
39+type NameInvalidMsg struct{}
40+
41+type errMsg struct{ err error }
42+
43+func (e errMsg) Error() string { return e.err.Error() }
44+
45+// Model holds the state of the username UI.
46+type Model struct {
47+ Done bool // true when it's time to exit this view
48+ Quit bool // true when the user wants to quit the whole program
49+
50+ dbpool db.DB
51+ user *db.User
52+ styles common.Styles
53+ state state
54+ newName string
55+ index index
56+ errMsg string
57+ input input.Model
58+ spinner spinner.Model
59+}
60+
61+// updateFocus updates the focused states in the model based on the current
62+// focus index.
63+func (m *Model) updateFocus() {
64+ if m.index == textInput && !m.input.Focused() {
65+ m.input.Focus()
66+ m.input.Prompt = m.styles.FocusedPrompt.String()
67+ } else if m.index != textInput && m.input.Focused() {
68+ m.input.Blur()
69+ m.input.Prompt = m.styles.Prompt.String()
70+ }
71+}
72+
73+// Move the focus index one unit forward.
74+func (m *Model) indexForward() {
75+ m.index++
76+ if m.index > cancelButton {
77+ m.index = textInput
78+ }
79+
80+ m.updateFocus()
81+}
82+
83+// Move the focus index one unit backwards.
84+func (m *Model) indexBackward() {
85+ m.index--
86+ if m.index < textInput {
87+ m.index = cancelButton
88+ }
89+
90+ m.updateFocus()
91+}
92+
93+// NewModel returns a new username model in its initial state.
94+func NewModel(dbpool db.DB, user *db.User, sshUser string) Model {
95+ st := common.DefaultStyles()
96+
97+ im := input.NewModel()
98+ im.CursorStyle = st.Cursor
99+ im.Placeholder = sshUser
100+ im.Prompt = st.FocusedPrompt.String()
101+ im.CharLimit = 50
102+ im.Focus()
103+
104+ return Model{
105+ Done: false,
106+ Quit: false,
107+ dbpool: dbpool,
108+ user: user,
109+ styles: st,
110+ state: ready,
111+ newName: "",
112+ index: textInput,
113+ errMsg: "",
114+ input: im,
115+ spinner: common.NewSpinner(),
116+ }
117+}
118+
119+// Init is the Bubble Tea initialization function.
120+func Init(dbpool db.DB, user *db.User, sshUser string) func() (Model, tea.Cmd) {
121+ return func() (Model, tea.Cmd) {
122+ m := NewModel(dbpool, user, sshUser)
123+ return m, InitialCmd()
124+ }
125+}
126+
127+// InitialCmd returns the initial command.
128+func InitialCmd() tea.Cmd {
129+ return input.Blink
130+}
131+
132+// Update is the Bubble Tea update loop.
133+func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
134+ switch msg := msg.(type) {
135+ case tea.KeyMsg:
136+ switch msg.Type {
137+ case tea.KeyCtrlC: // quit
138+ m.Quit = true
139+ return m, nil
140+ case tea.KeyEscape: // exit this mini-app
141+ m.Done = true
142+ return m, nil
143+
144+ default:
145+ // Ignore keys if we're submitting
146+ if m.state == submitting {
147+ return m, nil
148+ }
149+
150+ switch msg.String() {
151+ case "tab":
152+ m.indexForward()
153+ case "shift+tab":
154+ m.indexBackward()
155+ case "l", "k", "right":
156+ if m.index != textInput {
157+ m.indexForward()
158+ }
159+ case "h", "j", "left":
160+ if m.index != textInput {
161+ m.indexBackward()
162+ }
163+ case "up", "down":
164+ if m.index == textInput {
165+ m.indexForward()
166+ } else {
167+ m.index = textInput
168+ m.updateFocus()
169+ }
170+ case "enter":
171+ switch m.index {
172+ case textInput:
173+ fallthrough
174+ case okButton: // Submit the form
175+ m.state = submitting
176+ m.errMsg = ""
177+ m.newName = strings.TrimSpace(m.input.Value())
178+
179+ return m, tea.Batch(
180+ setName(m), // fire off the command, too
181+ spinner.Tick,
182+ )
183+ case cancelButton: // Exit this mini-app
184+ m.Done = true
185+ return m, nil
186+ }
187+ }
188+
189+ // Pass messages through to the input element if that's the element
190+ // in focus
191+ if m.index == textInput {
192+ var cmd tea.Cmd
193+ m.input, cmd = m.input.Update(msg)
194+
195+ return m, cmd
196+ }
197+
198+ return m, nil
199+ }
200+
201+ case NameTakenMsg:
202+ m.state = ready
203+ m.errMsg = m.styles.Subtle.Render("Sorry, ") +
204+ m.styles.Error.Render(m.newName) +
205+ m.styles.Subtle.Render(" is taken.")
206+
207+ return m, nil
208+
209+ case NameInvalidMsg:
210+ m.state = ready
211+ head := m.styles.Error.Render("Invalid name. ")
212+ deny := strings.Join(db.DenyList, ", ")
213+ helpMsg := fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
214+ body := m.styles.Subtle.Render(helpMsg)
215+ m.errMsg = m.styles.Wrap.Render(head + body)
216+
217+ return m, nil
218+
219+ case errMsg:
220+ m.state = ready
221+ head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
222+ body := m.styles.Subtle.Render(msg.Error())
223+ m.errMsg = m.styles.Wrap.Render(head + body)
224+
225+ return m, nil
226+
227+ case spinner.TickMsg:
228+ var cmd tea.Cmd
229+ m.spinner, cmd = m.spinner.Update(msg)
230+
231+ return m, cmd
232+
233+ default:
234+ var cmd tea.Cmd
235+ m.input, cmd = m.input.Update(msg) // Do we still need this?
236+
237+ return m, cmd
238+ }
239+}
240+
241+// View renders current view from the model.
242+func View(m Model) string {
243+ s := "Enter a new username\n\n"
244+ s += m.input.View() + "\n\n"
245+
246+ if m.state == submitting {
247+ s += spinnerView(m)
248+ } else {
249+ s += common.OKButtonView(m.index == 1, true)
250+ s += " " + common.CancelButtonView(m.index == 2, false)
251+ if m.errMsg != "" {
252+ s += "\n\n" + m.errMsg
253+ }
254+ }
255+
256+ return s
257+}
258+
259+func spinnerView(m Model) string {
260+ return m.spinner.View() + " Submitting..."
261+}
262+
263+// Attempt to update the username on the server.
264+func setName(m Model) tea.Cmd {
265+ return func() tea.Msg {
266+ // Validate before resetting the session to potentially save some
267+ // network traffic and keep things feeling speedy.
268+ if !m.dbpool.ValidateName(m.newName) {
269+ return NameInvalidMsg{}
270+ }
271+
272+ err := m.dbpool.SetUserName(m.user.ID, m.newName)
273+ if err == db.ErrNameTaken {
274+ return NameTakenMsg{}
275+ } else if err != nil {
276+ return errMsg{err}
277+ }
278+
279+ return NameSetMsg(m.newName)
280+ }
281+}
+16,
-0
1@@ -0,0 +1,16 @@
2+package util
3+
4+import (
5+ "encoding/base64"
6+ "fmt"
7+
8+ "github.com/gliderlabs/ssh"
9+)
10+
11+func KeyText(s ssh.Session) (string, error) {
12+ if s.PublicKey() == nil {
13+ return "", fmt.Errorf("Session doesn't have public key")
14+ }
15+ kb := base64.StdEncoding.EncodeToString(s.PublicKey().Marshal())
16+ return fmt.Sprintf("%s %s", s.PublicKey().Type(), kb), nil
17+}
+1,
-0
1@@ -0,0 +1 @@
2+package wish
+28,
-0
1@@ -0,0 +1,28 @@
2+package proxy
3+
4+import (
5+ "github.com/charmbracelet/wish"
6+ "github.com/gliderlabs/ssh"
7+)
8+
9+type Router func(sh ssh.Handler, s ssh.Session) []wish.Middleware
10+
11+func withMiddleware(mdw ...wish.Middleware) ssh.Handler {
12+ handler := func(s ssh.Session) {}
13+ for _, mw := range mdw {
14+ handler = mw(handler)
15+ }
16+ return handler
17+}
18+
19+func WithProxy(router Router) ssh.Option {
20+ mdw := func(sh ssh.Handler) ssh.Handler {
21+ return func(s ssh.Session) {
22+ mw := router(sh, s)
23+ fn := withMiddleware(mw...)
24+ fn(s)
25+ }
26+ }
27+
28+ return wish.WithMiddleware(mdw)
29+}
+142,
-0
1@@ -0,0 +1,142 @@
2+package scp
3+
4+import (
5+ "bufio"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "io/fs"
10+ "path/filepath"
11+ "regexp"
12+ "strconv"
13+
14+ "git.sr.ht/~erock/pico/wish/send/utils"
15+ "github.com/gliderlabs/ssh"
16+)
17+
18+var (
19+ reTimestamp = regexp.MustCompile(`^T(\d{10}) 0 (\d{10}) 0$`)
20+ reNewFolder = regexp.MustCompile(`^D(\d{4}) 0 (.*)$`)
21+ reNewFile = regexp.MustCompile(`^C(\d{4}) (\d+) (.*)$`)
22+)
23+
24+type parseError struct {
25+ subject string
26+}
27+
28+func (e parseError) Error() string {
29+ return fmt.Sprintf("failed to parse: %q", e.subject)
30+}
31+
32+func copyFromClient(session ssh.Session, info Info, handler utils.CopyFromClientHandler) error {
33+ // accepts the request
34+ _, _ = session.Write(utils.NULL)
35+
36+ writeErrors := []error{}
37+ writeSuccess := []string{}
38+
39+ var (
40+ path = info.Path
41+ r = bufio.NewReader(session)
42+ mtime int64
43+ atime int64
44+ )
45+
46+ for {
47+ line, _, err := r.ReadLine()
48+ if err != nil {
49+ if errors.Is(err, io.EOF) {
50+ break
51+ }
52+ return fmt.Errorf("failed to read line: %w", err)
53+ }
54+
55+ if matches := reTimestamp.FindAllStringSubmatch(string(line), 2); matches != nil {
56+ mtime, err = strconv.ParseInt(matches[0][1], 10, 64)
57+ if err != nil {
58+ return parseError{string(line)}
59+ }
60+ atime, err = strconv.ParseInt(matches[0][2], 10, 64)
61+ if err != nil {
62+ return parseError{string(line)}
63+ }
64+
65+ // accepts the header
66+ _, _ = session.Write(utils.NULL)
67+ continue
68+ }
69+
70+ if matches := reNewFile.FindAllStringSubmatch(string(line), 3); matches != nil {
71+ if len(matches) != 1 || len(matches[0]) != 4 {
72+ return parseError{string(line)}
73+ }
74+
75+ mode, err := strconv.ParseUint(matches[0][1], 8, 32)
76+ if err != nil {
77+ return parseError{string(line)}
78+ }
79+
80+ size, err := strconv.ParseInt(matches[0][2], 10, 64)
81+ if err != nil {
82+ return parseError{string(line)}
83+ }
84+ name := matches[0][3]
85+
86+ // accepts the header
87+ _, _ = session.Write(utils.NULL)
88+
89+ result, err := handler.Write(session, &utils.FileEntry{
90+ Name: name,
91+ Filepath: filepath.Join(path, name),
92+ Mode: fs.FileMode(mode),
93+ Size: size,
94+ Mtime: mtime,
95+ Atime: atime,
96+ Reader: utils.NewLimitReader(r, int(size)),
97+ })
98+
99+ if err == nil {
100+ writeSuccess = append(writeSuccess, result)
101+ } else {
102+ writeErrors = append(writeErrors, err)
103+ fmt.Printf("failed to write file: %q: %v\n", name, err)
104+ }
105+
106+ // read the trailing nil char
107+ _, _ = r.ReadByte() // TODO: check if it is indeed a utils.NULL?
108+
109+ mtime = 0
110+ atime = 0
111+ // says 'hey im done'
112+ _, _ = session.Write(utils.NULL)
113+ continue
114+ }
115+
116+ if matches := reNewFolder.FindAllStringSubmatch(string(line), 2); matches != nil {
117+ if len(matches) != 1 || len(matches[0]) != 3 {
118+ return parseError{string(line)}
119+ }
120+
121+ name := matches[0][2]
122+ path = filepath.Join(path, name)
123+ // says 'hey im done'
124+ _, _ = session.Write(utils.NULL)
125+ continue
126+ }
127+
128+ if string(line) == "E" {
129+ path = filepath.Dir(path)
130+
131+ // says 'hey im done'
132+ _, _ = session.Write(utils.NULL)
133+ continue
134+ }
135+
136+ return fmt.Errorf("unhandled input: %q", string(line))
137+ }
138+
139+ utils.PrintMsg(session, writeSuccess, writeErrors)
140+
141+ _, _ = session.Write(utils.NULL)
142+ return nil
143+}
+99,
-0
1@@ -0,0 +1,99 @@
2+package scp
3+
4+import (
5+ "fmt"
6+
7+ "git.sr.ht/~erock/pico/wish/send/utils"
8+ "github.com/charmbracelet/wish"
9+ "github.com/gliderlabs/ssh"
10+)
11+
12+func Middleware(writeHandler utils.CopyFromClientHandler) wish.Middleware {
13+ return func(sshHandler ssh.Handler) ssh.Handler {
14+ return func(session ssh.Session) {
15+ info := GetInfo(session.Command())
16+ if !info.Ok {
17+ sshHandler(session)
18+ return
19+ }
20+
21+ if info.Recursive {
22+ err := fmt.Errorf("recursive not supported.\n")
23+ utils.ErrorHandler(session, err)
24+ return
25+ }
26+
27+ err := writeHandler.Validate(session)
28+ if err != nil {
29+ utils.ErrorHandler(session, err)
30+ return
31+ }
32+
33+ switch info.Op {
34+ case OpCopyToClient:
35+ err = fmt.Errorf("copying from server to client not supported")
36+ case OpCopyFromClient:
37+ if writeHandler == nil {
38+ err = fmt.Errorf("no handler provided for scp -t")
39+ break
40+ }
41+ err = copyFromClient(session, info, writeHandler)
42+ }
43+ if err != nil {
44+ utils.ErrorHandler(session, err)
45+ return
46+ }
47+
48+ sshHandler(session)
49+ }
50+ }
51+}
52+
53+// Op defines which kind of SCP Operation is going on.
54+type Op byte
55+
56+const (
57+ // OpCopyToClient is when a file is being copied from the server to the client.
58+ OpCopyToClient Op = 'f'
59+
60+ // OpCopyFromClient is when a file is being copied from the client into the server.
61+ OpCopyFromClient Op = 't'
62+)
63+
64+// Info provides some information about the current SCP Operation.
65+type Info struct {
66+ // Ok is true if the current session is a SCP.
67+ Ok bool
68+
69+ // Recursice is true if its a recursive SCP.
70+ Recursive bool
71+
72+ // Path is the server path of the scp operation.
73+ Path string
74+
75+ // Op is the SCP operation kind.
76+ Op Op
77+}
78+
79+func GetInfo(cmd []string) Info {
80+ info := Info{}
81+ if len(cmd) == 0 || cmd[0] != "scp" {
82+ return info
83+ }
84+
85+ for i, p := range cmd {
86+ switch p {
87+ case "-r":
88+ info.Recursive = true
89+ case "-f":
90+ info.Op = OpCopyToClient
91+ info.Path = cmd[i+1]
92+ case "-t":
93+ info.Op = OpCopyFromClient
94+ info.Path = cmd[i+1]
95+ }
96+ }
97+
98+ info.Ok = true
99+ return info
100+}
+20,
-0
1@@ -0,0 +1,20 @@
2+package send
3+
4+import (
5+ "git.sr.ht/~erock/pico/wish/send/scp"
6+ "git.sr.ht/~erock/pico/wish/send/sftp"
7+ "git.sr.ht/~erock/pico/wish/send/utils"
8+ "github.com/charmbracelet/wish"
9+ "github.com/gliderlabs/ssh"
10+)
11+
12+func Middleware(writeHandler utils.CopyFromClientHandler) ssh.Option {
13+ return func(server *ssh.Server) error {
14+ err := wish.WithMiddleware(scp.Middleware(writeHandler))(server)
15+ if err != nil {
16+ return err
17+ }
18+
19+ return sftp.SSHOption(writeHandler)(server)
20+ }
21+}
+31,
-0
1@@ -0,0 +1,31 @@
2+package sftp
3+
4+import (
5+ "os"
6+ "time"
7+)
8+
9+type tempfile struct {
10+ name string
11+ isDir bool
12+ size int64
13+ modTime time.Time
14+ sys any
15+}
16+
17+func (f *tempfile) Name() string { return f.name }
18+func (f *tempfile) Size() int64 { return f.size }
19+func (f *tempfile) Mode() os.FileMode {
20+ if f.isDir {
21+ return os.FileMode(0755) | os.ModeDir
22+ }
23+ return os.FileMode(0644)
24+}
25+func (f *tempfile) ModTime() time.Time {
26+ if f.modTime.IsZero() {
27+ return time.Now()
28+ }
29+ return f.modTime
30+}
31+func (f *tempfile) IsDir() bool { return f.isDir }
32+func (f *tempfile) Sys() any { return f.sys }
+79,
-0
1@@ -0,0 +1,79 @@
2+package sftp
3+
4+import (
5+ "bytes"
6+ "errors"
7+ "io"
8+ "os"
9+ "path"
10+
11+ "git.sr.ht/~erock/pico/wish/send/utils"
12+ "github.com/gliderlabs/ssh"
13+ "github.com/pkg/sftp"
14+)
15+
16+type listerat []os.FileInfo
17+
18+func (f listerat) ListAt(ls []os.FileInfo, offset int64) (int, error) {
19+ var n int
20+ if offset >= int64(len(f)) {
21+ return 0, io.EOF
22+ }
23+ n = copy(ls, f[offset:])
24+ if n < len(ls) {
25+ return n, io.EOF
26+ }
27+ return n, nil
28+}
29+
30+type handler struct {
31+ session ssh.Session
32+ writeHandler utils.CopyFromClientHandler
33+ rootFile *tempfile
34+}
35+
36+func (f *handler) Filecmd(r *sftp.Request) error {
37+ return errors.New("unsupported")
38+}
39+
40+func (f *handler) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
41+ err := f.writeHandler.Validate(f.session)
42+ if err != nil {
43+ return nil, err
44+ }
45+
46+ switch r.Method {
47+ case "List":
48+ fallthrough
49+ case "Stat":
50+ if r.Filepath == "/" {
51+ return listerat{f.rootFile}, nil
52+ }
53+ }
54+
55+ return nil, errors.New("unsupported")
56+}
57+
58+func (f *handler) Filewrite(r *sftp.Request) (io.WriterAt, error) {
59+ fileEntry := &utils.FileEntry{
60+ Name: path.Base(r.Filepath),
61+ Filepath: r.Filepath,
62+ Mode: r.Attributes().FileMode(),
63+ Size: int64(r.Attributes().Size),
64+ Mtime: int64(r.Attributes().Mtime),
65+ Atime: int64(r.Attributes().Atime),
66+ }
67+
68+ buf := bytes.NewBuffer([]byte{})
69+ fileEntry.Reader = buf
70+
71+ return fakeWrite{fileEntry: fileEntry, buf: buf, handler: f}, nil
72+}
73+
74+func (f *handler) Fileread(r *sftp.Request) (io.ReaderAt, error) {
75+ if r.Filepath == "/" {
76+ return nil, os.ErrInvalid
77+ }
78+
79+ return nil, errors.New("unsupported")
80+}
+49,
-0
1@@ -0,0 +1,49 @@
2+package sftp
3+
4+import (
5+ "errors"
6+ "io"
7+ "log"
8+
9+ "git.sr.ht/~erock/pico/wish/send/utils"
10+ "github.com/gliderlabs/ssh"
11+ "github.com/pkg/sftp"
12+)
13+
14+func SSHOption(writeHandler utils.CopyFromClientHandler) ssh.Option {
15+ return func(server *ssh.Server) error {
16+ if server.SubsystemHandlers == nil {
17+ server.SubsystemHandlers = map[string]ssh.SubsystemHandler{}
18+ }
19+
20+ server.SubsystemHandlers["sftp"] = SubsystemHandler(writeHandler)
21+ return nil
22+ }
23+}
24+
25+func SubsystemHandler(writeHandler utils.CopyFromClientHandler) ssh.SubsystemHandler {
26+ return func(session ssh.Session) {
27+ rootFile := &tempfile{
28+ name: "/",
29+ isDir: true,
30+ }
31+ handler := &handler{
32+ session: session,
33+ writeHandler: writeHandler,
34+ rootFile: rootFile,
35+ }
36+ handlers := sftp.Handlers{
37+ FilePut: handler,
38+ FileList: handler,
39+ FileGet: handler,
40+ FileCmd: handler,
41+ }
42+
43+ requestServer := sftp.NewRequestServer(session, handlers)
44+
45+ err := requestServer.Serve()
46+ if err != nil && !errors.Is(err, io.EOF) {
47+ log.Println("Error serving sftp subsystem:", err)
48+ }
49+ }
50+}
+31,
-0
1@@ -0,0 +1,31 @@
2+package sftp
3+
4+import (
5+ "bytes"
6+ "fmt"
7+
8+ "git.sr.ht/~erock/pico/wish/send/utils"
9+)
10+
11+type fakeWrite struct {
12+ fileEntry *utils.FileEntry
13+ handler *handler
14+ buf *bytes.Buffer
15+}
16+
17+func (f fakeWrite) WriteAt(p []byte, off int64) (int, error) {
18+ return f.buf.Write(p)
19+}
20+
21+func (f fakeWrite) Close() error {
22+ msg, err := f.handler.writeHandler.Write(f.handler.session, f.fileEntry)
23+ if err != nil {
24+ errMsg := fmt.Sprintf("%s\n", err.Error())
25+ _, err = f.handler.session.Stderr().Write([]byte(errMsg))
26+ }
27+ if msg != "" {
28+ nMsg := fmt.Sprintf("%s\n", msg)
29+ _, err = f.handler.session.Stderr().Write([]byte(nMsg))
30+ }
31+ return err
32+}
+35,
-0
1@@ -0,0 +1,35 @@
2+package utils
3+
4+import (
5+ "io"
6+ "sync"
7+)
8+
9+func NewLimitReader(r io.Reader, limit int) io.Reader {
10+ return &LimitReader{
11+ r: r,
12+ left: limit,
13+ }
14+}
15+
16+type LimitReader struct {
17+ r io.Reader
18+
19+ lock sync.Mutex
20+ left int
21+}
22+
23+func (r *LimitReader) Read(b []byte) (int, error) {
24+ r.lock.Lock()
25+ defer r.lock.Unlock()
26+
27+ if r.left <= 0 {
28+ return 0, io.EOF
29+ }
30+ if len(b) > r.left {
31+ b = b[0:r.left]
32+ }
33+ n, err := r.r.Read(b)
34+ r.left -= n
35+ return n, err
36+}
+44,
-0
1@@ -0,0 +1,44 @@
2+package utils
3+
4+import (
5+ "bytes"
6+ "io"
7+ "testing"
8+
9+ "github.com/matryer/is"
10+)
11+
12+func TestLimitedReader(t *testing.T) {
13+ t.Run("partial", func(t *testing.T) {
14+ is := is.New(t)
15+ var b bytes.Buffer
16+ b.WriteString("writing some bytes")
17+ r := NewLimitReader(&b, 7)
18+
19+ bts, err := io.ReadAll(r)
20+ is.NoErr(err)
21+ is.Equal("writing", string(bts))
22+ })
23+
24+ t.Run("full", func(t *testing.T) {
25+ is := is.New(t)
26+ var b bytes.Buffer
27+ b.WriteString("some text")
28+ r := NewLimitReader(&b, b.Len())
29+
30+ bts, err := io.ReadAll(r)
31+ is.NoErr(err)
32+ is.Equal("some text", string(bts))
33+ })
34+
35+ t.Run("pass limit", func(t *testing.T) {
36+ is := is.New(t)
37+ var b bytes.Buffer
38+ b.WriteString("another text")
39+ r := NewLimitReader(&b, b.Len()+10)
40+
41+ bts, err := io.ReadAll(r)
42+ is.NoErr(err)
43+ is.Equal("another text", string(bts))
44+ })
45+}
+93,
-0
1@@ -0,0 +1,93 @@
2+package utils
3+
4+import (
5+ "encoding/base64"
6+ "fmt"
7+ "io"
8+ "io/fs"
9+ "strconv"
10+
11+ "github.com/gliderlabs/ssh"
12+)
13+
14+// NULL is an array with a single NULL byte.
15+var NULL = []byte{'\x00'}
16+
17+// FileEntry is an Entry that reads from a Reader, defining a file and
18+// its contents.
19+type FileEntry struct {
20+ Name string
21+ Filepath string
22+ Mode fs.FileMode
23+ Size int64
24+ Reader io.Reader
25+ Atime int64
26+ Mtime int64
27+}
28+
29+// Write a file to the given writer.
30+func (e *FileEntry) Write(w io.Writer) error {
31+ if e.Mtime > 0 && e.Atime > 0 {
32+ if _, err := fmt.Fprintf(w, "T%d 0 %d 0\n", e.Mtime, e.Atime); err != nil {
33+ return fmt.Errorf("failed to write file: %q: %w", e.Filepath, err)
34+ }
35+ }
36+ if _, err := fmt.Fprintf(w, "C%s %d %s\n", octalPerms(e.Mode), e.Size, e.Name); err != nil {
37+ return fmt.Errorf("failed to write file: %q: %w", e.Filepath, err)
38+ }
39+
40+ if _, err := io.Copy(w, e.Reader); err != nil {
41+ return fmt.Errorf("failed to read file: %q: %w", e.Filepath, err)
42+ }
43+
44+ if _, err := w.Write(NULL); err != nil {
45+ return fmt.Errorf("failed to write file: %q: %w", e.Filepath, err)
46+ }
47+ return nil
48+}
49+
50+func octalPerms(info fs.FileMode) string {
51+ return "0" + strconv.FormatUint(uint64(info.Perm()), 8)
52+}
53+
54+// CopyFromClientHandler is a handler that can be implemented to handle files
55+// being copied from the client to the server.
56+type CopyFromClientHandler interface {
57+ // Write should write the given file.
58+ Write(ssh.Session, *FileEntry) (string, error)
59+ Validate(ssh.Session) error
60+}
61+
62+func KeyText(session ssh.Session) (string, error) {
63+ if session.PublicKey() == nil {
64+ return "", fmt.Errorf("Session doesn't have public key")
65+ }
66+ kb := base64.StdEncoding.EncodeToString(session.PublicKey().Marshal())
67+ return fmt.Sprintf("%s %s", session.PublicKey().Type(), kb), nil
68+}
69+
70+func ErrorHandler(session ssh.Session, err error) {
71+ _, _ = fmt.Fprintln(session.Stderr(), err)
72+ _ = session.Exit(1)
73+ _ = session.Close()
74+}
75+
76+func PrintMsg(session ssh.Session, stdout []string, stderr []error) {
77+ output := ""
78+ if len(stdout) > 0 {
79+ for _, msg := range stdout {
80+ if msg != "" {
81+ output += fmt.Sprintf("%s\n", msg)
82+ }
83+ }
84+ _, _ = fmt.Fprintln(session.Stderr(), output)
85+ }
86+
87+ outputErr := ""
88+ if len(stderr) > 0 {
89+ for _, err := range stderr {
90+ outputErr += fmt.Sprintf("%v\n", err)
91+ }
92+ _, _ = fmt.Fprintln(session.Stderr(), outputErr)
93+ }
94+}