[very crude, wip] post to bsky without the distraction of feeds
1package srv
2
3import (
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.
20func (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
100type appPasswordSession struct {
101 DID string `json:"did"`
102 Handle string `json:"handle"`
103 AccessJwt string `json:"accessJwt"`
104 RefreshJwt string `json:"refreshJwt"`
105}
106
107func 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}