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 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;
}

View File

@ -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<boolean> {
const user = await requireAuth();
type Transaction = Parameters<Parameters<typeof db.transaction>[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<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) {
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<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[]> {
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<IBlock[]> {
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);
});
}

View File

@ -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<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 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<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;
await db.insert(blocksTable).values({ noteId: note.id, order: 1 });
redirect(`/notes/${note.id}`);
}
export async function getNotes(): Promise<INote[]> {
@ -49,19 +50,25 @@ export async function getNotes(): Promise<INote[]> {
}
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)

View File

@ -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<Metadata> {
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 (

View File

@ -67,7 +67,7 @@ export default function Block({ block }: { block: IBlock }) {
<div className="flex gap-2 ml-auto">
<form action={changeLock}>
<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
type="submit"
alwaysOn={block.isLocked}

View File

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

View File

@ -2,6 +2,10 @@ import { InputHTMLAttributes } from "react";
export default function LineInput(props: InputHTMLAttributes<HTMLInputElement>) {
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 */
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);