- 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.
+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:
+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"]
+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+}
+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+}
+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{
+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
+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{
+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,
+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,
+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)
+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)
+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()
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)
+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)
+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+}
+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:
+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
+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
+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
+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
+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
+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
+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
+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 == "" {
+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
+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
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 }
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+);
+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 }
+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+}
+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+}
+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+}