grain.social is a photo sharing platform built on atproto.
at main 33 kB view raw
1import { ProfileView } from "$lexicon/types/social/grain/actor/defs.ts"; 2import { 3 OutputSchema as GetActorFavsOutputSchema, 4 QueryParams as GetActorFavsQueryParams, 5} from "$lexicon/types/social/grain/actor/getActorFavs.ts"; 6import { 7 OutputSchema as GetProfileOutputSchema, 8 QueryParams as GetProfileQueryParams, 9} from "$lexicon/types/social/grain/actor/getProfile.ts"; 10import { 11 OutputSchema as SearchActorsOutputSchema, 12 QueryParams as SearchActorsQueryParams, 13} from "$lexicon/types/social/grain/actor/searchActors.ts"; 14import { 15 OutputSchema as UpdateAvatarOutputSchema, 16} from "$lexicon/types/social/grain/actor/updateAvatar.ts"; 17import { 18 InputSchema as UpdateProfileInputSchema, 19 OutputSchema as UpdateProfileOutputSchema, 20} from "$lexicon/types/social/grain/actor/updateProfile.ts"; 21import { 22 InputSchema as CreateCommentInputSchema, 23 OutputSchema as CreateCommentOutputSchema, 24} from "$lexicon/types/social/grain/comment/createComment.ts"; 25import { 26 InputSchema as DeleteCommentInputSchema, 27 OutputSchema as DeleteCommentOutputSchema, 28} from "$lexicon/types/social/grain/comment/deleteComment.ts"; 29import { 30 InputSchema as CreateFavoriteInputSchema, 31 OutputSchema as CreateFavoriteOutputSchema, 32} from "$lexicon/types/social/grain/favorite/createFavorite.ts"; 33import { 34 InputSchema as DeleteFavoriteInputSchema, 35 OutputSchema as DeleteFavoriteOutputSchema, 36} from "$lexicon/types/social/grain/favorite/deleteFavorite.ts"; 37import { 38 OutputSchema as GetTimelineOutputSchema, 39 QueryParams as GetTimelineQueryParams, 40} from "$lexicon/types/social/grain/feed/getTimeline.ts"; 41import { 42 InputSchema as ApplySortInputSchema, 43 OutputSchema as ApplySortOutputSchema, 44} from "$lexicon/types/social/grain/gallery/applySort.ts"; 45import { 46 InputSchema as CreateGalleryInputSchema, 47 OutputSchema as CreateGalleryOutputSchema, 48} from "$lexicon/types/social/grain/gallery/createGallery.ts"; 49import { 50 InputSchema as CreateGalleryItemInputSchema, 51 OutputSchema as CreateGalleryItemOutputSchema, 52} from "$lexicon/types/social/grain/gallery/createItem.ts"; 53import { 54 InputSchema as DeleteGalleryInputSchema, 55 OutputSchema as DeleteGalleryOutputSchema, 56} from "$lexicon/types/social/grain/gallery/deleteGallery.ts"; 57import { 58 InputSchema as DeleteGalleryItemInputSchema, 59 OutputSchema as DeleteGalleryItemOutputSchema, 60} from "$lexicon/types/social/grain/gallery/deleteItem.ts"; 61import { 62 OutputSchema as GetActorGalleriesOutputSchema, 63 QueryParams as GetActorGalleriesQueryParams, 64} from "$lexicon/types/social/grain/gallery/getActorGalleries.ts"; 65import { 66 OutputSchema as GetGalleryOutputSchema, 67 QueryParams as GetGalleryQueryParams, 68} from "$lexicon/types/social/grain/gallery/getGallery.ts"; 69import { 70 OutputSchema as GetGalleryThreadOutputSchema, 71 QueryParams as GetGalleryThreadQueryParams, 72} from "$lexicon/types/social/grain/gallery/getGalleryThread.ts"; 73import { 74 InputSchema as UpdateGalleryInputSchema, 75 OutputSchema as UpdateGalleryOutputSchema, 76} from "$lexicon/types/social/grain/gallery/updateGallery.ts"; 77import { 78 InputSchema as CreateFollowInputSchema, 79 OutputSchema as CreateFollowOutputSchema, 80} from "$lexicon/types/social/grain/graph/createFollow.ts"; 81import { 82 InputSchema as DeleteFollowInputSchema, 83 OutputSchema as DeleteFollowOutputSchema, 84} from "$lexicon/types/social/grain/graph/deleteFollow.ts"; 85import { 86 OutputSchema as GetFollowersOutputSchema, 87 QueryParams as GetFollowersQueryParams, 88} from "$lexicon/types/social/grain/graph/getFollowers.ts"; 89import { 90 OutputSchema as GetFollowsOutputSchema, 91 QueryParams as GetFollowsQueryParams, 92} from "$lexicon/types/social/grain/graph/getFollows.ts"; 93import { 94 OutputSchema as GetNotificationsOutputSchema, 95} from "$lexicon/types/social/grain/notification/getNotifications.ts"; 96import { 97 InputSchema as ApplyAltsInputSchema, 98 OutputSchema as ApplyAltsOutputSchema, 99} from "$lexicon/types/social/grain/photo/applyAlts.ts"; 100import { 101 InputSchema as DeletePhotoInputSchema, 102 OutputSchema as DeletePhotoOutputSchema, 103} from "$lexicon/types/social/grain/photo/deletePhoto.ts"; 104import { 105 OutputSchema as GetActorPhotosOutputSchema, 106 QueryParams as GetActorPhotosQueryParams, 107} from "$lexicon/types/social/grain/photo/getActorPhotos.ts"; 108import { 109 OutputSchema as UploadPhotoOutputSchema, 110} from "$lexicon/types/social/grain/photo/uploadPhoto.ts"; 111import { AtUri } from "@atproto/syntax"; 112import { BffMiddleware, route } from "@bigmoves/bff"; 113import { imageSize } from "image-size"; 114import { Buffer } from "node:buffer"; 115import { 116 getActorGalleries, 117 getActorGalleryFavs, 118 getActorPhotos, 119 getActorProfile, 120 getActorProfileDetailed, 121 searchActors, 122 updateActorProfile, 123} from "../lib/actor.ts"; 124import { XRPCError } from "../lib/errors.ts"; 125import { createFavorite } from "../lib/favs.ts"; 126import { 127 applySort, 128 createGallery, 129 createGalleryItem, 130 deleteGallery, 131 getGalleriesByHashtag, 132 getGallery, 133 updateGallery, 134} from "../lib/gallery.ts"; 135import { 136 createFollow, 137 getFollowersWithProfiles, 138 getFollowingWithProfiles, 139} from "../lib/graph.ts"; 140import { getNotificationsDetailed } from "../lib/notifications.ts"; 141import { 142 applyAlts, 143 createExif, 144 createPhoto, 145 deletePhoto, 146} from "../lib/photo.ts"; 147import { getTimeline } from "../lib/timeline.ts"; 148import { createComment, getGalleryComments } from "../modules/comments.tsx"; 149 150export const middlewares: BffMiddleware[] = [ 151 route( 152 "/xrpc/social.grain.gallery.createGallery", 153 ["POST"], 154 async (req, _params, ctx) => { 155 ctx.requireAuth(); 156 const { title, description } = await parseCreateGalleryInputs(req); 157 158 try { 159 const galleryUri = await createGallery(ctx, { title, description }); 160 return ctx.json({ galleryUri } satisfies CreateGalleryOutputSchema); 161 } catch (error) { 162 console.error("Error creating gallery:", error); 163 throw new XRPCError("InternalServerError", "Failed to create gallery"); 164 } 165 }, 166 ), 167 route( 168 "/xrpc/social.grain.gallery.updateGallery", 169 ["POST"], 170 async (req, _params, ctx) => { 171 ctx.requireAuth(); 172 const { galleryUri, title, description } = await parseUpdateGalleryInputs( 173 req, 174 ); 175 const success = await updateGallery(ctx, galleryUri, { 176 title, 177 description, 178 }); 179 return ctx.json( 180 { success } satisfies UpdateGalleryOutputSchema, 181 ); 182 }, 183 ), 184 route( 185 "/xrpc/social.grain.gallery.deleteGallery", 186 ["POST"], 187 async (req, _params, ctx) => { 188 ctx.requireAuth(); 189 const { uri, cascade = true } = await parseDeleteGalleryInputs( 190 req, 191 ); 192 const success = await deleteGallery(uri, cascade, ctx); 193 return ctx.json({ success } satisfies DeleteGalleryOutputSchema); 194 }, 195 ), 196 route( 197 "/xrpc/social.grain.gallery.createItem", 198 ["POST"], 199 async (req, _params, ctx) => { 200 ctx.requireAuth(); 201 const { galleryUri, photoUri } = await parseCreateGalleryItemInputs(req); 202 const createdItemUri = await createGalleryItem( 203 ctx, 204 galleryUri, 205 photoUri, 206 ); 207 if (!createdItemUri) { 208 return ctx.json( 209 { message: "Failed to create gallery item" }, 210 400, 211 ); 212 } 213 return ctx.json( 214 { itemUri: createdItemUri } satisfies CreateGalleryItemOutputSchema, 215 ); 216 }, 217 ), 218 route( 219 "/xrpc/social.grain.gallery.deleteItem", 220 ["POST"], 221 async (req, _params, ctx) => { 222 ctx.requireAuth(); 223 const { uri } = await parseDeleteGalleryItemInputs(req); 224 try { 225 await ctx.deleteRecord(uri); 226 } catch (error) { 227 console.error("Error deleting gallery item:", error); 228 return ctx.json( 229 { success: false } satisfies DeleteGalleryItemOutputSchema, 230 ); 231 } 232 return ctx.json( 233 { success: true } satisfies DeleteGalleryItemOutputSchema, 234 ); 235 }, 236 ), 237 route( 238 "/xrpc/social.grain.photo.uploadPhoto", 239 ["POST"], 240 async (req, _params, ctx) => { 241 ctx.requireAuth(); 242 if (!ctx.agent) { 243 return ctx.json( 244 { message: "Unauthorized" }, 245 401, 246 ); 247 } 248 249 const bytes = await req.arrayBuffer(); 250 if (!bytes || bytes.byteLength === 0) { 251 throw new XRPCError("InvalidRequest", "Missing blob"); 252 } 253 const MAX_SIZE = 1024 * 1024; // 1MB 254 if (bytes.byteLength > MAX_SIZE) { 255 throw new XRPCError( 256 "PayloadTooLarge", 257 "request entity too large", 258 ); 259 } 260 const { width, height } = imageSize(Buffer.from(bytes)); 261 const res = await ctx.agent.uploadBlob(new Uint8Array(bytes)); 262 if (!res.success) { 263 return ctx.json( 264 { message: "Failed to upload photo" }, 265 500, 266 ); 267 } 268 const blobRef = res.data.blob; 269 const photoUri = await createPhoto( 270 { 271 photo: blobRef, 272 aspectRatio: { 273 width, 274 height, 275 }, 276 }, 277 ctx, 278 ); 279 return ctx.json({ photoUri } as UploadPhotoOutputSchema); 280 }, 281 ), 282 route( 283 "/xrpc/social.grain.photo.createExif", 284 ["POST"], 285 async (req, _params, ctx) => { 286 ctx.requireAuth(); 287 const exifData = await parseExifInputs(req); 288 const exifUri = await createExif( 289 exifData, 290 ctx, 291 ); 292 if (!exifUri) { 293 return ctx.json( 294 { message: "Failed to create EXIF data" }, 295 500, 296 ); 297 } 298 return ctx.json({ exifUri }); 299 }, 300 ), 301 route( 302 "/xrpc/social.grain.photo.deletePhoto", 303 ["POST"], 304 async (req, _params, ctx) => { 305 ctx.requireAuth(); 306 const { uri, cascade = true } = await parseDeletePhotoInputs(req); 307 const success = await deletePhoto(uri, cascade, ctx); 308 return ctx.json({ success } satisfies DeletePhotoOutputSchema); 309 }, 310 ), 311 route( 312 "/xrpc/social.grain.graph.createFollow", 313 ["POST"], 314 async (req, _params, ctx) => { 315 ctx.requireAuth(); 316 const { subject } = await parseCreateFollowInputs(req); 317 if (!subject) { 318 throw new XRPCError("InvalidRequest", "Missing subject input"); 319 } 320 try { 321 const followUri = await createFollow(subject, ctx); 322 return ctx.json({ followUri } satisfies CreateFollowOutputSchema); 323 } catch (error) { 324 console.error("Error creating follow:", error); 325 throw new XRPCError("InternalServerError", "Failed to create follow"); 326 } 327 }, 328 ), 329 route( 330 "/xrpc/social.grain.graph.deleteFollow", 331 ["POST"], 332 async (req, _params, ctx) => { 333 ctx.requireAuth(); 334 const { uri } = await parseDeleteFollowInputs(req); 335 try { 336 await ctx.deleteRecord(uri); 337 return ctx.json({ success: true } satisfies DeleteFollowOutputSchema); 338 } catch (error) { 339 console.error("Error deleting follow:", error); 340 return ctx.json({ success: false } satisfies DeleteFollowOutputSchema); 341 } 342 }, 343 ), 344 route( 345 "/xrpc/social.grain.favorite.createFavorite", 346 ["POST"], 347 async (req, _params, ctx) => { 348 ctx.requireAuth(); 349 const { subject } = await parseCreateFavoriteInputs(req); 350 try { 351 const favoriteUri = await createFavorite(subject, ctx); 352 return ctx.json({ favoriteUri } as CreateFavoriteOutputSchema); 353 } catch (error) { 354 console.error("Error creating favorite:", error); 355 throw new XRPCError("InternalServerError", "Failed to create favorite"); 356 } 357 }, 358 ), 359 route( 360 "/xrpc/social.grain.favorite.deleteFavorite", 361 ["POST"], 362 async (req, _params, ctx) => { 363 ctx.requireAuth(); 364 const { uri } = await parseDeleteFavoriteInputs(req); 365 try { 366 await ctx.deleteRecord(uri); 367 return ctx.json({ success: true }); 368 } catch (error) { 369 console.error("Error deleting favorite:", error); 370 return ctx.json({ success: false } as DeleteFavoriteOutputSchema); 371 } 372 }, 373 ), 374 route( 375 "/xrpc/social.grain.comment.createComment", 376 ["POST"], 377 async (req, _params, ctx) => { 378 ctx.requireAuth(); 379 const { text, subject, focus, replyTo } = await parseCreateCommentInputs( 380 req, 381 ); 382 try { 383 const commentUri = await createComment( 384 { 385 text, 386 subject, 387 focus, 388 replyTo, 389 }, 390 ctx, 391 ); 392 return ctx.json({ commentUri } satisfies CreateCommentOutputSchema); 393 } catch (error) { 394 console.error("Error creating comment:", error); 395 throw new XRPCError("InternalServerError", "Failed to create comment"); 396 } 397 }, 398 ), 399 route( 400 "/xrpc/social.grain.comment.deleteComment", 401 ["POST"], 402 async (req, _params, ctx) => { 403 ctx.requireAuth(); 404 const { uri } = await parseDeleteCommentInputs(req); 405 try { 406 await ctx.deleteRecord(uri); 407 return ctx.json({ success: true } satisfies DeleteCommentOutputSchema); 408 } catch (error) { 409 console.error("Error deleting comment:", error); 410 return ctx.json({ success: false } satisfies DeleteCommentOutputSchema); 411 } 412 }, 413 ), 414 route( 415 "/xrpc/social.grain.actor.updateProfile", 416 ["POST"], 417 async (req, _params, ctx) => { 418 const { did } = ctx.requireAuth(); 419 const { displayName, description } = await parseUpdateProfileInputs(req); 420 try { 421 await updateActorProfile(did, ctx, { displayName, description }); 422 } catch (error) { 423 console.error("Error updating profile:", error); 424 ctx.json({ success: false } satisfies UpdateProfileOutputSchema); 425 } 426 return ctx.json({ success: true } satisfies UpdateProfileOutputSchema); 427 }, 428 ), 429 route( 430 "/xrpc/social.grain.actor.updateAvatar", 431 ["POST"], 432 async (req, _params, ctx) => { 433 const { did } = ctx.requireAuth(); 434 const bytes = await req.arrayBuffer(); 435 if (!bytes || bytes.byteLength === 0) { 436 throw new XRPCError("InvalidRequest", "Missing avatar blob"); 437 } 438 const MAX_SIZE = 1024 * 1024; // 1MB 439 if (bytes.byteLength > MAX_SIZE) { 440 throw new XRPCError( 441 "PayloadTooLarge", 442 "request entity too large", 443 ); 444 } 445 if (!ctx.agent) { 446 throw new XRPCError("AuthenticationRequired"); 447 } 448 const res = await ctx.agent.uploadBlob(new Uint8Array(bytes)); 449 if (!res.success) { 450 throw new XRPCError("InternalServerError", "Failed to upload avatar"); 451 } 452 const avatarBlob = res.data.blob; 453 try { 454 await updateActorProfile(did, ctx, { avatar: avatarBlob }); 455 } catch (error) { 456 console.error("Error updating profile:", error); 457 throw new XRPCError("InternalServerError", "Failed to update profile"); 458 } 459 return ctx.json({ success: true } satisfies UpdateAvatarOutputSchema); 460 }, 461 ), 462 route( 463 "/xrpc/social.grain.photo.applyAlts", 464 ["POST"], 465 async (req, _params, ctx) => { 466 ctx.requireAuth(); 467 const { writes } = await parseApplyAltsInputs(req); 468 const success = await applyAlts(writes, ctx); 469 return ctx.json({ success } satisfies ApplyAltsOutputSchema); 470 }, 471 ), 472 route( 473 "/xrpc/social.grain.gallery.applySort", 474 ["POST"], 475 async (req, _params, ctx) => { 476 ctx.requireAuth(); 477 const { writes } = await parseApplySortInputs(req); 478 const success = await applySort(writes, ctx); 479 return ctx.json({ success } satisfies ApplySortOutputSchema); 480 }, 481 ), 482 route("/xrpc/social.grain.actor.getProfile", (req, _params, ctx) => { 483 const url = new URL(req.url); 484 const { actor } = getProfileQueryParams(url); 485 const profile = getActorProfileDetailed(actor, ctx); 486 if (!profile) { 487 throw new XRPCError("NotFound", "Profile not found"); 488 } 489 return ctx.json(profile satisfies GetProfileOutputSchema); 490 }), 491 route("/xrpc/social.grain.gallery.getActorGalleries", (req, _params, ctx) => { 492 const url = new URL(req.url); 493 const { actor } = getActorGalleriesQueryParams(url); 494 const galleries = getActorGalleries(actor, ctx); 495 return ctx.json( 496 { items: galleries } satisfies GetActorGalleriesOutputSchema, 497 ); 498 }), 499 route("/xrpc/social.grain.actor.getActorFavs", (req, _params, ctx) => { 500 const url = new URL(req.url); 501 const { actor } = getActorFavsQueryParams(url); 502 const galleries = getActorGalleryFavs(actor, ctx); 503 return ctx.json({ items: galleries } satisfies GetActorFavsOutputSchema); 504 }), 505 route("/xrpc/social.grain.photo.getActorPhotos", (req, _params, ctx) => { 506 const url = new URL(req.url); 507 const { actor } = getActorPhotosQueryParams(url); 508 const photos = getActorPhotos(actor, ctx); 509 return ctx.json({ items: photos } satisfies GetActorPhotosOutputSchema); 510 }), 511 route("/xrpc/social.grain.gallery.getGallery", (req, _params, ctx) => { 512 const url = new URL(req.url); 513 const { uri } = getGalleryQueryParams(url); 514 const atUri = new AtUri(uri); 515 const did = atUri.hostname; 516 const rkey = atUri.rkey; 517 const gallery = getGallery(did, rkey, ctx); 518 if (!gallery) { 519 throw new XRPCError("NotFound", "Gallery not found"); 520 } 521 return ctx.json(gallery satisfies GetGalleryOutputSchema); 522 }), 523 route("/xrpc/social.grain.gallery.getGalleryThread", (req, _params, ctx) => { 524 const url = new URL(req.url); 525 const { uri } = getGalleryThreadQueryParams(url); 526 const atUri = new AtUri(uri); 527 const did = atUri.hostname; 528 const rkey = atUri.rkey; 529 const gallery = getGallery(did, rkey, ctx); 530 if (!gallery) { 531 throw new XRPCError("NotFound", "Gallery not found"); 532 } 533 const comments = getGalleryComments(uri, ctx); 534 return ctx.json( 535 { gallery, comments } satisfies GetGalleryThreadOutputSchema, 536 ); 537 }), 538 route("/xrpc/social.grain.feed.getTimeline", async (req, _params, ctx) => { 539 const url = new URL(req.url); 540 const { algorithm } = getTimelineQueryParams(url); 541 542 if (algorithm?.includes("hashtag")) { 543 const tag = algorithm.split("hashtag_")[1]; 544 545 const galleries = getGalleriesByHashtag(tag, ctx); 546 547 return ctx.json( 548 { feed: galleries } satisfies GetTimelineOutputSchema, 549 ); 550 } 551 552 const items = await getTimeline( 553 ctx, 554 algorithm === "following" ? "following" : "timeline", 555 "grain", 556 ); 557 return ctx.json( 558 { feed: items.map((i) => i.gallery) } satisfies GetTimelineOutputSchema, 559 ); 560 }), 561 route( 562 "/xrpc/social.grain.notification.getNotifications", 563 (_req, _params, ctx) => { 564 // @TODO: this redirects, we should have a json response 565 ctx.requireAuth(); 566 const notifications = getNotificationsDetailed( 567 ctx, 568 ); 569 return ctx.json( 570 { notifications } satisfies GetNotificationsOutputSchema, 571 ); 572 }, 573 ), 574 route( 575 "/xrpc/social.grain.actor.searchActors", 576 (req, _params, ctx) => { 577 const url = new URL(req.url); 578 const { q } = searchActorsQueryParams(url); 579 let results: ProfileView[] = []; 580 if (!q) { 581 results = []; 582 } else { 583 results = searchActors( 584 q, 585 ctx, 586 ); 587 } 588 return ctx.json( 589 { actors: results } satisfies SearchActorsOutputSchema, 590 ); 591 }, 592 ), 593 route("/xrpc/social.grain.graph.getFollows", (req, _params, ctx) => { 594 const url = new URL(req.url); 595 const { actor } = getFollowsQueryParams(url); 596 const subject = getActorProfile(actor, ctx); 597 if (!subject) { 598 throw new XRPCError("NotFound", "Actor not found"); 599 } 600 const follows = getFollowingWithProfiles(actor, ctx); 601 return ctx.json( 602 { 603 subject, 604 follows, 605 } satisfies GetFollowsOutputSchema, 606 ); 607 }), 608 route("/xrpc/social.grain.graph.getFollowers", (req, _params, ctx) => { 609 const url = new URL(req.url); 610 const { actor } = getFollowersQueryParams(url); 611 const subject = getActorProfile(actor, ctx); 612 if (!subject) { 613 throw new XRPCError("NotFound", "Subject not found"); 614 } 615 const followers = getFollowersWithProfiles(actor, ctx); 616 return ctx.json( 617 { 618 subject, 619 followers, 620 } satisfies GetFollowersOutputSchema, 621 ); 622 }), 623 route( 624 "/xrpc/social.grain.notification.updateSeen", 625 ["POST"], 626 async (req, _params, ctx) => { 627 ctx.requireAuth(); 628 const json = await req.json(); 629 const seenAt = json.seenAt satisfies string ?? undefined; 630 if (!seenAt) { 631 throw new XRPCError("InvalidRequest", "Missing seenAt input"); 632 } 633 ctx.updateSeen(seenAt); 634 return ctx.json(null); 635 }, 636 ), 637]; 638 639function getProfileQueryParams(url: URL): GetProfileQueryParams { 640 const actor = url.searchParams.get("actor"); 641 if (!actor) throw new XRPCError("InvalidRequest", "Missing actor parameter"); 642 return { actor }; 643} 644 645function getActorGalleriesQueryParams(url: URL): GetActorGalleriesQueryParams { 646 const actor = url.searchParams.get("actor"); 647 if (!actor) throw new XRPCError("InvalidRequest", "Missing actor parameter"); 648 const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); 649 if (isNaN(limit) || limit <= 0) { 650 throw new XRPCError("InvalidRequest", "Invalid limit parameter"); 651 } 652 const cursor = url.searchParams.get("cursor") ?? undefined; 653 return { actor, limit, cursor }; 654} 655 656function getActorFavsQueryParams(url: URL): GetActorFavsQueryParams { 657 const actor = url.searchParams.get("actor"); 658 if (!actor) throw new XRPCError("InvalidRequest", "Missing actor parameter"); 659 const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); 660 if (isNaN(limit) || limit <= 0) { 661 throw new XRPCError("InvalidRequest", "Invalid limit parameter"); 662 } 663 const cursor = url.searchParams.get("cursor") ?? undefined; 664 return { actor, limit, cursor }; 665} 666 667function getActorPhotosQueryParams(url: URL): GetActorPhotosQueryParams { 668 const actor = url.searchParams.get("actor"); 669 if (!actor) throw new XRPCError("InvalidRequest", "Missing actor parameter"); 670 const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); 671 if (isNaN(limit) || limit <= 0) { 672 throw new XRPCError("InvalidRequest", "Invalid limit parameter"); 673 } 674 const cursor = url.searchParams.get("cursor") ?? undefined; 675 return { actor, limit, cursor }; 676} 677 678function getGalleryQueryParams(url: URL): GetGalleryQueryParams { 679 const uri = url.searchParams.get("uri"); 680 if (!uri) throw new XRPCError("InvalidRequest", "Missing uri parameter"); 681 return { uri }; 682} 683 684function getGalleryThreadQueryParams(url: URL): GetGalleryThreadQueryParams { 685 const uri = url.searchParams.get("uri"); 686 if (!uri) throw new XRPCError("InvalidRequest", "Missing uri parameter"); 687 return { uri }; 688} 689 690function searchActorsQueryParams(url: URL): SearchActorsQueryParams { 691 const q = url.searchParams.get("q"); 692 if (!q) throw new XRPCError("InvalidRequest", "Missing q parameter"); 693 const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); 694 if (isNaN(limit) || limit <= 0) { 695 throw new XRPCError("InvalidRequest", "Invalid limit parameter"); 696 } 697 const cursor = url.searchParams.get("cursor") ?? undefined; 698 return { q, limit, cursor }; 699} 700 701function getFollowsQueryParams(url: URL): GetFollowsQueryParams { 702 const actor = url.searchParams.get("actor"); 703 if (!actor) throw new XRPCError("InvalidRequest", "Missing actor parameter"); 704 const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); 705 if (isNaN(limit) || limit <= 0) { 706 throw new XRPCError("InvalidRequest", "Invalid limit parameter"); 707 } 708 const cursor = url.searchParams.get("cursor") ?? undefined; 709 return { actor, limit, cursor }; 710} 711 712function getFollowersQueryParams(url: URL): GetFollowersQueryParams { 713 const actor = url.searchParams.get("actor"); 714 if (!actor) throw new XRPCError("InvalidRequest", "Missing actor parameter"); 715 const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); 716 if (isNaN(limit) || limit <= 0) { 717 throw new XRPCError("InvalidRequest", "Invalid limit parameter"); 718 } 719 const cursor = url.searchParams.get("cursor") ?? undefined; 720 return { actor, limit, cursor }; 721} 722 723function getTimelineQueryParams(url: URL): GetTimelineQueryParams { 724 const algorithm = url.searchParams.get("algorithm") ?? undefined; 725 const limit = parseInt(url.searchParams.get("limit") ?? "50", 10); 726 if (isNaN(limit) || limit <= 0) { 727 throw new XRPCError("InvalidRequest", "Invalid limit parameter"); 728 } 729 const cursor = url.searchParams.get("cursor") ?? undefined; 730 return { algorithm, limit, cursor }; 731} 732 733async function parseCreateGalleryInputs( 734 req: Request, 735): Promise<CreateGalleryInputSchema> { 736 const body = await req.json(); 737 const title = typeof body.title === "string" ? body.title : undefined; 738 if (!title) { 739 throw new XRPCError("InvalidRequest", "Missing title input"); 740 } 741 const description = typeof body.description === "string" 742 ? body.description 743 : undefined; 744 return { title, description }; 745} 746 747async function parseUpdateGalleryInputs( 748 req: Request, 749): Promise<UpdateGalleryInputSchema> { 750 const body = await req.json(); 751 const title = typeof body.title === "string" ? body.title : undefined; 752 if (!title) { 753 throw new XRPCError("InvalidRequest", "Missing title input"); 754 } 755 const description = typeof body.description === "string" 756 ? body.description 757 : undefined; 758 const galleryUri = typeof body.galleryUri === "string" 759 ? body.galleryUri 760 : undefined; 761 if (!galleryUri) { 762 throw new XRPCError("InvalidRequest", "Missing galleryUri input"); 763 } 764 return { title, description, galleryUri }; 765} 766 767async function parseDeleteGalleryInputs( 768 req: Request, 769): Promise<DeleteGalleryInputSchema> { 770 const body = await req.json(); 771 const uri = typeof body.uri === "string" ? body.uri : undefined; 772 if (!uri) { 773 throw new XRPCError("InvalidRequest", "Missing uri input"); 774 } 775 const cascade = typeof body.cascade === "boolean" ? body.cascade : undefined; 776 return { uri, cascade }; 777} 778 779async function parseCreateGalleryItemInputs( 780 req: Request, 781): Promise<CreateGalleryItemInputSchema> { 782 const body = await req.json(); 783 const galleryUri = typeof body.galleryUri === "string" 784 ? body.galleryUri 785 : undefined; 786 if (!galleryUri) { 787 throw new XRPCError("InvalidRequest", "Missing galleryUri input"); 788 } 789 const photoUri = typeof body.photoUri === "string" 790 ? body.photoUri 791 : undefined; 792 if (!photoUri) { 793 throw new XRPCError("InvalidRequest", "Missing photoUri input"); 794 } 795 const position = typeof body.position === "number" 796 ? body.position 797 : undefined; 798 if (position === undefined) { 799 throw new XRPCError("InvalidRequest", "Missing position input"); 800 } 801 return { galleryUri, photoUri, position }; 802} 803 804async function parseDeleteGalleryItemInputs( 805 req: Request, 806): Promise<DeleteGalleryItemInputSchema> { 807 const body = await req.json(); 808 const uri = typeof body.uri === "string" ? body.uri : undefined; 809 if (!uri) { 810 throw new XRPCError("InvalidRequest", "Missing uri input"); 811 } 812 return { uri }; 813} 814 815async function parseDeletePhotoInputs( 816 req: Request, 817): Promise<DeletePhotoInputSchema> { 818 const body = await req.json(); 819 const uri = typeof body.uri === "string" ? body.uri : undefined; 820 if (!uri) { 821 throw new XRPCError("InvalidRequest", "Missing uri input"); 822 } 823 const cascade = typeof body.cascade === "boolean" ? body.cascade : undefined; 824 return { uri, cascade }; 825} 826 827async function parseCreateFollowInputs( 828 req: Request, 829): Promise<CreateFollowInputSchema> { 830 const body = await req.json(); 831 const subject = typeof body.subject === "string" ? body.subject : undefined; 832 if (!subject) { 833 throw new XRPCError("InvalidRequest", "Missing subject input"); 834 } 835 return { subject }; 836} 837 838async function parseDeleteFollowInputs( 839 req: Request, 840): Promise<DeleteFollowInputSchema> { 841 const body = await req.json(); 842 const uri = typeof body.uri === "string" ? body.uri : undefined; 843 if (!uri) { 844 throw new XRPCError("InvalidRequest", "Missing uri input"); 845 } 846 return { uri }; 847} 848 849async function parseCreateFavoriteInputs( 850 req: Request, 851): Promise<CreateFavoriteInputSchema> { 852 const body = await req.json(); 853 const subject = typeof body.subject === "string" ? body.subject : undefined; 854 if (!subject) { 855 throw new XRPCError("InvalidRequest", "Missing subject input"); 856 } 857 return { subject }; 858} 859 860async function parseDeleteFavoriteInputs( 861 req: Request, 862): Promise<DeleteFavoriteInputSchema> { 863 const body = await req.json(); 864 const uri = typeof body.uri === "string" ? body.uri : undefined; 865 if (!uri) { 866 throw new XRPCError("InvalidRequest", "Missing uri input"); 867 } 868 return { uri }; 869} 870 871async function parseCreateCommentInputs( 872 req: Request, 873): Promise<CreateCommentInputSchema> { 874 const body = await req.json(); 875 const text = typeof body.text === "string" ? body.text : undefined; 876 if (!text) { 877 throw new XRPCError("InvalidRequest", "Missing text input"); 878 } 879 const subject = typeof body.subject === "string" ? body.subject : undefined; 880 if (!subject) { 881 throw new XRPCError("InvalidRequest", "Missing subject input"); 882 } 883 const focus = typeof body.focus === "string" ? body.focus : undefined; 884 const replyTo = typeof body.replyTo === "string" ? body.replyTo : undefined; 885 return { text, subject, focus, replyTo }; 886} 887 888async function parseDeleteCommentInputs( 889 req: Request, 890): Promise<DeleteCommentInputSchema> { 891 const body = await req.json(); 892 const uri = typeof body.uri === "string" ? body.uri : undefined; 893 if (!uri) { 894 throw new XRPCError("InvalidRequest", "Missing uri input"); 895 } 896 return { uri }; 897} 898 899async function parseUpdateProfileInputs( 900 req: Request, 901): Promise<UpdateProfileInputSchema> { 902 const body = await req.json(); 903 const displayName = typeof body.displayName === "string" 904 ? body.displayName 905 : undefined; 906 const description = typeof body.description === "string" 907 ? body.description 908 : undefined; 909 return { displayName, description }; 910} 911 912async function parseApplyAltsInputs( 913 req: Request, 914): Promise<ApplyAltsInputSchema> { 915 const body = await req.json(); 916 if (!body || typeof body !== "object" || !Array.isArray(body.writes)) { 917 throw new XRPCError("InvalidRequest", "Missing or invalid writes array"); 918 } 919 const writes = Array.isArray(body.writes) 920 ? body.writes.filter( 921 (item: unknown): item is { photoUri: string; alt: string } => 922 typeof item === "object" && 923 item !== null && 924 typeof (item as { photoUri?: unknown }).photoUri === "string" && 925 typeof (item as { alt?: unknown }).alt === "string", 926 ) 927 : []; 928 return { writes }; 929} 930 931async function parseApplySortInputs( 932 req: Request, 933): Promise<ApplySortInputSchema> { 934 const body = await req.json(); 935 const writes = Array.isArray(body.writes) && body.writes.every( 936 (item: unknown): item is { itemUri: string; position: number } => 937 typeof item === "object" && 938 item !== null && 939 typeof (item as { itemUri?: unknown }).itemUri === "string" && 940 typeof (item as { position?: unknown }).position === "number", 941 ) 942 ? body.writes 943 : []; 944 return { writes }; 945} 946 947async function parseExifInputs( 948 req: Request, 949): Promise<{ 950 photo: string; 951 dateTimeOriginal?: string; 952 exposureTime?: number; 953 fNumber?: number; 954 flash?: string; 955 focalLengthIn35mmFormat?: number; 956 iSO?: number; 957 lensMake?: string; 958 lensModel?: string; 959 make?: string; 960 model?: string; 961}> { 962 const body = await req.json(); 963 const photo = typeof body.photo === "string" ? body.photo : undefined; 964 if (!photo) { 965 throw new XRPCError("InvalidRequest", "Missing photo input"); 966 } 967 const dateTimeOriginal = typeof body.dateTimeOriginal === "string" 968 ? body.dateTimeOriginal 969 : undefined; 970 const exposureTime = typeof body.exposureTime === "number" 971 ? body.exposureTime 972 : undefined; 973 const fNumber = typeof body.fNumber === "number" ? body.fNumber : undefined; 974 const flash = typeof body.flash === "string" ? body.flash : undefined; 975 const focalLengthIn35mmFormat = 976 typeof body.focalLengthIn35mmFormat === "number" 977 ? body.focalLengthIn35mmFormat 978 : undefined; 979 const iSO = typeof body.iSO === "number" ? body.iSO : undefined; 980 const lensMake = typeof body.lensMake === "string" 981 ? body.lensMake 982 : undefined; 983 const lensModel = typeof body.lensModel === "string" 984 ? body.lensModel 985 : undefined; 986 const make = typeof body.make === "string" ? body.make : undefined; 987 const model = typeof body.model === "string" ? body.model : undefined; 988 989 return { 990 photo, 991 dateTimeOriginal, 992 exposureTime, 993 fNumber, 994 flash, 995 focalLengthIn35mmFormat, 996 iSO, 997 lensMake, 998 lensModel, 999 make, 1000 model, 1001 }; 1002}