From 785a5b574992296e187a66412dd42f7b4a686353 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Tue, 19 Mar 2024 00:33:11 +0000 Subject: Feature: Add support for uploading images and automatically inferring their tags (#2) * feature: Experimental support for asset uploads * feature(web): Add new bookmark type asset * feature: Add support for automatically tagging images * fix: Add support for image assets in preview page * use next Image for fetching the images * Fix auth and error codes in the route handlers * Add support for image uploads on mobile * Fix typing of upload requests * Remove the ugly dragging box * Bump mobile version to 1.3 * Change the editor card placeholder to mention uploading images * Fix a typo * Change ios icon for photo library * Silence typescript error --- apps/web/app/api/assets/[assetId]/route.ts | 29 +++++++++++++++++ apps/web/app/api/assets/route.ts | 52 ++++++++++++++++++++++++++++++ apps/web/app/api/trpc/[trpc]/route.ts | 19 ++--------- apps/web/app/dashboard/layout.tsx | 3 +- 4 files changed, 85 insertions(+), 18 deletions(-) create mode 100644 apps/web/app/api/assets/[assetId]/route.ts create mode 100644 apps/web/app/api/assets/route.ts (limited to 'apps/web/app') diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts new file mode 100644 index 00000000..6b583e51 --- /dev/null +++ b/apps/web/app/api/assets/[assetId]/route.ts @@ -0,0 +1,29 @@ +import { createContextFromRequest } from "@/server/api/client"; +import { and, eq } from "drizzle-orm"; + +import { db } from "@hoarder/db"; +import { assets } from "@hoarder/db/schema"; + +export const dynamic = "force-dynamic"; +export async function GET( + request: Request, + { params }: { params: { assetId: string } }, +) { + const ctx = await createContextFromRequest(request); + if (!ctx.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const asset = await db.query.assets.findFirst({ + where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)), + }); + + if (!asset) { + return Response.json({ error: "Asset not found" }, { status: 404 }); + } + return new Response(asset.blob as string, { + status: 200, + headers: { + "Content-type": asset.contentType, + }, + }); +} diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts new file mode 100644 index 00000000..2caa4d4c --- /dev/null +++ b/apps/web/app/api/assets/route.ts @@ -0,0 +1,52 @@ +import { createContextFromRequest } from "@/server/api/client"; + +import type { ZUploadResponse } from "@hoarder/trpc/types/uploads"; +import { db } from "@hoarder/db"; +import { assets } from "@hoarder/db/schema"; + +const SUPPORTED_ASSET_TYPES = new Set(["image/jpeg", "image/png"]); + +const MAX_UPLOAD_SIZE_BYTES = 4 * 1024 * 1024; + +export const dynamic = "force-dynamic"; +export async function POST(request: Request) { + const ctx = await createContextFromRequest(request); + if (!ctx.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const formData = await request.formData(); + const data = formData.get("image"); + let buffer; + let contentType; + if (data instanceof File) { + contentType = data.type; + if (!SUPPORTED_ASSET_TYPES.has(contentType)) { + return Response.json( + { error: "Unsupported asset type" }, + { status: 400 }, + ); + } + if (data.size > MAX_UPLOAD_SIZE_BYTES) { + return Response.json({ error: "Asset is too big" }, { status: 413 }); + } + buffer = Buffer.from(await data.arrayBuffer()); + } else { + return Response.json({ error: "Bad request" }, { status: 400 }); + } + + const [dbRes] = await db + .insert(assets) + .values({ + encoding: "binary", + contentType: contentType, + blob: buffer, + userId: ctx.user.id, + }) + .returning(); + + return Response.json({ + assetId: dbRes.id, + contentType: dbRes.contentType, + size: buffer.byteLength, + } satisfies ZUploadResponse); +} diff --git a/apps/web/app/api/trpc/[trpc]/route.ts b/apps/web/app/api/trpc/[trpc]/route.ts index 23df286f..1afcb886 100644 --- a/apps/web/app/api/trpc/[trpc]/route.ts +++ b/apps/web/app/api/trpc/[trpc]/route.ts @@ -1,8 +1,6 @@ -import { createContext } from "@/server/api/client"; +import { createContextFromRequest } from "@/server/api/client"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; -import { db } from "@hoarder/db"; -import { authenticateApiKey } from "@hoarder/trpc/auth"; import { appRouter } from "@hoarder/trpc/routers/_app"; const handler = (req: Request) => @@ -18,20 +16,7 @@ const handler = (req: Request) => }, createContext: async (opts) => { - // TODO: This is a hack until we offer a proper REST API instead of the trpc based one. - // Check if the request has an Authorization token, if it does, assume that API key authentication is requested. - const authorizationHeader = opts.req.headers.get("Authorization"); - if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { - const token = authorizationHeader.split(" ")[1]; - try { - const user = await authenticateApiKey(token); - return { user, db }; - } catch (e) { - // Fallthrough to cookie-based auth - } - } - - return createContext(); + return await createContextFromRequest(opts.req); }, }); export { handler as GET, handler as POST }; diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index dc3af9c7..27e06955 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -1,5 +1,6 @@ import MobileSidebar from "@/components/dashboard/sidebar/ModileSidebar"; import Sidebar from "@/components/dashboard/sidebar/Sidebar"; +import UploadDropzone from "@/components/dashboard/UploadDropzone"; import { Separator } from "@/components/ui/separator"; export default async function Dashboard({ @@ -17,7 +18,7 @@ export default async function Dashboard({ - {children} + {children} ); -- cgit v1.2.3-70-g09d2