grain.social is a photo sharing platform built on atproto.

add did/collection/rkey route and appropriate redirects

Changed files
+225 -74
src
+40 -5
src/lib/errors.ts
··· 1 1 import { OAUTH_ROUTES, RateLimitError, UnauthorizedError } from "@bigmoves/bff"; 2 2 import { formatDuration, intervalToDuration } from "date-fns"; 3 3 4 + function errorResponse(message: string, status: number): Response { 5 + return new Response(message, { 6 + status, 7 + headers: { "Content-Type": "text/plain; charset=utf-8" }, 8 + }); 9 + } 10 + 4 11 export function onError(err: unknown): Response { 12 + if (err instanceof BadRequestError) { 13 + return errorResponse(err.message, 400); 14 + } 15 + if (err instanceof ServerError) { 16 + return errorResponse(err.message, 500); 17 + } 18 + if (err instanceof NotFoundError) { 19 + return errorResponse(err.message, 404); 20 + } 5 21 if (err instanceof UnauthorizedError) { 6 22 const ctx = err.ctx; 7 23 return ctx.redirect(OAUTH_ROUTES.loginPage); ··· 18 34 { 19 35 status: 429, 20 36 headers: { 21 - ...err.retryAfter && { "Retry-After": err.retryAfter.toString() }, 22 - "Content-Type": "text/plain", 37 + ...(err.retryAfter && { "Retry-After": err.retryAfter.toString() }), 38 + "Content-Type": "text/plain; charset=utf-8", 23 39 }, 24 40 }, 25 41 ); 26 42 } 27 - return new Response("Internal Server Error", { 28 - status: 500, 29 - }); 43 + return errorResponse("Internal Server Error", 500); 44 + } 45 + 46 + export class NotFoundError extends Error { 47 + constructor(message = "Not Found") { 48 + super(message); 49 + this.name = "NotFoundError"; 50 + } 51 + } 52 + 53 + export const ServerError = class extends Error { 54 + constructor(message = "Internal Server Error") { 55 + super(message); 56 + this.name = "ServerError"; 57 + } 58 + }; 59 + 60 + export class BadRequestError extends Error { 61 + constructor(message: string = "Bad Request") { 62 + super(message); 63 + this.name = "BadRequestError"; 64 + } 30 65 }
-66
src/lib/uploads.tsx
··· 1 - import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 2 - import { BffMiddleware, route, RouteHandler } from "@bigmoves/bff"; 3 - import { PhotoPreview } from "../components/PhotoPreview.tsx"; 4 - import { photoThumb } from "./photo.ts"; 5 - 6 - function uploadPhoto(): RouteHandler { 7 - return async (req, _params, ctx) => { 8 - const { did } = ctx.requireAuth(); 9 - ctx.rateLimit({ 10 - namespace: "upload", 11 - points: 1, 12 - limit: 50, 13 - window: 24 * 60 * 60 * 1000, // 24 hours 14 - }); 15 - if (!ctx.agent) { 16 - return new Response("Agent has not been initialized", { status: 401 }); 17 - } 18 - try { 19 - const formData = await req.formData(); 20 - const file = formData.get("file") as File; 21 - const width = Number(formData.get("width")) || undefined; 22 - const height = Number(formData.get("height")) || undefined; 23 - if (!file) { 24 - return new Response("No file", { status: 400 }); 25 - } 26 - // Check if file size exceeds 20MB limit 27 - const maxSizeBytes = 20 * 1000 * 1000; // 20MB in bytes 28 - if (file.size > maxSizeBytes) { 29 - return new Response("File too large. Maximum size is 20MB", { 30 - status: 400, 31 - }); 32 - } 33 - const blobResponse = await ctx.agent.uploadBlob(file); 34 - const photoUri = await ctx.createRecord<Photo>("social.grain.photo", { 35 - photo: blobResponse.data.blob, 36 - aspectRatio: width && height 37 - ? { 38 - width, 39 - height, 40 - } 41 - : undefined, 42 - alt: "", 43 - createdAt: new Date().toISOString(), 44 - }); 45 - return ctx.html( 46 - <PhotoPreview 47 - src={photoThumb(did, blobResponse.data.blob.ref.toString())} 48 - uri={photoUri} 49 - />, 50 - ); 51 - } catch (e) { 52 - console.error("Error in uploadStart:", e); 53 - return new Response("Internal Server Error", { status: 500 }); 54 - } 55 - }; 56 - } 57 - 58 - export function photoUploadRoutes(): BffMiddleware[] { 59 - return [ 60 - route( 61 - `/actions/photo/upload`, 62 - ["POST"], 63 - uploadPhoto(), 64 - ), 65 - ]; 66 - }
+3 -2
src/main.tsx
··· 4 4 import { LoginPage } from "./components/LoginPage.tsx"; 5 5 import { PDS_HOST_URL } from "./env.ts"; 6 6 import { onError } from "./lib/errors.ts"; 7 - import { photoUploadRoutes } from "./lib/uploads.tsx"; 8 7 import * as actionHandlers from "./routes/actions.tsx"; 9 8 import * as dialogHandlers from "./routes/dialogs.tsx"; 10 9 import { handler as exploreHandler } from "./routes/explore.tsx"; ··· 12 11 import { handler as notificationsHandler } from "./routes/notifications.tsx"; 13 12 import { handler as onboardHandler } from "./routes/onboard.tsx"; 14 13 import { handler as profileHandler } from "./routes/profile.tsx"; 14 + import { handler as recordHandler } from "./routes/record.tsx"; 15 15 import { handler as timelineHandler } from "./routes/timeline.tsx"; 16 16 import { handler as uploadHandler } from "./routes/upload.tsx"; 17 17 import { appStateMiddleware, type State } from "./state.ts"; ··· 97 97 actionHandlers.gallerySort, 98 98 ), 99 99 route("/actions/get-blob", ["GET"], actionHandlers.getBlob), 100 - ...photoUploadRoutes(), 100 + route("/actions/photo/upload", ["POST"], actionHandlers.uploadPhoto), 101 + route("/:did/:collection/:rkey", recordHandler), 101 102 ], 102 103 });
+56 -1
src/routes/actions.tsx
··· 10 10 import { FavoriteButton } from "../components/FavoriteButton.tsx"; 11 11 import { FollowButton } from "../components/FollowButton.tsx"; 12 12 import { PhotoButton } from "../components/PhotoButton.tsx"; 13 + import { PhotoPreview } from "../components/PhotoPreview.tsx"; 13 14 import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx"; 14 15 import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts"; 15 - import { photoToView } from "../lib/photo.ts"; 16 + import { photoThumb, photoToView } from "../lib/photo.ts"; 16 17 import type { State } from "../state.ts"; 17 18 import { galleryLink } from "../utils.ts"; 18 19 ··· 428 429 return new Response("Error fetching blob", { status: 500 }); 429 430 } 430 431 }; 432 + 433 + export const uploadPhoto: RouteHandler = async ( 434 + req, 435 + _params, 436 + ctx: BffContext<State>, 437 + ) => { 438 + const { did } = ctx.requireAuth(); 439 + ctx.rateLimit({ 440 + namespace: "upload", 441 + points: 1, 442 + limit: 50, 443 + window: 24 * 60 * 60 * 1000, // 24 hours 444 + }); 445 + if (!ctx.agent) { 446 + return new Response("Agent has not been initialized", { status: 401 }); 447 + } 448 + try { 449 + const formData = await req.formData(); 450 + const file = formData.get("file") as File; 451 + const width = Number(formData.get("width")) || undefined; 452 + const height = Number(formData.get("height")) || undefined; 453 + if (!file) { 454 + return new Response("No file", { status: 400 }); 455 + } 456 + // Check if file size exceeds 20MB limit 457 + const maxSizeBytes = 20 * 1000 * 1000; // 20MB in bytes 458 + if (file.size > maxSizeBytes) { 459 + return new Response("File too large. Maximum size is 20MB", { 460 + status: 400, 461 + }); 462 + } 463 + const blobResponse = await ctx.agent.uploadBlob(file); 464 + const photoUri = await ctx.createRecord<Photo>("social.grain.photo", { 465 + photo: blobResponse.data.blob, 466 + aspectRatio: width && height 467 + ? { 468 + width, 469 + height, 470 + } 471 + : undefined, 472 + alt: "", 473 + createdAt: new Date().toISOString(), 474 + }); 475 + return ctx.html( 476 + <PhotoPreview 477 + src={photoThumb(did, blobResponse.data.blob.ref.toString())} 478 + uri={photoUri} 479 + />, 480 + ); 481 + } catch (e) { 482 + console.error("Error in uploadStart:", e); 483 + return new Response("Internal Server Error", { status: 500 }); 484 + } 485 + };
+126
src/routes/record.tsx
··· 1 + import { Record as Profile } from "$lexicon/types/social/grain/actor/profile.ts"; 2 + import { Record as Favorite } from "$lexicon/types/social/grain/favorite.ts"; 3 + import { Record as Gallery } from "$lexicon/types/social/grain/gallery.ts"; 4 + import { Record as GalleryItem } from "$lexicon/types/social/grain/gallery/item.ts"; 5 + import { Record as Photo } from "$lexicon/types/social/grain/photo.ts"; 6 + import { AtUri } from "@atproto/syntax"; 7 + import { BffContext, RouteHandler, WithBffMeta } from "@bigmoves/bff"; 8 + import { BadRequestError, NotFoundError, ServerError } from "../lib/errors.ts"; 9 + import type { State } from "../state.ts"; 10 + import { galleryLink } from "../utils.ts"; 11 + 12 + export const handler: RouteHandler = ( 13 + _req, 14 + params, 15 + ctx: BffContext<State>, 16 + ) => { 17 + const { did, collection, rkey } = params; 18 + 19 + if (!did || !collection || !rkey) { 20 + throw new BadRequestError("Invalid parameters for record handler"); 21 + } 22 + 23 + if (!did.startsWith("did:")) { 24 + throw new NotFoundError(); 25 + } 26 + 27 + const actor = ctx.indexService.getActor(did); 28 + if (!actor) { 29 + throw new NotFoundError( 30 + `Actor not found or missing handle for did: ${did}`, 31 + ); 32 + } 33 + 34 + switch (collection) { 35 + case "social.grain.actor.profile": { 36 + if (rkey !== "self") { 37 + throw new NotFoundError(`Invalid rkey for actor profile: ${rkey}`); 38 + } 39 + const profile = ctx.indexService.getRecord<WithBffMeta<Profile>>( 40 + `at://${did}/social.grain.actor.profile/${rkey}`, 41 + ); 42 + if (!profile) { 43 + throw new NotFoundError( 44 + `Profile not found for did: ${did}, rkey: ${rkey}`, 45 + ); 46 + } 47 + return ctx.redirect(`/profile/${actor.handle}`); 48 + } 49 + 50 + case "social.grain.gallery": { 51 + const gallery = ctx.indexService.getRecord<WithBffMeta<Gallery>>( 52 + `at://${did}/social.grain.gallery/${rkey}`, 53 + ); 54 + if (!gallery) { 55 + throw new NotFoundError( 56 + `Gallery not found for did: ${did}, rkey: ${rkey}`, 57 + ); 58 + } 59 + return ctx.redirect(galleryLink( 60 + actor.handle, 61 + new AtUri(gallery.uri).rkey, 62 + )); 63 + } 64 + 65 + case "social.grain.gallery.item": { 66 + const galleryItem = ctx.indexService.getRecord<WithBffMeta<GalleryItem>>( 67 + `at://${did}/social.grain.gallery.item/${rkey}`, 68 + ); 69 + if (!galleryItem) { 70 + throw new NotFoundError( 71 + `Gallery item not found for did: ${did}, rkey: ${rkey}`, 72 + ); 73 + } 74 + const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>( 75 + galleryItem.item, 76 + ); 77 + if (!photo) { 78 + throw new NotFoundError( 79 + `Photo not found for gallery item: ${galleryItem.item}`, 80 + ); 81 + } 82 + return ctx.redirect( 83 + `/actions/get-blob?did=${did}&cid=${photo.photo.ref.toString()}`, 84 + ); 85 + } 86 + 87 + case "social.grain.photo": { 88 + const photo = ctx.indexService.getRecord<WithBffMeta<Photo>>( 89 + `at://${did}/social.grain.photo/${rkey}`, 90 + ); 91 + if (!photo) { 92 + throw new NotFoundError( 93 + `Photo not found for did: ${did}, rkey: ${rkey}`, 94 + ); 95 + } 96 + return ctx.redirect( 97 + `/actions/get-blob?did=${did}&cid=${photo.photo.ref.toString()}`, 98 + ); 99 + } 100 + 101 + case "social.grain.favorite": { 102 + const favorite = ctx.indexService.getRecord<WithBffMeta<Favorite>>( 103 + `at://${did}/social.grain.favorite/${rkey}`, 104 + ); 105 + if (!favorite) { 106 + throw new NotFoundError( 107 + `Favorite not found for did: ${did}, rkey: ${rkey}`, 108 + ); 109 + } 110 + const subjectActor = ctx.indexService.getActor( 111 + new AtUri(favorite.subject).hostname, 112 + ); 113 + if (!subjectActor) { 114 + throw new NotFoundError( 115 + `Subject actor not found or missing handle for subject: ${favorite.subject}`, 116 + ); 117 + } 118 + return ctx.redirect( 119 + galleryLink(subjectActor.handle, new AtUri(favorite.subject).rkey), 120 + ); 121 + } 122 + 123 + default: 124 + throw new ServerError(`Unsupported collection: ${collection}`); 125 + } 126 + };