[very crude, wip] post to bsky without the distraction of feeds

init commit: working prototype

+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
···
··· 1 + # Agent Instructions 2 + 3 + This is a Go web application template for exe.dev. 4 + 5 + See README.md for details on the structure and components.
+10
Makefile
···
··· 1 + .PHONY: build clean stop start restart test 2 + 3 + build: 4 + go build -o bsky-poster ./cmd/srv 5 + 6 + clean: 7 + rm -f bsky-poster 8 + 9 + test: 10 + go test ./...
+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
···
··· 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

This is a binary file and will not be displayed.

db.sqlite3-wal

This is a binary file and will not be displayed.

+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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">&times;</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
···
··· 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
···
··· 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
···
··· 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>