repos / pico

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

commit
76d15e5
parent
aa8502f
author
Eric Bower
date
2023-09-23 02:01:44 +0000 UTC
feat(auth): web service (#42)

Authentication (oauth2) web service intended for soju bouncer.

We are also introducings tokens which users can create through the SSH CMS for all services. This will allow users to generate an API token so they can authenticate with our oauth2 API.
34 files changed,  +1168, -58
M .env.example
+10, -0
 1@@ -134,3 +134,13 @@ PGS_PROTOCOL=http
 2 PGS_ALLOW_REGISTER=1
 3 PGS_STORAGE_DIR=.storage
 4 PGS_DEBUG=1
 5+
 6+AUTH_V4=
 7+AUTH_V6=
 8+AUTH_HTTP_V4=$AUTH_V4:80
 9+AUTH_HTTP_V6=[$AUTH_V6]:80
10+AUTH_HTTPS_V4=$AUTH_V4:443
11+AUTH_HTTPS_V6=[$AUTH_V6]:443
12+AUTH_DOMAIN=http://auth.dev.pico.sh:3006
13+AUTH_ISSUER=pico.sh
14+AUTH_WEB_PORT=3000
M Makefile
+12, -4
 1@@ -33,12 +33,16 @@ bp-caddy: bp-setup
 2 	$(DOCKER_BUILDX_BUILD) -t ghcr.io/picosh/pico/caddy:$(DOCKER_TAG) -f caddy/Dockerfile .
 3 .PHONY: bp-caddy
 4 
 5+bp-auth: bp-setup
 6+	$(DOCKER_BUILDX_BUILD) -t ghcr.io/picosh/pico/auth:$(DOCKER_TAG) -f auth/Dockerfile .
 7+.PHONY: bp-auth
 8+
 9 bp-%: bp-setup
10 	$(DOCKER_BUILDX_BUILD) --build-arg "APP=$*" -t "ghcr.io/picosh/pico/$*-ssh:$(DOCKER_TAG)" --target release-ssh .
11 	$(DOCKER_BUILDX_BUILD) --build-arg "APP=$*" -t "ghcr.io/picosh/pico/$*-web:$(DOCKER_TAG)" --target release-web .
12 .PHONY: bp-%
13 
14-bp-all: bp-prose bp-lists bp-pastes bp-imgs bp-feeds bp-pgs
15+bp-all: bp-prose bp-lists bp-pastes bp-imgs bp-feeds bp-pgs bp-auth
16 .PHONY: bp-all
17 
18 bp-podman-%:
19@@ -51,12 +55,16 @@ bp-podman-%:
20 bp-podman-all: bp-podman-prose bp-podman-lists bp-podman-pastes bp-podman-imgs bp-podman-feeds bp-podman-pgs
21 .PHONY: all
22 
23+build-auth:
24+	go build -o "build/auth" "./cmd/auth"
25+.PHONY: build-auth
26+
27 build-%:
28 	go build -o "build/$*-web" "./cmd/$*/web"
29 	go build -o "build/$*-ssh" "./cmd/$*/ssh"
30 .PHONY: build-%
31 
32-build: build-prose build-lists build-pastes build-imgs build-feeds build-pgs
33+build: build-prose build-lists build-pastes build-imgs build-feeds build-pgs build-auth
34 .PHONY: build
35 
36 pgs-static:
37@@ -105,11 +113,11 @@ migrate:
38 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230310_add_aliases_table.sql
39 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230326_add_feed_items.sql
40 	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230707_add_projects_table.sql
41+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230921_add_tokens_table.sql
42 .PHONY: migrate
43 
44 latest:
45-	$(DOCKER_CMD) cp ./sql/migrations/20230707_add_projects_table.sql $(DB_CONTAINER):/tmp
46-	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) -f /tmp/20230707_add_projects_table.sql
47+	$(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20230921_add_tokens_table.sql
48 .PHONY: latest
49 
50 psql:
A auth/Dockerfile
+41, -0
 1@@ -0,0 +1,41 @@
 2+FROM --platform=$BUILDPLATFORM golang:1.21 as builder-deps
 3+LABEL maintainer="Pico Maintainers <hello@pico.sh>"
 4+
 5+WORKDIR /app
 6+
 7+RUN dpkg --add-architecture arm64 && dpkg --add-architecture amd64
 8+RUN apt-get update
 9+RUN apt-get install -y git ca-certificates \
10+    libwebp-dev:amd64 libwebp-dev:arm64 \
11+    crossbuild-essential-amd64 crossbuild-essential-arm64 \
12+    libc-dev:amd64 libc-dev:arm64
13+
14+COPY go.* ./
15+
16+RUN go mod download
17+
18+FROM builder-deps as builder
19+
20+COPY . .
21+
22+ARG TARGETOS
23+ARG TARGETARCH
24+
25+ENV CGO_ENABLED=1
26+ENV LDFLAGS="-s -w -linkmode external -extldflags '-static -lm -pthread'"
27+ENV CC=/app/scripts/gccwrap.sh
28+
29+ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH}
30+
31+RUN go build -ldflags "$LDFLAGS" -tags "netgo osusergo" -o /go/bin/auth ./cmd/auth
32+
33+FROM scratch as release-web
34+
35+WORKDIR /app
36+
37+ARG APP=lists
38+
39+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
40+COPY --from=builder /go/bin/auth ./web
41+
42+ENTRYPOINT ["/app/web"]
A auth/auth.go
+161, -0
  1@@ -0,0 +1,161 @@
  2+package auth
  3+
  4+import (
  5+	"context"
  6+	"encoding/json"
  7+	"fmt"
  8+	"net/http"
  9+	"strings"
 10+
 11+	"github.com/picosh/pico/db"
 12+	"github.com/picosh/pico/db/postgres"
 13+	"github.com/picosh/pico/shared"
 14+	"go.uber.org/zap"
 15+)
 16+
 17+type Client struct {
 18+	Cfg    *AuthCfg
 19+	Dbpool db.DB
 20+	Logger *zap.SugaredLogger
 21+}
 22+
 23+type ctxClient struct{}
 24+
 25+func getClient(r *http.Request) *Client {
 26+	return r.Context().Value(ctxClient{}).(*Client)
 27+}
 28+
 29+type oauth2Server struct {
 30+	Issuer                                    string   `json:"issuer"`
 31+	IntrospectionEndpoint                     string   `json:"introspection_endpoint"`
 32+	IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported"`
 33+}
 34+
 35+func getIntrospectURL(cfg *AuthCfg) string {
 36+	return fmt.Sprintf("%s/introspect", cfg.Domain)
 37+}
 38+
 39+func wellKnownHandler(w http.ResponseWriter, r *http.Request) {
 40+	client := getClient(r)
 41+	introspectURL := getIntrospectURL(client.Cfg)
 42+
 43+	p := oauth2Server{
 44+		Issuer:                client.Cfg.Issuer,
 45+		IntrospectionEndpoint: introspectURL,
 46+		IntrospectionEndpointAuthMethodsSupported: []string{
 47+			"none",
 48+		},
 49+	}
 50+	w.Header().Set("Content-Type", "application/json")
 51+	w.WriteHeader(http.StatusOK)
 52+	err := json.NewEncoder(w).Encode(p)
 53+	if err != nil {
 54+		client.Logger.Error(err)
 55+		http.Error(w, err.Error(), http.StatusInternalServerError)
 56+	}
 57+}
 58+
 59+type oauth2Introspection struct {
 60+	Active   bool   `json:"active"`
 61+	Username string `json:"username"`
 62+}
 63+
 64+func introspectHandler(w http.ResponseWriter, r *http.Request) {
 65+	client := getClient(r)
 66+	token := r.FormValue("token")
 67+	client.Logger.Infof("introspect token (%s)", token)
 68+
 69+	user, err := client.Dbpool.FindUserForToken(token)
 70+	if err != nil {
 71+		client.Logger.Error(err)
 72+		http.Error(w, err.Error(), http.StatusUnauthorized)
 73+		return
 74+	}
 75+
 76+	p := oauth2Introspection{
 77+		Active:   true,
 78+		Username: user.Name,
 79+	}
 80+	w.Header().Set("Content-Type", "application/json")
 81+	w.WriteHeader(http.StatusOK)
 82+	err = json.NewEncoder(w).Encode(p)
 83+	if err != nil {
 84+		client.Logger.Error(err)
 85+		http.Error(w, err.Error(), http.StatusInternalServerError)
 86+	}
 87+}
 88+
 89+func createMainRoutes() []shared.Route {
 90+	routes := []shared.Route{
 91+		shared.NewRoute("GET", "/.well-known/oauth-authorization-server", wellKnownHandler),
 92+		shared.NewRoute("POST", "/introspect", introspectHandler),
 93+	}
 94+
 95+	return routes
 96+}
 97+
 98+func handler(routes []shared.Route, client *Client) shared.ServeFn {
 99+	return func(w http.ResponseWriter, r *http.Request) {
100+		var allow []string
101+
102+		for _, route := range routes {
103+			matches := route.Regex.FindStringSubmatch(r.URL.Path)
104+			if len(matches) > 0 {
105+				if r.Method != route.Method {
106+					allow = append(allow, route.Method)
107+					continue
108+				}
109+				clientCtx := context.WithValue(r.Context(), ctxClient{}, client)
110+				route.Handler(w, r.WithContext(clientCtx))
111+				return
112+			}
113+		}
114+		if len(allow) > 0 {
115+			w.Header().Set("Allow", strings.Join(allow, ", "))
116+			http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
117+			return
118+		}
119+		http.NotFound(w, r)
120+	}
121+}
122+
123+type AuthCfg struct {
124+	Debug  bool
125+	Port   string
126+	DbURL  string
127+	Domain string
128+	Issuer string
129+}
130+
131+func StartApiServer() {
132+	debug := shared.GetEnv("AUTH_DEBUG", "0")
133+	cfg := &AuthCfg{
134+		DbURL:  shared.GetEnv("DATABASE_URL", ""),
135+		Debug:  debug == "1",
136+		Issuer: shared.GetEnv("AUTH_ISSUER", "pico.sh"),
137+		Domain: shared.GetEnv("AUTH_DOMAIN", "http://0.0.0.0:3000"),
138+		Port:   shared.GetEnv("AUTH_WEB_PORT", "3000"),
139+	}
140+
141+	logger := shared.CreateLogger()
142+	db := postgres.NewDB(cfg.DbURL, logger)
143+	defer db.Close()
144+
145+	client := &Client{
146+		Cfg:    cfg,
147+		Dbpool: db,
148+		Logger: logger,
149+	}
150+
151+	routes := createMainRoutes()
152+
153+	if cfg.Debug {
154+		routes = shared.CreatePProfRoutes(routes)
155+	}
156+
157+	router := http.HandlerFunc(handler(routes, client))
158+
159+	portStr := fmt.Sprintf(":%s", cfg.Port)
160+	client.Logger.Infof("Starting server on port %s", cfg.Port)
161+	client.Logger.Fatal(http.ListenAndServe(portStr, router))
162+}
A cmd/auth/main.go
+7, -0
1@@ -0,0 +1,7 @@
2+package main
3+
4+import "github.com/picosh/pico/auth"
5+
6+func main() {
7+	auth.StartApiServer()
8+}
M cmd/feeds/ssh/main.go
+1, -1
1@@ -65,7 +65,7 @@ func main() {
2 	promPort := shared.GetEnv("LISTS_PROM_PORT", "9222")
3 	cfg := feeds.NewConfigSite()
4 	logger := cfg.Logger
5-	dbh := postgres.NewDB(&cfg.ConfigCms)
6+	dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer dbh.Close()
8 
9 	hooks := &feeds.FeedHooks{
M cmd/imgs/ssh/main.go
+1, -1
1@@ -66,7 +66,7 @@ func main() {
2 	promPort := shared.GetEnv("IMGS_PROM_PORT", "9222")
3 	cfg := imgs.NewConfigSite()
4 	logger := cfg.Logger
5-	dbh := postgres.NewDB(&cfg.ConfigCms)
6+	dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer dbh.Close()
8 
9 	var st storage.ObjectStorage
M cmd/lists/ssh/main.go
+1, -1
1@@ -65,7 +65,7 @@ func main() {
2 	promPort := shared.GetEnv("LISTS_PROM_PORT", "9222")
3 	cfg := lists.NewConfigSite()
4 	logger := cfg.Logger
5-	dbh := postgres.NewDB(&cfg.ConfigCms)
6+	dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer dbh.Close()
8 
9 	hooks := &lists.ListHooks{
M cmd/pastes/ssh/main.go
+1, -1
1@@ -65,7 +65,7 @@ func main() {
2 	promPort := shared.GetEnv("PASTES_PROM_PORT", "9222")
3 	cfg := pastes.NewConfigSite()
4 	logger := cfg.Logger
5-	dbh := postgres.NewDB(&cfg.ConfigCms)
6+	dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer dbh.Close()
8 	hooks := &pastes.FileHooks{
9 		Cfg: cfg,
M cmd/prose/ssh/main.go
+1, -1
1@@ -65,7 +65,7 @@ func main() {
2 	promPort := shared.GetEnv("PROSE_PROM_PORT", "9222")
3 	cfg := prose.NewConfigSite()
4 	logger := cfg.Logger
5-	dbh := postgres.NewDB(&cfg.ConfigCms)
6+	dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer dbh.Close()
8 	hooks := &prose.MarkdownHooks{
9 		Cfg: cfg,
M cmd/scripts/dates/dates.go
+1, -1
1@@ -73,7 +73,7 @@ func main() {
2 	picoCfg := config.NewConfigCms()
3 	picoCfg.Logger = logger
4 	picoCfg.DbURL = os.Getenv("DATABASE_URL")
5-	picoDb := postgres.NewDB(picoCfg)
6+	picoDb := postgres.NewDB(picoCfg.DbURL, picoCfg.Logger)
7 
8 	logger.Info("fetching all posts")
9 	posts, err := findPosts(picoDb.Db)
M cmd/scripts/migrate/migrate.go
+3, -3
 1@@ -114,17 +114,17 @@ func main() {
 2 	listsCfg := config.NewConfigCms()
 3 	listsCfg.Logger = logger
 4 	listsCfg.DbURL = os.Getenv("LISTS_DB_URL")
 5-	listsDb := postgres.NewDB(listsCfg)
 6+	listsDb := postgres.NewDB(listsCfg.DbURL, listsCfg.Logger)
 7 
 8 	proseCfg := config.NewConfigCms()
 9 	proseCfg.DbURL = os.Getenv("PROSE_DB_URL")
10 	proseCfg.Logger = logger
11-	proseDb := postgres.NewDB(proseCfg)
12+	proseDb := postgres.NewDB(proseCfg.DbURL, proseCfg.Logger)
13 
14 	picoCfg := config.NewConfigCms()
15 	picoCfg.Logger = logger
16 	picoCfg.DbURL = os.Getenv("PICO_DB_URL")
17-	picoDb := postgres.NewDB(picoCfg)
18+	picoDb := postgres.NewDB(picoCfg.DbURL, picoCfg.Logger)
19 
20 	ctx := context.Background()
21 	tx, err := picoDb.Db.BeginTx(ctx, nil)
M cmd/scripts/shasum/shasum.go
+1, -1
1@@ -24,7 +24,7 @@ func main() {
2 	picoCfg := config.NewConfigCms()
3 	picoCfg.Logger = logger
4 	picoCfg.DbURL = os.Getenv("DATABASE_URL")
5-	picoDb := postgres.NewDB(picoCfg)
6+	picoDb := postgres.NewDB(picoCfg.DbURL, picoCfg.Logger)
7 
8 	logger.Info("fetching all posts")
9 	posts, err := picoDb.FindPosts()
M cmd/scripts/tags/tags.go
+1, -1
1@@ -66,7 +66,7 @@ func main() {
2 	picoCfg := config.NewConfigCms()
3 	picoCfg.Logger = logger
4 	picoCfg.DbURL = os.Getenv("DATABASE_URL")
5-	picoDb := postgres.NewDB(picoCfg)
6+	picoDb := postgres.NewDB(picoCfg.DbURL, picoCfg.Logger)
7 
8 	logger.Info("fetching all posts")
9 	posts, err := findPosts(picoDb.Db)
M cmd/scripts/webp/webp.go
+1, -1
1@@ -13,7 +13,7 @@ import (
2 
3 func main() {
4 	cfg := imgs.NewConfigSite()
5-	dbp := postgres.NewDB(&cfg.ConfigCms)
6+	dbp := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 
8 	cfg.Logger.Info("fetching all img posts")
9 	posts, err := dbp.FindAllPosts(&db.Pager{Num: 1000, Page: 0}, "imgs")
M db/db.go
+13, -0
 1@@ -139,6 +139,14 @@ type FeedItem struct {
 2 	CreatedAt *time.Time
 3 }
 4 
 5+type Token struct {
 6+	ID        string
 7+	UserID    string
 8+	Name      string
 9+	CreatedAt *time.Time
10+	ExpiresAt *time.Time
11+}
12+
13 type ErrMultiplePublicKeys struct{}
14 
15 func (m *ErrMultiplePublicKeys) Error() string {
16@@ -177,6 +185,11 @@ type DB interface {
17 	ValidateName(name string) (bool, error)
18 	SetUserName(userID string, name string) error
19 
20+	FindUserForToken(token string) (*User, error)
21+	FindTokensForUser(userID string) ([]*Token, error)
22+	InsertToken(userID, name string) (string, error)
23+	RemoveToken(tokenID string) error
24+
25 	FindPosts() ([]*Post, error)
26 	FindPost(postID string) (*Post, error)
27 	FindPostsForUser(pager *Pager, userID string, space string) (*Paginate[*Post], error)
M db/postgres/storage.go
+80, -26
  1@@ -12,7 +12,6 @@ import (
  2 	_ "github.com/lib/pq"
  3 	"github.com/picosh/pico/db"
  4 	"github.com/picosh/pico/shared"
  5-	"github.com/picosh/pico/wish/cms/config"
  6 	"go.uber.org/zap"
  7 	"golang.org/x/exp/slices"
  8 )
  9@@ -27,47 +26,47 @@ var (
 10 	sqlSelectPosts = fmt.Sprintf(`
 11 	SELECT %s
 12 	FROM posts
 13-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id`, SelectPost)
 14+	LEFT JOIN app_users ON app_users.id = posts.user_id`, SelectPost)
 15 
 16 	sqlSelectPostsBeforeDate = fmt.Sprintf(`
 17 	SELECT %s
 18 	FROM posts
 19-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 20+	LEFT JOIN app_users ON app_users.id = posts.user_id
 21 	WHERE publish_at::date <= $1 AND cur_space = $2`, SelectPost)
 22 
 23 	sqlSelectPostWithFilename = fmt.Sprintf(`
 24 	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
 25 	FROM posts
 26-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 27-	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
 28+	LEFT JOIN app_users ON app_users.id = posts.user_id
 29+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 30 	WHERE filename = $1 AND user_id = $2 AND cur_space = $3
 31 	GROUP BY %s`, SelectPost, SelectPost)
 32 
 33 	sqlSelectPostWithSlug = fmt.Sprintf(`
 34 	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
 35 	FROM posts
 36-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 37-	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
 38+	LEFT JOIN app_users ON app_users.id = posts.user_id
 39+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 40 	WHERE slug = $1 AND user_id = $2 AND cur_space = $3
 41 	GROUP BY %s`, SelectPost, SelectPost)
 42 
 43 	sqlSelectPost = fmt.Sprintf(`
 44 	SELECT %s
 45 	FROM posts
 46-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 47+	LEFT JOIN app_users ON app_users.id = posts.user_id
 48 	WHERE posts.id = $1`, SelectPost)
 49 
 50 	sqlSelectUpdatedPostsForUser = fmt.Sprintf(`
 51 	SELECT %s
 52 	FROM posts
 53-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 54+	LEFT JOIN app_users ON app_users.id = posts.user_id
 55 	WHERE user_id = $1 AND publish_at::date <= CURRENT_DATE AND cur_space = $2
 56 	ORDER BY posts.updated_at DESC`, SelectPost)
 57 
 58 	sqlSelectExpiredPosts = fmt.Sprintf(`
 59 		SELECT %s
 60 		FROM posts
 61-		LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 62+		LEFT JOIN app_users ON app_users.id = posts.user_id
 63 		WHERE
 64 			cur_space = $1 AND
 65 			expires_at <= now();
 66@@ -76,8 +75,8 @@ var (
 67 	sqlSelectPostsForUser = fmt.Sprintf(`
 68 	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
 69 	FROM posts
 70-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 71-	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
 72+	LEFT JOIN app_users ON app_users.id = posts.user_id
 73+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 74 	WHERE
 75 		hidden = FALSE AND
 76 		user_id = $1 AND
 77@@ -90,7 +89,7 @@ var (
 78 	sqlSelectAllPostsForUser = fmt.Sprintf(`
 79 	SELECT %s
 80 	FROM posts
 81-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 82+	LEFT JOIN app_users ON app_users.id = posts.user_id
 83 	WHERE
 84 		user_id = $1 AND
 85 		cur_space = $2
 86@@ -111,8 +110,8 @@ var (
 87 		posts.mime_type,
 88 		0 AS "score"
 89 	FROM posts
 90-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
 91-	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
 92+	LEFT JOIN app_users ON app_users.id = posts.user_id
 93+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 94 	WHERE
 95 		post_tags.name = $3 AND
 96 		publish_at::date <= CURRENT_DATE AND
 97@@ -123,8 +122,8 @@ var (
 98 	sqlSelectUserPostsByTag = fmt.Sprintf(`
 99 	SELECT %s
100 	FROM posts
101-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
102-	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
103+	LEFT JOIN app_users ON app_users.id = posts.user_id
104+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
105 	WHERE
106 		hidden = FALSE AND
107 		user_id = $1 AND
108@@ -140,9 +139,18 @@ const (
109 	sqlSelectPublicKeys        = `SELECT id, user_id, public_key, created_at FROM public_keys WHERE user_id = $1`
110 	sqlSelectUser              = `SELECT id, name, created_at FROM app_users WHERE id = $1`
111 	sqlSelectUserForName       = `SELECT id, name, created_at FROM app_users WHERE name = $1`
112-	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`
113+	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 JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`
114 	sqlSelectUsers             = `SELECT id, name, created_at FROM app_users ORDER BY name ASC`
115 
116+	sqlSelectUserForToken = `
117+	SELECT app_users.id, app_users.name, app_users.created_at
118+	FROM app_users
119+	LEFT JOIN tokens ON tokens.user_id = app_users.id
120+	WHERE tokens.token = $1 AND tokens.expires_at > NOW()`
121+	sqlInsertToken         = `INSERT INTO tokens (user_id, name) VALUES($1, $2) RETURNING token;`
122+	sqlRemoveToken         = `DELETE FROM tokens WHERE id = $1`
123+	sqlSelectTokensForUser = `SELECT id, user_id, name, created_at, expires_at FROM tokens WHERE user_id = $1`
124+
125 	sqlSelectTotalUsers          = `SELECT count(id) FROM app_users`
126 	sqlSelectUsersAfterDate      = `SELECT count(id) FROM app_users WHERE created_at >= $1`
127 	sqlSelectTotalPosts          = `SELECT count(id) FROM posts WHERE cur_space = $1`
128@@ -156,7 +164,7 @@ const (
129 	sqlSelectTagPostCount      = `
130 	SELECT count(posts.id)
131 	FROM posts
132-	LEFT OUTER JOIN post_tags ON post_tags.post_id = posts.id
133+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
134 	WHERE hidden = FALSE AND cur_space=$1 and post_tags.name = $2`
135 	sqlSelectPostCount       = `SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`
136 	sqlSelectAllUpdatedPosts = `
137@@ -174,7 +182,7 @@ const (
138 		posts.mime_type,
139 		0 AS "score"
140 	FROM posts
141-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
142+	LEFT JOIN app_users ON app_users.id = posts.user_id
143 	WHERE hidden = FALSE AND publish_at::date <= CURRENT_DATE AND cur_space = $3
144 	ORDER BY updated_at DESC
145 	LIMIT $1 OFFSET $2`
146@@ -199,7 +207,7 @@ const (
147 			)
148 		) AS "score"
149 	FROM posts
150-	LEFT OUTER JOIN app_users ON app_users.id = posts.user_id
151+	LEFT JOIN app_users ON app_users.id = posts.user_id
152 	WHERE
153 		hidden = FALSE AND
154 		publish_at::date <= CURRENT_DATE AND
155@@ -210,7 +218,7 @@ const (
156 	sqlSelectPopularTags = `
157 	SELECT name, count(post_id) as "tally"
158 	FROM post_tags
159-	LEFT OUTER JOIN posts ON posts.id = post_id
160+	LEFT JOIN posts ON posts.id = post_id
161 	WHERE posts.cur_space = $1
162 	GROUP BY name
163 	ORDER BY tally DESC
164@@ -251,7 +259,7 @@ const (
165 	sqlFindAllProjects    = `
166 	SELECT projects.id, user_id, app_users.name as username, projects.name, project_dir, projects.created_at
167 	FROM projects
168-	LEFT OUTER JOIN app_users ON app_users.id = projects.user_id
169+	LEFT JOIN app_users ON app_users.id = projects.user_id
170 	ORDER BY created_at ASC
171 	LIMIT $1 OFFSET $2`
172 	sqlFindProjectsByUser   = `SELECT id, user_id, name, project_dir FROM projects WHERE user_id = $1 ORDER BY name ASC;`
173@@ -338,11 +346,10 @@ func CreatePostWithTagsFromRow(r RowScanner) (*db.Post, error) {
174 	return post, nil
175 }
176 
177-func NewDB(cfg *config.ConfigCms) *PsqlDB {
178-	databaseUrl := cfg.DbURL
179+func NewDB(databaseUrl string, logger *zap.SugaredLogger) *PsqlDB {
180 	var err error
181 	d := &PsqlDB{
182-		Logger: cfg.Logger,
183+		Logger: logger,
184 	}
185 	d.Logger.Infof("Connecting to postgres: %s", databaseUrl)
186 
187@@ -580,6 +587,18 @@ func (me *PsqlDB) FindUserForNameAndKey(name string, key string) (*db.User, erro
188 	return user, nil
189 }
190 
191+func (me *PsqlDB) FindUserForToken(token string) (*db.User, error) {
192+	user := &db.User{}
193+
194+	r := me.Db.QueryRow(sqlSelectUserForToken, token)
195+	err := r.Scan(&user.ID, &user.Name, &user.CreatedAt)
196+	if err != nil {
197+		return nil, err
198+	}
199+
200+	return user, nil
201+}
202+
203 func (me *PsqlDB) SetUserName(userID string, name string) error {
204 	lowerName := strings.ToLower(name)
205 	valid, err := me.ValidateName(lowerName)
206@@ -1398,3 +1417,38 @@ func (me *PsqlDB) FindAllProjects(page *db.Pager) (*db.Paginate[*db.Project], er
207 
208 	return pager, nil
209 }
210+
211+func (me *PsqlDB) InsertToken(userID, name string) (string, error) {
212+	var token string
213+	err := me.Db.QueryRow(sqlInsertToken, userID, name).Scan(&token)
214+	if err != nil {
215+		return "", err
216+	}
217+	return token, nil
218+}
219+
220+func (me *PsqlDB) RemoveToken(tokenID string) error {
221+	_, err := me.Db.Exec(sqlRemoveToken, tokenID)
222+	return err
223+}
224+
225+func (me *PsqlDB) FindTokensForUser(userID string) ([]*db.Token, error) {
226+	var keys []*db.Token
227+	rs, err := me.Db.Query(sqlSelectTokensForUser, userID)
228+	if err != nil {
229+		return keys, err
230+	}
231+	for rs.Next() {
232+		pk := &db.Token{}
233+		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.CreatedAt, &pk.ExpiresAt)
234+		if err != nil {
235+			return keys, err
236+		}
237+
238+		keys = append(keys, pk)
239+	}
240+	if rs.Err() != nil {
241+		return keys, rs.Err()
242+	}
243+	return keys, nil
244+}
M docker-compose.override.yml
+7, -0
 1@@ -11,6 +11,13 @@ services:
 2     ports:
 3       - "9000:9000"
 4       - "9001:9001"
 5+  auth:
 6+    build:
 7+      dockerfile: ./auth/Dockerfile
 8+    env_file:
 9+      - .env.example
10+    ports:
11+      - "3006:3000"
12   lists-web:
13     build:
14       args:
M docker-compose.prod.yml
+29, -0
 1@@ -33,6 +33,35 @@ services:
 2     ports:
 3       - "9000:9000"
 4       - "9001:9001"
 5+  auth-caddy:
 6+    image: ghcr.io/picosh/pico/caddy:latest
 7+    restart: always
 8+    networks:
 9+      - auth
10+    env_file:
11+      - .env.prod
12+    environment:
13+      APP_DOMAIN: ${AUTH_DOMAIN:-auth.pico.sh}
14+    volumes:
15+      - ./caddy/Caddyfile:/etc/caddy/Caddyfile
16+      - ./data/auth-caddy/data:/data
17+      - ./data/auth-caddy/config:/config
18+    ports:
19+      - "${AUTH_HTTPS_V4:-443}:443"
20+      - "${AUTH_HTTP_V4:-80}:80"
21+      - "${AUTH_HTTPS_V6:-[::1]:443}:443"
22+      - "${AUTH_HTTP_V6:-[::1]:80}:80"
23+    profiles:
24+      - auth
25+      - caddy
26+      - all
27+  auth-web:
28+    networks:
29+      auth:
30+        aliases:
31+          - web
32+    env_file:
33+      - .env.prod
34   lists-caddy:
35     image: ghcr.io/picosh/pico/caddy:latest
36     restart: always
M docker-compose.yml
+7, -0
 1@@ -13,6 +13,13 @@ services:
 2     profiles:
 3       - db
 4       - all
 5+  auth:
 6+    image: ghcr.io/picosh/pico/auth:latest
 7+    restart: always
 8+    profiles:
 9+      - auth
10+      - services
11+      - all
12   lists-web:
13     image: ghcr.io/picosh/pico/lists-web:latest
14     restart: always
M feeds/api.go
+1, -1
1@@ -42,7 +42,7 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
2 
3 func StartApiServer() {
4 	cfg := NewConfigSite()
5-	db := postgres.NewDB(&cfg.ConfigCms)
6+	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer db.Close()
8 	logger := cfg.Logger
9 
M imgs/api.go
+1, -1
1@@ -777,7 +777,7 @@ func StartApiServer() {
2 	cfg := NewConfigSite()
3 	logger := cfg.Logger
4 
5-	db := postgres.NewDB(&cfg.ConfigCms)
6+	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer db.Close()
8 
9 	var st storage.ObjectStorage
M lists/api.go
+1, -1
1@@ -747,7 +747,7 @@ func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
2 
3 func StartApiServer() {
4 	cfg := NewConfigSite()
5-	db := postgres.NewDB(&cfg.ConfigCms)
6+	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer db.Close()
8 	logger := cfg.Logger
9 
M pastes/api.go
+1, -1
1@@ -384,7 +384,7 @@ func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
2 
3 func StartApiServer() {
4 	cfg := NewConfigSite()
5-	db := postgres.NewDB(&cfg.ConfigCms)
6+	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer db.Close()
8 	logger := cfg.Logger
9 
M pgs/api.go
+1, -1
1@@ -282,7 +282,7 @@ func StartApiServer() {
2 	cfg := NewConfigSite()
3 	logger := cfg.Logger
4 
5-	db := postgres.NewDB(&cfg.ConfigCms)
6+	db := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer db.Close()
8 
9 	var st storage.ObjectStorage
M pgs/cms.go
+1, -1
1@@ -94,7 +94,7 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
2 
3 		sshUser := s.User()
4 
5-		dbpool := postgres.NewDB(cfg)
6+		dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 
8 		var st storage.ObjectStorage
9 		if cfg.MinioURL == "" {
M pgs/ssh.go
+1, -1
1@@ -64,7 +64,7 @@ func StartSshServer() {
2 	promPort := shared.GetEnv("PGS_PROM_PORT", "9222")
3 	cfg := NewConfigSite()
4 	logger := cfg.Logger
5-	dbh := postgres.NewDB(&cfg.ConfigCms)
6+	dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
7 	defer dbh.Close()
8 
9 	var st storage.ObjectStorage
M prose/api.go
+1, -1
1@@ -885,7 +885,7 @@ func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
2 
3 func StartApiServer() {
4 	cfg := NewConfigSite()
5-	db := postgres.NewDB(&cfg.ConfigCms)
6+	db := postgres.NewDB(cfg.ConfigCms.DbURL, cfg.Logger)
7 	defer db.Close()
8 	logger := cfg.Logger
9 
M shared/router.go
+7, -7
 1@@ -16,9 +16,9 @@ import (
 2 )
 3 
 4 type Route struct {
 5-	method  string
 6-	regex   *regexp.Regexp
 7-	handler http.HandlerFunc
 8+	Method  string
 9+	Regex   *regexp.Regexp
10+	Handler http.HandlerFunc
11 }
12 
13 func NewRoute(method, pattern string, handler http.HandlerFunc) Route {
14@@ -72,10 +72,10 @@ func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpoo
15 		}
16 
17 		for _, route := range curRoutes {
18-			matches := route.regex.FindStringSubmatch(r.URL.Path)
19+			matches := route.Regex.FindStringSubmatch(r.URL.Path)
20 			if len(matches) > 0 {
21-				if r.Method != route.method {
22-					allow = append(allow, route.method)
23+				if r.Method != route.Method {
24+					allow = append(allow, route.Method)
25 					continue
26 				}
27 				loggerCtx := context.WithValue(r.Context(), ctxLoggerKey{}, logger)
28@@ -85,7 +85,7 @@ func CreateServe(routes []Route, subdomainRoutes []Route, cfg *ConfigSite, dbpoo
29 				cfgCtx := context.WithValue(storageCtx, ctxCfg{}, cfg)
30 				cacheCtx := context.WithValue(cfgCtx, ctxCacheKey{}, cache)
31 				ctx := context.WithValue(cacheCtx, ctxKey{}, matches[1:])
32-				route.handler(w, r.WithContext(ctx))
33+				route.Handler(w, r.WithContext(ctx))
34 				return
35 			}
36 		}
A sql/migrations/20230921_add_tokens_table.sql
+16, -0
 1@@ -0,0 +1,16 @@
 2+CREATE TABLE IF NOT EXISTS tokens (
 3+  id uuid NOT NULL DEFAULT uuid_generate_v4(),
 4+  user_id uuid NOT NULL,
 5+  name varchar(256) NOT NULL,
 6+  token varchar(256) NOT NULL DEFAULT uuid_generate_v4(),
 7+  created_at timestamp without time zone NOT NULL DEFAULT NOW(),
 8+  expires_at timestamp without time zone NOT NULL DEFAULT '2100-01-01 00:00:00',
 9+  CONSTRAINT user_tokens_pkey PRIMARY KEY (id),
10+  CONSTRAINT unique_token UNIQUE (token),
11+  CONSTRAINT unique_user_name UNIQUE (user_id, name),
12+  CONSTRAINT fk_user_tokens_owner
13+    FOREIGN KEY(user_id)
14+  REFERENCES app_users(id)
15+  ON DELETE CASCADE
16+  ON UPDATE CASCADE
17+);
M wish/cms/cms.go
+30, -1
  1@@ -21,6 +21,7 @@ import (
  2 	"github.com/picosh/pico/wish/cms/ui/info"
  3 	"github.com/picosh/pico/wish/cms/ui/keys"
  4 	"github.com/picosh/pico/wish/cms/ui/posts"
  5+	"github.com/picosh/pico/wish/cms/ui/tokens"
  6 	"github.com/picosh/pico/wish/cms/ui/username"
  7 	"github.com/picosh/pico/wish/cms/util"
  8 )
  9@@ -33,6 +34,7 @@ const (
 10 	statusNoAccount
 11 	statusBrowsingPosts
 12 	statusBrowsingKeys
 13+	statusBrowsingTokens
 14 	statusSettingUsername
 15 	statusQuitting
 16 )
 17@@ -57,6 +59,7 @@ const (
 18 	setUserChoice menuChoice = iota
 19 	postsChoice
 20 	keysChoice
 21+	tokensChoice
 22 	exitChoice
 23 	unsetChoice // set when no choice has been made
 24 )
 25@@ -65,6 +68,7 @@ const (
 26 var menuChoices = map[menuChoice]string{
 27 	setUserChoice: "Set username",
 28 	keysChoice:    "Manage keys",
 29+	tokensChoice:  "Manage tokens",
 30 	postsChoice:   "Manage posts",
 31 	exitChoice:    "Exit",
 32 }
 33@@ -99,7 +103,7 @@ func Middleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
 34 
 35 		sshUser := s.User()
 36 
 37-		dbpool := postgres.NewDB(cfg)
 38+		dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
 39 
 40 		var st storage.ObjectStorage
 41 		if cfg.MinioURL == "" {
 42@@ -156,6 +160,7 @@ type model struct {
 43 	username      username.Model
 44 	posts         posts.Model
 45 	keys          keys.Model
 46+	tokens        tokens.Model
 47 	createAccount account.CreateModel
 48 }
 49 
 50@@ -242,6 +247,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 51 		m.info = info.NewModel(m.cfg, m.urls, m.user)
 52 		m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st)
 53 		m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
 54+		m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
 55 		m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
 56 	}
 57 
 58@@ -251,6 +257,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 59 		m.info = info.NewModel(m.cfg, m.urls, m.user)
 60 		m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st)
 61 		m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
 62+		m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
 63 		m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
 64 		if m.user == nil {
 65 			m.status = statusNoAccount
 66@@ -303,6 +310,22 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
 67 			m.status = statusQuitting
 68 			return m, tea.Quit
 69 		}
 70+	case statusBrowsingTokens:
 71+		newModel, newCmd := m.tokens.Update(msg)
 72+		tokensModel, ok := newModel.(tokens.Model)
 73+		if !ok {
 74+			panic("could not perform assertion on posts model")
 75+		}
 76+		m.tokens = tokensModel
 77+		cmd = newCmd
 78+
 79+		if m.tokens.Exit {
 80+			m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
 81+			m.status = statusReady
 82+		} else if m.tokens.Quit {
 83+			m.status = statusQuitting
 84+			return m, tea.Quit
 85+		}
 86 	case statusSettingUsername:
 87 		m.username, cmd = username.Update(msg, m.username)
 88 		if m.username.Done {
 89@@ -337,6 +360,10 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
 90 		m.status = statusBrowsingKeys
 91 		m.menuChoice = unsetChoice
 92 		cmd = keys.LoadKeys(m.keys)
 93+	case tokensChoice:
 94+		m.status = statusBrowsingTokens
 95+		m.menuChoice = unsetChoice
 96+		cmd = tokens.LoadKeys(m.tokens)
 97 	case exitChoice:
 98 		m.status = statusQuitting
 99 		m.dbpool.Close()
100@@ -396,6 +423,8 @@ func (m model) View() string {
101 		s += m.posts.View()
102 	case statusBrowsingKeys:
103 		s += m.keys.View()
104+	case statusBrowsingTokens:
105+		s += m.tokens.View()
106 	}
107 	return m.styles.App.Render(wrap.String(wordwrap.String(s, w), w))
108 }
A wish/cms/ui/createtoken/create.go
+264, -0
  1@@ -0,0 +1,264 @@
  2+package createtoken
  3+
  4+import (
  5+	"fmt"
  6+	"strings"
  7+
  8+	"github.com/charmbracelet/bubbles/spinner"
  9+	input "github.com/charmbracelet/bubbles/textinput"
 10+	tea "github.com/charmbracelet/bubbletea"
 11+	"github.com/picosh/pico/db"
 12+	"github.com/picosh/pico/wish/cms/config"
 13+	"github.com/picosh/pico/wish/cms/ui/common"
 14+)
 15+
 16+type state int
 17+
 18+const (
 19+	ready state = iota
 20+	submitting
 21+	submitted
 22+)
 23+
 24+type index int
 25+
 26+const (
 27+	textInput index = iota
 28+	okButton
 29+	cancelButton
 30+)
 31+
 32+type TokenDismissed int
 33+
 34+type TokenSetMsg struct {
 35+	token string
 36+}
 37+
 38+type errMsg struct {
 39+	err error
 40+}
 41+
 42+func (e errMsg) Error() string { return e.err.Error() }
 43+
 44+type Model struct {
 45+	Done bool
 46+	Quit bool
 47+
 48+	dbpool    db.DB
 49+	user      *db.User
 50+	styles    common.Styles
 51+	state     state
 52+	tokenName string
 53+	token     string
 54+	index     index
 55+	errMsg    string
 56+	input     input.Model
 57+	spinner   spinner.Model
 58+}
 59+
 60+// updateFocus updates the focused states in the model based on the current
 61+// focus index.
 62+func (m *Model) updateFocus() {
 63+	if m.index == textInput && !m.input.Focused() {
 64+		m.input.Focus()
 65+		m.input.Prompt = m.styles.FocusedPrompt.String()
 66+	} else if m.index != textInput && m.input.Focused() {
 67+		m.input.Blur()
 68+		m.input.Prompt = m.styles.Prompt.String()
 69+	}
 70+}
 71+
 72+// Move the focus index one unit forward.
 73+func (m *Model) indexForward() {
 74+	m.index++
 75+	if m.index > cancelButton {
 76+		m.index = textInput
 77+	}
 78+
 79+	m.updateFocus()
 80+}
 81+
 82+// Move the focus index one unit backwards.
 83+func (m *Model) indexBackward() {
 84+	m.index--
 85+	if m.index < textInput {
 86+		m.index = cancelButton
 87+	}
 88+
 89+	m.updateFocus()
 90+}
 91+
 92+// NewModel returns a new username model in its initial state.
 93+func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 94+	st := common.DefaultStyles()
 95+
 96+	im := input.NewModel()
 97+	im.CursorStyle = st.Cursor
 98+	im.Placeholder = "A name used for your reference"
 99+	im.Prompt = st.FocusedPrompt.String()
100+	im.CharLimit = 256
101+	im.Focus()
102+
103+	return Model{
104+		Done:      false,
105+		Quit:      false,
106+		dbpool:    dbpool,
107+		user:      user,
108+		styles:    st,
109+		state:     ready,
110+		tokenName: "",
111+		token:     "",
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 (m Model) Init() tea.Cmd {
121+	return input.Blink
122+}
123+
124+// Update is the Bubble Tea update loop.
125+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
126+	fmt.Println(msg)
127+	switch msg := msg.(type) {
128+	case tea.KeyMsg:
129+		switch msg.Type {
130+		case tea.KeyCtrlC: // quit
131+			m.Quit = true
132+			return m, dismiss
133+		case tea.KeyEscape: // exit this mini-app
134+			m.Done = true
135+			return m, dismiss
136+
137+		default:
138+			// Ignore keys if we're submitting
139+			if m.state == submitting {
140+				return m, nil
141+			}
142+
143+			switch msg.String() {
144+			case "tab":
145+				m.indexForward()
146+			case "shift+tab":
147+				m.indexBackward()
148+			case "l", "k", "right":
149+				if m.index != textInput {
150+					m.indexForward()
151+				}
152+			case "h", "j", "left":
153+				if m.index != textInput {
154+					m.indexBackward()
155+				}
156+			case "up", "down":
157+				if m.index == textInput {
158+					m.indexForward()
159+				} else {
160+					m.index = textInput
161+					m.updateFocus()
162+				}
163+			case "enter":
164+				switch m.index {
165+				case textInput:
166+					fallthrough
167+				case okButton: // Submit the form
168+					// form already submitted so ok button exits
169+					if m.state == submitted {
170+						m.Done = true
171+						return m, dismiss
172+					}
173+
174+					m.state = submitting
175+					m.errMsg = ""
176+					m.tokenName = strings.TrimSpace(m.input.Value())
177+
178+					return m, tea.Batch(
179+						addToken(m), // fire off the command, too
180+						spinner.Tick,
181+					)
182+				case cancelButton: // Exit this mini-app
183+					m.Done = true
184+					return m, dismiss
185+				}
186+			}
187+
188+			// Pass messages through to the input element if that's the element
189+			// in focus
190+			if m.index == textInput {
191+				var cmd tea.Cmd
192+				m.input, cmd = m.input.Update(msg)
193+
194+				return m, cmd
195+			}
196+
197+			return m, nil
198+		}
199+
200+	case TokenSetMsg:
201+		fmt.Println("TOKEN SET")
202+		m.state = submitted
203+		m.token = msg.token
204+		return m, nil
205+
206+	case errMsg:
207+		m.state = ready
208+		head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
209+		body := m.styles.Subtle.Render(msg.Error())
210+		m.errMsg = m.styles.Wrap.Render(head + body)
211+
212+		return m, nil
213+
214+	case spinner.TickMsg:
215+		var cmd tea.Cmd
216+		m.spinner, cmd = m.spinner.Update(msg)
217+
218+		return m, cmd
219+
220+	default:
221+		var cmd tea.Cmd
222+		m.input, cmd = m.input.Update(msg) // Do we still need this?
223+
224+		return m, cmd
225+	}
226+}
227+
228+// View renders current view from the model.
229+func (m Model) View() string {
230+	s := "Enter a name for your token\n\n"
231+	s += m.input.View() + "\n\n"
232+
233+	if m.state == submitting {
234+		s += spinnerView(m)
235+	} else if m.state == submitted {
236+		s = fmt.Sprintf("Save this token:\n%s\n\n", m.token)
237+		s += "After you exit this screen you will *not* be able to see it again.\n\n"
238+		s += common.OKButtonView(m.index == 1, true)
239+	} else {
240+		s += common.OKButtonView(m.index == 1, true)
241+		s += " " + common.CancelButtonView(m.index == 2, false)
242+		if m.errMsg != "" {
243+			s += "\n\n" + m.errMsg
244+		}
245+	}
246+
247+	return s
248+}
249+
250+func spinnerView(m Model) string {
251+	return m.spinner.View() + " Submitting..."
252+}
253+
254+func dismiss() tea.Msg { return TokenDismissed(1) }
255+
256+func addToken(m Model) tea.Cmd {
257+	return func() tea.Msg {
258+		token, err := m.dbpool.InsertToken(m.user.ID, m.tokenName)
259+		if err != nil {
260+			return errMsg{err}
261+		}
262+
263+		return TokenSetMsg{token}
264+	}
265+}
A wish/cms/ui/tokens/tokens.go
+393, -0
  1@@ -0,0 +1,393 @@
  2+package tokens
  3+
  4+import (
  5+	"fmt"
  6+
  7+	pager "github.com/charmbracelet/bubbles/paginator"
  8+	"github.com/charmbracelet/bubbles/spinner"
  9+	tea "github.com/charmbracelet/bubbletea"
 10+	"github.com/picosh/pico/db"
 11+	"github.com/picosh/pico/wish/cms/config"
 12+	"github.com/picosh/pico/wish/cms/ui/common"
 13+	"github.com/picosh/pico/wish/cms/ui/createtoken"
 14+)
 15+
 16+const keysPerPage = 4
 17+
 18+type state int
 19+
 20+const (
 21+	stateLoading state = iota
 22+	stateNormal
 23+	stateDeletingKey
 24+	stateCreateKey
 25+	stateQuitting
 26+)
 27+
 28+type keyState int
 29+
 30+const (
 31+	keyNormal keyState = iota
 32+	keySelected
 33+	keyDeleting
 34+)
 35+
 36+type errMsg struct {
 37+	err error
 38+}
 39+
 40+func (e errMsg) Error() string { return e.err.Error() }
 41+
 42+type (
 43+	keysLoadedMsg  []*db.Token
 44+	unlinkedKeyMsg int
 45+)
 46+
 47+// Model is the Tea state model for this user interface.
 48+type Model struct {
 49+	cfg            *config.ConfigCms
 50+	dbpool         db.DB
 51+	user           *db.User
 52+	styles         common.Styles
 53+	pager          pager.Model
 54+	state          state
 55+	err            error
 56+	activeKeyIndex int         // index of the key in the below slice which is currently in use
 57+	tokens         []*db.Token // keys linked to user's account
 58+	index          int         // index of selected key in relation to the current page
 59+	Exit           bool
 60+	Quit           bool
 61+	spinner        spinner.Model
 62+	createKey      createtoken.Model
 63+}
 64+
 65+// getSelectedIndex returns the index of the cursor in relation to the total
 66+// number of items.
 67+func (m *Model) getSelectedIndex() int {
 68+	return m.index + m.pager.Page*m.pager.PerPage
 69+}
 70+
 71+// UpdatePaging runs an update against the underlying pagination model as well
 72+// as performing some related tasks on this model.
 73+func (m *Model) UpdatePaging(msg tea.Msg) {
 74+	// Handle paging
 75+	m.pager.SetTotalPages(len(m.tokens))
 76+	m.pager, _ = m.pager.Update(msg)
 77+
 78+	// If selected item is out of bounds, put it in bounds
 79+	numItems := m.pager.ItemsOnPage(len(m.tokens))
 80+	m.index = min(m.index, numItems-1)
 81+}
 82+
 83+// NewModel creates a new model with defaults.
 84+func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 85+	st := common.DefaultStyles()
 86+
 87+	p := pager.NewModel()
 88+	p.PerPage = keysPerPage
 89+	p.Type = pager.Dots
 90+	p.InactiveDot = st.InactivePagination.Render("•")
 91+
 92+	return Model{
 93+		cfg:            cfg,
 94+		dbpool:         dbpool,
 95+		user:           user,
 96+		styles:         st,
 97+		pager:          p,
 98+		state:          stateLoading,
 99+		err:            nil,
100+		activeKeyIndex: -1,
101+		tokens:         []*db.Token{},
102+		index:          0,
103+		spinner:        common.NewSpinner(),
104+		Exit:           false,
105+		Quit:           false,
106+	}
107+}
108+
109+// Init is the Tea initialization function.
110+func (m Model) Init() tea.Cmd {
111+	return tea.Batch(
112+		spinner.Tick,
113+	)
114+}
115+
116+// Update is the tea update function which handles incoming messages.
117+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118+	var (
119+		cmds []tea.Cmd
120+		cmd  tea.Cmd
121+	)
122+
123+	switch msg := msg.(type) {
124+	case tea.KeyMsg:
125+		switch msg.Type {
126+		case tea.KeyCtrlC:
127+			m.Exit = true
128+			return m, nil
129+		}
130+
131+		if m.state != stateCreateKey {
132+			switch msg.String() {
133+			case "q", "esc":
134+				m.Exit = true
135+				return m, nil
136+			case "up", "k":
137+				m.index--
138+				if m.index < 0 && m.pager.Page > 0 {
139+					m.index = m.pager.PerPage - 1
140+					m.pager.PrevPage()
141+				}
142+				m.index = max(0, m.index)
143+			case "down", "j":
144+				itemsOnPage := m.pager.ItemsOnPage(len(m.tokens))
145+				m.index++
146+				if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
147+					m.index = 0
148+					m.pager.NextPage()
149+				}
150+				m.index = min(itemsOnPage-1, m.index)
151+
152+			case "n":
153+				m.state = stateCreateKey
154+				return m, nil
155+
156+			// Delete
157+			case "x":
158+				m.state = stateDeletingKey
159+				m.UpdatePaging(msg)
160+				return m, nil
161+
162+			// Confirm Delete
163+			case "y":
164+				switch m.state {
165+				case stateDeletingKey:
166+					m.state = stateNormal
167+					return m, unlinkKey(m)
168+				}
169+			}
170+		}
171+
172+	case errMsg:
173+		m.err = msg.err
174+		return m, nil
175+
176+	case keysLoadedMsg:
177+		m.state = stateNormal
178+		m.index = 0
179+		m.tokens = msg
180+
181+	case unlinkedKeyMsg:
182+		if m.state == stateQuitting {
183+			return m, tea.Quit
184+		}
185+		i := m.getSelectedIndex()
186+
187+		// Remove key from array
188+		m.tokens = append(m.tokens[:i], m.tokens[i+1:]...)
189+
190+		// Update pagination
191+		m.pager.SetTotalPages(len(m.tokens))
192+		m.pager.Page = min(m.pager.Page, m.pager.TotalPages-1)
193+
194+		// Update cursor
195+		m.index = min(m.index, m.pager.ItemsOnPage(len(m.tokens)-1))
196+
197+		return m, nil
198+
199+	case createtoken.TokenDismissed:
200+		m.state = stateNormal
201+		return m, fetchKeys(m.dbpool, m.user)
202+
203+	case spinner.TickMsg:
204+		var cmd tea.Cmd
205+		if m.state < stateNormal {
206+			m.spinner, cmd = m.spinner.Update(msg)
207+		}
208+		return m, cmd
209+	}
210+
211+	switch m.state {
212+	case stateNormal:
213+		m.createKey = createtoken.NewModel(m.cfg, m.dbpool, m.user)
214+	case stateDeletingKey:
215+		// If an item is being confirmed for delete, any key (other than the key
216+		// used for confirmation above) cancels the deletion
217+		k, ok := msg.(tea.KeyMsg)
218+		if ok && k.String() != "y" {
219+			m.state = stateNormal
220+		}
221+	}
222+
223+	m.UpdatePaging(msg)
224+
225+	m, cmd = updateChildren(msg, m)
226+	if cmd != nil {
227+		cmds = append(cmds, cmd)
228+	}
229+
230+	return m, tea.Batch(cmds...)
231+}
232+
233+func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
234+	var cmd tea.Cmd
235+
236+	switch m.state {
237+	case stateCreateKey:
238+		newModel, newCmd := m.createKey.Update(msg)
239+		createKeyModel, ok := newModel.(createtoken.Model)
240+		if !ok {
241+			panic("could not perform assertion on posts model")
242+		}
243+		m.createKey = createKeyModel
244+		cmd = newCmd
245+		if m.createKey.Done {
246+			m.createKey = createtoken.NewModel(m.cfg, m.dbpool, m.user) // reset the state
247+			m.state = stateNormal
248+		} else if m.createKey.Quit {
249+			m.state = stateQuitting
250+			return m, tea.Quit
251+		}
252+
253+	}
254+
255+	return m, cmd
256+}
257+
258+// View renders the current UI into a string.
259+func (m Model) View() string {
260+	if m.err != nil {
261+		return m.err.Error()
262+	}
263+
264+	var s string
265+
266+	switch m.state {
267+	case stateLoading:
268+		s = m.spinner.View() + " Loading...\n\n"
269+	case stateQuitting:
270+		s = fmt.Sprintf("Thanks for using %s!\n", m.cfg.Domain)
271+	case stateCreateKey:
272+		s = m.createKey.View()
273+	default:
274+		s = "Here are the tokens linked to your account.\n\n"
275+		s += "A token can be used for connecting to our IRC bouncer from your client.\n"
276+		s += "Authenticating to our bouncer is simple:\n"
277+		s += "  `username` is your pico user, and \n"
278+		s += "  `password` are the tokens listed here.\n\n"
279+
280+		// Keys
281+		s += keysView(m)
282+		if m.pager.TotalPages > 1 {
283+			s += m.pager.View()
284+		}
285+
286+		// Footer
287+		switch m.state {
288+		case stateDeletingKey:
289+			s += m.promptView("Delete this key?")
290+		default:
291+			s += "\n\n" + helpView(m)
292+		}
293+	}
294+
295+	return s
296+}
297+
298+func keysView(m Model) string {
299+	var (
300+		s          string
301+		state      keyState
302+		start, end = m.pager.GetSliceBounds(len(m.tokens))
303+		slice      = m.tokens[start:end]
304+	)
305+
306+	destructiveState := m.state == stateDeletingKey
307+
308+	// Render key info
309+	for i, key := range slice {
310+		if destructiveState && m.index == i {
311+			state = keyDeleting
312+		} else if m.index == i {
313+			state = keySelected
314+		} else {
315+			state = keyNormal
316+		}
317+		s += m.newStyledKey(m.styles, key, i+start == m.activeKeyIndex).render(state)
318+	}
319+
320+	// If there aren't enough keys to fill the view, fill the missing parts
321+	// with whitespace
322+	if len(slice) < m.pager.PerPage {
323+		for i := len(slice); i < keysPerPage; i++ {
324+			s += "\n\n\n"
325+		}
326+	}
327+
328+	return s
329+}
330+
331+func helpView(m Model) string {
332+	var items []string
333+	if len(m.tokens) > 1 {
334+		items = append(items, "j/k, ↑/↓: choose")
335+	}
336+	if m.pager.TotalPages > 1 {
337+		items = append(items, "h/l, ←/→: page")
338+	}
339+	items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
340+	return common.HelpView(items...)
341+}
342+
343+func (m Model) promptView(prompt string) string {
344+	st := m.styles.Delete.Copy().MarginTop(2).MarginRight(1)
345+	return st.Render(prompt) +
346+		m.styles.DeleteDim.Render("(y/N)")
347+}
348+
349+// LoadKeys returns the command necessary for loading the keys.
350+func LoadKeys(m Model) tea.Cmd {
351+	return tea.Batch(
352+		fetchKeys(m.dbpool, m.user),
353+		spinner.Tick,
354+	)
355+}
356+
357+// fetchKeys loads the current set of keys via the charm client.
358+func fetchKeys(dbpool db.DB, user *db.User) tea.Cmd {
359+	return func() tea.Msg {
360+		ak, err := dbpool.FindTokensForUser(user.ID)
361+		if err != nil {
362+			return errMsg{err}
363+		}
364+		return keysLoadedMsg(ak)
365+	}
366+}
367+
368+// unlinkKey deletes the selected key.
369+func unlinkKey(m Model) tea.Cmd {
370+	return func() tea.Msg {
371+		id := m.tokens[m.getSelectedIndex()].ID
372+		err := m.dbpool.RemoveToken(id)
373+		if err != nil {
374+			return errMsg{err}
375+		}
376+		return unlinkedKeyMsg(m.index)
377+	}
378+}
379+
380+// Utils
381+
382+func min(a, b int) int {
383+	if a < b {
384+		return a
385+	}
386+	return b
387+}
388+
389+func max(a, b int) int {
390+	if a > b {
391+		return a
392+	}
393+	return b
394+}
A wish/cms/ui/tokens/tokenview.go
+71, -0
 1@@ -0,0 +1,71 @@
 2+package tokens
 3+
 4+import (
 5+	"fmt"
 6+
 7+	"github.com/picosh/pico/db"
 8+	"github.com/picosh/pico/wish/cms/ui/common"
 9+)
10+
11+type styledKey struct {
12+	styles       common.Styles
13+	nameLabel    string
14+	name         string
15+	date         string
16+	gutter       string
17+	dateLabel    string
18+	dateVal      string
19+	expiresLabel string
20+	expiresVal   string
21+}
22+
23+func (m Model) newStyledKey(styles common.Styles, token *db.Token, active bool) styledKey {
24+	date := token.CreatedAt.Format("02 Jan 2006 15:04:05 MST")
25+	expires := token.ExpiresAt.Format("02 Jan 2006 15:04:05 MST")
26+
27+	// Default state
28+	return styledKey{
29+		styles:       styles,
30+		date:         date,
31+		name:         token.Name,
32+		gutter:       " ",
33+		nameLabel:    "Name:",
34+		dateLabel:    "Added:",
35+		dateVal:      styles.LabelDim.Render(date),
36+		expiresLabel: "Expires:",
37+		expiresVal:   styles.LabelDim.Render(expires),
38+	}
39+}
40+
41+// Selected state.
42+func (k *styledKey) selected() {
43+	k.gutter = common.VerticalLine(common.StateSelected)
44+	k.nameLabel = k.styles.Label.Render("Name:")
45+	k.dateLabel = k.styles.Label.Render("Added:")
46+	k.expiresLabel = k.styles.Label.Render("Expires:")
47+}
48+
49+// Deleting state.
50+func (k *styledKey) deleting() {
51+	k.gutter = common.VerticalLine(common.StateDeleting)
52+	k.nameLabel = k.styles.Delete.Render("Name:")
53+	k.dateLabel = k.styles.Delete.Render("Added:")
54+	k.dateVal = k.styles.DeleteDim.Render(k.date)
55+	k.expiresLabel = k.styles.Delete.Render("Expires:")
56+	k.expiresVal = k.styles.DeleteDim.Render(k.expiresVal)
57+}
58+
59+func (k styledKey) render(state keyState) string {
60+	switch state {
61+	case keySelected:
62+		k.selected()
63+	case keyDeleting:
64+		k.deleting()
65+	}
66+	return fmt.Sprintf(
67+		"%s %s %s\n%s %s %s\n%s %s %s\n",
68+		k.gutter, k.nameLabel, k.name,
69+		k.gutter, k.dateLabel, k.dateVal,
70+		k.gutter, k.expiresLabel, k.expiresVal,
71+	)
72+}