fix: performance improvements

This commit is contained in:
Kirill Siukhin 2025-07-16 23:55:27 +05:00
parent 0e1ff2b415
commit bcf443c5e3
8 changed files with 187 additions and 167 deletions

View File

@ -8,7 +8,21 @@ import { eq } from "drizzle-orm";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import bcrypt from "bcrypt"; 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) { export async function login(_prevState: unknown, formData: FormData) {
const username = formData.get("username") as string; const username = formData.get("username") as string;
@ -23,21 +37,19 @@ export async function login(_prevState: unknown, formData: FormData) {
const users = await db.select() const users = await db.select()
.from(usersTable) .from(usersTable)
.where(eq(usersTable.username, username)); .where(eq(usersTable.username, username));
if (users.length === 0) { if (users.length === 0) {
return { error: "Invalid password or username!" }; 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 user = users[0];
const cookieStore = await cookies(); const passwordValid = await bcrypt.compare(password, user.password);
cookieStore.set("session", token, { if (!passwordValid) {
httpOnly: true, return { error: "Invalid password or username!" };
path: "/", }
maxAge: 60 * 60 * 24 * 7,
}); const token = jwt.sign({ sub: user.id }, JWT_SECRET!, { expiresIn: "7d" });
await setSessionCookie(token);
redirect("/notes"); redirect("/notes");
} }
@ -49,9 +61,7 @@ export async function register(_prevState: unknown, formData: FormData) {
if (password !== passwordConfirm) { if (password !== passwordConfirm) {
return { error: "Passwords do not match!" }; return { error: "Passwords do not match!" };
} } else if (username.length < 3) {
if (username.length < 3) {
return { error: "Username is too short!" }; return { error: "Username is too short!" };
} else if (password.length < 8) { } else if (password.length < 8) {
return { error: "Password is too short!" }; return { error: "Password is too short!" };
@ -61,23 +71,20 @@ export async function register(_prevState: unknown, formData: FormData) {
.from(usersTable) .from(usersTable)
.where(eq(usersTable.username, username)); .where(eq(usersTable.username, username));
if (users.length !== 0) { if (users.length > 0) {
return { error: "Username is already taken!" }; return { error: "Username is already taken!" };
} }
await db.insert(usersTable).values({ const [createdUser] = await db
username, .insert(usersTable)
password: bcrypt.hashSync(password, bcrypt.genSaltSync()), .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 token = jwt.sign({ sub: createdUser.id }, JWT_SECRET!, { expiresIn: "7d" });
await setSessionCookie(token);
const cookieStore = await cookies();
cookieStore.set("session", token, {
httpOnly: true,
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
redirect("/notes"); redirect("/notes");
} }
@ -96,15 +103,15 @@ export async function getAuth() {
} }
try { try {
const decodedToken = jwt.verify(token, JWT_SECRET) as jwt.JwtPayload; const decodedToken = jwt.verify(token, JWT_SECRET!) as jwt.JwtPayload;
const username = decodedToken.sub; const userId = decodedToken.sub;
if (!username) { if (!userId) {
return null; return null;
} }
const users = await db.select() const users = await db.select()
.from(usersTable) .from(usersTable)
.where(eq(usersTable.username, username)); .where(eq(usersTable.id, userId));
if (users.length === 0) { if (users.length === 0) {
return null; return null;
} }

View File

@ -1,29 +1,50 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { notFound } from "next/navigation";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { blocksTable, IBlock, notesTable } from "@/lib/db/schema"; import { blocksTable, IBlock, notesTable } from "@/lib/db/schema";
import { requireAuth } from "./auth"; import { requireAuth } from "./auth";
import { assertNoteOwner } from "./notes"; import { assertNoteOwner } from "./notes";
export async function assertBlockOwner(blockId: string): Promise<boolean> { type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const user = await requireAuth();
export async function assertBlockOwner(blockId: string, authorId: string) {
const block = await db.query.blocksTable.findFirst({ const block = await db.query.blocksTable.findFirst({
where: eq(blocksTable.id, blockId), where: eq(blocksTable.id, blockId),
with: { note: true }, 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<IBlock> {
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) { export async function createBlock(formData: FormData) {
const noteId = formData.get("noteId") as string; const noteId = formData.get("noteId") as string;
const isAllowed = await assertNoteOwner(noteId); const user = await requireAuth();
if (!isAllowed) return; await assertNoteOwner(noteId, user.id);
const blocks = await getBlocks(noteId); const blocks = await getBlocks(noteId);
const lastBlock = blocks.pop(); const lastBlock = blocks.pop();
@ -33,30 +54,16 @@ export async function createBlock(formData: FormData) {
await tx await tx
.insert(blocksTable) .insert(blocksTable)
.values({ noteId, order }); .values({ noteId, order });
await tx await updateLastEdited(tx, noteId);
.update(notesTable)
.set({ lastEdited: new Date() })
.where(eq(notesTable.id, noteId));
}); });
revalidatePath("/notes/[id]", "page"); revalidatePath("/notes/[id]", "page");
} }
async function getBlock(blockId: string): Promise<IBlock | null> {
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<IBlock[]> { export async function getBlocks(noteId: string): Promise<IBlock[]> {
const isAllowed = await assertNoteOwner(noteId); const user = await requireAuth();
if (!isAllowed) return []; await assertNoteOwner(noteId, user.id);
return db return db
.select() .select()
.from(blocksTable) .from(blocksTable)
@ -67,20 +74,19 @@ export async function getBlocks(noteId: string): Promise<IBlock[]> {
export async function deleteBlock(formData: FormData) { export async function deleteBlock(formData: FormData) {
const blockId = formData.get("blockId") as string; const blockId = formData.get("blockId") as string;
const isAllowed = await assertBlockOwner(blockId); const user = await requireAuth();
if (!isAllowed) return; await assertBlockOwner(blockId, user.id);
const block = await getBlock(blockId); const block = await getBlockIfOwned(blockId);
if (!block) return; if (!block) {
return;
};
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
.delete(blocksTable) .delete(blocksTable)
.where(eq(blocksTable.id, blockId)); .where(eq(blocksTable.id, blockId));
await tx await updateLastEdited(tx, block.noteId);
.update(notesTable)
.set({ lastEdited: new Date() })
.where(eq(notesTable.id, block.noteId));
}); });
revalidatePath("/notes/[id]", "page"); revalidatePath("/notes/[id]", "page");
@ -88,23 +94,22 @@ export async function deleteBlock(formData: FormData) {
export async function changeLock(formData: FormData) { export async function changeLock(formData: FormData) {
const blockId = formData.get("blockId") as string; 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); const user = await requireAuth();
if (!isAllowed) return; await assertBlockOwner(blockId, user.id);
const block = await getBlock(blockId); const block = await getBlockIfOwned(blockId);
if (!block) return; if (!block) {
return
}
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
.update(blocksTable) .update(blocksTable)
.set({ isLocked }) .set({ isLocked })
.where(eq(blocksTable.id, blockId)); .where(eq(blocksTable.id, blockId));
await tx await updateLastEdited(tx, block.noteId);
.update(notesTable)
.set({ lastEdited: new Date() })
.where(eq(notesTable.id, block.noteId));
}); });
revalidatePath("/notes/[id]", "page"); revalidatePath("/notes/[id]", "page");
@ -113,21 +118,20 @@ export async function changeLock(formData: FormData) {
export async function addLine(formData: FormData) { export async function addLine(formData: FormData) {
const blockId = formData.get("blockId") as string; const blockId = formData.get("blockId") as string;
const isAllowed = await assertBlockOwner(blockId); const user = await requireAuth();
if (!isAllowed) return; await assertBlockOwner(blockId, user.id);
const block = await getBlock(blockId); const block = await getBlockIfOwned(blockId);
if (!block) return; if (!block) {
return;
}
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
.update(blocksTable) .update(blocksTable)
.set({ lines: [...block.lines, ""] }) .set({ lines: [...block.lines, ""] })
.where(eq(blocksTable.id, blockId)); .where(eq(blocksTable.id, blockId));
await tx await updateLastEdited(tx, block.noteId);
.update(notesTable)
.set({ lastEdited: new Date() })
.where(eq(notesTable.id, block.noteId));
}); });
revalidatePath("/notes/[id]", "page"); revalidatePath("/notes/[id]", "page");
@ -136,21 +140,20 @@ export async function addLine(formData: FormData) {
export async function deleteLine(formData: FormData) { export async function deleteLine(formData: FormData) {
const blockId = formData.get("blockId") as string; const blockId = formData.get("blockId") as string;
const isAllowed = await assertBlockOwner(blockId); const user = await requireAuth();
if (!isAllowed) return; await assertBlockOwner(blockId, user.id);
const block = await getBlock(blockId); const block = await getBlockIfOwned(blockId);
if (!block) return; if (!block) {
return;
}
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
.update(blocksTable) .update(blocksTable)
.set({ lines: block.lines.slice(0, -1) }) .set({ lines: block.lines.slice(0, -1) })
.where(eq(blocksTable.id, blockId)); .where(eq(blocksTable.id, blockId));
await tx await updateLastEdited(tx, block.noteId);
.update(notesTable)
.set({ lastEdited: new Date() })
.where(eq(notesTable.id, block.noteId));
}); });
revalidatePath("/notes/[id]", "page"); revalidatePath("/notes/[id]", "page");
@ -158,18 +161,25 @@ export async function deleteLine(formData: FormData) {
export async function moveUp(formData: FormData) { export async function moveUp(formData: FormData) {
const blockId = formData.get("blockId") as string; const blockId = formData.get("blockId") as string;
const isAllowed = await assertBlockOwner(blockId);
if (!isAllowed) return;
const block = await getBlock(blockId); const user = await requireAuth();
if (!block) return; await assertBlockOwner(blockId, user.id);
const block = await getBlockIfOwned(blockId);
if (!block) {
return;
}
const siblings = await getBlocks(block.noteId); const siblings = await getBlocks(block.noteId);
const currentIndex = siblings.findIndex((b) => b.id === block.id); const currentIndex = siblings.findIndex((b) => b.id === block.id);
if (currentIndex <= 0) return; if (currentIndex <= 0) {
return;
}
const prevBlock = siblings[currentIndex - 1]; const prevBlock = siblings[currentIndex - 1];
if (!prevBlock) return; if (!prevBlock) {
return;
}
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
@ -180,10 +190,7 @@ export async function moveUp(formData: FormData) {
.update(blocksTable) .update(blocksTable)
.set({ order: block.order }) .set({ order: block.order })
.where(eq(blocksTable.id, prevBlock.id)); .where(eq(blocksTable.id, prevBlock.id));
await tx await updateLastEdited(tx, block.noteId);
.update(notesTable)
.set({ lastEdited: new Date() })
.where(eq(notesTable.id, block.noteId));
}); });
revalidatePath("/notes/[id]", "page"); revalidatePath("/notes/[id]", "page");
@ -191,18 +198,25 @@ export async function moveUp(formData: FormData) {
export async function moveDown(formData: FormData) { export async function moveDown(formData: FormData) {
const blockId = formData.get("blockId") as string; const blockId = formData.get("blockId") as string;
const isAllowed = await assertBlockOwner(blockId);
if (!isAllowed) return;
const block = await getBlock(blockId); const user = await requireAuth();
if (!block) return; await assertBlockOwner(blockId, user.id);
const block = await getBlockIfOwned(blockId);
if (!block) {
return;
}
const siblings = await getBlocks(block.noteId); const siblings = await getBlocks(block.noteId);
const currentIndex = siblings.findIndex((b) => b.id === block.id); 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]; const nextBlock = siblings[currentIndex + 1];
if (!nextBlock) return; if (!nextBlock) {
return;
}
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
@ -213,49 +227,44 @@ export async function moveDown(formData: FormData) {
.update(blocksTable) .update(blocksTable)
.set({ order: block.order }) .set({ order: block.order })
.where(eq(blocksTable.id, nextBlock.id)); .where(eq(blocksTable.id, nextBlock.id));
await tx await updateLastEdited(tx, block.noteId);
.update(notesTable)
.set({ lastEdited: new Date() })
.where(eq(notesTable.id, block.noteId));
}); });
revalidatePath("/notes/[id]", "page"); revalidatePath("/notes/[id]", "page");
} }
export async function setLines(blockId: string, lines: string[]) { export async function setLines(blockId: string, lines: string[]) {
const isAllowed = await assertBlockOwner(blockId); const user = await requireAuth();
if (!isAllowed) return; await assertBlockOwner(blockId, user.id);
const block = await getBlock(blockId); const block = await getBlockIfOwned(blockId);
if (!block) return; if (!block) {
return;
}
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
.update(blocksTable) .update(blocksTable)
.set({ lines }) .set({ lines })
.where(eq(blocksTable.id, blockId)); .where(eq(blocksTable.id, blockId));
await tx await updateLastEdited(tx, block.noteId);
.update(notesTable)
.set({ lastEdited: new Date() })
.where(eq(notesTable.id, block.noteId));
}); });
} }
export async function setTag(blockId: string, tag: string) { export async function setTag(blockId: string, tag: string) {
const isAllowed = await assertBlockOwner(blockId); const user = await requireAuth();
if (!isAllowed) return; await assertBlockOwner(blockId, user.id);
const block = await getBlock(blockId); const block = await getBlockIfOwned(blockId);
if (!block) return; if (!block) {
return;
}
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
.update(blocksTable) .update(blocksTable)
.set({ tag }) .set({ tag })
.where(eq(blocksTable.id, blockId)); .where(eq(blocksTable.id, blockId));
await tx await updateLastEdited(tx, block.noteId);
.update(notesTable)
.set({ lastEdited: new Date() })
.where(eq(notesTable.id, block.noteId));
}); });
} }

View File

@ -1,42 +1,43 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { desc, eq, and } from "drizzle-orm"; import { desc, eq, and } from "drizzle-orm";
import { blocksTable, INote, notesTable, usersTable } from "@/lib/db/schema"; import { blocksTable, INote, notesTable, usersTable } from "@/lib/db/schema";
import { requireAuth } from "./auth"; import { requireAuth } from "./auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
export async function assertNoteOwner(noteId: string): Promise<boolean> { 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<INote> {
const user = await requireAuth(); const user = await requireAuth();
const note = await db.query.notesTable.findFirst({ const note = await db.query.notesTable.findFirst({
where: eq(notesTable.id, noteId), where: eq(notesTable.id, noteId),
}); });
if (!note) return false; if (!note || note.authorId !== user.id) {
notFound();
}
return note.authorId === user.id; return note;
} }
export async function createNote() { export async function createNote() {
const user = await requireAuth(); const user = await requireAuth();
const result = await db const [note] = await db
.insert(notesTable) .insert(notesTable)
.values({ authorId: user.id }) .values({ authorId: user.id })
.returning({ id: usersTable.id }); .returning({ id: usersTable.id });
const noteId = result[0].id;
await db.insert(blocksTable).values({ noteId, order: 1 }); await db.insert(blocksTable).values({ noteId: note.id, order: 1 });
redirect(`/notes/${noteId}`); redirect(`/notes/${note.id}`);
}
export async function getNote(noteId: string): Promise<INote | null> {
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;
} }
export async function getNotes(): Promise<INote[]> { export async function getNotes(): Promise<INote[]> {
@ -49,19 +50,25 @@ export async function getNotes(): Promise<INote[]> {
} }
export async function deleteNote(formData: FormData) { export async function deleteNote(formData: FormData) {
const user = await requireAuth();
const noteId = formData.get("noteId") as string; const noteId = formData.get("noteId") as string;
const user = await requireAuth();
await assertNoteOwner(noteId, user.id);
await db await db
.delete(notesTable) .delete(notesTable)
.where(and(eq(notesTable.id, noteId), eq(notesTable.authorId, user.id))); .where(and(eq(notesTable.id, noteId), eq(notesTable.authorId, user.id)));
revalidatePath("/notes"); revalidatePath("/notes");
} }
export async function setTitle(noteId: string, title: string) { export async function setTitle(noteId: string, title: string) {
if (title === "") return; if (title === "") {
return;
}
const isAllowed = await assertNoteOwner(noteId); const user = await requireAuth();
if (!isAllowed) return; await assertNoteOwner(noteId, user.id);
await db await db
.update(notesTable) .update(notesTable)

View File

@ -1,24 +1,17 @@
import { Metadata } from "next"; import { Metadata } from "next";
import { notFound } from "next/navigation"; import { getNoteIfOwned } from "@/app/actions/notes";
import { getNote } from "@/app/actions/notes";
import { getBlocks } from "@/app/actions/blocks"; import { getBlocks } from "@/app/actions/blocks";
import Editor from "@/components/editor/Editor"; import Editor from "@/components/editor/Editor";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const note = await getNote(id); const note = await getNoteIfOwned(id);
if (!note) {
notFound();
}
return { title: note.title }; return { title: note.title };
} }
export default async function Note({ params }: { params: Promise<{ id: string }> }) { export default async function Note({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const note = await getNote(id); const note = await getNoteIfOwned(id);
if (!note) {
notFound();
}
const blocks = await getBlocks(note.id); const blocks = await getBlocks(note.id);
return ( return (

View File

@ -67,7 +67,7 @@ export default function Block({ block }: { block: IBlock }) {
<div className="flex gap-2 ml-auto"> <div className="flex gap-2 ml-auto">
<form action={changeLock}> <form action={changeLock}>
<input type="hidden" name="blockId" value={block.id} /> <input type="hidden" name="blockId" value={block.id} />
{!block.isLocked && <input type="hidden" name="isLocked" />} {!block.isLocked && <input type="hidden" name="isLocked" value="true" />}
<IconOnlyButton <IconOnlyButton
type="submit" type="submit"
alwaysOn={block.isLocked} alwaysOn={block.isLocked}

View File

@ -5,10 +5,10 @@ import { Copy, Plus } from "lucide-react";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { IBlock, INote } from "@/lib/db/schema"; import { IBlock, INote } from "@/lib/db/schema";
import { createBlock } from "@/app/actions/blocks"; import { createBlock } from "@/app/actions/blocks";
import { setTitle } from "@/app/actions/notes";
import { useDebounce } from "@/lib/hooks/useDebounce";
import IconOnlyButton from "../ui/IconOnlyButton"; import IconOnlyButton from "../ui/IconOnlyButton";
import Block from "./Block"; import Block from "./Block";
import { useDebounce } from "@/lib/hooks/useDebounce";
import { setTitle } from "@/app/actions/notes";
const defaultNoteId = uuidv4(); const defaultNoteId = uuidv4();

View File

@ -2,6 +2,10 @@ import { InputHTMLAttributes } from "react";
export default function LineInput(props: InputHTMLAttributes<HTMLInputElement>) { export default function LineInput(props: InputHTMLAttributes<HTMLInputElement>) {
return ( return (
<input type="text" className="border-b-2 border-b-neutral-600 focus:border-b-neutral-500 bg-neutral-800/30 p-2 my-2 rounded-t-md text-lg focus:outline-none font-mono w-full" {...props} /> <input
type="text"
className="border-b-2 border-b-neutral-600 focus:border-b-neutral-500 bg-neutral-800/30 p-2 my-2 rounded-t-md text-lg focus:outline-none font-mono w-full"
{...props}
/>
); );
} }

View File

@ -1,12 +1,12 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* 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) { export function useDebounce(callback: () => void, deps: DependencyList) {
const [isFirstTime, setIsFirstTime] = useState(true); const isFirstRun = useRef(true);
useEffect(() => { useEffect(() => {
if (isFirstTime) { if (isFirstRun.current) {
setIsFirstTime(false); isFirstRun.current = false;
} else { } else {
const timeout = setTimeout(callback, 1000); const timeout = setTimeout(callback, 1000);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);