diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md index a47a032..afdd78f 100644 --- a/crates/CLAUDE.md +++ b/crates/CLAUDE.md @@ -1 +1,3 @@ -- forms submitted should be validated using zod \ No newline at end of file +- forms submitted should be validated using zod +- packages should always be added to deno.jsonc as import maps +- errors should be handled in the mw.error.ts and thrown as custom errors \ No newline at end of file diff --git a/crates/api/errors.ts b/crates/api/errors.ts new file mode 100644 index 0000000..69c3bf9 --- /dev/null +++ b/crates/api/errors.ts @@ -0,0 +1,80 @@ +// Base custom error class +export class AppError extends Error { + status: number + details?: Record + override name: string + + constructor(message: string, status = 500, details?: Record) { + super(message) + this.name = this.constructor.name + this.status = status + this.details = details + } +} + +// Validation error +export class ValidationError extends AppError { + constructor(message = "Validation failed", details?: Record) { + super(message, 400, details) + this.name = "ValidationError" + } +} + +// Authentication errors +export class AuthError extends AppError { + constructor(message = "Authentication failed", status = 401) { + super(message, status) + this.name = "AuthError" + } +} + +export class InvalidCredentialsError extends AuthError { + constructor(message = "Invalid credentials") { + super(message) + this.name = "InvalidCredentialsError" + } +} + +export class NotLoggedInError extends AuthError { + constructor(message = "Not logged in") { + super(message) + this.name = "NotLoggedInError" + } +} + +export class SessionExpiredError extends AuthError { + constructor(message = "Session expired") { + super(message) + this.name = "SessionExpiredError" + } +} + +// Resource errors +export class ResourceError extends AppError { + constructor(message: string, status = 404) { + super(message, status) + this.name = "ResourceError" + } +} + +export class ResourceNotFoundError extends ResourceError { + constructor(resource = "Resource") { + super(`${resource} not found`) + this.name = "ResourceNotFoundError" + } +} + +export class ResourceExistsError extends ResourceError { + constructor(resource = "Resource") { + super(`${resource} already exists`, 409) + this.name = "ResourceExistsError" + } +} + +// Server error +export class ServerError extends AppError { + constructor(message = "An unexpected error occurred") { + super(message, 500) + this.name = "ServerError" + } +} \ No newline at end of file diff --git a/crates/api/mw.auth.ts b/crates/api/mw.auth.ts index adfac86..062f889 100644 --- a/crates/api/mw.auth.ts +++ b/crates/api/mw.auth.ts @@ -7,9 +7,49 @@ const PROTECTED = [ "/home", ] -// Sessions storage shared between routes -// In production, this would be Redis or a database -export const sessions = new Map() +// Open Deno KV database +const kv = await Deno.openKv(); + +// Session prefix for KV +const SESSION_PREFIX = "session:"; + +// Helper functions for session management +export async function createSession(userId: string, expirationHours = 24): Promise<{sessionId: string, expiresAt: Date}> { + const sessionId = crypto.randomUUID(); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + expirationHours); + + await kv.set([SESSION_PREFIX, sessionId], { + userId, + expiresAt: expiresAt.toISOString(), + }); + + return { sessionId, expiresAt }; +} + +export async function deleteSession(sessionId: string): Promise { + await kv.delete([SESSION_PREFIX, sessionId]); +} + +export async function getSession(sessionId: string): Promise<{ userId: string, expiresAt: Date } | null> { + if (!sessionId) return null; + + const sessionRes = await kv.get([SESSION_PREFIX, sessionId]); + const session = sessionRes.value as { userId: string, expiresAt: string } | null; + + if (!session) return null; + + // Check if session is expired + if (new Date() > new Date(session.expiresAt)) { + await deleteSession(sessionId); + return null; + } + + return { + userId: session.userId, + expiresAt: new Date(session.expiresAt) + }; +} export async function authMiddleware(req: Request, ctx: CratesCtx): Promise { const { pg } = ctx.state @@ -23,8 +63,6 @@ export async function authMiddleware(req: Request, ctx: CratesCtx): Promise session.expiresAt) { - if (session) { - sessions.delete(sessionId) + if (!session) { + if (path.startsWith("/api/")) { + return new Response(JSON.stringify({ error: "Invalid or expired session" }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); } - throw new Error("Invalid or expired session") + + // Redirect to login page for non-API routes + return new Response(null, { + status: 302, + headers: { Location: "/login" } + }); } // Get user from database diff --git a/crates/api/mw.error.ts b/crates/api/mw.error.ts index 7be1227..0e6c51c 100644 --- a/crates/api/mw.error.ts +++ b/crates/api/mw.error.ts @@ -1,4 +1,5 @@ import { FreshContext } from "$fresh/server.ts" +import { AppError } from "./errors.ts" export async function errorMiddleware(req: Request, ctx: FreshContext): Promise { try { @@ -6,5 +7,28 @@ export async function errorMiddleware(req: Request, ctx: FreshContext): Promise< return response } catch (error) { console.error(`Error handling ${req.method} ${new URL(req.url).pathname}:`, error) + + // Check if it's a custom app error + if (error instanceof AppError) { + return new Response( + JSON.stringify({ + error: error.message, + ...(error.details && { details: error.details }) + }), + { + status: error.status, + headers: { "Content-Type": "application/json" } + } + ) + } + + // Handle unexpected errors + return new Response( + JSON.stringify({ error: "An unexpected error occurred" }), + { + status: 500, + headers: { "Content-Type": "application/json" } + } + ) } } \ No newline at end of file diff --git a/crates/api/responses.ts b/crates/api/responses.ts new file mode 100755 index 0000000..a6576ae --- /dev/null +++ b/crates/api/responses.ts @@ -0,0 +1,6 @@ +function Ok(headers) { + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers + }) +} \ No newline at end of file diff --git a/crates/deno.jsonc b/crates/deno.jsonc index a490a12..a279e2a 100644 --- a/crates/deno.jsonc +++ b/crates/deno.jsonc @@ -1,11 +1,14 @@ { "lock": false, + + "unstable": ["kv"], + "tasks": { "check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx", "cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -", "manifest": "deno task cli manifest $(pwd)", - "start": "deno run -A --watch=routes/,.env dev.ts", - "build": "deno run -A dev.ts build", + "start": "deno run -A --unstable-kv --watch=routes/,.env dev.ts", + "build": "deno run -A --unstable-kv dev.ts build", "preview": "deno run -A main.ts", "prisma-clean": "rm -rf prisma/generated", "prisma-generate": "deno task prisma-clean && deno run -A --env-file npm:prisma generate", @@ -28,6 +31,8 @@ "less": "npm:less@4.2.0", "lucide-preact": "npm:lucide-preact@^0.475.0", "@prisma/client": "npm:@prisma/client@^6.6.0", + "zodix": "npm:zodix@^0.4.4", + "api/": "./api/", "api": "./api/", diff --git a/crates/fresh.gen.ts b/crates/fresh.gen.ts index bf81e7b..f884ff5 100644 --- a/crates/fresh.gen.ts +++ b/crates/fresh.gen.ts @@ -6,7 +6,6 @@ import * as $_404 from './routes/_404.tsx' import * as $_500 from './routes/_500.tsx' import * as $_app from './routes/_app.tsx' import * as $_middleware from './routes/_middleware.ts' -import * as $api_middleware from './routes/api/_middleware.ts' import * as $api_auth_login from './routes/api/auth/login.ts' import * as $api_auth_logout from './routes/api/auth/logout.ts' import * as $api_auth_register from './routes/api/auth/register.ts' @@ -25,7 +24,6 @@ const manifest = { './routes/_500.tsx': $_500, './routes/_app.tsx': $_app, './routes/_middleware.ts': $_middleware, - './routes/api/_middleware.ts': $api_middleware, './routes/api/auth/login.ts': $api_auth_login, './routes/api/auth/logout.ts': $api_auth_logout, './routes/api/auth/register.ts': $api_auth_register, diff --git a/crates/package.json b/crates/package.json index ac9dcd0..e266188 100644 --- a/crates/package.json +++ b/crates/package.json @@ -5,6 +5,7 @@ "dependencies": { "@prisma/client": "^6.6.0", "lodash": "^4.17.21", - "zod": "^3.24.3" + "zod": "^3.24.3", + "zodix": "^0.4.4" } } diff --git a/crates/routes/_middleware.ts b/crates/routes/_middleware.ts index 571e878..2fd4aaf 100644 --- a/crates/routes/_middleware.ts +++ b/crates/routes/_middleware.ts @@ -2,4 +2,4 @@ import { errorMiddleware } from 'api/mw.error.ts' import { serviceMiddleware } from 'api/mw.service.ts' import { authMiddleware } from 'api/mw.auth.ts' -export const handler = [serviceMiddleware, authMiddleware] +export const handler = [errorMiddleware, serviceMiddleware, authMiddleware] diff --git a/crates/routes/api/_middleware.ts b/crates/routes/api/_middleware.ts deleted file mode 100755 index 2e869e7..0000000 --- a/crates/routes/api/_middleware.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { authMiddleware } from 'api/mw.auth.ts' -export const handler = [authMiddleware] diff --git a/crates/routes/api/auth/login.ts b/crates/routes/api/auth/login.ts index 9428f59..caca34d 100644 --- a/crates/routes/api/auth/login.ts +++ b/crates/routes/api/auth/login.ts @@ -2,54 +2,41 @@ import { Handlers } from "$fresh/server.ts" import { setCookie } from "$std/http/cookie.ts" import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts" -import { createPg } from "api/pg.ts" -import { sessions } from "api/mw.auth.ts" +import { z } from "npm:zod" +import { zx } from "npm:zodix" -export const handler: Handlers = { - async POST(req, _ctx) { +import { createSession } from "api/mw.auth.ts" +import { + ValidationError, + InvalidCredentialsError, + NotLoggedInError, + ServerError +} from "api/errors.ts" +import { CratesState } from 'api/mw.service.ts' + +const LoginSchema = z.object({ + email: z.string().email("Invalid email format"), + password: z.string().min(1) +}) + +export const handler: Handlers<{email: string}, CratesState> = { + async POST(req, ctx) { try { - const data = await req.json() - const { email, password } = data - - if (!email || !password) { - return new Response(JSON.stringify({ error: "Email and password required" }), { - status: 400, - headers: { "Content-Type": "application/json" } - }) - } - + const { email, password } = await zx.parseForm(req, LoginSchema) + // Create pg instance for database operations - const pg = createPg(console) + const { pg } = ctx.state // Find user by email const user = await pg.userFindEmail(email) - - if (!user) { - return new Response(JSON.stringify({ error: "Invalid credentials" }), { - status: 401, - headers: { "Content-Type": "application/json" } - }) - } + if (!user) throw new InvalidCredentialsError() // Verify password const match = await bcrypt.compare(password, user.password) - - if (!match) { - return new Response(JSON.stringify({ error: "Invalid credentials" }), { - status: 401, - headers: { "Content-Type": "application/json" } - }) - } - - // Create session - const sessionId = crypto.randomUUID() - const expiresAt = new Date() - expiresAt.setDate(expiresAt.getDate() + 7) // Session expires in 7 days - - sessions.set(sessionId, { - userId: user.id, - expiresAt - }) + if (!match) throw new InvalidCredentialsError() + + // Create session using Deno KV + const { sessionId, expiresAt } = await createSession(user.id, 24 * 7) // 7 days in hours // Set cookie const headers = new Headers({ "Content-Type": "application/json" }) @@ -71,11 +58,14 @@ export const handler: Handlers = { headers }) } catch (error) { + // Let the error middleware handle custom errors + if (error instanceof ValidationError || + error instanceof InvalidCredentialsError) { + throw error + } + console.error("Login error:", error) - return new Response(JSON.stringify({ error: "An error occurred" }), { - status: 500, - headers: { "Content-Type": "application/json" } - }) + throw new ServerError("An error occurred during login") } }, @@ -86,10 +76,7 @@ export const handler: Handlers = { const user = ctx.state.user if (!user) { - return new Response(JSON.stringify({ error: "Not logged in" }), { - status: 401, - headers: { "Content-Type": "application/json" } - }) + throw new NotLoggedInError() } return new Response(JSON.stringify({ diff --git a/crates/routes/api/auth/logout.ts b/crates/routes/api/auth/logout.ts index bcd9594..93bcde6 100644 --- a/crates/routes/api/auth/logout.ts +++ b/crates/routes/api/auth/logout.ts @@ -1,6 +1,6 @@ import { Handlers } from "$fresh/server.ts" import { getCookies, setCookie } from "$std/http/cookie.ts" -import { sessions } from "../../../api/mw.auth.ts" +import { deleteSession } from "api/mw.auth.ts" export const handler: Handlers = { async POST(req, _ctx) { @@ -8,8 +8,8 @@ export const handler: Handlers = { const sessionId = cookies.session if (sessionId) { - // Remove session from storage - sessions.delete(sessionId) + // Remove session from Deno KV + await deleteSession(sessionId) } // Clear the cookie diff --git a/crates/routes/api/auth/register.ts b/crates/routes/api/auth/register.ts index d3034e0..0cc4d30 100644 --- a/crates/routes/api/auth/register.ts +++ b/crates/routes/api/auth/register.ts @@ -1,21 +1,36 @@ import { Handlers } from "$fresh/server.ts" import { setCookie } from "$std/http/cookie.ts" -import { createPg } from "../../../api/pg.ts" -import { sessions } from "../../../api/mw.auth.ts" +import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts" +import { createPg } from "api/pg.ts" +import { createSession } from "api/mw.auth.ts" +import { + ValidationError, + ResourceExistsError, + ServerError +} from "api/errors.ts" + +// Define validation schema with Zod +const RegisterSchema = z.object({ + email: z.string().email("Invalid email format"), + password: z.string().min(6, "Password must be at least 6 characters"), + discogs: z.string().optional() +}) export const handler: Handlers = { async POST(req, _ctx) { try { - const data = await req.json() - const { discogs, email, password } = data + const data = await req.formData() - if (!email || !password) { - return new Response(JSON.stringify({ error: "Email and password required" }), { - status: 400, - headers: { "Content-Type": "application/json" } - }) + // Validate input data with Zod + const result = RegisterSchema.safeParse(data) + + if (!result.success) { + // Throw validation error + throw new ValidationError("Validation failed", result.error.format()) } + const { discogs, email, password } = result.data + // Create pg instance for database operations const pg = createPg(console) @@ -23,25 +38,15 @@ export const handler: Handlers = { const existingUser = await pg.userFindEmail(email) if (existingUser) { - return new Response(JSON.stringify({ error: "Email already registered" }), { - status: 400, - headers: { "Content-Type": "application/json" } - }) + throw new ResourceExistsError("User with this email") } // Create user const params = { discogs, email, password, data: {} } const user = await pg.userCreate(params) - // Create session - const sessionId = crypto.randomUUID() - const expiresAt = new Date() - expiresAt.setDate(expiresAt.getDate() + 7) // Session expires in 7 days - - sessions.set(sessionId, { - userId: user.id, - expiresAt - }) + // Create session using Deno KV + const { sessionId, expiresAt } = await createSession(user.id, 24 * 7) // 7 days in hours // Set cookie const headers = new Headers({ "Content-Type": "application/json" }) @@ -64,11 +69,14 @@ export const handler: Handlers = { headers }) } catch (error) { + // Let the error middleware handle custom errors + if (error instanceof ValidationError || + error instanceof ResourceExistsError) { + throw error + } + console.error("Registration error:", error) - return new Response(JSON.stringify({ error: "An error occurred" }), { - status: 500, - headers: { "Content-Type": "application/json" } - }) + throw new ServerError("An error occurred during registration") } } } \ No newline at end of file diff --git a/crates/routes/home.tsx b/crates/routes/home.tsx index b6040e8..22affc4 100644 --- a/crates/routes/home.tsx +++ b/crates/routes/home.tsx @@ -1,11 +1,16 @@ import { Handlers, PageProps } from "$fresh/server.ts"; -import { createPg, Pg } from "../api/pg.ts"; import Main from "../components/main.tsx"; -import { CratesCtx } from "api/mw.service.ts" +import { CratesState } from "api/mw.service.ts" +import { Offer, Want } from "prismaClient" -export const handler: Handlers = { - async GET(_req, ctx: CratesCtx) { +interface HomeData { + wants: Want[], + offers: Offer[], +} + +export const handler: Handlers = { + async GET(_req, ctx) { const { pg, offers, user } = ctx.state console.log(user) @@ -13,7 +18,7 @@ export const handler: Handlers = { const wants = await pg.userWants(user); const uOffers = await offers.userOffers(user); - return ctx.render({ wants, offers: uOffers }); + return await ctx.render({ wants, offers: uOffers }); }, }; diff --git a/crates/routes/login.tsx b/crates/routes/login.tsx index 2c7d321..e109779 100644 --- a/crates/routes/login.tsx +++ b/crates/routes/login.tsx @@ -1,105 +1,5 @@ -import { Handlers, PageProps } from "$fresh/server.ts"; -import { getCookies, setCookie } from "$std/http/cookie.ts"; -import { z } from "npm:zod"; +import { PageProps } from "$fresh/server.ts"; import Swirl from "../components/swirl.tsx"; -import { sessions } from "api/mw.auth.ts"; -import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"; -import { createPg } from "api/pg.ts"; - -export const handler: Handlers = { - async GET(req, ctx) { - const cookies = getCookies(req.headers); - const sessionId = cookies.session; - - // If user is already logged in, redirect to home - if (sessionId && sessions.has(sessionId)) { - return new Response("", { - status: 307, - headers: { Location: "/home" }, - }); - } - - return ctx.render(); - }, - - async POST(req, ctx) { - const formData = await req.formData(); - - // Define login schema with Zod - const LoginSchema = z.object({ - email: z.string().email("Invalid email address"), - password: z.string().min(6, "Password must be at least 6 characters") - }); - - // Validate the form data - const result = LoginSchema.safeParse({ - email: formData.get("email"), - password: formData.get("password") - }); - - if (!result.success) { - // Format validation errors - const fieldErrors = result.error.flatten().fieldErrors; - const firstError = - fieldErrors.email?.[0] || - fieldErrors.password?.[0] || - "Invalid form data"; - - return ctx.render({ error: firstError }); - } - - const { email, password } = result.data; - - try { - // Create pg instance for database operations - const pg = createPg(console); - - // Find user by email - const user = await pg.userFindEmail(email); - - if (!user) { - return ctx.render({ error: "Invalid credentials" }); - } - - // Verify password - const match = await bcrypt.compare(password, user.password); - - if (!match) { - return ctx.render({ error: "Invalid credentials" }); - } - - // Create session - const sessionId = crypto.randomUUID(); - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + 7); // Session expires in 7 days - - sessions.set(sessionId, { - userId: user.id, - expiresAt - }); - - // Set cookie and redirect - const headers = new Headers({ Location: "/home" }); - setCookie(headers, { - name: "session", - value: sessionId, - expires: expiresAt, - path: "/", - httpOnly: true, - secure: true, - sameSite: "Lax" - }); - - return new Response(null, { - status: 303, - headers - }); - } catch (error) { - console.error("Login error:", error); - return ctx.render({ error: "An error occurred during login" }); - } - } -}; function Login({ error }: { error?: string }) { const errorEl = error @@ -109,7 +9,7 @@ function Login({ error }: { error?: string }) { return (