Skip to content

Commit f33780c

Browse files
feat(bot-token): added script to create bot token
1 parent e429a51 commit f33780c

3 files changed

Lines changed: 133 additions & 1 deletion

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ Seeds or updates a user in the database and prints a JWT for that user. `--email
130130
go run scripts/create_user/main.go --email dev@example.com --role dev
131131
```
132132

133+
### Create Bot Token
134+
Creates a bot token in the database and prints the full `token_id.secret` value once for use with the `X-Bot-Token` header. `--name` and `--created-by` are required; `--hours 0` means the token does not expire.
135+
```bash
136+
go run scripts/create_bot_token/main.go --name my-bot --created-by 00000000-0000-0000-0000-000000000001 --hours 24
137+
```
138+
133139
### Run DB-Connected Scripts Without Go in the API Image
134140
If you are running the API and Postgres with Docker Compose, the API container does not include the Go toolchain. To run local Go scripts that need database access, start a one-off Go container on the same Compose network and mount the repository into it.
135141

@@ -149,6 +155,17 @@ docker run --rm \
149155
go run scripts/create_user/main.go --email dev@example.com --role dev
150156
```
151157

158+
Bot token example:
159+
```bash
160+
docker run --rm \
161+
--network api_default \
162+
-v "$PWD":/app \
163+
-w /app \
164+
--env-file .env \
165+
golang:1.25 \
166+
go run scripts/create_bot_token/main.go --name my-bot --created-by 00000000-0000-0000-0000-000000000001 --hours 24
167+
```
168+
152169
If your Compose project name is different, the network name will usually be `<project>_default`. You can check it with:
153170
```bash
154171
docker network ls

internal/handler/auth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) {
403403
h.respondJSON(w, http.StatusCreated, BotTokenResponse{
404404
TokenID: token.TokenID,
405405
Name: token.Name,
406-
Token: rawToken, // Only returned on creation!
406+
Token: formatBotToken(token.TokenID, rawToken), // Only returned on creation
407407
CreatedAt: token.CreatedAt.Time,
408408
ExpiresAt: fromPgTimestamp(token.ExpiresAt),
409409
IsActive: token.IsActive.Bool,

scripts/create_bot_token/main.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/base64"
7+
"flag"
8+
"fmt"
9+
"log"
10+
"time"
11+
12+
"github.com/capyrpi/api/internal/config"
13+
"github.com/capyrpi/api/internal/database"
14+
"github.com/google/uuid"
15+
"github.com/jackc/pgx/v5/pgtype"
16+
"github.com/joho/godotenv"
17+
"golang.org/x/crypto/bcrypt"
18+
)
19+
20+
func main() {
21+
envFile := flag.String("env", ".env", "Path to .env file")
22+
name := flag.String("name", "", "Bot token name (required)")
23+
createdBy := flag.String("created-by", "", "Creator user ID (UUID, required)")
24+
hours := flag.Int("hours", 0, "Token validity in hours (0 means no expiry)")
25+
flag.Parse()
26+
27+
if *name == "" {
28+
log.Fatal("name is required")
29+
}
30+
31+
creatorID, err := uuid.Parse(*createdBy)
32+
if err != nil {
33+
log.Fatalf("invalid created-by UUID: %v", err)
34+
}
35+
36+
if *hours < 0 {
37+
log.Fatal("hours must be >= 0")
38+
}
39+
40+
if err := godotenv.Load(*envFile); err != nil {
41+
log.Printf("Warning: Error loading .env file: %v", err)
42+
}
43+
44+
cfg, err := config.Load()
45+
if err != nil {
46+
log.Fatalf("Failed to load config: %v", err)
47+
}
48+
49+
ctx := context.Background()
50+
pool, err := database.NewPool(ctx, cfg.Database.URL)
51+
if err != nil {
52+
log.Fatalf("Unable to connect to database: %v", err)
53+
}
54+
defer pool.Close()
55+
56+
rawToken, err := generateSecureToken(32)
57+
if err != nil {
58+
log.Fatalf("Failed to generate token: %v", err)
59+
}
60+
61+
hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), bcrypt.DefaultCost)
62+
if err != nil {
63+
log.Fatalf("Failed to hash token: %v", err)
64+
}
65+
66+
token, err := database.New(pool).CreateBotToken(ctx, database.CreateBotTokenParams{
67+
TokenHash: string(hashedToken),
68+
Name: *name,
69+
CreatedBy: creatorID,
70+
ExpiresAt: expiryTimestamp(*hours),
71+
})
72+
if err != nil {
73+
log.Fatalf("Failed to create bot token: %v", err)
74+
}
75+
76+
fmt.Println("\nBot token created successfully.")
77+
fmt.Println("---------------------------------------------------")
78+
fmt.Printf("Token ID: %s\n", token.TokenID)
79+
fmt.Printf("Name: %s\n", token.Name)
80+
fmt.Printf("Created By: %s\n", token.CreatedBy)
81+
if token.ExpiresAt.Valid {
82+
fmt.Printf("Expires At: %s\n", token.ExpiresAt.Time.Format(time.RFC3339))
83+
} else {
84+
fmt.Println("Expires At: never")
85+
}
86+
fmt.Println("---------------------------------------------------")
87+
fmt.Println("\nUsage with curl:")
88+
fmt.Printf("curl -H \"X-Bot-Token: %s\" http://localhost:8080/api/v1/bot/me\n", formatBotToken(token.TokenID, rawToken))
89+
fmt.Println("\nToken:")
90+
fmt.Println(formatBotToken(token.TokenID, rawToken))
91+
}
92+
93+
func generateSecureToken(byteLen int) (string, error) {
94+
buf := make([]byte, byteLen)
95+
if _, err := rand.Read(buf); err != nil {
96+
return "", err
97+
}
98+
99+
return base64.RawURLEncoding.EncodeToString(buf), nil
100+
}
101+
102+
func expiryTimestamp(hours int) pgtype.Timestamp {
103+
if hours == 0 {
104+
return pgtype.Timestamp{Valid: false}
105+
}
106+
107+
return pgtype.Timestamp{
108+
Time: time.Now().Add(time.Duration(hours) * time.Hour),
109+
Valid: true,
110+
}
111+
}
112+
113+
func formatBotToken(tokenID uuid.UUID, secret string) string {
114+
return tokenID.String() + "." + secret
115+
}

0 commit comments

Comments
 (0)