backend for xcvr appview

add profile updates

+256 -22
+6 -2
lexicons/org/xcvr/actor/defs.json
··· 32 32 "avatar": { 33 33 "type": "string", 34 34 "format": "uri" 35 - } 35 + }, 36 + "defaultNick": { 37 + "type": "string", 38 + "maxLength": 16 39 + } 36 40 } 37 41 } 38 42 } 39 - } 43 + }
+10 -8
lexicons/org/xcvr/actor/getChannel.json lexicons/org/xcvr/actor/getProfile.json
··· 1 + 1 2 { 2 3 "lexicon": 1, 3 - "id": "org.xcvr.actor.resolveChannel", 4 + "id": "org.xcvr.actor.getProfileView", 4 5 "defs": { 5 6 "main": { 6 7 "type": "query", 7 - "description": "get the url of a channel", 8 + "description": "gets a user's profileView", 8 9 "parameters": { 9 10 "type": "params" 10 11 "union": [ 11 12 { 12 13 "type": "object", 13 - "required": ["handle", "rkey"], 14 + "required": ["handle"], 14 15 "properties": { 15 16 "handle": {"type": "string"}, 16 - "rkey": {"type": "string"} 17 17 } 18 18 }, 19 19 { 20 20 "type": "object", 21 - "required": ["did", "rkey"], 21 + "required": ["did"], 22 22 "properties": { 23 23 "did": {"type": "string"}, 24 - "rkey": {"type": "string"} 25 24 } 26 25 } 27 26 ] ··· 30 29 "encoding": "application/json", 31 30 "schema": { 32 31 "type": "object", 33 - "required": ["url"], 32 + "required": ["profile"], 34 33 "properties": { 35 - "url": {"type": "string"} 34 + "profile": { 35 + "type": "ref", 36 + "ref": "org.xcvr.actor.defs#profileView" 37 + } 36 38 } 37 39 } 38 40 }
+1
migrations/001_init.down.sql
··· 6 6 7 7 DROP TABLE channels; 8 8 DROP TABLE did_handles; 9 + DROP TABLE profile_records; 9 10 DROP TABLE profiles; 10 11 11 12 DROP TABLE oauthrequests;
+8 -2
migrations/001_init.up.sql
··· 6 6 avatar_cid TEXT, 7 7 avatar_mime TEXT, 8 8 color INTEGER CHECK (color BETWEEN 0 and 16777215), 9 - uri TEXT NOT NULL UNIQUE, 10 - cid TEXT NOT NULL, 11 9 indexed_at TIMESTAMPTZ NOT NULL DEFAULT now() 12 10 ); 11 + 12 + CREATE TABLE profile_records ( 13 + uri TEXT NOT NULL PRIMARY KEY, 14 + profile_did TEXT NOT NULL, 15 + FOREIGN KEY (profile_did) REFERENCES profiles(did) ON DELETE CASCADE, 16 + cid TEXT NOT NULL, 17 + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now() 18 + ) 13 19 14 20 CREATE TABLE did_handles ( 15 21 handle TEXT PRIMARY KEY,
+2 -1
migrations/002_populate.down.sql
··· 2 2 DELETE FROM signets; 3 3 DELETE FROM channels; 4 4 DELETE FROM did_handles; 5 - DELETE FROM profiles 5 + DELETE FROM profile_records; 6 + DELETE FROM profiles;
+8 -3
migrations/002_populate.up.sql
··· 1 - INSERT INTO profiles (did, display_name, default_nick, status, avatar_cid, avatar_mime, color, uri, cid) 1 + INSERT INTO profiles (did, display_name, default_nick, status, avatar_cid, avatar_mime, color) 2 + VALUES 3 + ('did:example:alice', 'Alice Example', 'alice', 'Chilling', 'bafybeib6...', 'image/png', 16711680), 4 + ('did:example:bob', 'Bob Example', 'bobby', 'Working hard', 'bafybeib7...', 'image/jpeg', 65280); 5 + 6 + INSERT INTO profile_records(uri, profile_did, cid) 2 7 VALUES 3 - ('did:example:alice', 'Alice Example', 'alice', 'Chilling', 'bafybeib6...', 'image/png', 16711680, 'at://did:example:alice/app.bsky.actor.profile/self', 'cid1'), 4 - ('did:example:bob', 'Bob Example', 'bobby', 'Working hard', 'bafybeib7...', 'image/jpeg', 65280, 'at://did:example:bob/app.bsky.actor.profile/self', 'cid2'); 8 + ('at://did:example:alice/app.bsky.actor.profile/self', 'did:example:alice', 'cid1'), 9 + ('at://did:example:bob/app.bsky.actor.profile/self','did:example:bob', 'cid2'); 5 10 6 11 INSERT INTO did_handles (handle, did) 7 12 VALUES
+117
server/internal/db/lexicon.go
··· 1 + package db 2 + 3 + import ( 4 + "strings" 5 + "context" 6 + "fmt" 7 + "xcvr-backend/internal/types" 8 + "errors" 9 + ) 10 + 11 + func (s *Store) InitializeProfile(did string, handle string, ctx context.Context) error { 12 + _, err := s.pool.Exec(ctx, ` 13 + INSERT INTO profiles ( 14 + did, 15 + display_name, 16 + default_nick, 17 + status, 18 + color, 19 + ) VALUES ( 20 + $1, $2, $3, $4, $5 21 + ) ON CONFLICT (did) DO NOTHING 22 + `, did, handle, "wanderer", "just setting up my xcvr", 12517472) 23 + if err != nil { 24 + return errors.New("i'm not sure what happened: " + err.Error()) 25 + } 26 + return nil 27 + } 28 + 29 + type ProfileUpdate struct { 30 + DID string 31 + Name *string 32 + UpdateName bool 33 + Nick *string 34 + UpdateNick bool 35 + Status *string 36 + UpdateStatus bool 37 + Avatar *string 38 + UpdateAvatar bool 39 + Mime *string 40 + UpdateMime bool 41 + Color *uint32 42 + UpdateColor bool 43 + } 44 + 45 + func (s *Store) UpdateProfile(to ProfileUpdate, ctx context.Context) error { 46 + setParts := []string{} 47 + args := []any{to.DID} 48 + idx := 2 49 + if to.UpdateName { 50 + setParts = append(setParts, fmt.Sprintf("display_name = $%d", idx)) 51 + args = append(args, to.Name) 52 + idx += 1 53 + } 54 + if to.UpdateNick { 55 + setParts = append(setParts, fmt.Sprintf("default_nick = $%d", idx)) 56 + args = append(args, to.Nick) 57 + idx += 1 58 + } 59 + if to.UpdateStatus { 60 + setParts = append(setParts, fmt.Sprintf("status = $%d", idx)) 61 + args = append(args, to.Status) 62 + idx += 1 63 + } 64 + if to.UpdateAvatar { 65 + setParts = append(setParts, fmt.Sprintf("avatar_cid = $%d", idx)) 66 + args = append(args, to.Avatar) 67 + idx += 1 68 + } 69 + if to.UpdateMime { 70 + setParts = append(setParts, fmt.Sprintf("avatar_mime = $%d", idx)) 71 + args = append(args, to.Mime) 72 + idx += 1 73 + } 74 + if to.UpdateColor { 75 + setParts = append(setParts, fmt.Sprintf("color = $%d", idx)) 76 + args = append(args, to.Color) 77 + idx += 1 78 + } 79 + if idx == 2 { 80 + return nil 81 + } 82 + sql := fmt.Sprintf("UPDATE profiles SET %s WHERE did = $1", 83 + strings.Join(setParts, ", ")) 84 + _, err := s.pool.Exec(ctx, sql, args...) 85 + if err != nil { 86 + return errors.New("error updating profile: " + err.Error()) 87 + } 88 + return nil 89 + } 90 + 91 + func (s *Store) GetProfileView(did string, ctx context.Context) (*types.ProfileView, error) { 92 + row := s.pool.QueryRow(ctx, `SELECT 93 + p.display_name, 94 + p.default_nick, 95 + p.status, 96 + p.avatar_cid, 97 + p.color 98 + FROM profiles p 99 + WHERE p.did = $1 100 + `, did) 101 + var p types.ProfileView 102 + p.DID = did 103 + err := row.Scan(&p.DisplayName, 104 + &p.DefaultNick, 105 + &p.Status, 106 + &p.Avatar, 107 + &p.Color) 108 + if err != nil { 109 + return nil, errors.New("error scanning profile: " + err.Error()) 110 + } 111 + return &p, nil 112 + } 113 + 114 + 115 + 116 + 117 +
+11
server/internal/handler/handler.go
··· 25 25 mux.HandleFunc("GET /lrc/{user}/{rkey}/ws", h.acceptWebsocket) 26 26 mux.HandleFunc("POST /lrc/channel", postChannel) 27 27 mux.HandleFunc("POST /lrc/message", postMessage) 28 + // beep handlers 29 + mux.HandleFunc("POST /xcvr/profile", h.postProfile) 28 30 // lexicon handlers 29 31 mux.HandleFunc("GET /xrpc/org.xcvr.feed.getChannels", h.getChannels) 30 32 mux.HandleFunc("GET /xrpc/org.xcvr.lrc.getMessages", h.getMessages) 31 33 mux.HandleFunc("GET /xrpc/org.xcvr.actor.resolveChannel", h.resolveChannel) 34 + mux.HandleFunc("GET /xrpc/org.xcvr.actor.getProfileView", h.getProfileView) 32 35 // backend metadata handlers 33 36 mux.HandleFunc(clientMetadataPath(), h.serveClientMetadata) 34 37 mux.HandleFunc(clientTOSPath(), h.serveTOS) ··· 43 46 44 47 func (h *Handler) badRequest(w http.ResponseWriter, err error) { 45 48 h.logger.Deprintln(err.Error()) 49 + w.Header().Set("Content-Type", "application/json") 46 50 http.Error(w, `{"error":"Invalid JSON","message":"Could not parse request body"}`, http.StatusBadRequest) 47 51 } 48 52 49 53 func (h *Handler) serverError(w http.ResponseWriter, err error) { 50 54 h.logger.Println(err.Error()) 55 + w.Header().Set("Content-Type", "application/json") 51 56 http.Error(w, `{"error":"Internal server error","message":"Something went wrong"}`, http.StatusInternalServerError) 57 + } 58 + 59 + func (h *Handler) notFound(w http.ResponseWriter, err error) { 60 + h.logger.Println(err.Error()) 61 + w.Header().Set("Content-Type", "application/json") 62 + http.Error(w, `{"error":"Not Found","message":"I couldn't find your resource"}`, http.StatusNotFound) 52 63 } 53 64 54 65 func (h *Handler) WithCORSAll() http.Handler {
+28 -3
server/internal/handler/lexiconHandlers.go
··· 1 1 package handler 2 2 3 3 import ( 4 - "xcvr-backend/internal/types" 4 + "encoding/json" 5 5 "errors" 6 - "strconv" 7 6 "fmt" 8 - "encoding/json" 9 7 "net/http" 8 + "strconv" 9 + "xcvr-backend/internal/types" 10 10 ) 11 11 12 12 func (h *Handler) getChannels(w http.ResponseWriter, r *http.Request) { ··· 55 55 encoder := json.NewEncoder(w) 56 56 encoder.Encode(rchanres) 57 57 } 58 + 59 + func (h *Handler) getProfileView(w http.ResponseWriter, r *http.Request) { 60 + handle := r.URL.Query().Get("handle") 61 + did := r.URL.Query().Get("did") 62 + if did == "" { 63 + if handle == "" { 64 + h.badRequest(w, errors.New("did not provide did or handle")) 65 + return 66 + } 67 + var err error 68 + did, err = h.db.ResolveHandle(handle, r.Context()) 69 + if err != nil { 70 + h.serverError(w, err) 71 + return 72 + } 73 + } 74 + profile, err := h.db.GetProfileView(did, r.Context()) 75 + if err != nil { 76 + h.notFound(w, errors.New(fmt.Sprintf("couldn't find profile for handle %s / did %s: %s", handle, did, err.Error()))) 77 + return 78 + } 79 + w.Header().Set("Content-Type", "application/json") 80 + encoder := json.NewEncoder(w) 81 + encoder.Encode(profile) 82 + }
+7
server/internal/handler/oauthHandlers.go
··· 71 71 if err != nil { 72 72 h.logger.Deprintln("failed to store did handle: " + err.Error()) 73 73 } 74 + err = h.db.InitializeProfile(res.DID, handle, context.Background()) 75 + h.logger.Deprintln("initializing....") 76 + if err != nil { 77 + h.logger.Deprintln("failed to initialize profile: " + err.Error()) 78 + } 74 79 }() 75 80 http.Redirect(w, r, u.String(), http.StatusFound) 76 81 } ··· 174 179 "handle": handle, 175 180 }) 176 181 } 182 + 183 +
+48
server/internal/handler/xcvrHandlers.go
··· 1 + package handler 2 + 3 + import ( 4 + "net/http" 5 + "xcvr-backend/internal/types" 6 + "xcvr-backend/internal/db" 7 + "errors" 8 + "encoding/json" 9 + ) 10 + 11 + func (h *Handler) postProfile(w http.ResponseWriter, r *http.Request) { 12 + session, _ := h.sessionStore.Get(r, "oauthsession") 13 + did, ok := session.Values["did"].(string) 14 + if !ok || did == "" { 15 + http.Error(w, "not authenticated", http.StatusUnauthorized) 16 + return 17 + } 18 + var p types.PostProfileRequest 19 + decoder := json.NewDecoder(r.Body) 20 + err := decoder.Decode(&p) 21 + if err != nil { 22 + h.badRequest(w, errors.New("error decoding post profile request: " + err.Error())) 23 + return 24 + } 25 + var pu db.ProfileUpdate 26 + pu.DID = did 27 + if p.DisplayName != nil { 28 + pu.Name = p.DisplayName 29 + pu.UpdateName = true 30 + } 31 + if p.DefaultNick != nil { 32 + pu.Nick = p.DefaultNick 33 + pu.UpdateNick = true 34 + } 35 + if p.Status != nil { 36 + pu.Status = p.Status 37 + pu.UpdateStatus = true 38 + } 39 + if p.Avatar != nil { 40 + pu.Avatar = p.Avatar 41 + pu.UpdateAvatar = true 42 + } 43 + if p.Color != nil { 44 + pu.Color = p.Color 45 + pu.UpdateColor = true 46 + } 47 + h.db.UpdateProfile(pu, r.Context()) 48 + }
+10 -3
server/internal/types/lexicons.go
··· 9 9 Status *string 10 10 AvatarCID *string 11 11 AvatarMIME *string 12 - Color uint32 13 - URI string 14 - CID string 12 + Color *uint32 15 13 IndexedAt time.Time 16 14 } 17 15 16 + type PostProfileRequest struct { 17 + DisplayName *string `json:"displayName,omitempty"` 18 + Status *string `json:"status,omitempty"` 19 + Color *uint32 `json:"color,omitempty"` 20 + Avatar *string `json:"avatar,omitempty"` 21 + DefaultNick *string `json:"defaultNick,omitempty"` 22 + } 23 + 18 24 type ProfileView struct { 19 25 DID string `json:"did"` 20 26 Handle string `json:"handle"` ··· 22 28 Status *string `json:"status,omitempty"` 23 29 Color *uint32 `json:"color,omitempty"` 24 30 Avatar *string `json:"avatar,omitempty"` 31 + DefaultNick *string `json:"defaultNick,omitempty"` 25 32 } 26 33 27 34 type DIDHandle struct {