+27
.gitignore
+27
.gitignore
···
···
1
+
# Binary
2
+
bsky-poster
3
+
4
+
# Database
5
+
*.sqlite3
6
+
*.db
7
+
8
+
# IDE/Editor
9
+
.idea/
10
+
.vscode/
11
+
*.swp
12
+
*.swo
13
+
*~
14
+
15
+
# OS
16
+
.DS_Store
17
+
Thumbs.db
18
+
19
+
# Logs
20
+
*.log
21
+
22
+
# Environment
23
+
.env
24
+
.env.local
25
+
26
+
# Test coverage
27
+
coverage.out
+5
AGENT.md
+5
AGENT.md
+10
Makefile
+10
Makefile
+57
README.md
+57
README.md
···
···
1
+
# Go Shelley Template
2
+
3
+
This is a starter template for building Go web applications on exe.dev. It demonstrates end-to-end usage including HTTP handlers, authentication, database integration, and deployment.
4
+
5
+
Use this as a foundation to build your own service.
6
+
7
+
## Building and Running
8
+
9
+
Build with `make build`, then run `./srv`. The server listens on port 8000 by default.
10
+
11
+
## Running as a systemd service
12
+
13
+
To run the server as a systemd service:
14
+
15
+
```bash
16
+
# Install the service file
17
+
sudo cp srv.service /etc/systemd/system/srv.service
18
+
19
+
# Reload systemd and enable the service
20
+
sudo systemctl daemon-reload
21
+
sudo systemctl enable srv.service
22
+
23
+
# Start the service
24
+
sudo systemctl start srv
25
+
26
+
# Check status
27
+
systemctl status srv
28
+
29
+
# View logs
30
+
journalctl -u srv -f
31
+
```
32
+
33
+
To restart after code changes:
34
+
35
+
```bash
36
+
make build
37
+
sudo systemctl restart srv
38
+
```
39
+
40
+
## Authorization
41
+
42
+
exe.dev provides authorization headers and login/logout links
43
+
that this template uses.
44
+
45
+
When proxied through exed, requests will include `X-ExeDev-UserID` and
46
+
`X-ExeDev-Email` if the user is authenticated via exe.dev.
47
+
48
+
## Database
49
+
50
+
This template uses sqlite (`db.sqlite3`). SQL queries are managed with sqlc.
51
+
52
+
## Code layout
53
+
54
+
- `cmd/srv`: main package (binary entrypoint)
55
+
- `srv`: HTTP server logic (handlers)
56
+
- `srv/templates`: Go HTML templates
57
+
- `db`: SQLite open + migrations (001-base.sql)
+36
cmd/srv/main.go
+36
cmd/srv/main.go
···
···
1
+
package main
2
+
3
+
import (
4
+
"flag"
5
+
"fmt"
6
+
"os"
7
+
8
+
"srv.exe.dev/srv"
9
+
)
10
+
11
+
var flagListenAddr = flag.String("listen", ":8000", "address to listen on")
12
+
13
+
func main() {
14
+
if err := run(); err != nil {
15
+
fmt.Fprintln(os.Stderr, err)
16
+
}
17
+
}
18
+
19
+
func run() error {
20
+
flag.Parse()
21
+
hostname, err := os.Hostname()
22
+
if err != nil {
23
+
hostname = "unknown"
24
+
}
25
+
// BaseURL for OAuth callbacks - use exe.dev proxy URL (standard HTTPS port)
26
+
// Bluesky OAuth requires standard ports, so we use the main exe.xyz proxy
27
+
baseURL := os.Getenv("BASE_URL")
28
+
if baseURL == "" {
29
+
baseURL = fmt.Sprintf("https://%s.exe.xyz", hostname)
30
+
}
31
+
server, err := srv.New("db.sqlite3", hostname, baseURL)
32
+
if err != nil {
33
+
return fmt.Errorf("create server: %w", err)
34
+
}
35
+
return server.Serve(*flagListenAddr)
36
+
}
db.sqlite3-shm
db.sqlite3-shm
This is a binary file and will not be displayed.
db.sqlite3-wal
db.sqlite3-wal
This is a binary file and will not be displayed.
+115
db/db.go
+115
db/db.go
···
···
1
+
package db
2
+
3
+
import (
4
+
"database/sql"
5
+
"embed"
6
+
"errors"
7
+
"fmt"
8
+
"log/slog"
9
+
"regexp"
10
+
"sort"
11
+
"strconv"
12
+
13
+
_ "modernc.org/sqlite"
14
+
)
15
+
16
+
//go:generate go tool github.com/sqlc-dev/sqlc/cmd/sqlc generate
17
+
18
+
//go:embed migrations/*.sql
19
+
var migrationFS embed.FS
20
+
21
+
// Open opens an sqlite database and prepares pragmas suitable for a small web app.
22
+
func Open(path string) (*sql.DB, error) {
23
+
db, err := sql.Open("sqlite", path)
24
+
if err != nil {
25
+
return nil, err
26
+
}
27
+
// Light pragmas similar
28
+
if _, err := db.Exec("PRAGMA foreign_keys=ON;"); err != nil {
29
+
_ = db.Close()
30
+
return nil, fmt.Errorf("enable foreign keys: %w", err)
31
+
}
32
+
if _, err := db.Exec("PRAGMA journal_mode=wal;"); err != nil {
33
+
_ = db.Close()
34
+
return nil, fmt.Errorf("set WAL: %w", err)
35
+
}
36
+
if _, err := db.Exec("PRAGMA busy_timeout=1000;"); err != nil {
37
+
_ = db.Close()
38
+
return nil, fmt.Errorf("set busy_timeout: %w", err)
39
+
}
40
+
return db, nil
41
+
}
42
+
43
+
// RunMigrations executes database migrations in numeric order (NNN-*.sql),
44
+
// similar in spirit to exed's exedb.RunMigrations.
45
+
func RunMigrations(db *sql.DB) error {
46
+
entries, err := migrationFS.ReadDir("migrations")
47
+
if err != nil {
48
+
return fmt.Errorf("read migrations dir: %w", err)
49
+
}
50
+
var migrations []string
51
+
pat := regexp.MustCompile(`^(\d{3})-.*\.sql$`)
52
+
for _, e := range entries {
53
+
if e.IsDir() {
54
+
continue
55
+
}
56
+
name := e.Name()
57
+
if pat.MatchString(name) {
58
+
migrations = append(migrations, name)
59
+
}
60
+
}
61
+
sort.Strings(migrations)
62
+
63
+
executed := make(map[int]bool)
64
+
var tableName string
65
+
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&tableName)
66
+
switch {
67
+
case err == nil:
68
+
rows, err := db.Query("SELECT migration_number FROM migrations")
69
+
if err != nil {
70
+
return fmt.Errorf("query executed migrations: %w", err)
71
+
}
72
+
defer rows.Close()
73
+
for rows.Next() {
74
+
var n int
75
+
if err := rows.Scan(&n); err != nil {
76
+
return fmt.Errorf("scan migration number: %w", err)
77
+
}
78
+
executed[n] = true
79
+
}
80
+
case errors.Is(err, sql.ErrNoRows):
81
+
slog.Info("db: migrations table not found; running all migrations")
82
+
default:
83
+
return fmt.Errorf("check migrations table: %w", err)
84
+
}
85
+
86
+
for _, m := range migrations {
87
+
match := pat.FindStringSubmatch(m)
88
+
if len(match) != 2 {
89
+
return fmt.Errorf("invalid migration filename: %s", m)
90
+
}
91
+
n, err := strconv.Atoi(match[1])
92
+
if err != nil {
93
+
return fmt.Errorf("parse migration number %s: %w", m, err)
94
+
}
95
+
if executed[n] {
96
+
continue
97
+
}
98
+
if err := executeMigration(db, m); err != nil {
99
+
return fmt.Errorf("execute %s: %w", m, err)
100
+
}
101
+
slog.Info("db: applied migration", "file", m, "number", n)
102
+
}
103
+
return nil
104
+
}
105
+
106
+
func executeMigration(db *sql.DB, filename string) error {
107
+
content, err := migrationFS.ReadFile("migrations/" + filename)
108
+
if err != nil {
109
+
return fmt.Errorf("read %s: %w", filename, err)
110
+
}
111
+
if _, err := db.Exec(string(content)); err != nil {
112
+
return fmt.Errorf("exec %s: %w", filename, err)
113
+
}
114
+
return nil
115
+
}
+319
db/dbgen/bsky.sql.go
+319
db/dbgen/bsky.sql.go
···
···
1
+
// Code generated by sqlc. DO NOT EDIT.
2
+
// versions:
3
+
// sqlc v1.30.0
4
+
// source: bsky.sql
5
+
6
+
package dbgen
7
+
8
+
import (
9
+
"context"
10
+
"time"
11
+
)
12
+
13
+
const cleanExpiredOAuthStates = `-- name: CleanExpiredOAuthStates :exec
14
+
DELETE FROM oauth_states WHERE created_at < datetime('now', '-1 hour')
15
+
`
16
+
17
+
func (q *Queries) CleanExpiredOAuthStates(ctx context.Context) error {
18
+
_, err := q.db.ExecContext(ctx, cleanExpiredOAuthStates)
19
+
return err
20
+
}
21
+
22
+
const createOAuthSession = `-- name: CreateOAuthSession :exec
23
+
INSERT INTO oauth_sessions (id, did, handle, pds_url, access_token, refresh_token, token_type, expires_at, dpop_private_key, created_at, updated_at)
24
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
25
+
`
26
+
27
+
type CreateOAuthSessionParams struct {
28
+
ID string `json:"id"`
29
+
Did string `json:"did"`
30
+
Handle string `json:"handle"`
31
+
PdsUrl string `json:"pds_url"`
32
+
AccessToken string `json:"access_token"`
33
+
RefreshToken *string `json:"refresh_token"`
34
+
TokenType string `json:"token_type"`
35
+
ExpiresAt time.Time `json:"expires_at"`
36
+
DpopPrivateKey string `json:"dpop_private_key"`
37
+
CreatedAt time.Time `json:"created_at"`
38
+
UpdatedAt time.Time `json:"updated_at"`
39
+
}
40
+
41
+
func (q *Queries) CreateOAuthSession(ctx context.Context, arg CreateOAuthSessionParams) error {
42
+
_, err := q.db.ExecContext(ctx, createOAuthSession,
43
+
arg.ID,
44
+
arg.Did,
45
+
arg.Handle,
46
+
arg.PdsUrl,
47
+
arg.AccessToken,
48
+
arg.RefreshToken,
49
+
arg.TokenType,
50
+
arg.ExpiresAt,
51
+
arg.DpopPrivateKey,
52
+
arg.CreatedAt,
53
+
arg.UpdatedAt,
54
+
)
55
+
return err
56
+
}
57
+
58
+
const createOAuthState = `-- name: CreateOAuthState :exec
59
+
INSERT INTO oauth_states (state, code_verifier, did, pds_url, auth_server_url, dpop_private_key, created_at)
60
+
VALUES (?, ?, ?, ?, ?, ?, ?)
61
+
`
62
+
63
+
type CreateOAuthStateParams struct {
64
+
State string `json:"state"`
65
+
CodeVerifier string `json:"code_verifier"`
66
+
Did *string `json:"did"`
67
+
PdsUrl string `json:"pds_url"`
68
+
AuthServerUrl string `json:"auth_server_url"`
69
+
DpopPrivateKey string `json:"dpop_private_key"`
70
+
CreatedAt time.Time `json:"created_at"`
71
+
}
72
+
73
+
func (q *Queries) CreateOAuthState(ctx context.Context, arg CreateOAuthStateParams) error {
74
+
_, err := q.db.ExecContext(ctx, createOAuthState,
75
+
arg.State,
76
+
arg.CodeVerifier,
77
+
arg.Did,
78
+
arg.PdsUrl,
79
+
arg.AuthServerUrl,
80
+
arg.DpopPrivateKey,
81
+
arg.CreatedAt,
82
+
)
83
+
return err
84
+
}
85
+
86
+
const createPost = `-- name: CreatePost :one
87
+
INSERT INTO posts (session_id, did, handle, uri, cid, text, thread_root_uri, reply_parent_uri, created_at)
88
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
89
+
RETURNING id, session_id, did, handle, uri, cid, text, thread_root_uri, reply_parent_uri, created_at
90
+
`
91
+
92
+
type CreatePostParams struct {
93
+
SessionID string `json:"session_id"`
94
+
Did string `json:"did"`
95
+
Handle string `json:"handle"`
96
+
Uri string `json:"uri"`
97
+
Cid string `json:"cid"`
98
+
Text string `json:"text"`
99
+
ThreadRootUri *string `json:"thread_root_uri"`
100
+
ReplyParentUri *string `json:"reply_parent_uri"`
101
+
CreatedAt time.Time `json:"created_at"`
102
+
}
103
+
104
+
func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) (Post, error) {
105
+
row := q.db.QueryRowContext(ctx, createPost,
106
+
arg.SessionID,
107
+
arg.Did,
108
+
arg.Handle,
109
+
arg.Uri,
110
+
arg.Cid,
111
+
arg.Text,
112
+
arg.ThreadRootUri,
113
+
arg.ReplyParentUri,
114
+
arg.CreatedAt,
115
+
)
116
+
var i Post
117
+
err := row.Scan(
118
+
&i.ID,
119
+
&i.SessionID,
120
+
&i.Did,
121
+
&i.Handle,
122
+
&i.Uri,
123
+
&i.Cid,
124
+
&i.Text,
125
+
&i.ThreadRootUri,
126
+
&i.ReplyParentUri,
127
+
&i.CreatedAt,
128
+
)
129
+
return i, err
130
+
}
131
+
132
+
const deleteOAuthSession = `-- name: DeleteOAuthSession :exec
133
+
DELETE FROM oauth_sessions WHERE id = ?
134
+
`
135
+
136
+
func (q *Queries) DeleteOAuthSession(ctx context.Context, id string) error {
137
+
_, err := q.db.ExecContext(ctx, deleteOAuthSession, id)
138
+
return err
139
+
}
140
+
141
+
const deleteOAuthState = `-- name: DeleteOAuthState :exec
142
+
DELETE FROM oauth_states WHERE state = ?
143
+
`
144
+
145
+
func (q *Queries) DeleteOAuthState(ctx context.Context, state string) error {
146
+
_, err := q.db.ExecContext(ctx, deleteOAuthState, state)
147
+
return err
148
+
}
149
+
150
+
const getOAuthSession = `-- name: GetOAuthSession :one
151
+
SELECT id, did, handle, pds_url, access_token, refresh_token, token_type, expires_at, dpop_private_key, created_at, updated_at FROM oauth_sessions WHERE id = ?
152
+
`
153
+
154
+
func (q *Queries) GetOAuthSession(ctx context.Context, id string) (OauthSession, error) {
155
+
row := q.db.QueryRowContext(ctx, getOAuthSession, id)
156
+
var i OauthSession
157
+
err := row.Scan(
158
+
&i.ID,
159
+
&i.Did,
160
+
&i.Handle,
161
+
&i.PdsUrl,
162
+
&i.AccessToken,
163
+
&i.RefreshToken,
164
+
&i.TokenType,
165
+
&i.ExpiresAt,
166
+
&i.DpopPrivateKey,
167
+
&i.CreatedAt,
168
+
&i.UpdatedAt,
169
+
)
170
+
return i, err
171
+
}
172
+
173
+
const getOAuthSessionByDID = `-- name: GetOAuthSessionByDID :one
174
+
SELECT id, did, handle, pds_url, access_token, refresh_token, token_type, expires_at, dpop_private_key, created_at, updated_at FROM oauth_sessions WHERE did = ? ORDER BY updated_at DESC LIMIT 1
175
+
`
176
+
177
+
func (q *Queries) GetOAuthSessionByDID(ctx context.Context, did string) (OauthSession, error) {
178
+
row := q.db.QueryRowContext(ctx, getOAuthSessionByDID, did)
179
+
var i OauthSession
180
+
err := row.Scan(
181
+
&i.ID,
182
+
&i.Did,
183
+
&i.Handle,
184
+
&i.PdsUrl,
185
+
&i.AccessToken,
186
+
&i.RefreshToken,
187
+
&i.TokenType,
188
+
&i.ExpiresAt,
189
+
&i.DpopPrivateKey,
190
+
&i.CreatedAt,
191
+
&i.UpdatedAt,
192
+
)
193
+
return i, err
194
+
}
195
+
196
+
const getOAuthState = `-- name: GetOAuthState :one
197
+
SELECT state, code_verifier, did, pds_url, auth_server_url, dpop_private_key, created_at FROM oauth_states WHERE state = ?
198
+
`
199
+
200
+
func (q *Queries) GetOAuthState(ctx context.Context, state string) (OauthState, error) {
201
+
row := q.db.QueryRowContext(ctx, getOAuthState, state)
202
+
var i OauthState
203
+
err := row.Scan(
204
+
&i.State,
205
+
&i.CodeVerifier,
206
+
&i.Did,
207
+
&i.PdsUrl,
208
+
&i.AuthServerUrl,
209
+
&i.DpopPrivateKey,
210
+
&i.CreatedAt,
211
+
)
212
+
return i, err
213
+
}
214
+
215
+
const getPostsBySession = `-- name: GetPostsBySession :many
216
+
SELECT id, session_id, did, handle, uri, cid, text, thread_root_uri, reply_parent_uri, created_at FROM posts WHERE session_id = ? ORDER BY created_at DESC LIMIT ?
217
+
`
218
+
219
+
type GetPostsBySessionParams struct {
220
+
SessionID string `json:"session_id"`
221
+
Limit int64 `json:"limit"`
222
+
}
223
+
224
+
func (q *Queries) GetPostsBySession(ctx context.Context, arg GetPostsBySessionParams) ([]Post, error) {
225
+
rows, err := q.db.QueryContext(ctx, getPostsBySession, arg.SessionID, arg.Limit)
226
+
if err != nil {
227
+
return nil, err
228
+
}
229
+
defer rows.Close()
230
+
items := []Post{}
231
+
for rows.Next() {
232
+
var i Post
233
+
if err := rows.Scan(
234
+
&i.ID,
235
+
&i.SessionID,
236
+
&i.Did,
237
+
&i.Handle,
238
+
&i.Uri,
239
+
&i.Cid,
240
+
&i.Text,
241
+
&i.ThreadRootUri,
242
+
&i.ReplyParentUri,
243
+
&i.CreatedAt,
244
+
); err != nil {
245
+
return nil, err
246
+
}
247
+
items = append(items, i)
248
+
}
249
+
if err := rows.Close(); err != nil {
250
+
return nil, err
251
+
}
252
+
if err := rows.Err(); err != nil {
253
+
return nil, err
254
+
}
255
+
return items, nil
256
+
}
257
+
258
+
const getRecentPosts = `-- name: GetRecentPosts :many
259
+
SELECT id, session_id, did, handle, uri, cid, text, thread_root_uri, reply_parent_uri, created_at FROM posts ORDER BY created_at DESC LIMIT ?
260
+
`
261
+
262
+
func (q *Queries) GetRecentPosts(ctx context.Context, limit int64) ([]Post, error) {
263
+
rows, err := q.db.QueryContext(ctx, getRecentPosts, limit)
264
+
if err != nil {
265
+
return nil, err
266
+
}
267
+
defer rows.Close()
268
+
items := []Post{}
269
+
for rows.Next() {
270
+
var i Post
271
+
if err := rows.Scan(
272
+
&i.ID,
273
+
&i.SessionID,
274
+
&i.Did,
275
+
&i.Handle,
276
+
&i.Uri,
277
+
&i.Cid,
278
+
&i.Text,
279
+
&i.ThreadRootUri,
280
+
&i.ReplyParentUri,
281
+
&i.CreatedAt,
282
+
); err != nil {
283
+
return nil, err
284
+
}
285
+
items = append(items, i)
286
+
}
287
+
if err := rows.Close(); err != nil {
288
+
return nil, err
289
+
}
290
+
if err := rows.Err(); err != nil {
291
+
return nil, err
292
+
}
293
+
return items, nil
294
+
}
295
+
296
+
const updateOAuthSession = `-- name: UpdateOAuthSession :exec
297
+
UPDATE oauth_sessions
298
+
SET access_token = ?, refresh_token = ?, expires_at = ?, updated_at = ?
299
+
WHERE id = ?
300
+
`
301
+
302
+
type UpdateOAuthSessionParams struct {
303
+
AccessToken string `json:"access_token"`
304
+
RefreshToken *string `json:"refresh_token"`
305
+
ExpiresAt time.Time `json:"expires_at"`
306
+
UpdatedAt time.Time `json:"updated_at"`
307
+
ID string `json:"id"`
308
+
}
309
+
310
+
func (q *Queries) UpdateOAuthSession(ctx context.Context, arg UpdateOAuthSessionParams) error {
311
+
_, err := q.db.ExecContext(ctx, updateOAuthSession,
312
+
arg.AccessToken,
313
+
arg.RefreshToken,
314
+
arg.ExpiresAt,
315
+
arg.UpdatedAt,
316
+
arg.ID,
317
+
)
318
+
return err
319
+
}
+31
db/dbgen/db.go
+31
db/dbgen/db.go
···
···
1
+
// Code generated by sqlc. DO NOT EDIT.
2
+
// versions:
3
+
// sqlc v1.30.0
4
+
5
+
package dbgen
6
+
7
+
import (
8
+
"context"
9
+
"database/sql"
10
+
)
11
+
12
+
type DBTX interface {
13
+
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
14
+
PrepareContext(context.Context, string) (*sql.Stmt, error)
15
+
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
16
+
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
17
+
}
18
+
19
+
func New(db DBTX) *Queries {
20
+
return &Queries{db: db}
21
+
}
22
+
23
+
type Queries struct {
24
+
db DBTX
25
+
}
26
+
27
+
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
28
+
return &Queries{
29
+
db: tx,
30
+
}
31
+
}
+59
db/dbgen/models.go
+59
db/dbgen/models.go
···
···
1
+
// Code generated by sqlc. DO NOT EDIT.
2
+
// versions:
3
+
// sqlc v1.30.0
4
+
5
+
package dbgen
6
+
7
+
import (
8
+
"time"
9
+
)
10
+
11
+
type Migration struct {
12
+
MigrationNumber int64 `json:"migration_number"`
13
+
MigrationName string `json:"migration_name"`
14
+
ExecutedAt time.Time `json:"executed_at"`
15
+
}
16
+
17
+
type OauthSession struct {
18
+
ID string `json:"id"`
19
+
Did string `json:"did"`
20
+
Handle string `json:"handle"`
21
+
PdsUrl string `json:"pds_url"`
22
+
AccessToken string `json:"access_token"`
23
+
RefreshToken *string `json:"refresh_token"`
24
+
TokenType string `json:"token_type"`
25
+
ExpiresAt time.Time `json:"expires_at"`
26
+
DpopPrivateKey string `json:"dpop_private_key"`
27
+
CreatedAt time.Time `json:"created_at"`
28
+
UpdatedAt time.Time `json:"updated_at"`
29
+
}
30
+
31
+
type OauthState struct {
32
+
State string `json:"state"`
33
+
CodeVerifier string `json:"code_verifier"`
34
+
Did *string `json:"did"`
35
+
PdsUrl string `json:"pds_url"`
36
+
AuthServerUrl string `json:"auth_server_url"`
37
+
DpopPrivateKey string `json:"dpop_private_key"`
38
+
CreatedAt time.Time `json:"created_at"`
39
+
}
40
+
41
+
type Post struct {
42
+
ID int64 `json:"id"`
43
+
SessionID string `json:"session_id"`
44
+
Did string `json:"did"`
45
+
Handle string `json:"handle"`
46
+
Uri string `json:"uri"`
47
+
Cid string `json:"cid"`
48
+
Text string `json:"text"`
49
+
ThreadRootUri *string `json:"thread_root_uri"`
50
+
ReplyParentUri *string `json:"reply_parent_uri"`
51
+
CreatedAt time.Time `json:"created_at"`
52
+
}
53
+
54
+
type Visitor struct {
55
+
ID string `json:"id"`
56
+
ViewCount int64 `json:"view_count"`
57
+
CreatedAt time.Time `json:"created_at"`
58
+
LastSeen time.Time `json:"last_seen"`
59
+
}
+54
db/dbgen/visitors.sql.go
+54
db/dbgen/visitors.sql.go
···
···
1
+
// Code generated by sqlc. DO NOT EDIT.
2
+
// versions:
3
+
// sqlc v1.30.0
4
+
// source: visitors.sql
5
+
6
+
package dbgen
7
+
8
+
import (
9
+
"context"
10
+
"time"
11
+
)
12
+
13
+
const upsertVisitor = `-- name: UpsertVisitor :exec
14
+
INSERT INTO
15
+
visitors (id, view_count, created_at, last_seen)
16
+
VALUES
17
+
(?, 1, ?, ?) ON CONFLICT (id) DO
18
+
UPDATE
19
+
SET
20
+
view_count = view_count + 1,
21
+
last_seen = excluded.last_seen
22
+
`
23
+
24
+
type UpsertVisitorParams struct {
25
+
ID string `json:"id"`
26
+
CreatedAt time.Time `json:"created_at"`
27
+
LastSeen time.Time `json:"last_seen"`
28
+
}
29
+
30
+
func (q *Queries) UpsertVisitor(ctx context.Context, arg UpsertVisitorParams) error {
31
+
_, err := q.db.ExecContext(ctx, upsertVisitor, arg.ID, arg.CreatedAt, arg.LastSeen)
32
+
return err
33
+
}
34
+
35
+
const visitorWithID = `-- name: VisitorWithID :one
36
+
SELECT
37
+
id, view_count, created_at, last_seen
38
+
FROM
39
+
visitors
40
+
WHERE
41
+
id = ?
42
+
`
43
+
44
+
func (q *Queries) VisitorWithID(ctx context.Context, id string) (Visitor, error) {
45
+
row := q.db.QueryRowContext(ctx, visitorWithID, id)
46
+
var i Visitor
47
+
err := row.Scan(
48
+
&i.ID,
49
+
&i.ViewCount,
50
+
&i.CreatedAt,
51
+
&i.LastSeen,
52
+
)
53
+
return i, err
54
+
}
+22
db/migrations/001-base.sql
+22
db/migrations/001-base.sql
···
···
1
+
-- Base schema
2
+
--
3
+
-- Migrations tracking table
4
+
CREATE TABLE IF NOT EXISTS migrations (
5
+
migration_number INTEGER PRIMARY KEY,
6
+
migration_name TEXT NOT NULL,
7
+
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
8
+
);
9
+
10
+
-- Visitors table
11
+
CREATE TABLE IF NOT EXISTS visitors (
12
+
id TEXT PRIMARY KEY,
13
+
view_count INTEGER NOT NULL,
14
+
created_at TIMESTAMP NOT NULL,
15
+
last_seen TIMESTAMP NOT NULL
16
+
);
17
+
18
+
-- Record execution of this migration
19
+
INSERT
20
+
OR IGNORE INTO migrations (migration_number, migration_name)
21
+
VALUES
22
+
(001, '001-base');
+48
db/migrations/002-bsky.sql
+48
db/migrations/002-bsky.sql
···
···
1
+
-- OAuth sessions for Bluesky
2
+
CREATE TABLE IF NOT EXISTS oauth_sessions (
3
+
id TEXT PRIMARY KEY,
4
+
did TEXT NOT NULL,
5
+
handle TEXT NOT NULL,
6
+
pds_url TEXT NOT NULL,
7
+
access_token TEXT NOT NULL,
8
+
refresh_token TEXT,
9
+
token_type TEXT NOT NULL DEFAULT 'DPoP',
10
+
expires_at TIMESTAMP NOT NULL,
11
+
dpop_private_key TEXT NOT NULL,
12
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
13
+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
14
+
);
15
+
16
+
CREATE INDEX idx_oauth_sessions_did ON oauth_sessions(did);
17
+
18
+
-- OAuth state for PKCE flow
19
+
CREATE TABLE IF NOT EXISTS oauth_states (
20
+
state TEXT PRIMARY KEY,
21
+
code_verifier TEXT NOT NULL,
22
+
did TEXT,
23
+
pds_url TEXT NOT NULL,
24
+
auth_server_url TEXT NOT NULL,
25
+
dpop_private_key TEXT NOT NULL,
26
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
27
+
);
28
+
29
+
-- Posts made through this app
30
+
CREATE TABLE IF NOT EXISTS posts (
31
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+
session_id TEXT NOT NULL,
33
+
did TEXT NOT NULL,
34
+
handle TEXT NOT NULL,
35
+
uri TEXT NOT NULL,
36
+
cid TEXT NOT NULL,
37
+
text TEXT NOT NULL,
38
+
thread_root_uri TEXT,
39
+
reply_parent_uri TEXT,
40
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
41
+
FOREIGN KEY (session_id) REFERENCES oauth_sessions(id) ON DELETE CASCADE
42
+
);
43
+
44
+
CREATE INDEX idx_posts_session ON posts(session_id);
45
+
CREATE INDEX idx_posts_created ON posts(created_at DESC);
46
+
47
+
INSERT OR IGNORE INTO migrations (migration_number, migration_name)
48
+
VALUES (002, '002-bsky');
+41
db/queries/bsky.sql
+41
db/queries/bsky.sql
···
···
1
+
-- name: CreateOAuthState :exec
2
+
INSERT INTO oauth_states (state, code_verifier, did, pds_url, auth_server_url, dpop_private_key, created_at)
3
+
VALUES (?, ?, ?, ?, ?, ?, ?);
4
+
5
+
-- name: GetOAuthState :one
6
+
SELECT * FROM oauth_states WHERE state = ?;
7
+
8
+
-- name: DeleteOAuthState :exec
9
+
DELETE FROM oauth_states WHERE state = ?;
10
+
11
+
-- name: CleanExpiredOAuthStates :exec
12
+
DELETE FROM oauth_states WHERE created_at < datetime('now', '-1 hour');
13
+
14
+
-- name: CreateOAuthSession :exec
15
+
INSERT INTO oauth_sessions (id, did, handle, pds_url, access_token, refresh_token, token_type, expires_at, dpop_private_key, created_at, updated_at)
16
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
17
+
18
+
-- name: GetOAuthSession :one
19
+
SELECT * FROM oauth_sessions WHERE id = ?;
20
+
21
+
-- name: GetOAuthSessionByDID :one
22
+
SELECT * FROM oauth_sessions WHERE did = ? ORDER BY updated_at DESC LIMIT 1;
23
+
24
+
-- name: UpdateOAuthSession :exec
25
+
UPDATE oauth_sessions
26
+
SET access_token = ?, refresh_token = ?, expires_at = ?, updated_at = ?
27
+
WHERE id = ?;
28
+
29
+
-- name: DeleteOAuthSession :exec
30
+
DELETE FROM oauth_sessions WHERE id = ?;
31
+
32
+
-- name: CreatePost :one
33
+
INSERT INTO posts (session_id, did, handle, uri, cid, text, thread_root_uri, reply_parent_uri, created_at)
34
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
35
+
RETURNING *;
36
+
37
+
-- name: GetRecentPosts :many
38
+
SELECT * FROM posts ORDER BY created_at DESC LIMIT ?;
39
+
40
+
-- name: GetPostsBySession :many
41
+
SELECT * FROM posts WHERE session_id = ? ORDER BY created_at DESC LIMIT ?;
+17
db/queries/visitors.sql
+17
db/queries/visitors.sql
···
···
1
+
-- name: UpsertVisitor :exec
2
+
INSERT INTO
3
+
visitors (id, view_count, created_at, last_seen)
4
+
VALUES
5
+
(?, 1, ?, ?) ON CONFLICT (id) DO
6
+
UPDATE
7
+
SET
8
+
view_count = view_count + 1,
9
+
last_seen = excluded.last_seen;
10
+
11
+
-- name: VisitorWithID :one
12
+
SELECT
13
+
*
14
+
FROM
15
+
visitors
16
+
WHERE
17
+
id = ?;
+14
db/sqlc.yaml
+14
db/sqlc.yaml
···
···
1
+
version: "2"
2
+
sql:
3
+
- engine: "sqlite"
4
+
queries: "queries/"
5
+
schema: "migrations/"
6
+
gen:
7
+
go:
8
+
package: "dbgen"
9
+
out: "dbgen/"
10
+
emit_json_tags: true
11
+
emit_empty_slices: true
12
+
emit_pointers_for_null_types: true
13
+
json_tags_case_style: "snake"
14
+
sql_package: "database/sql"
+60
go.mod
+60
go.mod
···
···
1
+
module srv.exe.dev
2
+
3
+
go 1.25.5
4
+
5
+
require modernc.org/sqlite v1.39.0
6
+
7
+
require (
8
+
cel.dev/expr v0.24.0 // indirect
9
+
filippo.io/edwards25519 v1.1.0 // indirect
10
+
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
11
+
github.com/cubicdaiya/gonp v1.0.4 // indirect
12
+
github.com/davecgh/go-spew v1.1.1 // indirect
13
+
github.com/dustin/go-humanize v1.0.1 // indirect
14
+
github.com/fatih/structtag v1.2.0 // indirect
15
+
github.com/go-sql-driver/mysql v1.9.3 // indirect
16
+
github.com/google/cel-go v0.26.1 // indirect
17
+
github.com/google/uuid v1.6.0 // indirect
18
+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
19
+
github.com/jackc/pgpassfile v1.0.0 // indirect
20
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
21
+
github.com/jackc/pgx/v5 v5.7.5 // indirect
22
+
github.com/jackc/puddle/v2 v2.2.2 // indirect
23
+
github.com/jinzhu/inflection v1.0.0 // indirect
24
+
github.com/mattn/go-isatty v0.0.20 // indirect
25
+
github.com/ncruces/go-strftime v0.1.9 // indirect
26
+
github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect
27
+
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect
28
+
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
29
+
github.com/pingcap/log v1.1.0 // indirect
30
+
github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect
31
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
32
+
github.com/riza-io/grpc-go v0.2.0 // indirect
33
+
github.com/spf13/cobra v1.9.1 // indirect
34
+
github.com/spf13/pflag v1.0.7 // indirect
35
+
github.com/sqlc-dev/sqlc v1.30.0 // indirect
36
+
github.com/stoewer/go-strcase v1.2.0 // indirect
37
+
github.com/tetratelabs/wazero v1.9.0 // indirect
38
+
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
39
+
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
40
+
go.uber.org/atomic v1.11.0 // indirect
41
+
go.uber.org/multierr v1.11.0 // indirect
42
+
go.uber.org/zap v1.27.0 // indirect
43
+
golang.org/x/crypto v0.39.0 // indirect
44
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
45
+
golang.org/x/net v0.41.0 // indirect
46
+
golang.org/x/sync v0.16.0 // indirect
47
+
golang.org/x/sys v0.34.0 // indirect
48
+
golang.org/x/text v0.26.0 // indirect
49
+
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
50
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
51
+
google.golang.org/grpc v1.75.0 // indirect
52
+
google.golang.org/protobuf v1.36.8 // indirect
53
+
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
54
+
gopkg.in/yaml.v3 v3.0.1 // indirect
55
+
modernc.org/libc v1.66.3 // indirect
56
+
modernc.org/mathutil v1.7.1 // indirect
57
+
modernc.org/memory v1.11.0 // indirect
58
+
)
59
+
60
+
tool github.com/sqlc-dev/sqlc/cmd/sqlc
+209
go.sum
+209
go.sum
···
···
1
+
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
2
+
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
3
+
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
4
+
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
5
+
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
6
+
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
7
+
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
8
+
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
9
+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
10
+
github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws=
11
+
github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I=
12
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
14
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
15
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
16
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
17
+
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
18
+
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
19
+
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
20
+
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
21
+
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
22
+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
23
+
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
24
+
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
25
+
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
26
+
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
27
+
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
28
+
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
29
+
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
30
+
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
31
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
32
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
33
+
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
34
+
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
35
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
36
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
37
+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
38
+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
39
+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
40
+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
41
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
42
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
43
+
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
44
+
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
45
+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
46
+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
47
+
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
48
+
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
49
+
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
50
+
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
51
+
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
52
+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
53
+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
54
+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
55
+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
56
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
57
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
58
+
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
59
+
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
60
+
github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
61
+
github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
62
+
github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
63
+
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk=
64
+
github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg=
65
+
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE=
66
+
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4=
67
+
github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8=
68
+
github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4=
69
+
github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0=
70
+
github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE=
71
+
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
72
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
73
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
74
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
75
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
76
+
github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
77
+
github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
78
+
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
79
+
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
80
+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
81
+
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
82
+
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
83
+
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
84
+
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
85
+
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
86
+
github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c=
87
+
github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0=
88
+
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
89
+
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
90
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
91
+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
92
+
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
93
+
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
94
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
95
+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
96
+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
97
+
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
98
+
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
99
+
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
100
+
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
101
+
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
102
+
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
103
+
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
104
+
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
105
+
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
106
+
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
107
+
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
108
+
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
109
+
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
110
+
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
111
+
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
112
+
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
113
+
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
114
+
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
115
+
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
116
+
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
117
+
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
118
+
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
119
+
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
120
+
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
121
+
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
122
+
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
123
+
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
124
+
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
125
+
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
126
+
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
127
+
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
128
+
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
129
+
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
130
+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
131
+
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
132
+
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
133
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
134
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
135
+
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
136
+
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
137
+
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
138
+
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
139
+
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
140
+
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
141
+
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
142
+
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
143
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
144
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
145
+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
146
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
147
+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
148
+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
149
+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
150
+
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
151
+
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
152
+
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
153
+
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
154
+
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
155
+
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
156
+
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
157
+
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
158
+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
159
+
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
160
+
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
161
+
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
162
+
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
163
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
164
+
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
165
+
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
166
+
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
167
+
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
168
+
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
169
+
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
170
+
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
171
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
172
+
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
173
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
174
+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
175
+
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
176
+
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
177
+
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
178
+
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
179
+
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
180
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
181
+
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
182
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
183
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
184
+
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
185
+
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
186
+
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
187
+
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
188
+
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
189
+
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
190
+
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
191
+
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
192
+
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
193
+
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
194
+
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
195
+
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
196
+
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
197
+
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
198
+
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
199
+
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
200
+
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
201
+
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
202
+
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
203
+
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
204
+
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
205
+
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
206
+
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
207
+
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
208
+
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
209
+
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+18
srv.service
+18
srv.service
···
···
1
+
[Unit]
2
+
Description=Bluesky Poster Web App
3
+
4
+
[Service]
5
+
Type=simple
6
+
User=exedev
7
+
Group=exedev
8
+
WorkingDirectory=/home/exedev/bsky-poster
9
+
ExecStart=/home/exedev/bsky-poster/bsky-poster
10
+
Restart=always
11
+
RestartSec=5
12
+
Environment=HOME=/home/exedev
13
+
Environment=USER=exedev
14
+
StandardOutput=journal
15
+
StandardError=journal
16
+
17
+
[Install]
18
+
WantedBy=multi-user.target
+143
srv/apppassword.go
+143
srv/apppassword.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"fmt"
8
+
"io"
9
+
"log/slog"
10
+
"net/http"
11
+
"net/url"
12
+
"strings"
13
+
"time"
14
+
15
+
"github.com/google/uuid"
16
+
"srv.exe.dev/db/dbgen"
17
+
)
18
+
19
+
// HandleAppPasswordLogin handles login with app password.
20
+
func (s *Server) HandleAppPasswordLogin(w http.ResponseWriter, r *http.Request) {
21
+
handle := strings.TrimSpace(r.FormValue("handle"))
22
+
appPassword := strings.TrimSpace(r.FormValue("app_password"))
23
+
24
+
if handle == "" || appPassword == "" {
25
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Handle and app password required"), http.StatusSeeOther)
26
+
return
27
+
}
28
+
29
+
ctx := r.Context()
30
+
31
+
// Resolve handle to DID
32
+
did, err := ResolveHandle(ctx, handle)
33
+
if err != nil {
34
+
slog.Error("resolve handle", "handle", handle, "error", err)
35
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not resolve handle"), http.StatusSeeOther)
36
+
return
37
+
}
38
+
39
+
// Resolve DID to get PDS
40
+
didDoc, err := ResolveDID(ctx, did)
41
+
if err != nil {
42
+
slog.Error("resolve DID", "did", did, "error", err)
43
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not resolve DID"), http.StatusSeeOther)
44
+
return
45
+
}
46
+
47
+
pdsURL, err := GetPDSEndpoint(didDoc)
48
+
if err != nil {
49
+
slog.Error("get PDS endpoint", "error", err)
50
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not find PDS"), http.StatusSeeOther)
51
+
return
52
+
}
53
+
54
+
// Create session with PDS
55
+
session, err := createAppPasswordSession(ctx, pdsURL, handle, appPassword)
56
+
if err != nil {
57
+
slog.Error("create session", "error", err)
58
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Login failed: "+err.Error()), http.StatusSeeOther)
59
+
return
60
+
}
61
+
62
+
// Store session
63
+
sessionID := uuid.New().String()
64
+
q := dbgen.New(s.DB)
65
+
66
+
err = q.CreateOAuthSession(ctx, dbgen.CreateOAuthSessionParams{
67
+
ID: sessionID,
68
+
Did: session.DID,
69
+
Handle: session.Handle,
70
+
PdsUrl: pdsURL,
71
+
AccessToken: session.AccessJwt,
72
+
RefreshToken: &session.RefreshJwt,
73
+
TokenType: "Bearer",
74
+
ExpiresAt: time.Now().Add(24 * time.Hour), // App password sessions don't expire as quickly
75
+
DpopPrivateKey: "", // No DPoP for app passwords
76
+
CreatedAt: time.Now(),
77
+
UpdatedAt: time.Now(),
78
+
})
79
+
if err != nil {
80
+
slog.Error("store session", "error", err)
81
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not create session"), http.StatusSeeOther)
82
+
return
83
+
}
84
+
85
+
// Set session cookie
86
+
http.SetCookie(w, &http.Cookie{
87
+
Name: SessionCookie,
88
+
Value: sessionID,
89
+
Path: "/",
90
+
HttpOnly: true,
91
+
Secure: true,
92
+
SameSite: http.SameSiteLaxMode,
93
+
MaxAge: 86400 * 30,
94
+
})
95
+
96
+
slog.Info("user logged in via app password", "handle", session.Handle, "did", session.DID)
97
+
http.Redirect(w, r, "/?success="+url.QueryEscape("Logged in as "+session.Handle), http.StatusSeeOther)
98
+
}
99
+
100
+
type appPasswordSession struct {
101
+
DID string `json:"did"`
102
+
Handle string `json:"handle"`
103
+
AccessJwt string `json:"accessJwt"`
104
+
RefreshJwt string `json:"refreshJwt"`
105
+
}
106
+
107
+
func createAppPasswordSession(ctx context.Context, pdsURL, handle, appPassword string) (*appPasswordSession, error) {
108
+
reqBody := map[string]string{
109
+
"identifier": handle,
110
+
"password": appPassword,
111
+
}
112
+
113
+
bodyJSON, err := json.Marshal(reqBody)
114
+
if err != nil {
115
+
return nil, err
116
+
}
117
+
118
+
endpoint := strings.TrimSuffix(pdsURL, "/") + "/xrpc/com.atproto.server.createSession"
119
+
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(bodyJSON))
120
+
if err != nil {
121
+
return nil, err
122
+
}
123
+
req.Header.Set("Content-Type", "application/json")
124
+
125
+
client := &http.Client{Timeout: 30 * time.Second}
126
+
resp, err := client.Do(req)
127
+
if err != nil {
128
+
return nil, err
129
+
}
130
+
defer resp.Body.Close()
131
+
132
+
if resp.StatusCode != http.StatusOK {
133
+
body, _ := io.ReadAll(resp.Body)
134
+
return nil, fmt.Errorf("auth failed (%d): %s", resp.StatusCode, string(body))
135
+
}
136
+
137
+
var session appPasswordSession
138
+
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
139
+
return nil, err
140
+
}
141
+
142
+
return &session, nil
143
+
}
+130
srv/atproto.go
+130
srv/atproto.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"io"
8
+
"net/http"
9
+
"strings"
10
+
"time"
11
+
)
12
+
13
+
const (
14
+
DefaultPDS = "https://bsky.social"
15
+
PLCDirectory = "https://plc.directory"
16
+
BskyAppViewURL = "https://public.api.bsky.app"
17
+
)
18
+
19
+
// ResolveHandle resolves a Bluesky handle to a DID.
20
+
// It tries DNS TXT record first, then falls back to HTTP well-known.
21
+
func ResolveHandle(ctx context.Context, handle string) (string, error) {
22
+
handle = strings.TrimPrefix(handle, "@")
23
+
24
+
// Try HTTP well-known resolution via bsky.social
25
+
url := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", DefaultPDS, handle)
26
+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
27
+
if err != nil {
28
+
return "", fmt.Errorf("create request: %w", err)
29
+
}
30
+
31
+
client := &http.Client{Timeout: 10 * time.Second}
32
+
resp, err := client.Do(req)
33
+
if err != nil {
34
+
return "", fmt.Errorf("resolve handle: %w", err)
35
+
}
36
+
defer resp.Body.Close()
37
+
38
+
if resp.StatusCode != http.StatusOK {
39
+
body, _ := io.ReadAll(resp.Body)
40
+
return "", fmt.Errorf("resolve handle failed (%d): %s", resp.StatusCode, string(body))
41
+
}
42
+
43
+
var result struct {
44
+
DID string `json:"did"`
45
+
}
46
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
47
+
return "", fmt.Errorf("decode response: %w", err)
48
+
}
49
+
50
+
return result.DID, nil
51
+
}
52
+
53
+
// DIDDocument represents an AT Protocol DID document.
54
+
type DIDDocument struct {
55
+
ID string `json:"id"`
56
+
AlsoKnownAs []string `json:"alsoKnownAs"`
57
+
VerificationMethod []VerificationMethod `json:"verificationMethod"`
58
+
Service []Service `json:"service"`
59
+
}
60
+
61
+
type VerificationMethod struct {
62
+
ID string `json:"id"`
63
+
Type string `json:"type"`
64
+
Controller string `json:"controller"`
65
+
PublicKeyMultibase string `json:"publicKeyMultibase"`
66
+
}
67
+
68
+
type Service struct {
69
+
ID string `json:"id"`
70
+
Type string `json:"type"`
71
+
ServiceEndpoint string `json:"serviceEndpoint"`
72
+
}
73
+
74
+
// ResolveDID resolves a DID to its DID document.
75
+
func ResolveDID(ctx context.Context, did string) (*DIDDocument, error) {
76
+
var url string
77
+
if strings.HasPrefix(did, "did:plc:") {
78
+
url = fmt.Sprintf("%s/%s", PLCDirectory, did)
79
+
} else if strings.HasPrefix(did, "did:web:") {
80
+
// did:web:example.com -> https://example.com/.well-known/did.json
81
+
domain := strings.TrimPrefix(did, "did:web:")
82
+
url = fmt.Sprintf("https://%s/.well-known/did.json", domain)
83
+
} else {
84
+
return nil, fmt.Errorf("unsupported DID method: %s", did)
85
+
}
86
+
87
+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
88
+
if err != nil {
89
+
return nil, fmt.Errorf("create request: %w", err)
90
+
}
91
+
92
+
client := &http.Client{Timeout: 10 * time.Second}
93
+
resp, err := client.Do(req)
94
+
if err != nil {
95
+
return nil, fmt.Errorf("fetch DID document: %w", err)
96
+
}
97
+
defer resp.Body.Close()
98
+
99
+
if resp.StatusCode != http.StatusOK {
100
+
body, _ := io.ReadAll(resp.Body)
101
+
return nil, fmt.Errorf("fetch DID document failed (%d): %s", resp.StatusCode, string(body))
102
+
}
103
+
104
+
var doc DIDDocument
105
+
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
106
+
return nil, fmt.Errorf("decode DID document: %w", err)
107
+
}
108
+
109
+
return &doc, nil
110
+
}
111
+
112
+
// GetPDSEndpoint extracts the PDS endpoint from a DID document.
113
+
func GetPDSEndpoint(doc *DIDDocument) (string, error) {
114
+
for _, svc := range doc.Service {
115
+
if svc.Type == "AtprotoPersonalDataServer" || svc.ID == "#atproto_pds" {
116
+
return svc.ServiceEndpoint, nil
117
+
}
118
+
}
119
+
return "", fmt.Errorf("no PDS service found in DID document")
120
+
}
121
+
122
+
// GetHandleFromDID extracts the handle from a DID document's alsoKnownAs.
123
+
func GetHandleFromDID(doc *DIDDocument) string {
124
+
for _, aka := range doc.AlsoKnownAs {
125
+
if strings.HasPrefix(aka, "at://") {
126
+
return strings.TrimPrefix(aka, "at://")
127
+
}
128
+
}
129
+
return ""
130
+
}
+164
srv/dpop.go
+164
srv/dpop.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"crypto/ecdsa"
5
+
"crypto/elliptic"
6
+
"crypto/rand"
7
+
"crypto/sha256"
8
+
"encoding/base64"
9
+
"encoding/json"
10
+
"fmt"
11
+
"math/big"
12
+
"time"
13
+
14
+
"github.com/google/uuid"
15
+
)
16
+
17
+
// DPoPKey represents an EC key pair for DPoP.
18
+
type DPoPKey struct {
19
+
Private *ecdsa.PrivateKey
20
+
}
21
+
22
+
// GenerateDPoPKey generates a new ES256 key pair for DPoP.
23
+
func GenerateDPoPKey() (*DPoPKey, error) {
24
+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
25
+
if err != nil {
26
+
return nil, fmt.Errorf("generate key: %w", err)
27
+
}
28
+
return &DPoPKey{Private: privateKey}, nil
29
+
}
30
+
31
+
// MarshalPrivateKey serializes the private key to JSON.
32
+
func (k *DPoPKey) MarshalPrivateKey() (string, error) {
33
+
jwk := map[string]string{
34
+
"kty": "EC",
35
+
"crv": "P-256",
36
+
"x": base64URLEncode(k.Private.PublicKey.X.Bytes()),
37
+
"y": base64URLEncode(k.Private.PublicKey.Y.Bytes()),
38
+
"d": base64URLEncode(k.Private.D.Bytes()),
39
+
}
40
+
data, err := json.Marshal(jwk)
41
+
if err != nil {
42
+
return "", err
43
+
}
44
+
return string(data), nil
45
+
}
46
+
47
+
// UnmarshalPrivateKey deserializes a private key from JSON.
48
+
func UnmarshalPrivateKey(data string) (*DPoPKey, error) {
49
+
var jwk map[string]string
50
+
if err := json.Unmarshal([]byte(data), &jwk); err != nil {
51
+
return nil, fmt.Errorf("unmarshal JWK: %w", err)
52
+
}
53
+
54
+
xBytes, err := base64URLDecode(jwk["x"])
55
+
if err != nil {
56
+
return nil, fmt.Errorf("decode x: %w", err)
57
+
}
58
+
yBytes, err := base64URLDecode(jwk["y"])
59
+
if err != nil {
60
+
return nil, fmt.Errorf("decode y: %w", err)
61
+
}
62
+
dBytes, err := base64URLDecode(jwk["d"])
63
+
if err != nil {
64
+
return nil, fmt.Errorf("decode d: %w", err)
65
+
}
66
+
67
+
privateKey := &ecdsa.PrivateKey{
68
+
PublicKey: ecdsa.PublicKey{
69
+
Curve: elliptic.P256(),
70
+
X: new(big.Int).SetBytes(xBytes),
71
+
Y: new(big.Int).SetBytes(yBytes),
72
+
},
73
+
D: new(big.Int).SetBytes(dBytes),
74
+
}
75
+
76
+
return &DPoPKey{Private: privateKey}, nil
77
+
}
78
+
79
+
// PublicJWK returns the public key as a JWK map.
80
+
func (k *DPoPKey) PublicJWK() map[string]string {
81
+
return map[string]string{
82
+
"kty": "EC",
83
+
"crv": "P-256",
84
+
"x": base64URLEncode(k.Private.PublicKey.X.Bytes()),
85
+
"y": base64URLEncode(k.Private.PublicKey.Y.Bytes()),
86
+
}
87
+
}
88
+
89
+
// JWKThumbprint computes the JWK thumbprint (SHA-256) of the public key.
90
+
func (k *DPoPKey) JWKThumbprint() string {
91
+
// Canonical JSON: {"crv":"P-256","kty":"EC","x":"...","y":"..."}
92
+
canonical := fmt.Sprintf(`{"crv":"P-256","kty":"EC","x":"%s","y":"%s"}`,
93
+
base64URLEncode(k.Private.PublicKey.X.Bytes()),
94
+
base64URLEncode(k.Private.PublicKey.Y.Bytes()))
95
+
hash := sha256.Sum256([]byte(canonical))
96
+
return base64URLEncode(hash[:])
97
+
}
98
+
99
+
// CreateDPoPProof creates a DPoP proof JWT for a request.
100
+
func (k *DPoPKey) CreateDPoPProof(method, url string, accessToken string, nonce string) (string, error) {
101
+
header := map[string]interface{}{
102
+
"typ": "dpop+jwt",
103
+
"alg": "ES256",
104
+
"jwk": k.PublicJWK(),
105
+
}
106
+
107
+
payload := map[string]interface{}{
108
+
"jti": uuid.New().String(),
109
+
"htm": method,
110
+
"htu": url,
111
+
"iat": time.Now().Unix(),
112
+
}
113
+
114
+
if nonce != "" {
115
+
payload["nonce"] = nonce
116
+
}
117
+
118
+
// If we have an access token, include its hash
119
+
if accessToken != "" {
120
+
hash := sha256.Sum256([]byte(accessToken))
121
+
payload["ath"] = base64URLEncode(hash[:])
122
+
}
123
+
124
+
return k.signJWT(header, payload)
125
+
}
126
+
127
+
func (k *DPoPKey) signJWT(header, payload map[string]interface{}) (string, error) {
128
+
headerJSON, err := json.Marshal(header)
129
+
if err != nil {
130
+
return "", fmt.Errorf("marshal header: %w", err)
131
+
}
132
+
payloadJSON, err := json.Marshal(payload)
133
+
if err != nil {
134
+
return "", fmt.Errorf("marshal payload: %w", err)
135
+
}
136
+
137
+
headerB64 := base64URLEncode(headerJSON)
138
+
payloadB64 := base64URLEncode(payloadJSON)
139
+
140
+
signingInput := headerB64 + "." + payloadB64
141
+
hash := sha256.Sum256([]byte(signingInput))
142
+
143
+
r, s, err := ecdsa.Sign(rand.Reader, k.Private, hash[:])
144
+
if err != nil {
145
+
return "", fmt.Errorf("sign: %w", err)
146
+
}
147
+
148
+
// ES256 signature is r || s, each 32 bytes
149
+
sig := make([]byte, 64)
150
+
rBytes := r.Bytes()
151
+
sBytes := s.Bytes()
152
+
copy(sig[32-len(rBytes):32], rBytes)
153
+
copy(sig[64-len(sBytes):64], sBytes)
154
+
155
+
return signingInput + "." + base64URLEncode(sig), nil
156
+
}
157
+
158
+
func base64URLEncode(data []byte) string {
159
+
return base64.RawURLEncoding.EncodeToString(data)
160
+
}
161
+
162
+
func base64URLDecode(s string) ([]byte, error) {
163
+
return base64.RawURLEncoding.DecodeString(s)
164
+
}
+72
srv/facets.go
+72
srv/facets.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"regexp"
5
+
"strings"
6
+
)
7
+
8
+
var (
9
+
// URL regex - simplified but functional
10
+
urlRegex = regexp.MustCompile(`https?://[^\s<>"'\)\]]+`)
11
+
// Mention regex - @handle.domain.tld
12
+
mentionRegex = regexp.MustCompile(`@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?`)
13
+
// Hashtag regex
14
+
hashtagRegex = regexp.MustCompile(`#[^\s#\[\](){}]+`)
15
+
)
16
+
17
+
// extractFacets extracts links, mentions, and hashtags from post text.
18
+
func extractFacets(text string) []Facet {
19
+
var facets []Facet
20
+
21
+
// Extract URLs
22
+
for _, match := range urlRegex.FindAllStringIndex(text, -1) {
23
+
url := text[match[0]:match[1]]
24
+
// Clean up trailing punctuation
25
+
url = strings.TrimRight(url, ".,;:!?")
26
+
27
+
facets = append(facets, Facet{
28
+
Index: FacetIndex{
29
+
ByteStart: match[0],
30
+
ByteEnd: match[0] + len(url),
31
+
},
32
+
Features: []FacetFeature{{
33
+
Type: "app.bsky.richtext.facet#link",
34
+
URI: url,
35
+
}},
36
+
})
37
+
}
38
+
39
+
// Extract mentions
40
+
for _, match := range mentionRegex.FindAllStringIndex(text, -1) {
41
+
handle := text[match[0]+1 : match[1]] // Skip @
42
+
43
+
facets = append(facets, Facet{
44
+
Index: FacetIndex{
45
+
ByteStart: match[0],
46
+
ByteEnd: match[1],
47
+
},
48
+
Features: []FacetFeature{{
49
+
Type: "app.bsky.richtext.facet#mention",
50
+
DID: "at://" + handle, // Will be resolved by server
51
+
}},
52
+
})
53
+
}
54
+
55
+
// Extract hashtags
56
+
for _, match := range hashtagRegex.FindAllStringIndex(text, -1) {
57
+
tag := text[match[0]+1 : match[1]] // Skip #
58
+
59
+
facets = append(facets, Facet{
60
+
Index: FacetIndex{
61
+
ByteStart: match[0],
62
+
ByteEnd: match[1],
63
+
},
64
+
Features: []FacetFeature{{
65
+
Type: "app.bsky.richtext.facet#tag",
66
+
Tag: tag,
67
+
}},
68
+
})
69
+
}
70
+
71
+
return facets
72
+
}
+115
srv/oauth.go
+115
srv/oauth.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"encoding/json"
7
+
"fmt"
8
+
"io"
9
+
"log/slog"
10
+
"net/http"
11
+
"strings"
12
+
"time"
13
+
)
14
+
15
+
// OAuthClient handles AT Protocol OAuth.
16
+
type OAuthClient struct {
17
+
BaseURL string
18
+
ClientID string
19
+
DB *sql.DB
20
+
}
21
+
22
+
// AuthServerMetadata represents OAuth authorization server metadata.
23
+
type AuthServerMetadata struct {
24
+
Issuer string `json:"issuer"`
25
+
AuthorizationEndpoint string `json:"authorization_endpoint"`
26
+
TokenEndpoint string `json:"token_endpoint"`
27
+
PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
28
+
ScopesSupported []string `json:"scopes_supported"`
29
+
ResponseTypesSupported []string `json:"response_types_supported"`
30
+
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
31
+
}
32
+
33
+
// NewOAuthClient creates a new OAuth client.
34
+
func NewOAuthClient(baseURL string, db *sql.DB) *OAuthClient {
35
+
return &OAuthClient{
36
+
BaseURL: baseURL,
37
+
ClientID: baseURL + "/oauth-client-metadata.json",
38
+
DB: db,
39
+
}
40
+
}
41
+
42
+
// GetAuthServerMetadata fetches OAuth metadata from a PDS.
43
+
func GetAuthServerMetadata(ctx context.Context, pdsURL string) (*AuthServerMetadata, error) {
44
+
// First try to get the protected resource metadata to find the auth server
45
+
resourceMeta, err := getProtectedResourceMetadata(ctx, pdsURL)
46
+
if err != nil {
47
+
slog.Warn("failed to get protected resource metadata, trying direct", "error", err)
48
+
// Fall back to trying the PDS directly as the auth server
49
+
return getAuthServerMetadataFromURL(ctx, pdsURL)
50
+
}
51
+
52
+
if len(resourceMeta.AuthorizationServers) == 0 {
53
+
return nil, fmt.Errorf("no authorization servers found")
54
+
}
55
+
56
+
return getAuthServerMetadataFromURL(ctx, resourceMeta.AuthorizationServers[0])
57
+
}
58
+
59
+
type protectedResourceMetadata struct {
60
+
Resource string `json:"resource"`
61
+
AuthorizationServers []string `json:"authorization_servers"`
62
+
}
63
+
64
+
func getProtectedResourceMetadata(ctx context.Context, pdsURL string) (*protectedResourceMetadata, error) {
65
+
metaURL := strings.TrimSuffix(pdsURL, "/") + "/.well-known/oauth-protected-resource"
66
+
67
+
req, err := http.NewRequestWithContext(ctx, "GET", metaURL, nil)
68
+
if err != nil {
69
+
return nil, err
70
+
}
71
+
72
+
client := &http.Client{Timeout: 10 * time.Second}
73
+
resp, err := client.Do(req)
74
+
if err != nil {
75
+
return nil, err
76
+
}
77
+
defer resp.Body.Close()
78
+
79
+
if resp.StatusCode != http.StatusOK {
80
+
return nil, fmt.Errorf("status %d", resp.StatusCode)
81
+
}
82
+
83
+
var meta protectedResourceMetadata
84
+
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
85
+
return nil, err
86
+
}
87
+
return &meta, nil
88
+
}
89
+
90
+
func getAuthServerMetadataFromURL(ctx context.Context, authServerURL string) (*AuthServerMetadata, error) {
91
+
metaURL := strings.TrimSuffix(authServerURL, "/") + "/.well-known/oauth-authorization-server"
92
+
93
+
req, err := http.NewRequestWithContext(ctx, "GET", metaURL, nil)
94
+
if err != nil {
95
+
return nil, err
96
+
}
97
+
98
+
client := &http.Client{Timeout: 10 * time.Second}
99
+
resp, err := client.Do(req)
100
+
if err != nil {
101
+
return nil, err
102
+
}
103
+
defer resp.Body.Close()
104
+
105
+
if resp.StatusCode != http.StatusOK {
106
+
body, _ := io.ReadAll(resp.Body)
107
+
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
108
+
}
109
+
110
+
var meta AuthServerMetadata
111
+
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
112
+
return nil, err
113
+
}
114
+
return &meta, nil
115
+
}
+238
srv/oauth_callback.go
+238
srv/oauth_callback.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"fmt"
7
+
"io"
8
+
"log/slog"
9
+
"net/http"
10
+
"net/url"
11
+
"strings"
12
+
"time"
13
+
14
+
"github.com/google/uuid"
15
+
"srv.exe.dev/db/dbgen"
16
+
)
17
+
18
+
// HandleCallback handles the OAuth callback.
19
+
func (s *Server) HandleCallback(w http.ResponseWriter, r *http.Request) {
20
+
ctx := r.Context()
21
+
22
+
// Check for errors from auth server
23
+
if errCode := r.URL.Query().Get("error"); errCode != "" {
24
+
errDesc := r.URL.Query().Get("error_description")
25
+
slog.Error("auth callback error", "error", errCode, "description", errDesc)
26
+
http.Redirect(w, r, "/?error="+url.QueryEscape(fmt.Sprintf("Auth failed: %s", errDesc)), http.StatusSeeOther)
27
+
return
28
+
}
29
+
30
+
code := r.URL.Query().Get("code")
31
+
state := r.URL.Query().Get("state")
32
+
iss := r.URL.Query().Get("iss")
33
+
34
+
if code == "" || state == "" {
35
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Missing code or state"), http.StatusSeeOther)
36
+
return
37
+
}
38
+
39
+
// Look up state
40
+
q := dbgen.New(s.DB)
41
+
oauthState, err := q.GetOAuthState(ctx, state)
42
+
if err != nil {
43
+
slog.Error("get oauth state", "state", state, "error", err)
44
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Invalid state"), http.StatusSeeOther)
45
+
return
46
+
}
47
+
48
+
// Verify issuer matches
49
+
if iss != "" && iss != oauthState.AuthServerUrl {
50
+
slog.Error("issuer mismatch", "expected", oauthState.AuthServerUrl, "got", iss)
51
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Issuer mismatch"), http.StatusSeeOther)
52
+
return
53
+
}
54
+
55
+
// Delete state (one-time use)
56
+
q.DeleteOAuthState(ctx, state)
57
+
58
+
// Restore DPoP key
59
+
dpopKey, err := UnmarshalPrivateKey(oauthState.DpopPrivateKey)
60
+
if err != nil {
61
+
slog.Error("unmarshal DPoP key", "error", err)
62
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Internal error"), http.StatusSeeOther)
63
+
return
64
+
}
65
+
66
+
// Get auth server metadata for token endpoint
67
+
authMeta, err := getAuthServerMetadataFromURL(ctx, oauthState.AuthServerUrl)
68
+
if err != nil {
69
+
slog.Error("get auth server metadata", "error", err)
70
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not reach auth server"), http.StatusSeeOther)
71
+
return
72
+
}
73
+
74
+
// Exchange code for tokens
75
+
tokenResp, err := s.exchangeCode(ctx, authMeta, code, oauthState.CodeVerifier, dpopKey)
76
+
if err != nil {
77
+
slog.Error("exchange code", "error", err)
78
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Token exchange failed: "+err.Error()), http.StatusSeeOther)
79
+
return
80
+
}
81
+
82
+
// Get the handle from DID doc
83
+
var handle string
84
+
if oauthState.Did != nil && *oauthState.Did != "" {
85
+
didDoc, err := ResolveDID(ctx, *oauthState.Did)
86
+
if err == nil {
87
+
handle = GetHandleFromDID(didDoc)
88
+
}
89
+
}
90
+
if handle == "" && tokenResp.Sub != "" {
91
+
// Try to get handle from sub claim
92
+
didDoc, err := ResolveDID(ctx, tokenResp.Sub)
93
+
if err == nil {
94
+
handle = GetHandleFromDID(didDoc)
95
+
}
96
+
}
97
+
if handle == "" {
98
+
handle = tokenResp.Sub // Fall back to DID as handle
99
+
}
100
+
101
+
// Create session
102
+
sessionID := uuid.New().String()
103
+
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
104
+
105
+
var refreshToken *string
106
+
if tokenResp.RefreshToken != "" {
107
+
refreshToken = &tokenResp.RefreshToken
108
+
}
109
+
110
+
err = q.CreateOAuthSession(ctx, dbgen.CreateOAuthSessionParams{
111
+
ID: sessionID,
112
+
Did: tokenResp.Sub,
113
+
Handle: handle,
114
+
PdsUrl: oauthState.PdsUrl,
115
+
AccessToken: tokenResp.AccessToken,
116
+
RefreshToken: refreshToken,
117
+
TokenType: tokenResp.TokenType,
118
+
ExpiresAt: expiresAt,
119
+
DpopPrivateKey: oauthState.DpopPrivateKey,
120
+
CreatedAt: time.Now(),
121
+
UpdatedAt: time.Now(),
122
+
})
123
+
if err != nil {
124
+
slog.Error("create session", "error", err)
125
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not create session"), http.StatusSeeOther)
126
+
return
127
+
}
128
+
129
+
// Set session cookie
130
+
http.SetCookie(w, &http.Cookie{
131
+
Name: SessionCookie,
132
+
Value: sessionID,
133
+
Path: "/",
134
+
HttpOnly: true,
135
+
Secure: true,
136
+
SameSite: http.SameSiteLaxMode,
137
+
MaxAge: 86400 * 30, // 30 days
138
+
})
139
+
140
+
slog.Info("user logged in", "handle", handle, "did", tokenResp.Sub)
141
+
http.Redirect(w, r, "/?success="+url.QueryEscape("Logged in as "+handle), http.StatusSeeOther)
142
+
}
143
+
144
+
type tokenResponse struct {
145
+
AccessToken string `json:"access_token"`
146
+
TokenType string `json:"token_type"`
147
+
ExpiresIn int `json:"expires_in"`
148
+
RefreshToken string `json:"refresh_token"`
149
+
Scope string `json:"scope"`
150
+
Sub string `json:"sub"`
151
+
}
152
+
153
+
func (s *Server) exchangeCode(ctx context.Context, authMeta *AuthServerMetadata, code, codeVerifier string, dpopKey *DPoPKey) (*tokenResponse, error) {
154
+
tokenData := url.Values{}
155
+
tokenData.Set("grant_type", "authorization_code")
156
+
tokenData.Set("code", code)
157
+
tokenData.Set("redirect_uri", s.BaseURL+"/auth/callback")
158
+
tokenData.Set("client_id", s.oauth.ClientID)
159
+
tokenData.Set("code_verifier", codeVerifier)
160
+
161
+
var lastErr error
162
+
var nonce string
163
+
164
+
// Retry loop for DPoP nonce
165
+
for i := 0; i < 3; i++ {
166
+
dpopProof, err := dpopKey.CreateDPoPProof("POST", authMeta.TokenEndpoint, "", nonce)
167
+
if err != nil {
168
+
return nil, fmt.Errorf("create DPoP proof: %w", err)
169
+
}
170
+
171
+
req, err := http.NewRequestWithContext(ctx, "POST", authMeta.TokenEndpoint, strings.NewReader(tokenData.Encode()))
172
+
if err != nil {
173
+
return nil, fmt.Errorf("create request: %w", err)
174
+
}
175
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
176
+
req.Header.Set("DPoP", dpopProof)
177
+
178
+
client := &http.Client{Timeout: 30 * time.Second}
179
+
resp, err := client.Do(req)
180
+
if err != nil {
181
+
lastErr = fmt.Errorf("token request: %w", err)
182
+
continue
183
+
}
184
+
185
+
// Check for DPoP nonce requirement
186
+
if newNonce := resp.Header.Get("DPoP-Nonce"); newNonce != "" {
187
+
nonce = newNonce
188
+
}
189
+
190
+
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnauthorized {
191
+
body, _ := io.ReadAll(resp.Body)
192
+
resp.Body.Close()
193
+
194
+
if nonce != "" && i < 2 {
195
+
slog.Info("retrying token exchange with nonce", "nonce", nonce)
196
+
continue
197
+
}
198
+
return nil, fmt.Errorf("token endpoint error (%d): %s", resp.StatusCode, string(body))
199
+
}
200
+
201
+
if resp.StatusCode != http.StatusOK {
202
+
body, _ := io.ReadAll(resp.Body)
203
+
resp.Body.Close()
204
+
return nil, fmt.Errorf("token endpoint status %d: %s", resp.StatusCode, string(body))
205
+
}
206
+
207
+
var tokenResp tokenResponse
208
+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
209
+
resp.Body.Close()
210
+
return nil, fmt.Errorf("decode token response: %w", err)
211
+
}
212
+
resp.Body.Close()
213
+
214
+
return &tokenResp, nil
215
+
}
216
+
217
+
return nil, lastErr
218
+
}
219
+
220
+
// HandleLogout clears the session.
221
+
func (s *Server) HandleLogout(w http.ResponseWriter, r *http.Request) {
222
+
cookie, err := r.Cookie(SessionCookie)
223
+
if err == nil && cookie.Value != "" {
224
+
q := dbgen.New(s.DB)
225
+
q.DeleteOAuthSession(r.Context(), cookie.Value)
226
+
}
227
+
228
+
http.SetCookie(w, &http.Cookie{
229
+
Name: SessionCookie,
230
+
Value: "",
231
+
Path: "/",
232
+
MaxAge: -1,
233
+
HttpOnly: true,
234
+
Secure: true,
235
+
})
236
+
237
+
http.Redirect(w, r, "/", http.StatusSeeOther)
238
+
}
+240
srv/oauth_handlers.go
+240
srv/oauth_handlers.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"crypto/rand"
5
+
"crypto/sha256"
6
+
"encoding/base64"
7
+
"encoding/json"
8
+
"io"
9
+
"log/slog"
10
+
"net/http"
11
+
"net/url"
12
+
"strings"
13
+
"time"
14
+
15
+
"github.com/google/uuid"
16
+
"srv.exe.dev/db/dbgen"
17
+
)
18
+
19
+
// HandleClientMetadata serves the OAuth client metadata.
20
+
func (s *Server) HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
21
+
metadata := map[string]interface{}{
22
+
"client_id": s.oauth.ClientID,
23
+
"client_name": "Bluesky Poster",
24
+
"client_uri": s.BaseURL,
25
+
"redirect_uris": []string{s.BaseURL + "/auth/callback"},
26
+
"grant_types": []string{"authorization_code", "refresh_token"},
27
+
"response_types": []string{"code"},
28
+
"scope": "atproto",
29
+
"token_endpoint_auth_method": "none",
30
+
"application_type": "web",
31
+
"dpop_bound_access_tokens": true,
32
+
}
33
+
34
+
w.Header().Set("Content-Type", "application/json")
35
+
json.NewEncoder(w).Encode(metadata)
36
+
}
37
+
38
+
// HandleLogin initiates the OAuth flow.
39
+
func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) {
40
+
handle := strings.TrimSpace(r.FormValue("handle"))
41
+
if handle == "" {
42
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Please enter a handle"), http.StatusSeeOther)
43
+
return
44
+
}
45
+
46
+
ctx := r.Context()
47
+
48
+
// Resolve handle to DID
49
+
did, err := ResolveHandle(ctx, handle)
50
+
if err != nil {
51
+
slog.Error("resolve handle", "handle", handle, "error", err)
52
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not resolve handle: "+err.Error()), http.StatusSeeOther)
53
+
return
54
+
}
55
+
56
+
slog.Info("resolved handle", "handle", handle, "did", did)
57
+
58
+
// Resolve DID to get PDS
59
+
didDoc, err := ResolveDID(ctx, did)
60
+
if err != nil {
61
+
slog.Error("resolve DID", "did", did, "error", err)
62
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not resolve DID: "+err.Error()), http.StatusSeeOther)
63
+
return
64
+
}
65
+
66
+
pdsURL, err := GetPDSEndpoint(didDoc)
67
+
if err != nil {
68
+
slog.Error("get PDS endpoint", "did", did, "error", err)
69
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not find PDS: "+err.Error()), http.StatusSeeOther)
70
+
return
71
+
}
72
+
73
+
slog.Info("found PDS", "pds", pdsURL)
74
+
75
+
// Get authorization server metadata
76
+
authMeta, err := GetAuthServerMetadata(ctx, pdsURL)
77
+
if err != nil {
78
+
slog.Error("get auth server metadata", "pds", pdsURL, "error", err)
79
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Could not get auth server: "+err.Error()), http.StatusSeeOther)
80
+
return
81
+
}
82
+
83
+
slog.Info("got auth server metadata", "issuer", authMeta.Issuer, "auth_endpoint", authMeta.AuthorizationEndpoint)
84
+
85
+
// Generate PKCE challenge
86
+
codeVerifier := generateCodeVerifier()
87
+
codeChallenge := generateCodeChallenge(codeVerifier)
88
+
89
+
// Generate state
90
+
state := uuid.New().String()
91
+
92
+
// Generate DPoP key
93
+
dpopKey, err := GenerateDPoPKey()
94
+
if err != nil {
95
+
slog.Error("generate DPoP key", "error", err)
96
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Internal error"), http.StatusSeeOther)
97
+
return
98
+
}
99
+
100
+
dpopKeyJSON, err := dpopKey.MarshalPrivateKey()
101
+
if err != nil {
102
+
slog.Error("marshal DPoP key", "error", err)
103
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Internal error"), http.StatusSeeOther)
104
+
return
105
+
}
106
+
107
+
// Store state in database
108
+
q := dbgen.New(s.DB)
109
+
err = q.CreateOAuthState(ctx, dbgen.CreateOAuthStateParams{
110
+
State: state,
111
+
CodeVerifier: codeVerifier,
112
+
Did: &did,
113
+
PdsUrl: pdsURL,
114
+
AuthServerUrl: authMeta.Issuer,
115
+
DpopPrivateKey: dpopKeyJSON,
116
+
CreatedAt: time.Now(),
117
+
})
118
+
if err != nil {
119
+
slog.Error("create oauth state", "error", err)
120
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Internal error"), http.StatusSeeOther)
121
+
return
122
+
}
123
+
124
+
// Use PAR if available, otherwise direct authorization
125
+
if authMeta.PushedAuthorizationRequestEndpoint != "" {
126
+
s.handlePARLogin(w, r, authMeta, state, codeChallenge, dpopKey)
127
+
} else {
128
+
s.handleDirectLogin(w, r, authMeta, state, codeChallenge)
129
+
}
130
+
}
131
+
132
+
func (s *Server) handlePARLogin(w http.ResponseWriter, r *http.Request, authMeta *AuthServerMetadata, state, codeChallenge string, dpopKey *DPoPKey) {
133
+
// Create DPoP proof for PAR request
134
+
dpopProof, err := dpopKey.CreateDPoPProof("POST", authMeta.PushedAuthorizationRequestEndpoint, "", "")
135
+
if err != nil {
136
+
slog.Error("create DPoP proof", "error", err)
137
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Internal error"), http.StatusSeeOther)
138
+
return
139
+
}
140
+
141
+
// PAR request
142
+
parData := url.Values{}
143
+
parData.Set("client_id", s.oauth.ClientID)
144
+
parData.Set("redirect_uri", s.BaseURL+"/auth/callback")
145
+
parData.Set("response_type", "code")
146
+
parData.Set("scope", "atproto transition:generic")
147
+
parData.Set("state", state)
148
+
parData.Set("code_challenge", codeChallenge)
149
+
parData.Set("code_challenge_method", "S256")
150
+
151
+
req, err := http.NewRequestWithContext(r.Context(), "POST", authMeta.PushedAuthorizationRequestEndpoint, strings.NewReader(parData.Encode()))
152
+
if err != nil {
153
+
slog.Error("create PAR request", "error", err)
154
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Internal error"), http.StatusSeeOther)
155
+
return
156
+
}
157
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
158
+
req.Header.Set("DPoP", dpopProof)
159
+
160
+
client := &http.Client{Timeout: 30 * time.Second}
161
+
resp, err := client.Do(req)
162
+
if err != nil {
163
+
slog.Error("PAR request", "error", err)
164
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Auth server error"), http.StatusSeeOther)
165
+
return
166
+
}
167
+
defer resp.Body.Close()
168
+
169
+
// Check for DPoP nonce requirement and retry
170
+
if resp.StatusCode == http.StatusBadRequest {
171
+
nonce := resp.Header.Get("DPoP-Nonce")
172
+
if nonce != "" {
173
+
slog.Info("retrying PAR with nonce", "nonce", nonce)
174
+
dpopProof, _ = dpopKey.CreateDPoPProof("POST", authMeta.PushedAuthorizationRequestEndpoint, "", nonce)
175
+
req, _ = http.NewRequestWithContext(r.Context(), "POST", authMeta.PushedAuthorizationRequestEndpoint, strings.NewReader(parData.Encode()))
176
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
177
+
req.Header.Set("DPoP", dpopProof)
178
+
resp, err = client.Do(req)
179
+
if err != nil {
180
+
slog.Error("PAR retry request", "error", err)
181
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Auth server error"), http.StatusSeeOther)
182
+
return
183
+
}
184
+
defer resp.Body.Close()
185
+
}
186
+
}
187
+
188
+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
189
+
body, _ := io.ReadAll(resp.Body)
190
+
slog.Error("PAR failed", "status", resp.StatusCode, "body", string(body))
191
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Auth server rejected request"), http.StatusSeeOther)
192
+
return
193
+
}
194
+
195
+
var parResp struct {
196
+
RequestURI string `json:"request_uri"`
197
+
ExpiresIn int `json:"expires_in"`
198
+
}
199
+
if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil {
200
+
slog.Error("decode PAR response", "error", err)
201
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Internal error"), http.StatusSeeOther)
202
+
return
203
+
}
204
+
205
+
// Redirect to authorization endpoint with request_uri
206
+
authURL, _ := url.Parse(authMeta.AuthorizationEndpoint)
207
+
q := authURL.Query()
208
+
q.Set("client_id", s.oauth.ClientID)
209
+
q.Set("request_uri", parResp.RequestURI)
210
+
authURL.RawQuery = q.Encode()
211
+
212
+
slog.Info("redirecting to auth", "url", authURL.String())
213
+
http.Redirect(w, r, authURL.String(), http.StatusSeeOther)
214
+
}
215
+
216
+
func (s *Server) handleDirectLogin(w http.ResponseWriter, r *http.Request, authMeta *AuthServerMetadata, state, codeChallenge string) {
217
+
authURL, _ := url.Parse(authMeta.AuthorizationEndpoint)
218
+
q := authURL.Query()
219
+
q.Set("client_id", s.oauth.ClientID)
220
+
q.Set("redirect_uri", s.BaseURL+"/auth/callback")
221
+
q.Set("response_type", "code")
222
+
q.Set("scope", "atproto transition:generic")
223
+
q.Set("state", state)
224
+
q.Set("code_challenge", codeChallenge)
225
+
q.Set("code_challenge_method", "S256")
226
+
authURL.RawQuery = q.Encode()
227
+
228
+
http.Redirect(w, r, authURL.String(), http.StatusSeeOther)
229
+
}
230
+
231
+
func generateCodeVerifier() string {
232
+
b := make([]byte, 32)
233
+
rand.Read(b)
234
+
return base64.RawURLEncoding.EncodeToString(b)
235
+
}
236
+
237
+
func generateCodeChallenge(verifier string) string {
238
+
hash := sha256.Sum256([]byte(verifier))
239
+
return base64.RawURLEncoding.EncodeToString(hash[:])
240
+
}
+285
srv/posting.go
+285
srv/posting.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"fmt"
8
+
"io"
9
+
"log/slog"
10
+
"net/http"
11
+
"net/url"
12
+
"strings"
13
+
"time"
14
+
"unicode/utf8"
15
+
16
+
"srv.exe.dev/db/dbgen"
17
+
)
18
+
19
+
// HandlePost creates a new post or thread.
20
+
func (s *Server) HandlePost(w http.ResponseWriter, r *http.Request) {
21
+
ctx := r.Context()
22
+
23
+
// Get session
24
+
cookie, err := r.Cookie(SessionCookie)
25
+
if err != nil || cookie.Value == "" {
26
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Not logged in"), http.StatusSeeOther)
27
+
return
28
+
}
29
+
30
+
q := dbgen.New(s.DB)
31
+
session, err := q.GetOAuthSession(ctx, cookie.Value)
32
+
if err != nil {
33
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Session expired"), http.StatusSeeOther)
34
+
return
35
+
}
36
+
37
+
// Check if token needs refresh
38
+
if time.Now().After(session.ExpiresAt) {
39
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Session expired, please log in again"), http.StatusSeeOther)
40
+
return
41
+
}
42
+
43
+
// Get post content - can be multiple posts for a thread
44
+
if err := r.ParseForm(); err != nil {
45
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Invalid form data"), http.StatusSeeOther)
46
+
return
47
+
}
48
+
49
+
posts := r.Form["post"]
50
+
if len(posts) == 0 || (len(posts) == 1 && strings.TrimSpace(posts[0]) == "") {
51
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Post cannot be empty"), http.StatusSeeOther)
52
+
return
53
+
}
54
+
55
+
// Validate all posts
56
+
for i, post := range posts {
57
+
post = strings.TrimSpace(post)
58
+
if post == "" {
59
+
continue
60
+
}
61
+
if utf8.RuneCountInString(post) > BskyCharLimit {
62
+
http.Redirect(w, r, "/?error="+url.QueryEscape(fmt.Sprintf("Post %d exceeds %d character limit", i+1, BskyCharLimit)), http.StatusSeeOther)
63
+
return
64
+
}
65
+
}
66
+
67
+
// Restore DPoP key (if present)
68
+
var dpopKey *DPoPKey
69
+
if session.DpopPrivateKey != "" {
70
+
dpopKey, err = UnmarshalPrivateKey(session.DpopPrivateKey)
71
+
if err != nil {
72
+
slog.Error("unmarshal DPoP key", "error", err)
73
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Internal error"), http.StatusSeeOther)
74
+
return
75
+
}
76
+
}
77
+
78
+
// Create posts
79
+
var threadRootURI, threadRootCID string
80
+
var lastPostURI, lastPostCID string
81
+
var createdCount int
82
+
83
+
for _, postText := range posts {
84
+
postText = strings.TrimSpace(postText)
85
+
if postText == "" {
86
+
continue
87
+
}
88
+
89
+
var replyRef *ReplyRef
90
+
if lastPostURI != "" {
91
+
replyRef = &ReplyRef{
92
+
Root: PostRef{
93
+
URI: threadRootURI,
94
+
CID: threadRootCID,
95
+
},
96
+
Parent: PostRef{
97
+
URI: lastPostURI,
98
+
CID: lastPostCID,
99
+
},
100
+
}
101
+
}
102
+
103
+
uri, cid, err := s.createPost(ctx, session, dpopKey, postText, replyRef)
104
+
if err != nil {
105
+
slog.Error("create post", "error", err)
106
+
if createdCount > 0 {
107
+
http.Redirect(w, r, "/?error="+url.QueryEscape(fmt.Sprintf("Created %d posts but failed on post %d: %s", createdCount, createdCount+1, err.Error())), http.StatusSeeOther)
108
+
} else {
109
+
http.Redirect(w, r, "/?error="+url.QueryEscape("Failed to create post: "+err.Error()), http.StatusSeeOther)
110
+
}
111
+
return
112
+
}
113
+
114
+
// Track thread
115
+
if threadRootURI == "" {
116
+
threadRootURI = uri
117
+
threadRootCID = cid
118
+
}
119
+
lastPostURI = uri
120
+
lastPostCID = cid
121
+
122
+
// Store in database
123
+
var rootPtr, parentPtr *string
124
+
if replyRef != nil {
125
+
rootPtr = &replyRef.Root.URI
126
+
parentPtr = &replyRef.Parent.URI
127
+
}
128
+
129
+
_, err = q.CreatePost(ctx, dbgen.CreatePostParams{
130
+
SessionID: session.ID,
131
+
Did: session.Did,
132
+
Handle: session.Handle,
133
+
Uri: uri,
134
+
Cid: cid,
135
+
Text: postText,
136
+
ThreadRootUri: rootPtr,
137
+
ReplyParentUri: parentPtr,
138
+
CreatedAt: time.Now(),
139
+
})
140
+
if err != nil {
141
+
slog.Error("store post", "error", err)
142
+
// Don't fail the request, post was created on bsky
143
+
}
144
+
145
+
createdCount++
146
+
}
147
+
148
+
if createdCount == 1 {
149
+
http.Redirect(w, r, "/?success="+url.QueryEscape("Post created!"), http.StatusSeeOther)
150
+
} else {
151
+
http.Redirect(w, r, "/?success="+url.QueryEscape(fmt.Sprintf("Thread created with %d posts!", createdCount)), http.StatusSeeOther)
152
+
}
153
+
}
154
+
155
+
type PostRef struct {
156
+
URI string `json:"uri"`
157
+
CID string `json:"cid"`
158
+
}
159
+
160
+
type ReplyRef struct {
161
+
Root PostRef `json:"root"`
162
+
Parent PostRef `json:"parent"`
163
+
}
164
+
165
+
type createRecordRequest struct {
166
+
Repo string `json:"repo"`
167
+
Collection string `json:"collection"`
168
+
Record interface{} `json:"record"`
169
+
}
170
+
171
+
type postRecord struct {
172
+
Type string `json:"$type"`
173
+
Text string `json:"text"`
174
+
CreatedAt string `json:"createdAt"`
175
+
Reply *ReplyRef `json:"reply,omitempty"`
176
+
Facets []Facet `json:"facets,omitempty"`
177
+
}
178
+
179
+
type Facet struct {
180
+
Index FacetIndex `json:"index"`
181
+
Features []FacetFeature `json:"features"`
182
+
}
183
+
184
+
type FacetIndex struct {
185
+
ByteStart int `json:"byteStart"`
186
+
ByteEnd int `json:"byteEnd"`
187
+
}
188
+
189
+
type FacetFeature struct {
190
+
Type string `json:"$type"`
191
+
URI string `json:"uri,omitempty"`
192
+
DID string `json:"did,omitempty"`
193
+
Tag string `json:"tag,omitempty"`
194
+
}
195
+
196
+
func (s *Server) createPost(ctx context.Context, session dbgen.OauthSession, dpopKey *DPoPKey, text string, reply *ReplyRef) (uri, cid string, err error) {
197
+
// Check if we're using DPoP or Bearer auth
198
+
useDPoP := session.DpopPrivateKey != "" && dpopKey != nil
199
+
200
+
record := postRecord{
201
+
Type: "app.bsky.feed.post",
202
+
Text: text,
203
+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
204
+
Reply: reply,
205
+
Facets: extractFacets(text),
206
+
}
207
+
208
+
reqBody := createRecordRequest{
209
+
Repo: session.Did,
210
+
Collection: "app.bsky.feed.post",
211
+
Record: record,
212
+
}
213
+
214
+
bodyJSON, err := json.Marshal(reqBody)
215
+
if err != nil {
216
+
return "", "", fmt.Errorf("marshal request: %w", err)
217
+
}
218
+
219
+
endpoint := session.PdsUrl + "/xrpc/com.atproto.repo.createRecord"
220
+
221
+
// Try with nonce retry (for DPoP) or single attempt (for Bearer)
222
+
var nonce string
223
+
maxRetries := 1
224
+
if useDPoP {
225
+
maxRetries = 3
226
+
}
227
+
228
+
for i := 0; i < maxRetries; i++ {
229
+
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(bodyJSON))
230
+
if err != nil {
231
+
return "", "", fmt.Errorf("create request: %w", err)
232
+
}
233
+
req.Header.Set("Content-Type", "application/json")
234
+
235
+
if useDPoP {
236
+
dpopProof, err := dpopKey.CreateDPoPProof("POST", endpoint, session.AccessToken, nonce)
237
+
if err != nil {
238
+
return "", "", fmt.Errorf("create DPoP proof: %w", err)
239
+
}
240
+
req.Header.Set("Authorization", "DPoP "+session.AccessToken)
241
+
req.Header.Set("DPoP", dpopProof)
242
+
} else {
243
+
req.Header.Set("Authorization", "Bearer "+session.AccessToken)
244
+
}
245
+
246
+
client := &http.Client{Timeout: 30 * time.Second}
247
+
resp, err := client.Do(req)
248
+
if err != nil {
249
+
return "", "", fmt.Errorf("request failed: %w", err)
250
+
}
251
+
252
+
// Check for DPoP nonce
253
+
if useDPoP {
254
+
if newNonce := resp.Header.Get("DPoP-Nonce"); newNonce != "" {
255
+
nonce = newNonce
256
+
}
257
+
258
+
if resp.StatusCode == http.StatusUnauthorized && nonce != "" && i < maxRetries-1 {
259
+
resp.Body.Close()
260
+
slog.Info("retrying createRecord with nonce")
261
+
continue
262
+
}
263
+
}
264
+
265
+
if resp.StatusCode != http.StatusOK {
266
+
body, _ := io.ReadAll(resp.Body)
267
+
resp.Body.Close()
268
+
return "", "", fmt.Errorf("createRecord failed (%d): %s", resp.StatusCode, string(body))
269
+
}
270
+
271
+
var result struct {
272
+
URI string `json:"uri"`
273
+
CID string `json:"cid"`
274
+
}
275
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
276
+
resp.Body.Close()
277
+
return "", "", fmt.Errorf("decode response: %w", err)
278
+
}
279
+
resp.Body.Close()
280
+
281
+
return result.URI, result.CID, nil
282
+
}
283
+
284
+
return "", "", fmt.Errorf("failed after retries")
285
+
}
+144
srv/server.go
+144
srv/server.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"database/sql"
5
+
"fmt"
6
+
"html/template"
7
+
"log/slog"
8
+
"net/http"
9
+
"path/filepath"
10
+
"runtime"
11
+
"time"
12
+
13
+
"srv.exe.dev/db"
14
+
"srv.exe.dev/db/dbgen"
15
+
)
16
+
17
+
const (
18
+
BskyCharLimit = 300 // Bluesky post character limit (graphemes)
19
+
SessionCookie = "bsky_session"
20
+
)
21
+
22
+
type Server struct {
23
+
DB *sql.DB
24
+
Hostname string
25
+
BaseURL string
26
+
TemplatesDir string
27
+
StaticDir string
28
+
oauth *OAuthClient
29
+
}
30
+
31
+
func New(dbPath, hostname, baseURL string) (*Server, error) {
32
+
_, thisFile, _, _ := runtime.Caller(0)
33
+
baseDir := filepath.Dir(thisFile)
34
+
srv := &Server{
35
+
Hostname: hostname,
36
+
BaseURL: baseURL,
37
+
TemplatesDir: filepath.Join(baseDir, "templates"),
38
+
StaticDir: filepath.Join(baseDir, "static"),
39
+
}
40
+
if err := srv.setUpDatabase(dbPath); err != nil {
41
+
return nil, err
42
+
}
43
+
srv.oauth = NewOAuthClient(baseURL, srv.DB)
44
+
return srv, nil
45
+
}
46
+
47
+
func (s *Server) setUpDatabase(dbPath string) error {
48
+
wdb, err := db.Open(dbPath)
49
+
if err != nil {
50
+
return fmt.Errorf("failed to open db: %w", err)
51
+
}
52
+
s.DB = wdb
53
+
if err := db.RunMigrations(wdb); err != nil {
54
+
return fmt.Errorf("failed to run migrations: %w", err)
55
+
}
56
+
return nil
57
+
}
58
+
59
+
func (s *Server) Serve(addr string) error {
60
+
mux := http.NewServeMux()
61
+
62
+
// Main pages
63
+
mux.HandleFunc("GET /{$}", s.HandleHome)
64
+
65
+
// Auth endpoints
66
+
mux.HandleFunc("POST /auth/login", s.HandleLogin)
67
+
mux.HandleFunc("POST /auth/apppassword", s.HandleAppPasswordLogin)
68
+
mux.HandleFunc("GET /auth/callback", s.HandleCallback)
69
+
mux.HandleFunc("POST /auth/logout", s.HandleLogout)
70
+
71
+
// Client metadata for OAuth
72
+
mux.HandleFunc("GET /oauth-client-metadata.json", s.HandleClientMetadata)
73
+
74
+
// Posting
75
+
mux.HandleFunc("POST /post", s.HandlePost)
76
+
77
+
// Static files
78
+
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.StaticDir))))
79
+
80
+
slog.Info("starting server", "addr", addr, "baseURL", s.BaseURL)
81
+
return http.ListenAndServe(addr, mux)
82
+
}
83
+
84
+
type homePageData struct {
85
+
LoggedIn bool
86
+
Handle string
87
+
DID string
88
+
Posts []dbgen.Post
89
+
CharLimit int
90
+
Error string
91
+
Success string
92
+
}
93
+
94
+
func (s *Server) HandleHome(w http.ResponseWriter, r *http.Request) {
95
+
data := homePageData{
96
+
CharLimit: BskyCharLimit,
97
+
}
98
+
99
+
// Check for session cookie
100
+
cookie, err := r.Cookie(SessionCookie)
101
+
if err == nil && cookie.Value != "" {
102
+
q := dbgen.New(s.DB)
103
+
session, err := q.GetOAuthSession(r.Context(), cookie.Value)
104
+
if err == nil {
105
+
// Check if session is still valid (token not expired)
106
+
if time.Now().Before(session.ExpiresAt) {
107
+
data.LoggedIn = true
108
+
data.Handle = session.Handle
109
+
data.DID = session.Did
110
+
111
+
// Get recent posts from this session
112
+
posts, err := q.GetPostsBySession(r.Context(), dbgen.GetPostsBySessionParams{
113
+
SessionID: session.ID,
114
+
Limit: 50,
115
+
})
116
+
if err == nil {
117
+
data.Posts = posts
118
+
}
119
+
}
120
+
}
121
+
}
122
+
123
+
// Check for flash messages in query params
124
+
data.Error = r.URL.Query().Get("error")
125
+
data.Success = r.URL.Query().Get("success")
126
+
127
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
128
+
if err := s.renderTemplate(w, "home.html", data); err != nil {
129
+
slog.Error("render template", "error", err)
130
+
http.Error(w, "Internal server error", http.StatusInternalServerError)
131
+
}
132
+
}
133
+
134
+
func (s *Server) renderTemplate(w http.ResponseWriter, name string, data any) error {
135
+
path := filepath.Join(s.TemplatesDir, name)
136
+
tmpl, err := template.ParseFiles(path)
137
+
if err != nil {
138
+
return fmt.Errorf("parse template %q: %w", name, err)
139
+
}
140
+
if err := tmpl.Execute(w, data); err != nil {
141
+
return fmt.Errorf("execute template %q: %w", name, err)
142
+
}
143
+
return nil
144
+
}
+117
srv/server_test.go
+117
srv/server_test.go
···
···
1
+
package srv
2
+
3
+
import (
4
+
"net/http"
5
+
"net/http/httptest"
6
+
"os"
7
+
"path/filepath"
8
+
"strings"
9
+
"testing"
10
+
)
11
+
12
+
func TestServerSetupAndHandlers(t *testing.T) {
13
+
tempDB := filepath.Join(t.TempDir(), "test_server.sqlite3")
14
+
t.Cleanup(func() { os.Remove(tempDB) })
15
+
16
+
server, err := New(tempDB, "test-hostname")
17
+
if err != nil {
18
+
t.Fatalf("failed to create server: %v", err)
19
+
}
20
+
21
+
// Test root endpoint without auth
22
+
t.Run("root endpoint unauthenticated", func(t *testing.T) {
23
+
req := httptest.NewRequest(http.MethodGet, "/", nil)
24
+
w := httptest.NewRecorder()
25
+
26
+
server.HandleRoot(w, req)
27
+
28
+
if w.Code != http.StatusOK {
29
+
t.Errorf("expected status 200, got %d", w.Code)
30
+
}
31
+
32
+
body := w.Body.String()
33
+
if !strings.Contains(body, "test-hostname") {
34
+
t.Errorf("expected page to show hostname, got body: %s", body)
35
+
}
36
+
if !strings.Contains(body, "Go Template Project") {
37
+
t.Errorf("expected page to contain headline, got body: %s", body)
38
+
}
39
+
if strings.Contains(body, "Signed in as") {
40
+
t.Errorf("expected page to not be logged in, got body: %s", body)
41
+
}
42
+
if !strings.Contains(body, "Not signed in") {
43
+
t.Errorf("expected page to show 'Not signed in', got body: %s", body)
44
+
}
45
+
})
46
+
47
+
// Test root endpoint with auth headers
48
+
t.Run("root endpoint authenticated", func(t *testing.T) {
49
+
req := httptest.NewRequest(http.MethodGet, "/", nil)
50
+
req.Header.Set("X-ExeDev-UserID", "user123")
51
+
req.Header.Set("X-ExeDev-Email", "test@example.com")
52
+
w := httptest.NewRecorder()
53
+
54
+
server.HandleRoot(w, req)
55
+
56
+
if w.Code != http.StatusOK {
57
+
t.Errorf("expected status 200, got %d", w.Code)
58
+
}
59
+
60
+
body := w.Body.String()
61
+
if !strings.Contains(body, "Signed in as") {
62
+
t.Errorf("expected page to show logged in state, got body: %s", body)
63
+
}
64
+
if !strings.Contains(body, "test@example.com") {
65
+
t.Error("expected page to show user email")
66
+
}
67
+
})
68
+
69
+
// Test view counter functionality
70
+
t.Run("view counter increments", func(t *testing.T) {
71
+
// Make first request
72
+
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
73
+
req1.Header.Set("X-ExeDev-UserID", "counter-test")
74
+
req1.RemoteAddr = "192.168.1.100:12345"
75
+
w1 := httptest.NewRecorder()
76
+
server.HandleRoot(w1, req1)
77
+
78
+
// Should show "1 times" or similar
79
+
body1 := w1.Body.String()
80
+
if !strings.Contains(body1, "1</strong> times") {
81
+
t.Error("expected first visit to show 1 time")
82
+
}
83
+
84
+
// Make second request with same user
85
+
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
86
+
req2.Header.Set("X-ExeDev-UserID", "counter-test")
87
+
req2.RemoteAddr = "192.168.1.100:12345"
88
+
w2 := httptest.NewRecorder()
89
+
server.HandleRoot(w2, req2)
90
+
91
+
// Should show "2 times" or similar
92
+
body2 := w2.Body.String()
93
+
if !strings.Contains(body2, "2</strong> times") {
94
+
t.Error("expected second visit to show 2 times")
95
+
}
96
+
})
97
+
}
98
+
99
+
func TestUtilityFunctions(t *testing.T) {
100
+
t.Run("mainDomainFromHost function", func(t *testing.T) {
101
+
tests := []struct {
102
+
input string
103
+
expected string
104
+
}{
105
+
{"example.exe.cloud:8080", "exe.cloud:8080"},
106
+
{"example.exe.dev", "exe.dev"},
107
+
{"example.exe.cloud", "exe.cloud"},
108
+
}
109
+
110
+
for _, test := range tests {
111
+
result := mainDomainFromHost(test.input)
112
+
if result != test.expected {
113
+
t.Errorf("mainDomainFromHost(%q) = %q, expected %q", test.input, result, test.expected)
114
+
}
115
+
}
116
+
})
117
+
}
+103
srv/static/script.js
+103
srv/static/script.js
···
···
1
+
// Tab switching for login
2
+
document.addEventListener('DOMContentLoaded', function() {
3
+
const tabs = document.querySelectorAll('.login-tabs .tab');
4
+
tabs.forEach(function(tab) {
5
+
tab.addEventListener('click', function() {
6
+
// Update active tab
7
+
tabs.forEach(t => t.classList.remove('active'));
8
+
this.classList.add('active');
9
+
10
+
// Update visible content
11
+
const targetId = this.dataset.tab + '-form';
12
+
document.querySelectorAll('.tab-content').forEach(function(content) {
13
+
content.classList.remove('active');
14
+
});
15
+
document.getElementById(targetId).classList.add('active');
16
+
});
17
+
});
18
+
});
19
+
20
+
// Character count and thread management
21
+
document.addEventListener('DOMContentLoaded', function() {
22
+
const charLimit = 300;
23
+
const container = document.getElementById('posts-container');
24
+
const addButton = document.getElementById('addPost');
25
+
26
+
if (!container) return;
27
+
28
+
// Update character count for a textarea
29
+
function updateCharCount(textarea) {
30
+
const countSpan = textarea.parentElement.querySelector('.count');
31
+
if (!countSpan) return;
32
+
33
+
const count = textarea.value.length;
34
+
countSpan.textContent = count;
35
+
36
+
const countDiv = textarea.parentElement.querySelector('.char-count');
37
+
countDiv.classList.remove('warning', 'danger');
38
+
39
+
if (count > charLimit * 0.9) {
40
+
countDiv.classList.add('danger');
41
+
} else if (count > charLimit * 0.75) {
42
+
countDiv.classList.add('warning');
43
+
}
44
+
}
45
+
46
+
// Attach listeners to textareas
47
+
function attachListeners(textarea) {
48
+
textarea.addEventListener('input', function() {
49
+
updateCharCount(this);
50
+
});
51
+
}
52
+
53
+
// Initialize existing textareas
54
+
document.querySelectorAll('.post-input textarea').forEach(attachListeners);
55
+
56
+
// Add post to thread
57
+
let postIndex = 1;
58
+
if (addButton) {
59
+
addButton.addEventListener('click', function() {
60
+
const div = document.createElement('div');
61
+
div.className = 'post-input';
62
+
div.dataset.index = postIndex;
63
+
div.innerHTML = `
64
+
<button type="button" class="remove-post" title="Remove">×</button>
65
+
<textarea name="post" placeholder="Continue your thread..." maxlength="${charLimit}"></textarea>
66
+
<div class="char-count"><span class="count">0</span>/${charLimit}</div>
67
+
`;
68
+
69
+
container.appendChild(div);
70
+
71
+
const textarea = div.querySelector('textarea');
72
+
attachListeners(textarea);
73
+
textarea.focus();
74
+
75
+
// Remove button handler
76
+
div.querySelector('.remove-post').addEventListener('click', function() {
77
+
div.remove();
78
+
});
79
+
80
+
postIndex++;
81
+
});
82
+
}
83
+
84
+
// Form validation
85
+
const form = document.getElementById('postForm');
86
+
if (form) {
87
+
form.addEventListener('submit', function(e) {
88
+
const textareas = form.querySelectorAll('textarea');
89
+
let hasContent = false;
90
+
91
+
textareas.forEach(function(ta) {
92
+
if (ta.value.trim()) {
93
+
hasContent = true;
94
+
}
95
+
});
96
+
97
+
if (!hasContent) {
98
+
e.preventDefault();
99
+
alert('Please write something to post!');
100
+
}
101
+
});
102
+
}
103
+
});
+362
srv/static/style.css
+362
srv/static/style.css
···
···
1
+
* {
2
+
margin: 0;
3
+
padding: 0;
4
+
box-sizing: border-box;
5
+
}
6
+
7
+
body {
8
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
9
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
10
+
color: #e8e8e8;
11
+
min-height: 100vh;
12
+
line-height: 1.6;
13
+
}
14
+
15
+
main {
16
+
max-width: 600px;
17
+
margin: 0 auto;
18
+
padding: 40px 20px;
19
+
}
20
+
21
+
h1 {
22
+
font-size: 2.5rem;
23
+
margin-bottom: 8px;
24
+
color: #fff;
25
+
}
26
+
27
+
.tagline {
28
+
color: #8b9dc3;
29
+
margin-bottom: 30px;
30
+
}
31
+
32
+
/* Messages */
33
+
.message {
34
+
padding: 12px 16px;
35
+
border-radius: 8px;
36
+
margin-bottom: 20px;
37
+
}
38
+
39
+
.message.error {
40
+
background: rgba(220, 53, 69, 0.2);
41
+
border: 1px solid #dc3545;
42
+
color: #ff6b7a;
43
+
}
44
+
45
+
.message.success {
46
+
background: rgba(40, 167, 69, 0.2);
47
+
border: 1px solid #28a745;
48
+
color: #5cd87b;
49
+
}
50
+
51
+
/* User info */
52
+
.user-info {
53
+
display: flex;
54
+
align-items: center;
55
+
gap: 16px;
56
+
margin-bottom: 24px;
57
+
padding: 12px 16px;
58
+
background: rgba(255, 255, 255, 0.05);
59
+
border-radius: 8px;
60
+
}
61
+
62
+
.user-info .handle {
63
+
font-weight: 600;
64
+
color: #0085ff;
65
+
}
66
+
67
+
.inline {
68
+
display: inline;
69
+
}
70
+
71
+
.btn-link {
72
+
background: none;
73
+
border: none;
74
+
color: #8b9dc3;
75
+
cursor: pointer;
76
+
font-size: 14px;
77
+
}
78
+
79
+
.btn-link:hover {
80
+
color: #fff;
81
+
text-decoration: underline;
82
+
}
83
+
84
+
/* Compose section */
85
+
.compose {
86
+
margin-bottom: 40px;
87
+
}
88
+
89
+
.post-input {
90
+
position: relative;
91
+
margin-bottom: 12px;
92
+
}
93
+
94
+
.post-input textarea {
95
+
width: 100%;
96
+
min-height: 120px;
97
+
padding: 16px;
98
+
border: 2px solid rgba(255, 255, 255, 0.1);
99
+
border-radius: 12px;
100
+
background: rgba(255, 255, 255, 0.05);
101
+
color: #fff;
102
+
font-size: 16px;
103
+
font-family: inherit;
104
+
resize: vertical;
105
+
transition: border-color 0.2s;
106
+
}
107
+
108
+
.post-input textarea:focus {
109
+
outline: none;
110
+
border-color: #0085ff;
111
+
}
112
+
113
+
.post-input textarea::placeholder {
114
+
color: #6b7c93;
115
+
}
116
+
117
+
.char-count {
118
+
position: absolute;
119
+
bottom: 12px;
120
+
right: 12px;
121
+
font-size: 12px;
122
+
color: #6b7c93;
123
+
}
124
+
125
+
.char-count.warning {
126
+
color: #ffc107;
127
+
}
128
+
129
+
.char-count.danger {
130
+
color: #dc3545;
131
+
}
132
+
133
+
.post-input + .post-input {
134
+
padding-top: 12px;
135
+
border-top: 1px dashed rgba(255, 255, 255, 0.1);
136
+
}
137
+
138
+
.post-input .remove-post {
139
+
position: absolute;
140
+
top: 8px;
141
+
right: 8px;
142
+
background: rgba(220, 53, 69, 0.3);
143
+
border: none;
144
+
color: #ff6b7a;
145
+
width: 24px;
146
+
height: 24px;
147
+
border-radius: 50%;
148
+
cursor: pointer;
149
+
font-size: 14px;
150
+
line-height: 1;
151
+
}
152
+
153
+
.compose-actions {
154
+
display: flex;
155
+
gap: 12px;
156
+
justify-content: flex-end;
157
+
}
158
+
159
+
.btn-primary {
160
+
background: #0085ff;
161
+
color: #fff;
162
+
border: none;
163
+
padding: 12px 24px;
164
+
border-radius: 8px;
165
+
font-size: 16px;
166
+
font-weight: 600;
167
+
cursor: pointer;
168
+
transition: background 0.2s;
169
+
}
170
+
171
+
.btn-primary:hover {
172
+
background: #0066cc;
173
+
}
174
+
175
+
.btn-secondary {
176
+
background: rgba(255, 255, 255, 0.1);
177
+
color: #8b9dc3;
178
+
border: 1px solid rgba(255, 255, 255, 0.1);
179
+
padding: 12px 24px;
180
+
border-radius: 8px;
181
+
font-size: 14px;
182
+
cursor: pointer;
183
+
transition: all 0.2s;
184
+
}
185
+
186
+
.btn-secondary:hover {
187
+
background: rgba(255, 255, 255, 0.15);
188
+
color: #fff;
189
+
}
190
+
191
+
/* Login section */
192
+
.login-section {
193
+
background: rgba(255, 255, 255, 0.05);
194
+
padding: 32px;
195
+
border-radius: 16px;
196
+
text-align: center;
197
+
}
198
+
199
+
.login-section h2 {
200
+
font-size: 1.3rem;
201
+
margin-bottom: 20px;
202
+
font-weight: 500;
203
+
}
204
+
205
+
.login-section p {
206
+
margin-bottom: 24px;
207
+
}
208
+
209
+
.login-tabs {
210
+
display: flex;
211
+
gap: 8px;
212
+
justify-content: center;
213
+
margin-bottom: 24px;
214
+
}
215
+
216
+
.login-tabs .tab {
217
+
background: rgba(255, 255, 255, 0.05);
218
+
border: 1px solid rgba(255, 255, 255, 0.1);
219
+
color: #8b9dc3;
220
+
padding: 10px 20px;
221
+
border-radius: 6px;
222
+
cursor: pointer;
223
+
font-size: 14px;
224
+
transition: all 0.2s;
225
+
}
226
+
227
+
.login-tabs .tab:hover {
228
+
background: rgba(255, 255, 255, 0.1);
229
+
}
230
+
231
+
.login-tabs .tab.active {
232
+
background: #0085ff;
233
+
border-color: #0085ff;
234
+
color: #fff;
235
+
}
236
+
237
+
.tab-content {
238
+
display: none;
239
+
}
240
+
241
+
.tab-content.active {
242
+
display: block;
243
+
}
244
+
245
+
.login-form {
246
+
max-width: 300px;
247
+
margin: 0 auto;
248
+
}
249
+
250
+
.input-group {
251
+
margin-bottom: 16px;
252
+
text-align: left;
253
+
}
254
+
255
+
.input-group label {
256
+
display: block;
257
+
margin-bottom: 6px;
258
+
color: #8b9dc3;
259
+
font-size: 14px;
260
+
}
261
+
262
+
.input-group input {
263
+
width: 100%;
264
+
padding: 12px 16px;
265
+
border: 2px solid rgba(255, 255, 255, 0.1);
266
+
border-radius: 8px;
267
+
background: rgba(255, 255, 255, 0.05);
268
+
color: #fff;
269
+
font-size: 16px;
270
+
}
271
+
272
+
.input-group input:focus {
273
+
outline: none;
274
+
border-color: #0085ff;
275
+
}
276
+
277
+
.login-form .btn-primary {
278
+
width: 100%;
279
+
margin-bottom: 16px;
280
+
}
281
+
282
+
.note {
283
+
font-size: 13px;
284
+
color: #6b7c93;
285
+
}
286
+
287
+
/* Recent posts */
288
+
.recent-posts {
289
+
margin-top: 40px;
290
+
}
291
+
292
+
.recent-posts h2 {
293
+
font-size: 1.2rem;
294
+
margin-bottom: 16px;
295
+
color: #8b9dc3;
296
+
}
297
+
298
+
.post {
299
+
background: rgba(255, 255, 255, 0.05);
300
+
padding: 16px;
301
+
border-radius: 12px;
302
+
margin-bottom: 12px;
303
+
}
304
+
305
+
.post-header {
306
+
display: flex;
307
+
justify-content: space-between;
308
+
align-items: center;
309
+
margin-bottom: 8px;
310
+
}
311
+
312
+
.post-header .handle {
313
+
color: #0085ff;
314
+
font-weight: 600;
315
+
}
316
+
317
+
.post-header time {
318
+
color: #6b7c93;
319
+
font-size: 13px;
320
+
}
321
+
322
+
.post-text {
323
+
white-space: pre-wrap;
324
+
word-wrap: break-word;
325
+
}
326
+
327
+
.thread-indicator {
328
+
display: inline-block;
329
+
margin-top: 8px;
330
+
font-size: 12px;
331
+
color: #6b7c93;
332
+
background: rgba(255, 255, 255, 0.05);
333
+
padding: 2px 8px;
334
+
border-radius: 4px;
335
+
}
336
+
337
+
/* Footer */
338
+
footer {
339
+
text-align: center;
340
+
padding: 40px 20px;
341
+
color: #6b7c93;
342
+
font-size: 14px;
343
+
}
344
+
345
+
/* Responsive */
346
+
@media (max-width: 480px) {
347
+
main {
348
+
padding: 20px 16px;
349
+
}
350
+
351
+
h1 {
352
+
font-size: 2rem;
353
+
}
354
+
355
+
.compose-actions {
356
+
flex-direction: column;
357
+
}
358
+
359
+
.btn-secondary {
360
+
order: 1;
361
+
}
362
+
}
+107
srv/templates/home.html
+107
srv/templates/home.html
···
···
1
+
<!DOCTYPE html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="utf-8">
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+
<title>Bluesky Poster</title>
7
+
<link rel="stylesheet" href="/static/style.css">
8
+
</head>
9
+
<body>
10
+
<main>
11
+
<h1>🦋 Bluesky Poster</h1>
12
+
<p class="tagline">Post to Bluesky without the noise</p>
13
+
14
+
{{if .Error}}
15
+
<div class="message error">{{.Error}}</div>
16
+
{{end}}
17
+
18
+
{{if .Success}}
19
+
<div class="message success">{{.Success}}</div>
20
+
{{end}}
21
+
22
+
{{if .LoggedIn}}
23
+
<div class="user-info">
24
+
<span class="handle">@{{.Handle}}</span>
25
+
<form method="POST" action="/auth/logout" class="inline">
26
+
<button type="submit" class="btn-link">Log out</button>
27
+
</form>
28
+
</div>
29
+
30
+
<section class="compose">
31
+
<form method="POST" action="/post" id="postForm">
32
+
<div id="posts-container">
33
+
<div class="post-input" data-index="0">
34
+
<textarea name="post" placeholder="What's on your mind?" maxlength="{{.CharLimit}}" required></textarea>
35
+
<div class="char-count"><span class="count">0</span>/{{.CharLimit}}</div>
36
+
</div>
37
+
</div>
38
+
<div class="compose-actions">
39
+
<button type="button" id="addPost" class="btn-secondary">+ Add to thread</button>
40
+
<button type="submit" class="btn-primary">Post</button>
41
+
</div>
42
+
</form>
43
+
</section>
44
+
45
+
{{if .Posts}}
46
+
<section class="recent-posts">
47
+
<h2>Your recent posts</h2>
48
+
{{range .Posts}}
49
+
<article class="post">
50
+
<div class="post-header">
51
+
<span class="handle">@{{.Handle}}</span>
52
+
<time>{{.CreatedAt.Format "Jan 2, 3:04 PM"}}</time>
53
+
</div>
54
+
<p class="post-text">{{.Text}}</p>
55
+
{{if .ThreadRootUri}}
56
+
<span class="thread-indicator">Part of thread</span>
57
+
{{end}}
58
+
</article>
59
+
{{end}}
60
+
</section>
61
+
{{end}}
62
+
63
+
{{else}}
64
+
<section class="login-section">
65
+
<h2>Log in with Bluesky</h2>
66
+
67
+
<div class="login-tabs">
68
+
<button type="button" class="tab active" data-tab="oauth">OAuth</button>
69
+
<button type="button" class="tab" data-tab="apppassword">App Password</button>
70
+
</div>
71
+
72
+
<div id="oauth-form" class="tab-content active">
73
+
<form method="POST" action="/auth/login" class="login-form">
74
+
<div class="input-group">
75
+
<label for="handle">Handle</label>
76
+
<input type="text" id="handle" name="handle" placeholder="yourname.bsky.social" required>
77
+
</div>
78
+
<button type="submit" class="btn-primary">Log in with OAuth</button>
79
+
</form>
80
+
<p class="note">You'll be redirected to Bluesky to authorize this app.</p>
81
+
</div>
82
+
83
+
<div id="apppassword-form" class="tab-content">
84
+
<form method="POST" action="/auth/apppassword" class="login-form">
85
+
<div class="input-group">
86
+
<label for="handle2">Handle</label>
87
+
<input type="text" id="handle2" name="handle" placeholder="yourname.bsky.social" required>
88
+
</div>
89
+
<div class="input-group">
90
+
<label for="app_password">App Password</label>
91
+
<input type="password" id="app_password" name="app_password" placeholder="xxxx-xxxx-xxxx-xxxx" required>
92
+
</div>
93
+
<button type="submit" class="btn-primary">Log in with App Password</button>
94
+
</form>
95
+
<p class="note">Create an app password in Bluesky Settings → App Passwords. Never use your main password!</p>
96
+
</div>
97
+
</section>
98
+
{{end}}
99
+
</main>
100
+
101
+
<footer>
102
+
<p>This app only shows posts you create here. No feed, no distractions.</p>
103
+
</footer>
104
+
105
+
<script src="/static/script.js"></script>
106
+
</body>
107
+
</html>
+149
srv/templates/welcome.html
+149
srv/templates/welcome.html
···
···
1
+
<!doctype html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="utf-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
<title>{{.Hostname}}</title>
7
+
<link rel="stylesheet" href="/static/style.css" />
8
+
</head>
9
+
<body>
10
+
<main>
11
+
<h1>Go Template Project</h1>
12
+
13
+
<section class="intro">
14
+
<p>
15
+
This is a starter template for building Go web applications on exe.dev. Customize this page and the code to create your own service.
16
+
</p>
17
+
<p>
18
+
This VM has a persistent disk, sudo, HTTPS, and SSH all wired up.
19
+
</p>
20
+
</section>
21
+
22
+
<section class="next-steps">
23
+
<h2>What next?</h2>
24
+
<div class="step-grid">
25
+
<article class="step-card">
26
+
<h3>Customize this template</h3>
27
+
<p>Edit the code to build your application.</p>
28
+
<ul>
29
+
<li>Source code is in <code>~/src</code></li>
30
+
<li>Edit via SSH or <a href="vscode://vscode-remote/ssh-remote+{{.Hostname}}@exe.dev/home/exedev/src?windowId=_blank">open in VS Code</a></li>
31
+
<li>Chat with our coding agent, <a href="https://{{.Hostname}}.exe.dev:9999" target="_blank">Shelley</a></li>
32
+
<li><code>sudo systemctl restart srv</code> to pick up changes</li>
33
+
</ul>
34
+
</article>
35
+
<article class="step-card">
36
+
<h3>What's included</h3>
37
+
<p>This template provides the essentials.</p>
38
+
<ul>
39
+
<li>HTTP server with routing and templates</li>
40
+
<li>SQLite database with migrations</li>
41
+
<li>Authentication via exe.dev headers</li>
42
+
<li>Systemd service configuration</li>
43
+
</ul>
44
+
</article>
45
+
</div>
46
+
</section>
47
+
48
+
<div class="hostname">{{.Hostname}}</div>
49
+
50
+
<ul class="actions">
51
+
<li>
52
+
<a href="#" class="ssh-copy" data-copy="ssh {{.Hostname}}@exe.dev">
53
+
<span class="action-icon">🔑</span>
54
+
<span class="action-label">SSH</span>
55
+
<span class="action-detail">ssh {{.Hostname}}.exe.dev</span>
56
+
</a>
57
+
</li>
58
+
<li>
59
+
<a href="https://{{.Hostname}}.xterm.exe.dev" target="_blank">
60
+
<span class="action-icon">💻</span>
61
+
<span class="action-label">Terminal</span>
62
+
<span class="action-detail">{{.Hostname}}.xterm.exe.dev</span>
63
+
</a>
64
+
</li>
65
+
<li>
66
+
<a href="https://{{.Hostname}}.exe.dev:9999" target="_blank">
67
+
<span class="action-icon">🤖</span>
68
+
<span class="action-label">Shelley Agent</span>
69
+
<span class="action-detail">{{.Hostname}}.exe.dev:9999</span>
70
+
</a>
71
+
</li>
72
+
<li>
73
+
<a href="https://exe.dev" target="_blank">
74
+
<span class="action-icon">🏠</span>
75
+
<span class="action-label">exe.dev</span>
76
+
<span class="action-detail">exe.dev</span>
77
+
</a>
78
+
</li>
79
+
</ul>
80
+
81
+
<hr />
82
+
83
+
<div class="user-info">
84
+
<p class="user-status">
85
+
{{if .UserEmail}}
86
+
Signed in as <strong>{{.UserEmail}}</strong>
87
+
{{else}}
88
+
Not signed in
89
+
{{end}}
90
+
</p>
91
+
<div class="auth-buttons">
92
+
{{if .UserEmail}}
93
+
<form method="POST" action="{{.LogoutURL}}" style="display:inline">
94
+
<button type="submit">logout</button>
95
+
</form>
96
+
{{else}}
97
+
<a href="{{.LoginURL}}">login</a>
98
+
{{end}}
99
+
</div>
100
+
</div>
101
+
102
+
{{if .Headers}}
103
+
<section class="headers">
104
+
<div class="headers-title">
105
+
<h2>HTTP headers from exe.dev</h2>
106
+
<div class="headers-notes">
107
+
<p>exe.dev adds extra headers to HTTP requests so that:</p>
108
+
<ul>
109
+
<li>you don't have to build auth</li>
110
+
<li>you know where the request came from</li>
111
+
</ul>
112
+
<p>These are all the HTTP headers we received from exe.dev for this request.</p>
113
+
<p>The <code>X-ExeDev-*</code> and <code>X-Forwarded-*</code> headers are added by exe.dev.</p>
114
+
</div>
115
+
</div>
116
+
<div class="headers-table">
117
+
<div class="headers-row headers-head">
118
+
<div>Header</div>
119
+
<div>Value</div>
120
+
</div>
121
+
{{range .Headers}}
122
+
<div class="headers-row{{if .AddedByExe}} exe-header{{end}}">
123
+
<div class="header-name"><code>{{.Name}}</code></div>
124
+
<div class="header-value">
125
+
{{if .Values}}
126
+
{{range $i, $value := .Values}}
127
+
{{if $i}}<br />{{end}}
128
+
<code>{{$value}}</code>
129
+
{{end}}
130
+
{{else}}
131
+
<span class="header-empty">—</span>
132
+
{{end}}
133
+
</div>
134
+
</div>
135
+
{{end}}
136
+
</div>
137
+
</section>
138
+
{{end}}
139
+
140
+
{{if .VisitCount}}
141
+
<p class="counter">You've viewed this page <strong>{{.VisitCount}}</strong> times.</p>
142
+
{{end}}
143
+
</main>
144
+
145
+
<div class="copied-feedback" id="copiedFeedback">Copied to clipboard!</div>
146
+
147
+
<script src="/static/script.js"></script>
148
+
</body>
149
+
</html>