+40
-5
src/lib/errors.ts
+40
-5
src/lib/errors.ts
···
1
import { OAUTH_ROUTES, RateLimitError, UnauthorizedError } from "@bigmoves/bff";
2
import { formatDuration, intervalToDuration } from "date-fns";
3
4
export function onError(err: unknown): Response {
5
if (err instanceof UnauthorizedError) {
6
const ctx = err.ctx;
7
return ctx.redirect(OAUTH_ROUTES.loginPage);
···
18
{
19
status: 429,
20
headers: {
21
-
...err.retryAfter && { "Retry-After": err.retryAfter.toString() },
22
-
"Content-Type": "text/plain",
23
},
24
},
25
);
26
}
27
-
return new Response("Internal Server Error", {
28
-
status: 500,
29
-
});
30
}
···
1
import { OAUTH_ROUTES, RateLimitError, UnauthorizedError } from "@bigmoves/bff";
2
import { formatDuration, intervalToDuration } from "date-fns";
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
+
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
+
}
21
if (err instanceof UnauthorizedError) {
22
const ctx = err.ctx;
23
return ctx.redirect(OAUTH_ROUTES.loginPage);
···
34
{
35
status: 429,
36
headers: {
37
+
...(err.retryAfter && { "Retry-After": err.retryAfter.toString() }),
38
+
"Content-Type": "text/plain; charset=utf-8",
39
},
40
},
41
);
42
}
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
+
}
65
}
-66
src/lib/uploads.tsx
-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
+3
-2
src/main.tsx
···
4
import { LoginPage } from "./components/LoginPage.tsx";
5
import { PDS_HOST_URL } from "./env.ts";
6
import { onError } from "./lib/errors.ts";
7
-
import { photoUploadRoutes } from "./lib/uploads.tsx";
8
import * as actionHandlers from "./routes/actions.tsx";
9
import * as dialogHandlers from "./routes/dialogs.tsx";
10
import { handler as exploreHandler } from "./routes/explore.tsx";
···
12
import { handler as notificationsHandler } from "./routes/notifications.tsx";
13
import { handler as onboardHandler } from "./routes/onboard.tsx";
14
import { handler as profileHandler } from "./routes/profile.tsx";
15
import { handler as timelineHandler } from "./routes/timeline.tsx";
16
import { handler as uploadHandler } from "./routes/upload.tsx";
17
import { appStateMiddleware, type State } from "./state.ts";
···
97
actionHandlers.gallerySort,
98
),
99
route("/actions/get-blob", ["GET"], actionHandlers.getBlob),
100
-
...photoUploadRoutes(),
101
],
102
});
···
4
import { LoginPage } from "./components/LoginPage.tsx";
5
import { PDS_HOST_URL } from "./env.ts";
6
import { onError } from "./lib/errors.ts";
7
import * as actionHandlers from "./routes/actions.tsx";
8
import * as dialogHandlers from "./routes/dialogs.tsx";
9
import { handler as exploreHandler } from "./routes/explore.tsx";
···
11
import { handler as notificationsHandler } from "./routes/notifications.tsx";
12
import { handler as onboardHandler } from "./routes/onboard.tsx";
13
import { handler as profileHandler } from "./routes/profile.tsx";
14
+
import { handler as recordHandler } from "./routes/record.tsx";
15
import { handler as timelineHandler } from "./routes/timeline.tsx";
16
import { handler as uploadHandler } from "./routes/upload.tsx";
17
import { appStateMiddleware, type State } from "./state.ts";
···
97
actionHandlers.gallerySort,
98
),
99
route("/actions/get-blob", ["GET"], actionHandlers.getBlob),
100
+
route("/actions/photo/upload", ["POST"], actionHandlers.uploadPhoto),
101
+
route("/:did/:collection/:rkey", recordHandler),
102
],
103
});
+56
-1
src/routes/actions.tsx
+56
-1
src/routes/actions.tsx
···
10
import { FavoriteButton } from "../components/FavoriteButton.tsx";
11
import { FollowButton } from "../components/FollowButton.tsx";
12
import { PhotoButton } from "../components/PhotoButton.tsx";
13
import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx";
14
import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts";
15
-
import { photoToView } from "../lib/photo.ts";
16
import type { State } from "../state.ts";
17
import { galleryLink } from "../utils.ts";
18
···
428
return new Response("Error fetching blob", { status: 500 });
429
}
430
};
···
10
import { FavoriteButton } from "../components/FavoriteButton.tsx";
11
import { FollowButton } from "../components/FollowButton.tsx";
12
import { PhotoButton } from "../components/PhotoButton.tsx";
13
+
import { PhotoPreview } from "../components/PhotoPreview.tsx";
14
import { PhotoSelectButton } from "../components/PhotoSelectButton.tsx";
15
import { deleteGallery, getGallery, getGalleryFavs } from "../lib/gallery.ts";
16
+
import { photoThumb, photoToView } from "../lib/photo.ts";
17
import type { State } from "../state.ts";
18
import { galleryLink } from "../utils.ts";
19
···
429
return new Response("Error fetching blob", { status: 500 });
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
+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
+
};