grain.social is a photo sharing platform built on atproto.
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}