···16ATPROTO_CLIENT_ID=
17ATPROTO_METADATA_URL=
18ATPROTO_CALLBACK_URL=
19+ATPROTO_CLIENT_SECRET_KEY={goat key generate -t P-256}
20+ATPROTO_CLIENT_SECRET_KEY_ID={can be whatever usually a timestamp}
2122# Last.fm
23LASTFM_API_KEY=
-1
Dockerfile
···29#Overwrite the main.css with the one from the builder
30COPY --from=node_builder /app/static/main.css /app/pages/static/main.css
31 #generate the jwks
32-RUN go run github.com/haileyok/atproto-oauth-golang/cmd/helper generate-jwks
33RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags='-w -s -extldflags "-static"' -o main ./cmd
34ARG TARGETOS=${TARGETPLATFORM%%/*}
35ARG TARGETARCH=${TARGETPLATFORM##*/}
···29#Overwrite the main.css with the one from the builder
30COPY --from=node_builder /app/static/main.css /app/pages/static/main.css
31 #generate the jwks
032RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags='-w -s -extldflags "-static"' -o main ./cmd
33ARG TARGETOS=${TARGETPLATFORM%%/*}
34ARG TARGETARCH=${TARGETPLATFORM##*/}
-4
Makefile
···25 --build-file ./lexcfg.json \
26 ../atproto/lexicons \
27 ./lexicons/teal
28-29-.PHONY: jwtgen
30-jwtgen:
31- go run github.com/haileyok/atproto-oauth-golang/cmd/helper generate-jwks
···2324This is a break down of what each env variable is and what it may look like
250000000026- `SERVER_PORT` - The port piper is hosted on
27- `SERVER_HOST` - The server host. `localhost` is fine here, or `0.0.0.0` for docker
28- `SERVER_ROOT_URL` - This needs to be the pubically accessible url created in [Setup](#setup). Like `https://piper.teal.fm`
···53run some make scripts:
5455```
56-make jwtgen
5758make dev-setup
59```
···2324This is a break down of what each env variable is and what it may look like
2526+**_breaking piper/v0.0.2 changes env_**
27+28+You now have to bring your own private key to run piper. Can do this via goat `goat key generate -t P-256`. You want the one that is labeled under "Secret Key (Multibase Syntax): save this securely (eg, add to password manager)"
29+30+- `ATPROTO_CLIENT_SECRET_KEY` - Private key for oauth confidential client. This can be generated via goat `goat key generate -t P-256`
31+- `ATPROTO_CLIENT_SECRET_KEY_ID` - Key ID for oauth confidential client. This needs to be persistent and unique, can use a timestamp. Here's one for you: `1758199756`
32+33+34- `SERVER_PORT` - The port piper is hosted on
35- `SERVER_HOST` - The server host. `localhost` is fine here, or `0.0.0.0` for docker
36- `SERVER_ROOT_URL` - This needs to be the pubically accessible url created in [Setup](#setup). Like `https://piper.teal.fm`
···61run some make scripts:
6263```
06465make dev-setup
66```
···18 LastFMUsername *string
1920 // atp info
21+ ATProtoDID *string
22+ //This is meant to only be used by the automated music stamping service. If the user ever does an
23+ //atproto action from the web ui use the atproto session id for the logged-in session
24+ MostRecentAtProtoSessionID *string
25+ //ATProtoAccessToken *string
26+ //ATProtoRefreshToken *string
27+ //ATProtoTokenExpiry *time.Time
2829 CreatedAt time.Time
30 UpdatedAt time.Time
+104-134
oauth/atproto/atproto.go
···3import (
4 "context"
5 "fmt"
00000000006 "log"
7 "net/http"
8 "net/url"
9-10- oauth "github.com/haileyok/atproto-oauth-golang"
11- "github.com/haileyok/atproto-oauth-golang/helpers"
12- "github.com/lestrrat-go/jwx/v2/jwk"
13- "github.com/teal-fm/piper/db"
14- "github.com/teal-fm/piper/models"
15)
1617type ATprotoAuthService struct {
18- client *oauth.Client
19- jwks jwk.Key
20- DB *db.DB
21- clientId string
22- callbackUrl string
23- xrpc *oauth.XrpcClient
24}
2526-func NewATprotoAuthService(db *db.DB, jwks jwk.Key, clientId string, callbackUrl string) (*ATprotoAuthService, error) {
27 fmt.Println(clientId, callbackUrl)
28- cli, err := oauth.NewClient(oauth.ClientArgs{
29- ClientJwk: jwks,
30- ClientId: clientId,
31- RedirectUri: callbackUrl,
32- })
0033 if err != nil {
34- return nil, fmt.Errorf("failed to create atproto oauth client: %w", err)
35 }
0000000036 svc := &ATprotoAuthService{
37- client: cli,
38- jwks: jwks,
39- callbackUrl: callbackUrl,
40- DB: db,
41- clientId: clientId,
042 }
43- svc.NewXrpcClient()
44 return svc, nil
45}
4647-func (a *ATprotoAuthService) GetATProtoClient() (*oauth.Client, error) {
48- if a.client != nil {
49- return a.client, nil
050 }
5152- if a.client == nil {
53- cli, err := oauth.NewClient(oauth.ClientArgs{
54- ClientJwk: a.jwks,
55- ClientId: a.clientId,
56- RedirectUri: a.callbackUrl,
57- })
58- if err != nil {
59- return nil, fmt.Errorf("failed to create atproto oauth client: %w", err)
60- }
61- a.client = cli
62 }
6364- return a.client, nil
65-}
6667-func LoadJwks(jwksBytes []byte) (jwk.Key, error) {
68- key, err := helpers.ParseJWKFromBytes(jwksBytes)
69- if err != nil {
70- return nil, fmt.Errorf("failed to parse JWK from bytes: %w", err)
71- }
72- return key, nil
73}
7475func (a *ATprotoAuthService) HandleLogin(w http.ResponseWriter, r *http.Request) {
76 handle := r.URL.Query().Get("handle")
77 if handle == "" {
78- log.Printf("ATProto Login Error: handle is required")
79 http.Error(w, "handle query parameter is required", http.StatusBadRequest)
80 return
81 }
82-83- authUrl, err := a.getLoginUrlAndSaveState(r.Context(), handle)
000084 if err != nil {
85- log.Printf("ATProto Login Error: Failed to get login URL for handle %s: %v", handle, err)
86 http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError)
87- return
88 }
8990- log.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String())
91 http.Redirect(w, r, authUrl.String(), http.StatusFound)
92}
9394-func (a *ATprotoAuthService) getLoginUrlAndSaveState(ctx context.Context, handle string) (*url.URL, error) {
95- scope := "atproto transition:generic"
96- // resolve
97- ui, err := a.getUserInformation(ctx, handle)
98- if err != nil {
99- return nil, fmt.Errorf("failed to get user information for %s: %w", handle, err)
100- }
101-102- fmt.Println("user info: ", ui.AuthServer, ui.AuthService)
103-104- // create a dpop jwk for this session
105- k, err := helpers.GenerateKey(nil) // Generate ephemeral DPoP key for this flow
106- if err != nil {
107- return nil, fmt.Errorf("failed to generate DPoP key: %w", err)
108- }
109110- // Send PAR auth req
111- parResp, err := a.client.SendParAuthRequest(ctx, ui.AuthServer, ui.AuthMeta, ui.Handle, scope, k)
112- if err != nil {
113- return nil, fmt.Errorf("failed PAR request to %s: %w", ui.AuthServer, err)
114- }
0115116- // Save state
117- data := &models.ATprotoAuthData{
118- State: parResp.State,
119- DID: ui.DID,
120- PDSUrl: ui.AuthService,
121- AuthServerIssuer: ui.AuthMeta.Issuer,
122- PKCEVerifier: parResp.PkceVerifier,
123- DPoPAuthServerNonce: parResp.DpopAuthserverNonce,
124- DPoPPrivateJWK: k,
125- }
126127- // print data
128- fmt.Println(data)
000129130- err = a.DB.SaveATprotoAuthData(data)
131- if err != nil {
132- return nil, fmt.Errorf("failed to save ATProto auth data for state %s: %w", parResp.State, err)
000133 }
134135- // Construct authorization URL using the request_uri from PAR response
136- authEndpointURL, err := url.Parse(ui.AuthMeta.AuthorizationEndpoint)
137- if err != nil {
138- return nil, fmt.Errorf("invalid authorization endpoint URL %s: %w", ui.AuthMeta.AuthorizationEndpoint, err)
139- }
140- q := authEndpointURL.Query()
141- q.Set("client_id", a.clientId)
142- q.Set("request_uri", parResp.RequestUri)
143- q.Set("state", parResp.State)
144- authEndpointURL.RawQuery = q.Encode()
145146- return authEndpointURL, nil
147}
148149func (a *ATprotoAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) {
150- state := r.URL.Query().Get("state")
151- code := r.URL.Query().Get("code")
152- issuer := r.URL.Query().Get("iss") // Issuer (auth base URL) is needed for token request
153-154- if state == "" || code == "" || issuer == "" {
155- errMsg := r.URL.Query().Get("error")
156- errDesc := r.URL.Query().Get("error_description")
157- log.Printf("ATProto Callback Error: Missing parameters. State: '%s', Code: '%s', Issuer: '%s'. Error: '%s', Description: '%s'", state, code, issuer, errMsg, errDesc)
158- http.Error(w, fmt.Sprintf("Authorization callback failed: %s (%s). Missing state, code, or issuer.", errMsg, errDesc), http.StatusBadRequest)
159- return 0, fmt.Errorf("missing state, code, or issuer")
160- }
161162- // Retrieve saved data using state
163- data, err := a.DB.GetATprotoAuthData(state)
164 if err != nil {
165- log.Printf("ATProto Callback Error: Failed to retrieve auth data for state '%s': %v", state, err)
166- http.Error(w, "Invalid or expired state.", http.StatusBadRequest)
167- return 0, fmt.Errorf("invalid or expired state")
168 }
169170- // Clean up the temporary auth data now that we've retrieved it
171- // defer a.DB.DeleteATprotoAuthData(state) // Consider adding deletion logic
172- // if issuers don't match, return an error
173- if data.AuthServerIssuer != issuer {
174- log.Printf("ATProto Callback Error: Issuer mismatch for state '%s', expected '%s', got '%s'", state, data.AuthServerIssuer, issuer)
175- http.Error(w, "Invalid or expired state.", http.StatusBadRequest)
176- return 0, fmt.Errorf("issuer mismatch")
177 }
178179- resp, err := a.client.InitialTokenRequest(r.Context(), code, issuer, data.PKCEVerifier, data.DPoPAuthServerNonce, data.DPoPPrivateJWK)
180 if err != nil {
181- log.Printf("ATProto Callback Error: Failed initial token request for state '%s', issuer '%s': %v", state, issuer, err)
182- http.Error(w, fmt.Sprintf("Error exchanging code for token: %v", err), http.StatusInternalServerError)
183- return 0, fmt.Errorf("failed initial token request")
184- }
185-186- userID, err := a.DB.FindOrCreateUserByDID(data.DID)
187- if err != nil {
188- log.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", data.DID, err)
189 http.Error(w, "Failed to process user information.", http.StatusInternalServerError)
190 return 0, fmt.Errorf("failed to find or create user")
191 }
192193- err = a.DB.SaveATprotoSession(resp, data.AuthServerIssuer, data.DPoPPrivateJWK, data.PDSUrl)
00000194 if err != nil {
195- log.Printf("ATProto Callback Error: Failed to save ATProto tokens for user %d (DID %s): %v", userID.ID, data.DID, err)
196 }
197198- log.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", userID.ID, data.DID)
199- return userID.ID, nil
200}
···3import (
4 "context"
5 "fmt"
6+7+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
8+ _ "github.com/bluesky-social/indigo/atproto/auth/oauth"
9+ "github.com/bluesky-social/indigo/atproto/client"
10+ "github.com/bluesky-social/indigo/atproto/crypto"
11+ "github.com/bluesky-social/indigo/atproto/syntax"
12+ "github.com/teal-fm/piper/db"
13+14+ "github.com/teal-fm/piper/session"
15+16 "log"
17 "net/http"
18 "net/url"
19+ "os"
20+ "slices"
000021)
2223type ATprotoAuthService struct {
24+ clientApp *oauth.ClientApp
25+ DB *db.DB
26+ sessionManager *session.SessionManager
27+ clientId string
28+ callbackUrl string
29+ logger *log.Logger
30}
3132+func NewATprotoAuthService(database *db.DB, sessionManager *session.SessionManager, clientSecretKey string, clientId string, callbackUrl string, clientSecretId string) (*ATprotoAuthService, error) {
33 fmt.Println(clientId, callbackUrl)
34+35+ scopes := []string{"atproto", "repo:fm.teal.alpha.feed.play", "repo:fm.teal.alpha.actor.status"}
36+37+ var config oauth.ClientConfig
38+ config = oauth.NewPublicConfig(clientId, callbackUrl, scopes)
39+40+ priv, err := crypto.ParsePrivateMultibase(clientSecretKey)
41 if err != nil {
42+ return nil, err
43 }
44+ if err := config.SetClientSecret(priv, clientSecretId); err != nil {
45+ return nil, err
46+ }
47+48+ oauthClient := oauth.NewClientApp(&config, db.NewSqliteATProtoStore(database.DB))
49+50+ logger := log.New(os.Stdout, "ATProto oauth: ", log.LstdFlags|log.Lmsgprefix)
51+52 svc := &ATprotoAuthService{
53+ clientApp: oauthClient,
54+ callbackUrl: callbackUrl,
55+ DB: database,
56+ sessionManager: sessionManager,
57+ clientId: clientId,
58+ logger: logger,
59 }
060 return svc, nil
61}
6263+func (a *ATprotoAuthService) GetATProtoClient(accountDID string, sessionID string, ctx context.Context) (*client.APIClient, error) {
64+ did, err := syntax.ParseDID(accountDID)
65+ if err != nil {
66+ return nil, err
67 }
6869+ oauthSess, err := a.clientApp.ResumeSession(ctx, did, sessionID)
70+ if err != nil {
71+ return nil, err
000000072 }
7374+ return oauthSess.APIClient(), nil
07500000076}
7778func (a *ATprotoAuthService) HandleLogin(w http.ResponseWriter, r *http.Request) {
79 handle := r.URL.Query().Get("handle")
80 if handle == "" {
81+ a.logger.Printf("ATProto Login Error: handle is required")
82 http.Error(w, "handle query parameter is required", http.StatusBadRequest)
83 return
84 }
85+ ctx := r.Context()
86+ redirectURL, err := a.clientApp.StartAuthFlow(ctx, handle)
87+ if err != nil {
88+ http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError)
89+ }
90+ authUrl, err := url.Parse(redirectURL)
91 if err != nil {
092 http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError)
093 }
9495+ a.logger.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String())
96 http.Redirect(w, r, authUrl.String(), http.StatusFound)
97}
9899+func (a *ATprotoAuthService) HandleLogout(w http.ResponseWriter, r *http.Request) {
100+ cookie, err := r.Cookie("session")
0000000000000101102+ if err == nil {
103+ session, exists := a.sessionManager.GetSession(cookie.Value)
104+ if !exists {
105+ http.Redirect(w, r, "/", http.StatusSeeOther)
106+ return
107+ }
108109+ dbUser, err := a.DB.GetUserByID(session.UserID)
110+ if err != nil {
111+ http.Redirect(w, r, "/", http.StatusSeeOther)
112+ return
113+ }
114+ did, err := syntax.ParseDID(*dbUser.ATProtoDID)
0000115116+ if err != nil {
117+ a.logger.Printf("Should not happen: %s", err)
118+ a.sessionManager.ClearSessionCookie(w)
119+ http.Redirect(w, r, "/", http.StatusSeeOther)
120+ }
121122+ ctx := r.Context()
123+ err = a.clientApp.Logout(ctx, did, session.ATProtoSessionID)
124+ if err != nil {
125+ a.logger.Printf("Error logging the user: %s out: %s", did, err)
126+ }
127+ a.sessionManager.DeleteSession(cookie.Value)
128 }
129130+ a.sessionManager.ClearSessionCookie(w)
000000000131132+ http.Redirect(w, r, "/", http.StatusSeeOther)
133}
134135func (a *ATprotoAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) {
136+ ctx := r.Context()
0000000000137138+ sessData, err := a.clientApp.ProcessCallback(ctx, r.URL.Query())
0139 if err != nil {
140+ errMsg := fmt.Errorf("processing OAuth callback: %w", err)
141+ http.Error(w, errMsg.Error(), http.StatusBadRequest)
142+ return 0, errMsg
143 }
144145+ // It's in the example repo and leaving for some debugging cause i've seen different scopes cause issues before
146+ // so may be some nice debugging info to have
147+ if !slices.Equal(sessData.Scopes, a.clientApp.Config.Scopes) {
148+ a.logger.Printf("session auth scopes did not match those requested")
000149 }
150151+ user, err := a.DB.FindOrCreateUserByDID(sessData.AccountDID.String())
152 if err != nil {
153+ a.logger.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", sessData.AccountDID.String(), err)
0000000154 http.Error(w, "Failed to process user information.", http.StatusInternalServerError)
155 return 0, fmt.Errorf("failed to find or create user")
156 }
157158+ //This is piper's session for manging piper, not atproto sessions
159+ createdSession := a.sessionManager.CreateSession(user.ID, sessData.SessionID)
160+ a.sessionManager.SetSessionCookie(w, createdSession)
161+ a.logger.Printf("Created session for user %d via service atproto", user.ATProtoDID)
162+163+ err = a.DB.SetLatestATProtoSessionId(sessData.AccountDID.String(), sessData.SessionID)
164 if err != nil {
165+ a.logger.Printf("Failed to set latest atproto session id for user %d: %v", user.ID, err)
166 }
167168+ a.logger.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", user.ID, user.ATProtoDID)
169+ return user.ID, nil
170}
···86 http.Redirect(w, r, authURL, http.StatusSeeOther)
87}
880000089func (o *OAuth2Service) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) {
90 state := r.URL.Query().Get("state")
91 if state != o.state {
···86 http.Redirect(w, r, authURL, http.StatusSeeOther)
87}
8889+func (o *OAuth2Service) HandleLogout(w http.ResponseWriter, r *http.Request) {
90+ //TODO not implemented yet. not sure what the api call is for this package
91+ http.Redirect(w, r, "/", http.StatusSeeOther)
92+}
93+94func (o *OAuth2Service) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) {
95 state := r.URL.Query().Get("state")
96 if state != o.state {
···10 // handles the callback for the provider. is responsible for inserting
11 // sessions in the db
12 HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error)
0013}
1415// optional but recommended
···10 // handles the callback for the provider. is responsible for inserting
11 // sessions in the db
12 HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error)
13+14+ HandleLogout(w http.ResponseWriter, r *http.Request)
15}
1617// optional but recommended
···8 "strconv"
9 "time"
1011- "github.com/bluesky-social/indigo/api/atproto"
12 lexutil "github.com/bluesky-social/indigo/lex/util"
13- "github.com/bluesky-social/indigo/xrpc"
14- oauth "github.com/haileyok/atproto-oauth-golang"
15 "github.com/spf13/viper"
0016 "github.com/teal-fm/piper/api/teal"
17 "github.com/teal-fm/piper/db"
18 "github.com/teal-fm/piper/models"
···5253 did := *user.ATProtoDID
5455- // Get ATProto client
56- client, err := p.atprotoService.GetATProtoClient()
57- if err != nil || client == nil {
58- return fmt.Errorf("failed to get ATProto client: %w", err)
59- }
60-61- xrpcClient := p.atprotoService.GetXrpcClient()
62- if xrpcClient == nil {
63- return fmt.Errorf("xrpc client is not available")
64- }
65-66- // Get user session
67- sess, err := p.db.GetAtprotoSession(did, ctx, *client)
68- if err != nil {
69- return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err)
70 }
7172 // Convert track to PlayView format
···86 Item: playView,
87 }
8889- authArgs := db.AtpSessionToAuthArgs(sess)
90 var swapRecord *string
91- swapRecord, err = p.getStatusSwapRecord(ctx, xrpcClient, sess, authArgs)
92 if err != nil {
93 return err
94 }
9596 // Create the record input
97- input := atproto.RepoPutRecord_Input{
98 Collection: "fm.teal.alpha.actor.status",
99- Repo: sess.DID,
100 Rkey: "self", // Use "self" as the record key for current status
101 Record: &lexutil.LexiconTypeDecoder{Val: status},
102 SwapRecord: swapRecord,
103 }
104105 // Submit to PDS
106- var out atproto.RepoPutRecord_Output
107- if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
108 p.logger.Printf("Error creating playing now status for DID %s: %v", did, err)
109 return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err)
110 }
···131 did := *user.ATProtoDID
132133 // Get ATProto clients
134- client, err := p.atprotoService.GetATProtoClient()
135- if err != nil || client == nil {
136- return fmt.Errorf("failed to get ATProto client: %w", err)
137- }
138-139- xrpcClient := p.atprotoService.GetXrpcClient()
140- if xrpcClient == nil {
141- return fmt.Errorf("xrpc client is not available")
142- }
143-144- // Get user session
145- sess, err := p.db.GetAtprotoSession(did, ctx, *client)
146- if err != nil {
147- return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err)
148 }
149150 // Create an expired status (essentially clearing it)
···164 Item: emptyPlayView,
165 }
166167- authArgs := db.AtpSessionToAuthArgs(sess)
168 var swapRecord *string
169- swapRecord, err = p.getStatusSwapRecord(ctx, xrpcClient, sess, authArgs)
170 if err != nil {
171 return err
172 }
173174 // Update the record
175- input := atproto.RepoPutRecord_Input{
176 Collection: "fm.teal.alpha.actor.status",
177- Repo: sess.DID,
178 Rkey: "self",
179 Record: &lexutil.LexiconTypeDecoder{Val: status},
180 SwapRecord: swapRecord,
181 }
182183- var out atproto.RepoPutRecord_Output
184- if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
185 p.logger.Printf("Error clearing playing now status for DID %s: %v", did, err)
186 return fmt.Errorf("failed to clear playing now status for DID %s: %w", did, err)
187 }
···242 // Get submission client agent
243 submissionAgent := viper.GetString("app.submission_agent")
244 if submissionAgent == "" {
245- submissionAgent = "piper/v0.0.1"
246 }
247248 playView := &teal.AlphaFeedDefs_PlayView{
···264265// getStatusSwapRecord retrieves the current swap record (CID) for the actor status record.
266// Returns (nil, nil) if the record does not exist yet.
267-func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, xrpcClient *oauth.XrpcClient, sess *models.ATprotoAuthSession, authArgs *oauth.XrpcAuthedRequestArgs) (*string, error) {
268- getOutput := atproto.RepoGetRecord_Output{}
269- if err := xrpcClient.Do(ctx, authArgs, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{
270- "repo": sess.DID,
271- "collection": "fm.teal.alpha.actor.status",
272- "rkey": "self",
273- }, nil, &getOutput); err != nil {
274- xErr, ok := err.(*xrpc.Error)
275 if !ok {
276- return nil, fmt.Errorf("could not get record: %w", err)
277 }
278- if xErr.StatusCode != 400 { // 400 means not found in this API
279- return nil, fmt.Errorf("could not get record: %w", err)
280 }
281- return nil, nil
00282 }
283- return getOutput.Cid, nil
284}
···8 "strconv"
9 "time"
1011+ "github.com/bluesky-social/indigo/atproto/client"
12 lexutil "github.com/bluesky-social/indigo/lex/util"
0013 "github.com/spf13/viper"
14+15+ comatproto "github.com/bluesky-social/indigo/api/atproto"
16 "github.com/teal-fm/piper/api/teal"
17 "github.com/teal-fm/piper/db"
18 "github.com/teal-fm/piper/models"
···5253 did := *user.ATProtoDID
5455+ // Get ATProto atProtoClient
56+ atProtoClient, err := p.atprotoService.GetATProtoClient(did, *user.MostRecentAtProtoSessionID, ctx)
57+ if err != nil || atProtoClient == nil {
58+ return fmt.Errorf("failed to get ATProto atProtoClient: %w", err)
0000000000059 }
6061 // Convert track to PlayView format
···75 Item: playView,
76 }
77078 var swapRecord *string
79+ swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient)
80 if err != nil {
81 return err
82 }
8384 // Create the record input
85+ input := comatproto.RepoPutRecord_Input{
86 Collection: "fm.teal.alpha.actor.status",
87+ Repo: atProtoClient.AccountDID.String(),
88 Rkey: "self", // Use "self" as the record key for current status
89 Record: &lexutil.LexiconTypeDecoder{Val: status},
90 SwapRecord: swapRecord,
91 }
9293 // Submit to PDS
94+ if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil {
095 p.logger.Printf("Error creating playing now status for DID %s: %v", did, err)
96 return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err)
97 }
···118 did := *user.ATProtoDID
119120 // Get ATProto clients
121+ atProtoClient, err := p.atprotoService.GetATProtoClient(did, *user.MostRecentAtProtoSessionID, ctx)
122+ if err != nil || atProtoClient == nil {
123+ return fmt.Errorf("failed to get ATProto atProtoClient: %w", err)
00000000000124 }
125126 // Create an expired status (essentially clearing it)
···140 Item: emptyPlayView,
141 }
1420143 var swapRecord *string
144+ swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient)
145 if err != nil {
146 return err
147 }
148149 // Update the record
150+ input := comatproto.RepoPutRecord_Input{
151 Collection: "fm.teal.alpha.actor.status",
152+ Repo: atProtoClient.AccountDID.String(),
153 Rkey: "self",
154 Record: &lexutil.LexiconTypeDecoder{Val: status},
155 SwapRecord: swapRecord,
156 }
157158+ if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil {
0159 p.logger.Printf("Error clearing playing now status for DID %s: %v", did, err)
160 return fmt.Errorf("failed to clear playing now status for DID %s: %w", did, err)
161 }
···216 // Get submission client agent
217 submissionAgent := viper.GetString("app.submission_agent")
218 if submissionAgent == "" {
219+ submissionAgent = "piper/v0.0.2"
220 }
221222 playView := &teal.AlphaFeedDefs_PlayView{
···238239// getStatusSwapRecord retrieves the current swap record (CID) for the actor status record.
240// Returns (nil, nil) if the record does not exist yet.
241+func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, atApiClient *client.APIClient) (*string, error) {
242+ result, err := comatproto.RepoGetRecord(ctx, atApiClient, "", "fm.teal.alpha.actor.status", atApiClient.AccountDID.String(), "self")
243+244+ if err != nil {
245+ xErr, ok := err.(*client.APIError)
000246 if !ok {
247+ return nil, fmt.Errorf("error getting the record: %w", err)
248 }
249+ if xErr.StatusCode == 400 { // 400 means not found in this API, which would be the case if the record does not exist yet
250+ return nil, nil
251 }
252+253+ return nil, fmt.Errorf("error getting the record: %w", err)
254+255 }
256+ return result.Cid, nil
257}
+1-1
service/spotify/spotify.go
···657658 s.logger.Printf("User %d (%d): Attempting to submit track '%s' by %s to PDS (DID: %s)", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, artistName, *dbUser.ATProtoDID)
659 // Use context.Background() for now, or pass down a context if available
660- if errPDS := s.SubmitTrackToPDS(*dbUser.ATProtoDID, trackToSubmitToPDS, context.Background()); errPDS != nil {
661 s.logger.Printf("User %d (%d): Error submitting track '%s' to PDS: %v", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, errPDS)
662 } else {
663 s.logger.Printf("User %d (%d): Successfully submitted track '%s' to PDS.", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name)
···657658 s.logger.Printf("User %d (%d): Attempting to submit track '%s' by %s to PDS (DID: %s)", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, artistName, *dbUser.ATProtoDID)
659 // Use context.Background() for now, or pass down a context if available
660+ if errPDS := s.SubmitTrackToPDS(*dbUser.ATProtoDID, *dbUser.MostRecentAtProtoSessionID, trackToSubmitToPDS, context.Background()); errPDS != nil {
661 s.logger.Printf("User %d (%d): Error submitting track '%s' to PDS: %v", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, errPDS)
662 } else {
663 s.logger.Printf("User %d (%d): Successfully submitted track '%s' to PDS.", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name)
+22-29
session/session.go
···1718// session/session.go
19type Session struct {
20- ID string
21- UserID int64
22- ATprotoDID string
23- ATprotoAccessToken string
24- ATprotoRefreshToken string
25- CreatedAt time.Time
26- ExpiresAt time.Time
0027}
2829type SessionManager struct {
···38 _, err := database.Exec(`
39 CREATE TABLE IF NOT EXISTS sessions (
40 id TEXT PRIMARY KEY,
41- user_id INTEGER NOT NULL,
042 created_at TIMESTAMP,
43 expires_at TIMESTAMP,
44 FOREIGN KEY (user_id) REFERENCES users(id)
···58}
5960// create a new session for a user
61-func (sm *SessionManager) CreateSession(userID int64) *Session {
62 sm.mu.Lock()
63 defer sm.mu.Unlock()
64···71 expiresAt := now.Add(24 * time.Hour) // 24-hour session
7273 session := &Session{
74- ID: sessionID,
75- UserID: userID,
76- CreatedAt: now,
77- ExpiresAt: expiresAt,
078 }
7980 // store session in memory
···83 // store session in database if available
84 if sm.db != nil {
85 _, err := sm.db.Exec(`
86- INSERT INTO sessions (id, user_id, created_at, expires_at)
87- VALUES (?, ?, ?, ?)`,
88- sessionID, userID, now, expiresAt)
8990 if err != nil {
91 log.Printf("Error storing session in database: %v", err)
···116 session = &Session{ID: sessionID}
117118 err := sm.db.QueryRow(`
119- SELECT user_id, created_at, expires_at
120 FROM sessions WHERE id = ?`, sessionID).Scan(
121- &session.UserID, &session.CreatedAt, &session.ExpiresAt)
122123 if err != nil {
124 return nil, false
···178 MaxAge: -1,
179 }
180 http.SetCookie(w, cookie)
181-}
182-183-func (sm *SessionManager) HandleLogout(w http.ResponseWriter, r *http.Request) {
184- cookie, err := r.Cookie("session")
185- if err == nil {
186- sm.DeleteSession(cookie.Value)
187- }
188-189- sm.ClearSessionCookie(w)
190-191- http.Redirect(w, r, "/", http.StatusSeeOther)
192}
193194func (sm *SessionManager) GetAPIKeyManager() *apikey.ApiKeyManager {
···1718// session/session.go
19type Session struct {
20+21+ //need to re work this. May add onto it for atproto oauth. But need to be careful about that expiresd
22+ //Maybe a speerate oauth session store table and it has a created date? yeah do that then can look it up by session id from this table for user actions
23+24+ ID string
25+ UserID int64
26+ ATProtoSessionID string
27+ CreatedAt time.Time
28+ ExpiresAt time.Time
29}
3031type SessionManager struct {
···40 _, err := database.Exec(`
41 CREATE TABLE IF NOT EXISTS sessions (
42 id TEXT PRIMARY KEY,
43+ user_id INTEGER NOT NULL,
44+ at_proto_session_id TEXT NOT NULL,
45 created_at TIMESTAMP,
46 expires_at TIMESTAMP,
47 FOREIGN KEY (user_id) REFERENCES users(id)
···61}
6263// create a new session for a user
64+func (sm *SessionManager) CreateSession(userID int64, atProtoSessionId string) *Session {
65 sm.mu.Lock()
66 defer sm.mu.Unlock()
67···74 expiresAt := now.Add(24 * time.Hour) // 24-hour session
7576 session := &Session{
77+ ID: sessionID,
78+ UserID: userID,
79+ ATProtoSessionID: atProtoSessionId,
80+ CreatedAt: now,
81+ ExpiresAt: expiresAt,
82 }
8384 // store session in memory
···87 // store session in database if available
88 if sm.db != nil {
89 _, err := sm.db.Exec(`
90+ INSERT INTO sessions (id, user_id, at_proto_session_id, created_at, expires_at)
91+ VALUES (?, ?, ?, ?, ?)`,
92+ sessionID, userID, atProtoSessionId, now, expiresAt)
9394 if err != nil {
95 log.Printf("Error storing session in database: %v", err)
···120 session = &Session{ID: sessionID}
121122 err := sm.db.QueryRow(`
123+ SELECT user_id, at_proto_session_id, created_at, expires_at
124 FROM sessions WHERE id = ?`, sessionID).Scan(
125+ &session.UserID, &session.ATProtoSessionID, &session.CreatedAt, &session.ExpiresAt)
126127 if err != nil {
128 return nil, false
···182 MaxAge: -1,
183 }
184 http.SetCookie(w, cookie)
00000000000185}
186187func (sm *SessionManager) GetAPIKeyManager() *apikey.ApiKeyManager {