repos / pico

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

commit
e594875
parent
701e0e7
author
Eric Bower
date
2022-07-29 12:54:45 +0000 UTC
chore: added wish
51 files changed,  +4257, -59
M go.mod
M go.sum
M cmd/lists/ssh/main.go
+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"
M cmd/migrate/migrate.go
+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 
M cmd/pastes/ssh/main.go
+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"
M cmd/prose/ssh/main.go
+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=
M lists/api.go
+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 )
M lists/config.go
+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 
M lists/db_handler.go
+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 )
M lists/gemini/gemini.go
+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 
M lists/gemini/router.go
+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 
M lists/router.go
+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 
M pastes/api.go
+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 {
M pastes/config.go
+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 
M pastes/cron.go
+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 {
M pastes/db_handler.go
+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 
M pastes/router.go
+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 
M prose/api.go
+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 )
M prose/config.go
+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 
M prose/db_handler.go
+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 )
M prose/router.go
+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 
A wish/README.md
+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)
A wish/cmd/server/main.go
+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+}
A wish/cms/cms.go
+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+}
A wish/cms/config/config.go
+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+}
A wish/cms/db/db.go
+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+}
A wish/cms/db/postgres/storage.go
+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+}
A wish/cms/db/util.go
+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+}
A wish/cms/ui/account/create.go
+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+}
A wish/cms/ui/common/styles.go
+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+}
A wish/cms/ui/common/views.go
+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+}
A wish/cms/ui/createkey/create.go
+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+}
A wish/cms/ui/info/info.go
+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+}
A wish/cms/ui/keys/keys.go
+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+}
A wish/cms/ui/keys/keyview.go
+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+}
A wish/cms/ui/posts/post_view.go
+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+}
A wish/cms/ui/posts/posts.go
+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+}
A wish/cms/ui/username/username.go
+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+}
A wish/cms/util/util.go
+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+}
A wish/main.go
+1, -0
1@@ -0,0 +1 @@
2+package wish
A wish/proxy/middleware.go
+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+}
A wish/send/scp/copy_from_client.go
+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+}
A wish/send/scp/scp.go
+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+}
A wish/send/send.go
+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+}
A wish/send/sftp/file.go
+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 }
A wish/send/sftp/handler.go
+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+}
A wish/send/sftp/sftp.go
+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+}
A wish/send/sftp/writer.go
+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+}
A wish/send/utils/limit_reader.go
+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+}
A wish/send/utils/limit_reader_test.go
+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+}
A wish/send/utils/utils.go
+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+}