backend for xcvr appview

some initial image stuff

Changed files
+447 -5
lexicons
org
migrations
server
internal
+49
lexicons/org/xcvr/lrc/media.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "org.xcvr.lrc.media", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A piece of media", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["signetURI", "media"], 12 + "properties": { 13 + "signetURI": { 14 + "type": "string", 15 + "format": "at-uri" 16 + }, 17 + "media": { 18 + "type": "union", 19 + "required": ["image", "alt"], 20 + "properties": { 21 + "image": { 22 + "type": "blob", 23 + "accept": ["image/*"], 24 + "maxSize": 1000000 25 + }, 26 + "alt": { 27 + "type": "string", 28 + "description": "Alt text description of the image, for accessibility." 29 + } 30 + } 31 + }, 32 + "color": { 33 + "type": "integer", 34 + "minimum": 0, 35 + "maximum": 16777215 36 + }, 37 + "nick": { 38 + "type": "string", 39 + "maxLength": 16, 40 + }, 41 + "postedAt": { 42 + "type": "string", 43 + "format": "datetime" 44 + } 45 + } 46 + } 47 + } 48 + } 49 + }
+3 -4
lexicons/org/xcvr/lrc/message.json
··· 14 14 "format": "at-uri" 15 15 }, 16 16 "body": { 17 - "type": "string", 17 + "type": "string" 18 18 }, 19 19 "nick": { 20 20 "type": "string", 21 - "maxLength": 16, 22 - "default": "wanderer" 21 + "maxLength": 16 23 22 }, 24 23 "color": { 25 24 "type": "integer", ··· 34 33 } 35 34 } 36 35 } 37 - } 36 + }
+2
migrations/005_initimages.down.sql
··· 1 + DROP INDEX IF EXISTS medias_signet_uri_idx; 2 + DROP TABLE IF EXISTS medias;
+16
migrations/005_initimages.up.sql
··· 1 + CREATE TABLE medias ( 2 + uri TEXT PRIMARY KEY, 3 + did TEXT NOT NULL, 4 + signet_uri TEXT NOT NULL, 5 + FOREIGN KEY (signet_uri) REFERENCES signets(uri) ON DELETE CASCADE, 6 + media_cid TEXT, 7 + media_mime TEXT, 8 + alt TEXT, 9 + nick TEXT NOT NULL, 10 + color INTEGER CHECK (color BETWEEN 0 AND 16777215), 11 + cid TEXT NOT NULL, 12 + posted_at TIMESTAMPTZ NOT NULL DEFAULT now(), 13 + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now() 14 + ); 15 + 16 + CREATE INDEX ON medias (signet_uri);
+99
server/internal/db/lexicon.go
··· 287 287 `, uri) 288 288 return err 289 289 } 290 + 291 + func (s *Store) StoreImage(image *types.Image, ctx context.Context) error { 292 + _, err := s.pool.Exec(ctx, `INSERT INTO medias ( 293 + uri, 294 + did, 295 + signet_uri, 296 + image_cid, 297 + image_mime, 298 + alt, 299 + nick, 300 + color, 301 + cid, 302 + posted_at 303 + ) VALUES ( 304 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 305 + ) ON CONFLICT (uri) DO NOTHING`, 306 + image.URI, 307 + image.DID, 308 + image.SignetURI, 309 + image.ImageCID, 310 + image.ImageMIME, 311 + image.Alt, 312 + image.Nick, 313 + image.Color, 314 + image.CID, 315 + image.PostedAt) 316 + if err != nil { 317 + return errors.New("effor storing image: " + err.Error()) 318 + } 319 + return nil 320 + } 321 + 322 + func (s *Store) UpdateImage(image *types.Image, ctx context.Context) error { 323 + _, err := s.pool.Exec(ctx, `INSERT INTO medias ( 324 + uri, 325 + did, 326 + signet_uri, 327 + image_cid, 328 + image_mime, 329 + alt, 330 + nick, 331 + color, 332 + cid, 333 + posted_at 334 + ) VALUES ( 335 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 336 + )`, 337 + image.URI, 338 + image.DID, 339 + image.SignetURI, 340 + image.ImageCID, 341 + image.ImageMIME, 342 + image.Alt, 343 + image.Nick, 344 + image.Color, 345 + image.CID, 346 + image.PostedAt) 347 + if err != nil { 348 + return errors.New("effor updating image: " + err.Error()) 349 + } 350 + return nil 351 + } 352 + 353 + func (s *Store) DeleteImage(uri string, ctx context.Context) error { 354 + _, err := s.pool.Exec(ctx, `DELETE from medias m WHERE m.uri = $1`, uri) 355 + if err != nil { 356 + return errors.New("bep bep bop: " + err.Error()) 357 + } 358 + return nil 359 + } 360 + 361 + func (s *Store) GetImage(uri string, ctx context.Context) (*types.Image, error) { 362 + row := s.pool.QueryRow(ctx, `SELECT FROM medias ( 363 + did, 364 + signet_uri, 365 + media_cid, 366 + media_mime, 367 + alt, 368 + nick, 369 + color, 370 + cid, 371 + posted_at 372 + ) WHERE uri = $1`, uri) 373 + var image types.Image 374 + err := row.Scan(&image.DID, 375 + &image.SignetURI, 376 + &image.ImageCID, 377 + &image.ImageMIME, 378 + &image.Alt, 379 + &image.Nick, 380 + &image.Color, 381 + &image.CID, 382 + &image.PostedAt) 383 + if err != nil { 384 + return nil, errors.New("effor storing image: " + err.Error()) 385 + } 386 + image.URI = uri 387 + return &image, nil 388 + }
+4
server/internal/handler/handler.go
··· 31 31 mux.HandleFunc("DELETE /lrc/{user}/{rkey}/ws", h.oauthMiddleware(h.deleteChannel)) 32 32 mux.HandleFunc("POST /lrc/channel", h.oauthMiddleware(h.postChannel)) 33 33 mux.HandleFunc("POST /lrc/message", h.oauthMiddleware(h.postMessage)) 34 + mux.HandleFunc("POST /lrc/image", h.oauthMiddleware(h.uploadImage)) 35 + mux.HandleFunc("POST /lrc/media", h.oauthMiddleware(h.postMedia)) 36 + // mux.HandleFunc("GET /lrc/image", h.getImage) 37 + mux.HandleFunc("POST /lrc/imagePub", h.oauthMiddleware(h.postImagePub)) 34 38 mux.HandleFunc("POST /lrc/mymessage", h.postMyMessage) 35 39 // xcvr handlers 36 40 mux.HandleFunc("POST /xcvr/profile", h.oauthMiddleware(h.postProfile))
+92 -1
server/internal/handler/lrcHandlers.go
··· 5 5 "encoding/json" 6 6 "errors" 7 7 "fmt" 8 - atoauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 8 "net/http" 10 9 "os" 11 10 "rvcx/internal/atputils" 12 11 "rvcx/internal/types" 12 + "strings" 13 + 14 + atoauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 15 ) 14 16 15 17 func (h *Handler) acceptWebsocket(w http.ResponseWriter, r *http.Request) { ··· 138 140 } 139 141 f(w, r) 140 142 } 143 + 144 + func (h *Handler) uploadImage(cs *atoauth.ClientSession, w http.ResponseWriter, r *http.Request) { 145 + if cs == nil { 146 + h.badRequest(w, errors.New("must be authorized to post image")) 147 + return 148 + } 149 + err := r.ParseMultipartForm(1 << 20) 150 + if err != nil { 151 + h.badRequest(w, errors.New("beep bop bad image: "+err.Error())) 152 + return 153 + } 154 + file, fheader, err := r.FormFile("image") 155 + if err != nil { 156 + h.badRequest(w, errors.New("failed to formfile: "+err.Error())) 157 + return 158 + } 159 + defer file.Close() 160 + ct := fheader.Header.Get("Content-Type") 161 + if !strings.HasPrefix(ct, "image/") { 162 + h.badRequest(w, errors.New("must post an image")) 163 + return 164 + } 165 + blob, err := h.rm.PostImage(cs, file, r.Context()) 166 + if err != nil { 167 + h.serverError(w, errors.New("failed to upload: "+err.Error())) 168 + return 169 + } 170 + w.Header().Set("Content-Type", "application/json") 171 + encoder := json.NewEncoder(w) 172 + encoder.Encode(blob) 173 + } 174 + 175 + func (h *Handler) postMedia(cs *atoauth.ClientSession, w http.ResponseWriter, r *http.Request) { 176 + if cs == nil { 177 + h.badRequest(w, errors.New("must be authorized to post media")) 178 + } 179 + mr, err := parseMediaRequest(r) 180 + if err != nil { 181 + h.badRequest(w, err) 182 + return 183 + } 184 + h.rm.PostMedia(cs, mr, r.Context()) 185 + } 186 + 187 + func parseMediaRequest(r *http.Request) (*types.ParseMediaRequest, error) { 188 + beep := json.NewDecoder(r.Body) 189 + var mr types.ParseMediaRequest 190 + err := beep.Decode(&mr) 191 + if err != nil { 192 + return nil, errors.New("A aaaaaa : " + err.Error()) 193 + } 194 + return &mr, nil 195 + } 196 + 197 + // func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) { 198 + // vals := r.URL.Query() 199 + // uri := vals.Get("uri") 200 + // if uri == "" { 201 + // h.badRequest(w, errors.New("must provide a did and cid")) 202 + // return 203 + // } 204 + // image, err := h.db.GetImage(uri, r.Context()) 205 + // if err != nil { 206 + // h.notFound(w, err) 207 + // return 208 + // } 209 + // uploadDir := fmt.Sprintf("./uploads/%s", image.DID) 210 + // _, err = os.Stat(uploadDir) 211 + // if os.IsNotExist(err) { 212 + // os.Mkdir(uploadDir, 0755) 213 + // } 214 + // 215 + // imgPath := fmt.Sprintf("./uploads/%s", image.ImageCID) 216 + // _, err = os.Stat(imgPath) 217 + // if err != nil { 218 + // syncGetBlob(image.DID, image.ImageCID) 219 + // } 220 + // 221 + // img, err := os.Open(fmt.Sprintf("%s/%s", uploadDir, image.ImageCID)) 222 + // img.WriteTo(w) 223 + // } 224 + 225 + // func syncGetBlob(did string, cid *string) { 226 + // //TODO: impl 227 + // } 228 + 229 + func (h *Handler) postImagePub(cs *atoauth.ClientSession, w http.ResponseWriter, r *http.Request) { 230 + 231 + }
+25
server/internal/lex/types.go
··· 44 44 AuthorHandle string `json:"authorHandle" cborgen:"authorHandle"` 45 45 StartedAt *string `json:"startedAt,omitempty" cborgen:"startedAt,omitempty"` 46 46 } 47 + 48 + type MediaRecord struct { 49 + LexiconTypeID string `json:"$type,const=org.xcvr.lrc.media" cborgen:"$type,const=org.xcvr.lrc.media"` 50 + SignetURI string `json:"signetURI" cborgen:"signetURI"` 51 + Media Media `json:"media" cborgen:"media"` 52 + Nick *string `json:"nick,omitempty" cborgen:"nick,omitempty"` 53 + Color *uint64 `json:"color,omitempty" cborgen:"color,omitempty"` 54 + PostedAt string `json:"postedAt" cborgen:"postedAt"` 55 + } 56 + 57 + type Media struct { 58 + Image *Image 59 + } 60 + 61 + type Image struct { 62 + LexiconTypeID string `json:"$type,const=org.xcvr.lrc.image" cborgen:"$type,const=org.xcvr.lrc.image"` 63 + Alt string `json:"alt" cborgen:"alt"` 64 + AspectRatio *AspectRatio `json:"aspectRatio,omitempty" cborgen:"aspectRatio,omitempty"` 65 + Image *util.BlobSchema `json:"image,omitempty" cborgen:"image,omitempty"` 66 + } 67 + 68 + type AspectRatio struct { 69 + Height int64 `json:"height" cborgen:"height"` 70 + Width int64 `json:"width" cborgen:"width"` 71 + }
+43
server/internal/oauth/oauthclient.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "errors" 7 + "fmt" 7 8 8 9 "github.com/bluesky-social/indigo/api/atproto" 9 10 "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + atpclient "github.com/bluesky-social/indigo/atproto/client" 10 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 14 15 + "mime/multipart" 12 16 "rvcx/internal/lex" 13 17 "rvcx/internal/log" 14 18 "rvcx/internal/types" ··· 170 174 } 171 175 return profile, nil 172 176 } 177 + 178 + func UploadBLOB(cs *oauth.ClientSession, file multipart.File, ctx context.Context) (*lexutil.BlobSchema, error) { 179 + client := cs.APIClient() 180 + 181 + req := atpclient.NewAPIRequest("POST", "com.atproto.repo.uploadBlob", file) 182 + resp, err := client.Do(ctx, req) 183 + if err != nil { 184 + return nil, err 185 + } 186 + defer resp.Body.Close() 187 + if resp.StatusCode != 200 { 188 + return nil, fmt.Errorf("upload failed withy status %d", resp.StatusCode) 189 + } 190 + var result lexutil.BlobSchema 191 + decoder := json.NewDecoder(resp.Body) 192 + err = decoder.Decode(&result) 193 + if err != nil { 194 + return nil, errors.New("failed to decode: " + err.Error()) 195 + } 196 + return &result, nil 197 + } 198 + 199 + func CreateXCVRMedia(cs *oauth.ClientSession, imr *lex.MediaRecord, ctx context.Context) (uri string, cid string, err error) { 200 + c := cs.APIClient() 201 + body := map[string]any{ 202 + "collection": "org.xcvr.lrc.message", 203 + "repo": *c.AccountDID, 204 + "record": imr, 205 + } 206 + var out atproto.RepoCreateRecord_Output 207 + err = c.Post(ctx, "com.atproto.repo.createRecord", body, &out) 208 + if err != nil { 209 + err = errors.New("oops! failed to create a media: " + err.Error()) 210 + return 211 + } 212 + uri = out.Uri 213 + cid = out.Cid 214 + return 215 + }
+91
server/internal/recordmanager/media.go
··· 1 + package recordmanager 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + atoauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 + "mime/multipart" 10 + "rvcx/internal/lex" 11 + "rvcx/internal/oauth" 12 + "rvcx/internal/types" 13 + "time" 14 + ) 15 + 16 + func (rm *RecordManager) PostImage(cs *atoauth.ClientSession, file multipart.File, ctx context.Context) (*lexutil.BlobSchema, error) { 17 + return oauth.UploadBLOB(cs, file, ctx) 18 + } 19 + 20 + func (rm *RecordManager) PostMedia(cs *atoauth.ClientSession, mr *types.ParseMediaRequest, ctx context.Context) error { 21 + switch mr.Type { 22 + case "image": 23 + return rm.postImageRecord(cs, mr, ctx) 24 + default: 25 + return nil 26 + } 27 + } 28 + 29 + func (rm *RecordManager) postImageRecord(cs *atoauth.ClientSession, mr *types.ParseMediaRequest, ctx context.Context) error { 30 + imr, now, err := rm.validateImageRecord(mr) 31 + if err != nil { 32 + return errors.New("coudlnt validate media record: " + err.Error()) 33 + } 34 + img, err := rm.createImageRecord(cs, imr, now, ctx) 35 + if err != nil { 36 + return errors.New("coudlnt validate media record: " + err.Error()) 37 + } 38 + err = rm.db.StoreImage(img, ctx) 39 + if err != nil { 40 + return errors.New("beeped that up!: " + err.Error()) 41 + } 42 + return nil 43 + } 44 + 45 + func (rm *RecordManager) validateImageRecord(mr *types.ParseMediaRequest) (*lex.MediaRecord, *time.Time, error) { 46 + var imr lex.MediaRecord 47 + imr.SignetURI = mr.SignetURI 48 + imr.Nick = mr.Nick 49 + cptr := mr.Color 50 + if cptr != nil { 51 + cnum := uint64(*cptr) 52 + imr.Color = &cnum 53 + } 54 + imr.Media.Image = mr.Image 55 + nowsyn := syntax.DatetimeNow() 56 + imr.PostedAt = nowsyn.String() 57 + nt := nowsyn.Time() 58 + now := &nt 59 + return &imr, now, nil 60 + } 61 + 62 + func (rm *RecordManager) createImageRecord(cs *atoauth.ClientSession, imr *lex.MediaRecord, now *time.Time, ctx context.Context) (*types.Image, error) { 63 + uri, cid, err := oauth.CreateXCVRMedia(cs, imr, ctx) 64 + if err != nil { 65 + return nil, errors.New("beeped up: " + err.Error()) 66 + } 67 + var img types.Image 68 + img.URI = uri 69 + img.DID = cs.Data.AccountDID.String() 70 + img.SignetURI = imr.SignetURI 71 + if imr.Media.Image != nil { 72 + img.Alt = imr.Media.Image.Alt 73 + if imr.Media.Image.Image != nil { 74 + img.ImageMIME = &imr.Media.Image.Image.MimeType 75 + icid := imr.Media.Image.Image.Ref.String() 76 + img.ImageCID = &icid 77 + } 78 + } 79 + img.Nick = imr.Nick 80 + img.CID = cid 81 + if imr.Color != nil { 82 + c := uint32(*imr.Color) 83 + img.Color = &c 84 + } 85 + if now != nil { 86 + img.PostedAt = *now 87 + } else { 88 + img.PostedAt = time.Now() 89 + } 90 + return &img, nil 91 + }
+23
server/internal/types/lexicons.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "rvcx/internal/lex" 5 6 "time" 6 7 ) 7 8 ··· 209 210 Messages []SignedMessageView `json:"messages"` 210 211 Cursor *string `json:"cursor,omitempty"` 211 212 } 213 + 214 + type Image struct { 215 + URI string 216 + DID string 217 + SignetURI string 218 + ImageCID *string 219 + ImageMIME *string 220 + Alt string 221 + Nick *string 222 + Color *uint32 223 + CID string 224 + PostedAt time.Time 225 + IndexedAt time.Time 226 + } 227 + 228 + type ParseMediaRequest struct { 229 + Nick *string `json:"nick,omitempty"` 230 + Color *uint32 `json:"color,omitempty"` 231 + SignetURI string `json:"signetURI"` 232 + Image *lex.Image `json:"image,omitempty"` 233 + Type string `json:"type"` 234 + }