diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index 9472ae0..2212c4f 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -8,7 +8,21 @@ import { eq } from "drizzle-orm"; import jwt from "jsonwebtoken"; import bcrypt from "bcrypt"; -const JWT_SECRET = process.env.JWT_SECRET!; +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + throw new Error("JWT_SECRET is not defined"); +} + +async function setSessionCookie(token: string) { + const cookieStore = await cookies(); + cookieStore.set("session", token, { + httpOnly: true, + path: "/", + maxAge: 60 * 60 * 24 * 7, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + }); +} export async function login(_prevState: unknown, formData: FormData) { const username = formData.get("username") as string; @@ -23,22 +37,20 @@ export async function login(_prevState: unknown, formData: FormData) { const users = await db.select() .from(usersTable) .where(eq(usersTable.username, username)); - if (users.length === 0) { return { error: "Invalid password or username!" }; - } else if (!bcrypt.compareSync(password, users[0].password)) { - return { error: "Invalid password or username!" }; } - const token = jwt.sign({ sub: username }, JWT_SECRET, { expiresIn: "7d" }); - - const cookieStore = await cookies(); - cookieStore.set("session", token, { - httpOnly: true, - path: "/", - maxAge: 60 * 60 * 24 * 7, - }); + const user = users[0]; + + const passwordValid = await bcrypt.compare(password, user.password); + if (!passwordValid) { + return { error: "Invalid password or username!" }; + } + const token = jwt.sign({ sub: user.id }, JWT_SECRET!, { expiresIn: "7d" }); + await setSessionCookie(token); + redirect("/notes"); } @@ -49,9 +61,7 @@ export async function register(_prevState: unknown, formData: FormData) { if (password !== passwordConfirm) { return { error: "Passwords do not match!" }; - } - - if (username.length < 3) { + } else if (username.length < 3) { return { error: "Username is too short!" }; } else if (password.length < 8) { return { error: "Password is too short!" }; @@ -61,23 +71,20 @@ export async function register(_prevState: unknown, formData: FormData) { .from(usersTable) .where(eq(usersTable.username, username)); - if (users.length !== 0) { + if (users.length > 0) { return { error: "Username is already taken!" }; } - await db.insert(usersTable).values({ - username, - password: bcrypt.hashSync(password, bcrypt.genSaltSync()), - }); + const [createdUser] = await db + .insert(usersTable) + .values({ + username, + password: await bcrypt.hash(password, await bcrypt.genSalt()), + }) + .returning({ id: usersTable.id }); - const token = jwt.sign({ sub: username }, JWT_SECRET, { expiresIn: "7d" }); - - const cookieStore = await cookies(); - cookieStore.set("session", token, { - httpOnly: true, - path: "/", - maxAge: 60 * 60 * 24 * 7, - }); + const token = jwt.sign({ sub: createdUser.id }, JWT_SECRET!, { expiresIn: "7d" }); + await setSessionCookie(token); redirect("/notes"); } @@ -96,15 +103,15 @@ export async function getAuth() { } try { - const decodedToken = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; - const username = decodedToken.sub; - if (!username) { + const decodedToken = jwt.verify(token, JWT_SECRET!) as jwt.JwtPayload; + const userId = decodedToken.sub; + if (!userId) { return null; } const users = await db.select() .from(usersTable) - .where(eq(usersTable.username, username)); + .where(eq(usersTable.id, userId)); if (users.length === 0) { return null; } diff --git a/src/app/actions/blocks.ts b/src/app/actions/blocks.ts index d88d371..336a280 100644 --- a/src/app/actions/blocks.ts +++ b/src/app/actions/blocks.ts @@ -1,29 +1,50 @@ "use server"; import { revalidatePath } from "next/cache"; +import { notFound } from "next/navigation"; import { eq } from "drizzle-orm"; import { db } from "@/lib/db"; import { blocksTable, IBlock, notesTable } from "@/lib/db/schema"; import { requireAuth } from "./auth"; import { assertNoteOwner } from "./notes"; -export async function assertBlockOwner(blockId: string): Promise { - const user = await requireAuth(); +type Transaction = Parameters[0]>[0]; +export async function assertBlockOwner(blockId: string, authorId: string) { const block = await db.query.blocksTable.findFirst({ where: eq(blocksTable.id, blockId), with: { note: true }, }); - if (!block) return false; + if (!block || block.note.authorId !== authorId) { + notFound(); + } +} - return block.note.authorId === user.id; +async function updateLastEdited(tx: Transaction, noteId: string) { + await tx + .update(notesTable) + .set({ lastEdited: new Date() }) + .where(eq(notesTable.id, noteId)); +} + +export async function getBlockIfOwned(blockId: string): Promise { + const user = await requireAuth(); + const block = await db.query.blocksTable.findFirst({ + where: eq(blocksTable.id, blockId), + with: { note: true }, + }); + if (!block || block.note.authorId !== user.id) { + notFound(); + } + + return block; } export async function createBlock(formData: FormData) { const noteId = formData.get("noteId") as string; - const isAllowed = await assertNoteOwner(noteId); - if (!isAllowed) return; + const user = await requireAuth(); + await assertNoteOwner(noteId, user.id); const blocks = await getBlocks(noteId); const lastBlock = blocks.pop(); @@ -33,30 +54,16 @@ export async function createBlock(formData: FormData) { await tx .insert(blocksTable) .values({ noteId, order }); - await tx - .update(notesTable) - .set({ lastEdited: new Date() }) - .where(eq(notesTable.id, noteId)); + await updateLastEdited(tx, noteId); }); revalidatePath("/notes/[id]", "page"); } -async function getBlock(blockId: string): Promise { - const isAllowed = await assertBlockOwner(blockId); - if (!isAllowed) return null; - - const blocks = await db - .select() - .from(blocksTable) - .where(eq(blocksTable.id, blockId)); - - return blocks.length === 0 ? null : blocks[0]; -} - export async function getBlocks(noteId: string): Promise { - const isAllowed = await assertNoteOwner(noteId); - if (!isAllowed) return []; + const user = await requireAuth(); + await assertNoteOwner(noteId, user.id); + return db .select() .from(blocksTable) @@ -66,21 +73,20 @@ export async function getBlocks(noteId: string): Promise { export async function deleteBlock(formData: FormData) { const blockId = formData.get("blockId") as string; + + const user = await requireAuth(); + await assertBlockOwner(blockId, user.id); - const isAllowed = await assertBlockOwner(blockId); - if (!isAllowed) return; - - const block = await getBlock(blockId); - if (!block) return; + const block = await getBlockIfOwned(blockId); + if (!block) { + return; + }; await db.transaction(async (tx) => { await tx .delete(blocksTable) .where(eq(blocksTable.id, blockId)); - await tx - .update(notesTable) - .set({ lastEdited: new Date() }) - .where(eq(notesTable.id, block.noteId)); + await updateLastEdited(tx, block.noteId); }); revalidatePath("/notes/[id]", "page"); @@ -88,23 +94,22 @@ export async function deleteBlock(formData: FormData) { export async function changeLock(formData: FormData) { const blockId = formData.get("blockId") as string; - const isLocked = formData.get("isLocked") === null ? false : true; + const isLocked = formData.get("isLocked") === "true"; - const isAllowed = await assertBlockOwner(blockId); - if (!isAllowed) return; + const user = await requireAuth(); + await assertBlockOwner(blockId, user.id); - const block = await getBlock(blockId); - if (!block) return; + const block = await getBlockIfOwned(blockId); + if (!block) { + return + } await db.transaction(async (tx) => { await tx .update(blocksTable) .set({ isLocked }) .where(eq(blocksTable.id, blockId)); - await tx - .update(notesTable) - .set({ lastEdited: new Date() }) - .where(eq(notesTable.id, block.noteId)); + await updateLastEdited(tx, block.noteId); }); revalidatePath("/notes/[id]", "page"); @@ -113,21 +118,20 @@ export async function changeLock(formData: FormData) { export async function addLine(formData: FormData) { const blockId = formData.get("blockId") as string; - const isAllowed = await assertBlockOwner(blockId); - if (!isAllowed) return; + const user = await requireAuth(); + await assertBlockOwner(blockId, user.id); - const block = await getBlock(blockId); - if (!block) return; + const block = await getBlockIfOwned(blockId); + if (!block) { + return; + } await db.transaction(async (tx) => { await tx .update(blocksTable) .set({ lines: [...block.lines, ""] }) .where(eq(blocksTable.id, blockId)); - await tx - .update(notesTable) - .set({ lastEdited: new Date() }) - .where(eq(notesTable.id, block.noteId)); + await updateLastEdited(tx, block.noteId); }); revalidatePath("/notes/[id]", "page"); @@ -136,21 +140,20 @@ export async function addLine(formData: FormData) { export async function deleteLine(formData: FormData) { const blockId = formData.get("blockId") as string; - const isAllowed = await assertBlockOwner(blockId); - if (!isAllowed) return; + const user = await requireAuth(); + await assertBlockOwner(blockId, user.id); - const block = await getBlock(blockId); - if (!block) return; + const block = await getBlockIfOwned(blockId); + if (!block) { + return; + } await db.transaction(async (tx) => { await tx .update(blocksTable) .set({ lines: block.lines.slice(0, -1) }) .where(eq(blocksTable.id, blockId)); - await tx - .update(notesTable) - .set({ lastEdited: new Date() }) - .where(eq(notesTable.id, block.noteId)); + await updateLastEdited(tx, block.noteId); }); revalidatePath("/notes/[id]", "page"); @@ -158,18 +161,25 @@ export async function deleteLine(formData: FormData) { export async function moveUp(formData: FormData) { const blockId = formData.get("blockId") as string; - const isAllowed = await assertBlockOwner(blockId); - if (!isAllowed) return; + + const user = await requireAuth(); + await assertBlockOwner(blockId, user.id); - const block = await getBlock(blockId); - if (!block) return; + const block = await getBlockIfOwned(blockId); + if (!block) { + return; + } const siblings = await getBlocks(block.noteId); const currentIndex = siblings.findIndex((b) => b.id === block.id); - if (currentIndex <= 0) return; + if (currentIndex <= 0) { + return; + } const prevBlock = siblings[currentIndex - 1]; - if (!prevBlock) return; + if (!prevBlock) { + return; + } await db.transaction(async (tx) => { await tx @@ -180,10 +190,7 @@ export async function moveUp(formData: FormData) { .update(blocksTable) .set({ order: block.order }) .where(eq(blocksTable.id, prevBlock.id)); - await tx - .update(notesTable) - .set({ lastEdited: new Date() }) - .where(eq(notesTable.id, block.noteId)); + await updateLastEdited(tx, block.noteId); }); revalidatePath("/notes/[id]", "page"); @@ -191,18 +198,25 @@ export async function moveUp(formData: FormData) { export async function moveDown(formData: FormData) { const blockId = formData.get("blockId") as string; - const isAllowed = await assertBlockOwner(blockId); - if (!isAllowed) return; - const block = await getBlock(blockId); - if (!block) return; + const user = await requireAuth(); + await assertBlockOwner(blockId, user.id); + + const block = await getBlockIfOwned(blockId); + if (!block) { + return; + } const siblings = await getBlocks(block.noteId); const currentIndex = siblings.findIndex((b) => b.id === block.id); - if (currentIndex === -1 || currentIndex >= siblings.length - 1) return; + if (currentIndex === -1 || currentIndex >= siblings.length - 1) { + return; + } const nextBlock = siblings[currentIndex + 1]; - if (!nextBlock) return; + if (!nextBlock) { + return; + } await db.transaction(async (tx) => { await tx @@ -213,49 +227,44 @@ export async function moveDown(formData: FormData) { .update(blocksTable) .set({ order: block.order }) .where(eq(blocksTable.id, nextBlock.id)); - await tx - .update(notesTable) - .set({ lastEdited: new Date() }) - .where(eq(notesTable.id, block.noteId)); + await updateLastEdited(tx, block.noteId); }); revalidatePath("/notes/[id]", "page"); } export async function setLines(blockId: string, lines: string[]) { - const isAllowed = await assertBlockOwner(blockId); - if (!isAllowed) return; + const user = await requireAuth(); + await assertBlockOwner(blockId, user.id); - const block = await getBlock(blockId); - if (!block) return; + const block = await getBlockIfOwned(blockId); + if (!block) { + return; + } await db.transaction(async (tx) => { await tx .update(blocksTable) .set({ lines }) .where(eq(blocksTable.id, blockId)); - await tx - .update(notesTable) - .set({ lastEdited: new Date() }) - .where(eq(notesTable.id, block.noteId)); + await updateLastEdited(tx, block.noteId); }); } export async function setTag(blockId: string, tag: string) { - const isAllowed = await assertBlockOwner(blockId); - if (!isAllowed) return; + const user = await requireAuth(); + await assertBlockOwner(blockId, user.id); - const block = await getBlock(blockId); - if (!block) return; + const block = await getBlockIfOwned(blockId); + if (!block) { + return; + } await db.transaction(async (tx) => { await tx .update(blocksTable) .set({ tag }) .where(eq(blocksTable.id, blockId)); - await tx - .update(notesTable) - .set({ lastEdited: new Date() }) - .where(eq(notesTable.id, block.noteId)); + await updateLastEdited(tx, block.noteId); }); } diff --git a/src/app/actions/notes.ts b/src/app/actions/notes.ts index 97880a0..af176c9 100644 --- a/src/app/actions/notes.ts +++ b/src/app/actions/notes.ts @@ -1,42 +1,43 @@ "use server"; import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { desc, eq, and } from "drizzle-orm"; import { blocksTable, INote, notesTable, usersTable } from "@/lib/db/schema"; import { requireAuth } from "./auth"; import { db } from "@/lib/db"; -export async function assertNoteOwner(noteId: string): Promise { +export async function assertNoteOwner(noteId: string, userId: string) { + const note = await db.query.notesTable.findFirst({ + where: eq(notesTable.id, noteId), + }); + if (!note || note.authorId !== userId) { + notFound(); + } +} + +export async function getNoteIfOwned(noteId: string): Promise { const user = await requireAuth(); const note = await db.query.notesTable.findFirst({ where: eq(notesTable.id, noteId), }); - if (!note) return false; - - return note.authorId === user.id; + if (!note || note.authorId !== user.id) { + notFound(); + } + + return note; } export async function createNote() { const user = await requireAuth(); - const result = await db + const [note] = await db .insert(notesTable) .values({ authorId: user.id }) .returning({ id: usersTable.id }); - const noteId = result[0].id; - await db.insert(blocksTable).values({ noteId, order: 1 }); - redirect(`/notes/${noteId}`); -} - -export async function getNote(noteId: string): Promise { - const user = await requireAuth(); - const note = await db - .select() - .from(notesTable) - .where(and(eq(notesTable.id, noteId), eq(notesTable.authorId, user.id))); - return note.length > 0 ? note[0] : null; + await db.insert(blocksTable).values({ noteId: note.id, order: 1 }); + redirect(`/notes/${note.id}`); } export async function getNotes(): Promise { @@ -49,19 +50,25 @@ export async function getNotes(): Promise { } export async function deleteNote(formData: FormData) { - const user = await requireAuth(); const noteId = formData.get("noteId") as string; + + const user = await requireAuth(); + await assertNoteOwner(noteId, user.id); + await db .delete(notesTable) .where(and(eq(notesTable.id, noteId), eq(notesTable.authorId, user.id))); + revalidatePath("/notes"); } export async function setTitle(noteId: string, title: string) { - if (title === "") return; - - const isAllowed = await assertNoteOwner(noteId); - if (!isAllowed) return; + if (title === "") { + return; + } + + const user = await requireAuth(); + await assertNoteOwner(noteId, user.id); await db .update(notesTable) diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index 90591c0..0cecfec 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -1,24 +1,17 @@ import { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { getNote } from "@/app/actions/notes"; +import { getNoteIfOwned } from "@/app/actions/notes"; import { getBlocks } from "@/app/actions/blocks"; import Editor from "@/components/editor/Editor"; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params; - const note = await getNote(id); - if (!note) { - notFound(); - } + const note = await getNoteIfOwned(id); return { title: note.title }; } export default async function Note({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const note = await getNote(id); - if (!note) { - notFound(); - } + const note = await getNoteIfOwned(id); const blocks = await getBlocks(note.id); return ( diff --git a/src/components/editor/Block.tsx b/src/components/editor/Block.tsx index 3273008..7c649ad 100644 --- a/src/components/editor/Block.tsx +++ b/src/components/editor/Block.tsx @@ -67,7 +67,7 @@ export default function Block({ block }: { block: IBlock }) {
- {!block.isLocked && } + {!block.isLocked && } ) { return ( - + ); } diff --git a/src/lib/hooks/useDebounce.ts b/src/lib/hooks/useDebounce.ts index 363e20f..bf9d12c 100644 --- a/src/lib/hooks/useDebounce.ts +++ b/src/lib/hooks/useDebounce.ts @@ -1,12 +1,12 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { DependencyList, useEffect, useState } from "react"; +import { DependencyList, useEffect, useRef } from "react"; export function useDebounce(callback: () => void, deps: DependencyList) { - const [isFirstTime, setIsFirstTime] = useState(true); + const isFirstRun = useRef(true); useEffect(() => { - if (isFirstTime) { - setIsFirstTime(false); + if (isFirstRun.current) { + isFirstRun.current = false; } else { const timeout = setTimeout(callback, 1000); return () => clearTimeout(timeout);