This commit is contained in:
Nathan Rashleigh committed 2025-05-02 01:07:56 +10:00
1 parent bdc22c5c42
commit a4d8608a3e
16 files changed
+261 -304

No files matched your search

+2
View File
@@ -1 +1,3 @@
- forms submitted should be validated using zod - 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
+80
View File
@@ -0,0 +1,80 @@
// Base custom error class
export class AppError extends Error {
status: number
details?: Record<string, unknown>
override name: string
constructor(message: string, status = 500, details?: Record<string, unknown>) {
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<string, unknown>) {
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"
}
}
+57 -11
View File
@@ -7,9 +7,49 @@ const PROTECTED = [
"/home", "/home",
] ]
// Sessions storage shared between routes // Open Deno KV database
// In production, this would be Redis or a database const kv = await Deno.openKv();
export const sessions = new Map<string, { userId: string, expiresAt: Date }>()
// 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<void> {
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<Response> { export async function authMiddleware(req: Request, ctx: CratesCtx): Promise<Response> {
const { pg } = ctx.state const { pg } = ctx.state
@@ -23,8 +63,6 @@ export async function authMiddleware(req: Request, ctx: CratesCtx): Promise<Resp
// Allow public paths without authentication // Allow public paths without authentication
if (!PROTECTED.includes(path)) { if (!PROTECTED.includes(path)) {
console.log('skip', path)
return await ctx.next() return await ctx.next()
} }
@@ -43,14 +81,22 @@ export async function authMiddleware(req: Request, ctx: CratesCtx): Promise<Resp
}) })
} }
// Verify session // Verify session using getSession helper
const session = sessions.get(sessionId) const session = await getSession(sessionId);
if (!session || new Date() > session.expiresAt) { if (!session) {
if (session) { if (path.startsWith("/api/")) {
sessions.delete(sessionId) 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 // Get user from database
+24
View File
@@ -1,4 +1,5 @@
import { FreshContext } from "$fresh/server.ts" import { FreshContext } from "$fresh/server.ts"
import { AppError } from "./errors.ts"
export async function errorMiddleware(req: Request, ctx: FreshContext): Promise<Response> { export async function errorMiddleware(req: Request, ctx: FreshContext): Promise<Response> {
try { try {
@@ -6,5 +7,28 @@ export async function errorMiddleware(req: Request, ctx: FreshContext): Promise<
return response return response
} catch (error) { } catch (error) {
console.error(`Error handling ${req.method} ${new URL(req.url).pathname}:`, 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" }
}
)
} }
} }
+6
View File
@@ -0,0 +1,6 @@
function Ok(headers) {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers
})
}
+7 -2
View File
@@ -1,11 +1,14 @@
{ {
"lock": false, "lock": false,
"unstable": ["kv"],
"tasks": { "tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx", "check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -", "cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
"manifest": "deno task cli manifest $(pwd)", "manifest": "deno task cli manifest $(pwd)",
"start": "deno run -A --watch=routes/,.env dev.ts", "start": "deno run -A --unstable-kv --watch=routes/,.env dev.ts",
"build": "deno run -A dev.ts build", "build": "deno run -A --unstable-kv dev.ts build",
"preview": "deno run -A main.ts", "preview": "deno run -A main.ts",
"prisma-clean": "rm -rf prisma/generated", "prisma-clean": "rm -rf prisma/generated",
"prisma-generate": "deno task prisma-clean && deno run -A --env-file npm:prisma generate", "prisma-generate": "deno task prisma-clean && deno run -A --env-file npm:prisma generate",
@@ -28,6 +31,8 @@
"less": "npm:less@4.2.0", "less": "npm:less@4.2.0",
"lucide-preact": "npm:lucide-preact@^0.475.0", "lucide-preact": "npm:lucide-preact@^0.475.0",
"@prisma/client": "npm:@prisma/client@^6.6.0", "@prisma/client": "npm:@prisma/client@^6.6.0",
"zodix": "npm:zodix@^0.4.4",
"api/": "./api/", "api/": "./api/",
"api": "./api/", "api": "./api/",
-2
View File
@@ -6,7 +6,6 @@ import * as $_404 from './routes/_404.tsx'
import * as $_500 from './routes/_500.tsx' import * as $_500 from './routes/_500.tsx'
import * as $_app from './routes/_app.tsx' import * as $_app from './routes/_app.tsx'
import * as $_middleware from './routes/_middleware.ts' 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_login from './routes/api/auth/login.ts'
import * as $api_auth_logout from './routes/api/auth/logout.ts' import * as $api_auth_logout from './routes/api/auth/logout.ts'
import * as $api_auth_register from './routes/api/auth/register.ts' import * as $api_auth_register from './routes/api/auth/register.ts'
@@ -25,7 +24,6 @@ const manifest = {
'./routes/_500.tsx': $_500, './routes/_500.tsx': $_500,
'./routes/_app.tsx': $_app, './routes/_app.tsx': $_app,
'./routes/_middleware.ts': $_middleware, './routes/_middleware.ts': $_middleware,
'./routes/api/_middleware.ts': $api_middleware,
'./routes/api/auth/login.ts': $api_auth_login, './routes/api/auth/login.ts': $api_auth_login,
'./routes/api/auth/logout.ts': $api_auth_logout, './routes/api/auth/logout.ts': $api_auth_logout,
'./routes/api/auth/register.ts': $api_auth_register, './routes/api/auth/register.ts': $api_auth_register,
+2 -1
View File
@@ -5,6 +5,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"zod": "^3.24.3" "zod": "^3.24.3",
"zodix": "^0.4.4"
} }
} }
+1 -1
View File
@@ -2,4 +2,4 @@ import { errorMiddleware } from 'api/mw.error.ts'
import { serviceMiddleware } from 'api/mw.service.ts' import { serviceMiddleware } from 'api/mw.service.ts'
import { authMiddleware } from 'api/mw.auth.ts' import { authMiddleware } from 'api/mw.auth.ts'
export const handler = [serviceMiddleware, authMiddleware] export const handler = [errorMiddleware, serviceMiddleware, authMiddleware]
-2
View File
@@ -1,2 +0,0 @@
import { authMiddleware } from 'api/mw.auth.ts'
export const handler = [authMiddleware]
+32 -45
View File
@@ -2,54 +2,41 @@ import { Handlers } from "$fresh/server.ts"
import { setCookie } from "$std/http/cookie.ts" import { setCookie } from "$std/http/cookie.ts"
import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts" import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts"
import { createPg } from "api/pg.ts" import { z } from "npm:zod"
import { sessions } from "api/mw.auth.ts" import { zx } from "npm:zodix"
export const handler: Handlers = { import { createSession } from "api/mw.auth.ts"
async POST(req, _ctx) { 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 { try {
const data = await req.json() const { email, password } = await zx.parseForm(req, LoginSchema)
const { email, password } = data
if (!email || !password) {
return new Response(JSON.stringify({ error: "Email and password required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
})
}
// Create pg instance for database operations // Create pg instance for database operations
const pg = createPg(console) const { pg } = ctx.state
// Find user by email // Find user by email
const user = await pg.userFindEmail(email) const user = await pg.userFindEmail(email)
if (!user) throw new InvalidCredentialsError()
if (!user) {
return new Response(JSON.stringify({ error: "Invalid credentials" }), {
status: 401,
headers: { "Content-Type": "application/json" }
})
}
// Verify password // Verify password
const match = await bcrypt.compare(password, user.password) const match = await bcrypt.compare(password, user.password)
if (!match) throw new InvalidCredentialsError()
if (!match) { // Create session using Deno KV
return new Response(JSON.stringify({ error: "Invalid credentials" }), { const { sessionId, expiresAt } = await createSession(user.id, 24 * 7) // 7 days in hours
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
})
// Set cookie // Set cookie
const headers = new Headers({ "Content-Type": "application/json" }) const headers = new Headers({ "Content-Type": "application/json" })
@@ -71,11 +58,14 @@ export const handler: Handlers = {
headers headers
}) })
} catch (error) { } catch (error) {
// Let the error middleware handle custom errors
if (error instanceof ValidationError ||
error instanceof InvalidCredentialsError) {
throw error
}
console.error("Login error:", error) console.error("Login error:", error)
return new Response(JSON.stringify({ error: "An error occurred" }), { throw new ServerError("An error occurred during login")
status: 500,
headers: { "Content-Type": "application/json" }
})
} }
}, },
@@ -86,10 +76,7 @@ export const handler: Handlers = {
const user = ctx.state.user const user = ctx.state.user
if (!user) { if (!user) {
return new Response(JSON.stringify({ error: "Not logged in" }), { throw new NotLoggedInError()
status: 401,
headers: { "Content-Type": "application/json" }
})
} }
return new Response(JSON.stringify({ return new Response(JSON.stringify({
+3 -3
View File
@@ -1,6 +1,6 @@
import { Handlers } from "$fresh/server.ts" import { Handlers } from "$fresh/server.ts"
import { getCookies, setCookie } from "$std/http/cookie.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 = { export const handler: Handlers = {
async POST(req, _ctx) { async POST(req, _ctx) {
@@ -8,8 +8,8 @@ export const handler: Handlers = {
const sessionId = cookies.session const sessionId = cookies.session
if (sessionId) { if (sessionId) {
// Remove session from storage // Remove session from Deno KV
sessions.delete(sessionId) await deleteSession(sessionId)
} }
// Clear the cookie // Clear the cookie
+34 -26
View File
@@ -1,21 +1,36 @@
import { Handlers } from "$fresh/server.ts" import { Handlers } from "$fresh/server.ts"
import { setCookie } from "$std/http/cookie.ts" import { setCookie } from "$std/http/cookie.ts"
import { createPg } from "../../../api/pg.ts" import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"
import { sessions } from "../../../api/mw.auth.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 = { export const handler: Handlers = {
async POST(req, _ctx) { async POST(req, _ctx) {
try { try {
const data = await req.json() const data = await req.formData()
const { discogs, email, password } = data
if (!email || !password) { // Validate input data with Zod
return new Response(JSON.stringify({ error: "Email and password required" }), { const result = RegisterSchema.safeParse(data)
status: 400,
headers: { "Content-Type": "application/json" } 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 // Create pg instance for database operations
const pg = createPg(console) const pg = createPg(console)
@@ -23,25 +38,15 @@ export const handler: Handlers = {
const existingUser = await pg.userFindEmail(email) const existingUser = await pg.userFindEmail(email)
if (existingUser) { if (existingUser) {
return new Response(JSON.stringify({ error: "Email already registered" }), { throw new ResourceExistsError("User with this email")
status: 400,
headers: { "Content-Type": "application/json" }
})
} }
// Create user // Create user
const params = { discogs, email, password, data: {} } const params = { discogs, email, password, data: {} }
const user = await pg.userCreate(params) const user = await pg.userCreate(params)
// Create session // Create session using Deno KV
const sessionId = crypto.randomUUID() const { sessionId, expiresAt } = await createSession(user.id, 24 * 7) // 7 days in hours
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7) // Session expires in 7 days
sessions.set(sessionId, {
userId: user.id,
expiresAt
})
// Set cookie // Set cookie
const headers = new Headers({ "Content-Type": "application/json" }) const headers = new Headers({ "Content-Type": "application/json" })
@@ -64,11 +69,14 @@ export const handler: Handlers = {
headers headers
}) })
} catch (error) { } catch (error) {
// Let the error middleware handle custom errors
if (error instanceof ValidationError ||
error instanceof ResourceExistsError) {
throw error
}
console.error("Registration error:", error) console.error("Registration error:", error)
return new Response(JSON.stringify({ error: "An error occurred" }), { throw new ServerError("An error occurred during registration")
status: 500,
headers: { "Content-Type": "application/json" }
})
} }
} }
} }
+10 -5
View File
@@ -1,11 +1,16 @@
import { Handlers, PageProps } from "$fresh/server.ts"; import { Handlers, PageProps } from "$fresh/server.ts";
import { createPg, Pg } from "../api/pg.ts";
import Main from "../components/main.tsx"; 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 = { interface HomeData {
async GET(_req, ctx: CratesCtx) { wants: Want[],
offers: Offer[],
}
export const handler: Handlers<HomeData, CratesState> = {
async GET(_req, ctx) {
const { pg, offers, user } = ctx.state const { pg, offers, user } = ctx.state
console.log(user) console.log(user)
@@ -13,7 +18,7 @@ export const handler: Handlers = {
const wants = await pg.userWants(user); const wants = await pg.userWants(user);
const uOffers = await offers.userOffers(user); const uOffers = await offers.userOffers(user);
return ctx.render({ wants, offers: uOffers }); return await ctx.render({ wants, offers: uOffers });
}, },
}; };
+2 -102
View File
@@ -1,105 +1,5 @@
import { Handlers, PageProps } from "$fresh/server.ts"; import { PageProps } from "$fresh/server.ts";
import { getCookies, setCookie } from "$std/http/cookie.ts";
import { z } from "npm:zod";
import Swirl from "../components/swirl.tsx"; 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 }) { function Login({ error }: { error?: string }) {
const errorEl = error const errorEl = error
@@ -109,7 +9,7 @@ function Login({ error }: { error?: string }) {
return ( return (
<div class="login"> <div class="login">
{errorEl} {errorEl}
<form method="POST" action="/login"> <form method="POST" action="/api/auth/login">
<div class="item"> <div class="item">
<label for="email">email</label> <label for="email">email</label>
<input type="email" name="email" id="email" required /> <input type="email" name="email" id="email" required />
+1 -104
View File
@@ -1,107 +1,4 @@
import { h } from "preact";
import { Handlers, PageProps } from "$fresh/server.ts";
import { getCookies, setCookie } from "$std/http/cookie.ts";
import { z } from "npm:zod";
import Swirl from "../components/swirl.tsx"; import Swirl from "../components/swirl.tsx";
import { sessions } from "../api/mw.auth.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: "/" },
});
}
return ctx.render();
},
async POST(req, ctx) {
const form = await req.formData();
// Define register schema with Zod
const RegisterSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
confirm: z.string()
}).refine((data) => data.password === data.confirm, {
message: "Passwords do not match",
path: ["confirm"]
});
// Validate the form data
const result = RegisterSchema.safeParse({
email: form.get("email"),
password: form.get("password"),
confirm: form.get("confirm")
});
if (!result.success) {
// Format validation errors
const fieldErrors = result.error.flatten().fieldErrors;
const firstError =
fieldErrors.email?.[0] ||
fieldErrors.password?.[0] ||
fieldErrors.confirm?.[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);
// Check if user exists
const existingUser = await pg.userFindEmail(email);
if (existingUser) {
return ctx.render({ error: "Email already registered" });
}
// Create user
const params = { 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
});
// Set cookie and redirect
const headers = new Headers({ Location: "/" });
setCookie(headers, {
name: "session",
value: sessionId,
expires: expiresAt,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax"
});
return new Response(null, {
status: 303,
headers
});
} catch (err) {
console.error("Registration error:", err);
return ctx.render({ error: "An error occurred during registration" });
}
}
};
function Register({ error = "" }: { error?: string }) { function Register({ error = "" }: { error?: string }) {
@@ -112,7 +9,7 @@ function Register({ error = "" }: { error?: string }) {
return ( return (
<div class="login"> <div class="login">
{errorEl} {errorEl}
<form action="/register" method="post"> <form action="/api/auth/register" method="post">
<div class="item"> <div class="item">
<label for="email">email</label> <label for="email">email</label>
<input type="email" name="email" id="email" required /> <input type="email" name="email" id="email" required />