diff --git a/apps/web/app/api/[...route]/route.ts b/apps/web/app/api/[...route]/route.ts index 5f1ac04..b7ac169 100644 --- a/apps/web/app/api/[...route]/route.ts +++ b/apps/web/app/api/[...route]/route.ts @@ -1,11 +1,47 @@ -import { Hono } from "hono"; +import { Context, Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; import { logger } from "hono/logger"; import { prettyJSON } from "hono/pretty-json"; import { handle } from "hono/vercel"; import { createClient } from "@/lib/server/supabase"; +import { createId } from "@/lib/create-id"; export const dynamic = "force-dynamic"; +import bcrypt from "bcrypt"; +import { axiom } from "lib/axiom"; + +const UnauthorizedError = (c: Context) => { + return c.json({ message: "Unauthorized" }, { status: 401 }); +}; + +const BlogNotFoundError = (c: Context) => { + return c.json({ message: "Blog not found" }, { status: 404 }); +}; + +const ErrorRotatingAPIKey = (c: Context) => { + return c.json({ message: "Error rotating API key" }, { status: 500 }); +}; + +const errors = { + UnauthorizedError, + BlogNotFoundError, + ErrorRotatingAPIKey, +}; + +const handleError = (c: Context, error: keyof typeof errors, rawLog: any) => { + console.log("🔴", error); + axiom.ingest("api", { + message: error, + error: true, + blogId: c.req.param("blogId"), + userId: c.req.param("userId"), + method: c.req.method, + path: c.req.url, + rawLog, + }); + + return errors[error](c); +}; const getUser = async () => { const supabase = createClient(); @@ -17,26 +53,65 @@ const getUser = async () => { }; }; -const api = new Hono().post( - "/test", - zValidator( - "json", - z.object({ - name: z.string().min(1), - }) - ), - async (c) => { +const api = new Hono() + // Rotates the API key for a blog + .post("/blogs/:blogId/api-keys/rotate", async (c) => { const { user } = await getUser(); - console.log("USER -->>", user?.email); - return c.json({ message: "test" }); - } -); + const supabase = createClient(); + + if (!user) { + return UnauthorizedError(c); + } + + // Check that the user is the owner of the blog + const { data: blog, error: blogError } = await supabase + .from("blogs") + .select("id") + .eq("id", c.req.param("blogId")) + .eq("user_id", user?.id) + .single(); + + if (blogError || !blog) { + return BlogNotFoundError(c); + } + + // Create API key + const newAPIKey = createId({ type: "blog", secret: true }); + const hashedAPIKey = await bcrypt.hash(newAPIKey, 10); + + const { data, error } = await supabase + .from("blogs") + .update({ + access_token: hashedAPIKey, + }) + .eq("id", blog?.id); + + if (error) { + console.log(error); + return handleError(c, "ErrorRotatingAPIKey", error); + } + + // Return the new API key to the client for use / storage + return c.json({ message: "success", apiKey: newAPIKey }, 200); + }); const app = new Hono() .basePath("/api") // MIDDLEWARE .use("*", logger()) .use("*", prettyJSON()) + .use("*", async (c, next) => { + const start = Date.now(); + await next(); + const duration = Date.now() - start; + axiom.ingest("api", { + message: "Request completed", + duration, + method: c.req.method, + path: c.req.url, + status: c.res.status, + }); + }) // ROUTES .route("/v2", api); diff --git a/apps/web/lib/axiom.ts b/apps/web/lib/axiom.ts new file mode 100644 index 0000000..91dde61 --- /dev/null +++ b/apps/web/lib/axiom.ts @@ -0,0 +1,7 @@ +import { Axiom } from "@axiomhq/js"; + +const axiom = new Axiom({ + token: process.env.AXIOM_TOKEN, +}); + +export { axiom }; diff --git a/apps/web/package.json b/apps/web/package.json index b08b352..3befb7b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "supabase:typegen": "npx supabase gen types typescript --local --schema public > src/types/supabase.ts" }, "dependencies": { + "@axiomhq/js": "^1.2.0", "@builder.io/react-hydration-overlay": "^0.0.8", "@chronark/zod-bird": "^0.3.9", "@emoji-mart/data": "^1.1.2", @@ -65,6 +66,7 @@ "@uidotdev/usehooks": "^2.0.1", "@vercel/og": "^0.6.2", "ai": "^3.1.30", + "bcrypt": "^5.1.1", "browser-image-compression": "^2.0.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", @@ -112,6 +114,7 @@ }, "devDependencies": { "@tailwindcss/forms": "^0.5.3", + "@types/bcrypt": "^5.0.2", "@types/eslint": "^8.21.3", "@types/formidable": "^3.4.3", "@types/lowlight": "^0.0.7", diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 1da6072..ea7b0fd 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -5,17 +5,19 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-semibold ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-200 focus-visible focus-visible:border-orange-700 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-zinc-950 ring-1 ring-transparent antialiased dark:focus-visible:ring-zinc-300 gap-1.5 [&>svg]:w-4 leading-none [&>svg]:opacity-60 active:scale-95", + "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-semibold ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-200 focus-visible focus-visible:border-orange-700 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-zinc-950 ring-1 ring-transparent antialiased dark:focus-visible:ring-zinc-300 gap-1.5 [&>svg]:w-4 leading-none [&>svg]:opacity-60 active:scale-95 border border-transparent", { variants: { variant: { default: - "border border-slate-500/20 border-b-slate-800/70 border-t-slate-500/70 bg-gradient-to-b from-slate-700 to-slate-800 text-white shadow-md ring-slate-800 flex items-center hover:brightness-125", - destructive: "bg-red-500 text-zinc-50 hover:bg-red-500/90", + "border-slate-500/20 border-b-slate-800/70 border-t-slate-500/70 bg-gradient-to-b from-slate-700 to-slate-800 text-white shadow-md ring-slate-800 flex items-center hover:brightness-125", + destructive: + "border-red-400/20 border-b-red-700/70 border-t-red-400/70 bg-gradient-to-b from-red-500 to-red-500 text-white shadow-md ring-red-600 flex items-center hover:brightness-110", + outline: - "border border-transparent ring-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 text-zinc-600", + "border-transparent ring-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 text-zinc-600", secondary: - "bg-zinc-100 hover:bg-orange-100/70 hover:text-orange-600 text-zinc-600", + "bg-zinc-100 hover:bg-zinc-100/70 text-zinc-700 hover:text-zinc-800", ghost: "hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 text-zinc-600", link: "text-zinc-800 underline-offset-4 hover:underline dark:text-zinc-50", diff --git a/apps/web/src/lib/http/api.ts b/apps/web/src/lib/http/api.ts deleted file mode 100644 index 7fe71d3..0000000 --- a/apps/web/src/lib/http/api.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { Blog, DeleteBlogRes, PatchBlog } from "@/lib/models/blogs/Blogs"; -import { z } from "zod"; -import { getPostBySlugRes, getPostsRes } from "../models/posts/Posts"; -import { GetBlogRes } from "../models/blogs/Blogs"; -import { JSONContent } from "@tiptap/react"; - -export type UpdatePostBody = { - title?: string; - slug?: string; - published?: boolean; - content?: JSONContent; - cover_image?: string; -}; - -export function createAPIClient() { - async function _fetch( - input: RequestInfo, - init: RequestInit, - type: T - ): Promise> { - const headers = new Headers(init?.headers); - const URL_START = "/api/v1"; - const res = await fetch(URL_START + input, { ...init, headers }); - - if (!res.ok) { - console.error(`[🚨 ${init.method} ${input}] `, res.statusText); - throw new Error(res.statusText); - } - const data = await res.json(); - - const result = await type.safeParseAsync(data); - - if (!result.success) { - console.error(`[ZOD] `, result.error.message, data); - throw new Error(result.error.message); - } - - return result.data; - } - - async function deleteBlog(slug: string) { - const res = await _fetch( - `/blogs/${slug}`, - { method: "DELETE" }, - DeleteBlogRes - ); - - return res; - } - - async function patchBlog(slug: string, body: PatchBlog) { - const res = await _fetch( - `/blogs/${slug}`, - { method: "PATCH", body: JSON.stringify(body) }, - GetBlogRes - ); - - return res; - } - - async function createBlog({ - title, - description, - emoji, - }: { - title: string; - description: string; - emoji: string; - }) { - const res = await _fetch( - "/blogs", - { - method: "POST", - body: JSON.stringify({ - title, - description, - emoji, - }), - }, - GetBlogRes - ); - - return res; - } - - async function getPostsForBlog(blogId: string) { - const res = await _fetch( - `/blogs/${blogId}/posts`, - { method: "GET" }, - getPostsRes - ); - - return res; - } - - async function getPostBySlug(blogId: string, postSlug: string) { - const res = await _fetch( - `/blogs/${blogId}/posts/${postSlug}`, - { method: "GET" }, - getPostBySlugRes - ); - - return res; - } - - async function deletePostBySlug(blogId: string, postSlug: string) { - const res = await _fetch( - `/blogs/${blogId}/posts/${postSlug}`, - { method: "DELETE" }, - z.object({ success: z.boolean() }) - ); - - return res; - } - - async function updatePostBySlug( - blogId: string, - postSlug: string, - body: UpdatePostBody - ) { - const res = await _fetch( - `/blogs/${blogId}/posts/${postSlug}`, - { method: "PATCH", body: JSON.stringify(body) }, - z.object({ success: z.boolean() }) - ); - - return res; - } - - // TODO!: move these methods to separate files this is getting too big - return { - user: { - setup: () => - _fetch( - "/user/setup", - { method: "GET" }, - z.object({ success: z.boolean() }) - ), - }, - blogs: { - update: patchBlog, - delete: deleteBlog, - create: createBlog, - }, - posts: { - getAll: getPostsForBlog, - get: getPostBySlug, - delete: deletePostBySlug, - update: updatePostBySlug, - }, - images: { - getAll: (blogId: string) => - _fetch( - `/blogs/${blogId}/images`, - { method: "GET" }, - z.array( - z.object({ - id: z.string(), - name: z.string(), - url: z.string(), - createdAt: z.string(), - updatedAt: z.string(), - }) - ) - ), - - upload(blogId: string, file: File) { - const body = new FormData(); - body.append("file", file); - - return _fetch( - `/upload`, - { method: "POST", body: JSON.stringify(body) }, - z.any() - ); - }, - }, - }; -} diff --git a/apps/web/src/pages/blogs/[blogId]/settings.tsx b/apps/web/src/pages/blogs/[blogId]/settings.tsx index 970fcee..640401a 100644 --- a/apps/web/src/pages/blogs/[blogId]/settings.tsx +++ b/apps/web/src/pages/blogs/[blogId]/settings.tsx @@ -17,6 +17,16 @@ import Link from "next/link"; import { getHostedBlogUrl } from "@/utils/get-hosted-blog-url"; import { Textarea } from "@/components/ui/textarea"; import { createId } from "@/lib/create-id"; +import { API } from "app/utils/api-client"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, +} from "@/components/ui/dialog"; +import { DialogTitle } from "@radix-ui/react-dialog"; export default function BlogSettings() { type FormData = { @@ -45,12 +55,6 @@ export default function BlogSettings() { const updateBlog = useUpdateBlogMutation(); - const accessTokenToShow = blog?.access_token - ? blog.access_token.slice(0, 4) + - "......................" + - blog.access_token.slice(-4) - : ""; - const onSubmit = handleSubmit(async (formData) => { try { console.log(formData); @@ -90,11 +94,26 @@ export default function BlogSettings() { } } - const generateAccessToken = async () => { - const newId = createId({ type: "blog", secret: true }); - await updateBlog.mutateAsync({ id: blogId, access_token: newId }); + const [newAPIKey, setNewAPIKey] = useState(""); + const [showNewAPIKeyDialog, setShowNewAPIKeyDialog] = useState(false); + const generateAPIKey = async () => { + const res = await API().v2.blogs[":blogId"]["api-keys"].rotate.$post({ + param: { + blogId, + }, + }); + + if (!res.ok) { + toast.error("Error generating api key"); + return; + } + + const data = (await res.json()) as { message: string; apiKey: string }; + setNewAPIKey(data.apiKey); + setShowNewAPIKeyDialog(true); + refetchBlog(); - toast.success("Access token created"); + toast.success("API key created"); }; if (blogLoading) { @@ -167,68 +186,89 @@ export default function BlogSettings() { /> -
- {isDirty && } +
+
-

API Access Tokens

-

Use this token to fetch your blogs content through the API.

+

Blog API key

+

+ Use this key to fetch your blogs content through the API. +

{blog.access_token ? ( -
- - {accessTokenToShow} - - - +
) : ( <> )}
+ + + + New API key + + Make sure to save this key in a secure location.
It + will not be shown again.{" "} +
+
+
+ + +
+ + + + +
+

- Danger zone

-

+

This action cannot be undone. This will delete all posts in the blog.

diff --git a/apps/web/src/pages/blogs/index.tsx b/apps/web/src/pages/blogs/index.tsx index 0cab37c..70ac6d9 100644 --- a/apps/web/src/pages/blogs/index.tsx +++ b/apps/web/src/pages/blogs/index.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { Button } from "@/components/ui/button"; import { PiPencilLine } from "react-icons/pi"; -import { Paintbrush, Pencil, Plus, Settings } from "lucide-react"; +import { Paintbrush, Plus, Settings } from "lucide-react"; import { useUser } from "@/utils/supabase/browser"; import { useState } from "react"; @@ -97,7 +97,9 @@ export default function Dashboard() {