Compare commits

..
10 Commits
Author SHA1 Message Date
davide 43a3efc94f fix(security): clamp pagination parameters to prevent negative or overflow values
Replace raw parseInt() with Math.max/min bounds: page >= 1, limit 1-100.
Affects public products, admin orders, and admin reviews endpoints.
2026-05-19 10:12:17 +02:00
davide e18bc8fbda fix(security): block deletion of categories and product types in use
Return 409 Conflict if any products reference the entity being deleted,
preventing accidental data corruption from orphaned foreign keys.
2026-05-19 10:12:03 +02:00
davide 5654964d09 fix(security): sanitize error logging and remove Zod schema details from responses
- Stripe webhook: log only error.message instead of full error objects
  to avoid exposing stack traces in aggregated logs
- Admin products POST: return only first Zod error message instead of
  the full error array which reveals internal schema structure
2026-05-19 10:11:47 +02:00
davide 8cf038443f fix(security): remove hardcoded default credentials from config files
- .env.example: replace weak default INITIAL_ADMIN_PASSWORD and
  AUTH_SECRET with instructive placeholders requiring manual generation
- docker-compose.yml: parameterize POSTGRES_USER, POSTGRES_PASSWORD,
  POSTGRES_DB and DATABASE_URL via environment variables with local fallbacks
2026-05-19 10:11:30 +02:00
davide d4b3398de5 fix(security): enforce order status state machine in admin endpoint
Add VALID_TRANSITIONS map and validate each status change before updating
the database. Prevents skipping payment (e.g. PENDING→FULFILLED) or
reopening closed orders.
2026-05-19 10:11:16 +02:00
davide f4eedaffe2 fix(security): replace in-memory rate limiting with persistent DB-backed limiter
- Add LoginAttempt model to Prisma schema with migration
- Create rate-limit.ts utility (10 attempts / 15 min window, DB-backed)
- Apply rate limiting to login endpoint (replaces in-memory Map)
- Apply rate limiting to change-password endpoint (previously unprotected)
- Rate limit state survives server restarts and works across multiple instances
2026-05-19 10:10:57 +02:00
davide 45a50dc906 fix(security): whitelist allowed keys in admin settings endpoint
Reject any key not in the explicit allowlist before writing to the database,
preventing arbitrary configuration injection by a malicious admin.
2026-05-19 10:10:42 +02:00
davide fcfa0707a1 fix(security): replace localStorage user state with server-side session
- Add GET /api/auth/me endpoint returning current user from httpOnly cookie
- Add UserContext + useUser() hook that fetches from /api/auth/me on mount
- Wrap root layout with UserProvider
- Remove all localStorage.setItem/getItem('user') calls from login, register,
  navbar, account pages, change-password, and checkout
- mustChangePassword redirect now reads from refreshed server session
2026-05-19 10:10:24 +02:00
davide 0395a78008 fix(security): add HTTP security headers (CSP, HSTS, X-Frame-Options)
- middleware.ts: set X-Frame-Options, X-Content-Type-Options,
  Referrer-Policy, Permissions-Policy, Content-Security-Policy on all responses
- Caddyfile: add Strict-Transport-Security (HSTS 1y), X-Frame-Options,
  X-Content-Type-Options at reverse proxy level
2026-05-19 10:10:08 +02:00
davide 2a6c3a1222 fix(security): validate file uploads with magic bytes, remove SVG from favicon whitelist
- Add validateImageMagicBytes() to storage.ts reading first 12 bytes
  to verify JPEG/PNG/WebP/ICO signatures regardless of declared MIME type
- Remove image/svg+xml from favicon upload whitelist (SVG can embed scripts)
- Apply magic bytes check in product image and favicon upload endpoints
2026-05-19 10:09:53 +02:00
31 changed files with 291 additions and 96 deletions
+3 -2
View File
@@ -1,8 +1,8 @@
APP_URL=http://localhost
DATABASE_URL=postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
AUTH_SECRET=dev-secret-change-in-production-32chars
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=Admin1234!test
INITIAL_ADMIN_PASSWORD=<change-this-use-openssl-rand-base64-32>
STRIPE_SECRET_KEY=sk_test_placeholder
STRIPE_WEBHOOK_SECRET=whsec_placeholder
SMTP_HOST=mailpit
@@ -10,3 +10,4 @@ SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@localhost
POSTGRES_PASSWORD=ecommerce_password
+5
View File
@@ -1,4 +1,9 @@
localhost {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
}
handle /uploads/* {
root * /srv
file_server
@@ -0,0 +1,7 @@
CREATE TABLE "LoginAttempt" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoginAttempt_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "LoginAttempt_key_createdAt_idx" ON "LoginAttempt"("key", "createdAt");
+8
View File
@@ -255,3 +255,11 @@ model AuditLog {
metadata Json?
createdAt DateTime @default(now())
}
model LoginAttempt {
id String @id @default(cuid())
key String // IP address or identifier
createdAt DateTime @default(now())
@@index([key, createdAt])
}
+4 -2
View File
@@ -6,6 +6,7 @@ import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
import { Badge } from '@/components/ui/Badge'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
interface Order {
id: string
@@ -35,13 +36,14 @@ export default function OrdersPage() {
function OrdersContent() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const { user, isLoading: userLoading } = useUser()
const router = useRouter()
const searchParams = useSearchParams()
const success = searchParams.get('success')
useEffect(() => {
const stored = localStorage.getItem('user')
if (!stored) {
if (userLoading) return
if (!user) {
router.push('/login?redirect=/account/orders')
return
}
+6 -19
View File
@@ -1,35 +1,22 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Navbar } from '@/components/storefront/Navbar'
interface User {
id: string
email: string
name?: string
role: string
}
import { useUser } from '@/context/UserContext'
export default function AccountPage() {
const [user, setUser] = useState<User | null>(null)
const { user, isLoading } = useUser()
const router = useRouter()
useEffect(() => {
const stored = localStorage.getItem('user')
if (!stored) {
if (!isLoading && !user) {
router.push('/login?redirect=/account')
return
}
try {
setUser(JSON.parse(stored))
} catch {
router.push('/login')
}
}, [router])
}, [isLoading, user, router])
if (!user) return null
if (isLoading || !user) return null
return (
<div>
+5 -6
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
export default function ChangePasswordPage() {
const [currentPassword, setCurrentPassword] = useState('')
@@ -14,6 +15,7 @@ export default function ChangePasswordPage() {
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter()
const { user, setUser } = useUser()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -41,12 +43,9 @@ export default function ChangePasswordPage() {
}
setSuccess(true)
// Update localStorage to remove mustChangePassword flag
const userStr = localStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
user.mustChangePassword = false
localStorage.setItem('user', JSON.stringify(user))
// Update context to remove mustChangePassword flag
if (user) {
setUser({ ...user, mustChangePassword: false })
}
setTimeout(() => router.push('/admin'), 1500)
@@ -72,6 +72,14 @@ export async function DELETE(request: NextRequest) {
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
const productCount = await prisma.productCategory.count({ where: { categoryId: id } })
if (productCount > 0) {
return NextResponse.json(
{ error: `Cannot delete: ${productCount} product(s) use this category` },
{ status: 409 }
)
}
await prisma.category.delete({ where: { id } })
return NextResponse.json({ success: true })
@@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
const VALID_TRANSITIONS: Record<string, string[]> = {
PENDING: ['PAID', 'CANCELLED'],
PAID: ['FULFILLED', 'REFUNDED', 'CANCELLED'],
FULFILLED: ['REFUNDED'],
CANCELLED: [],
REFUNDED: [],
}
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
@@ -50,6 +58,17 @@ export async function PUT(
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
}
const currentOrder = await prisma.order.findUnique({ where: { id: params.id }, select: { status: true } })
if (!currentOrder) return NextResponse.json({ error: 'Order not found' }, { status: 404 })
const allowed = VALID_TRANSITIONS[currentOrder.status] ?? []
if (!allowed.includes(status)) {
return NextResponse.json(
{ error: `Cannot transition order from ${currentOrder.status} to ${status}` },
{ status: 422 }
)
}
const order = await prisma.order.update({
where: { id: params.id },
data: { status: status as 'PENDING' | 'PAID' | 'CANCELLED' | 'REFUNDED' | 'FULFILLED' },
+2 -2
View File
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const status = searchParams.get('status')
const skip = (page - 1) * limit
@@ -73,6 +73,14 @@ export async function DELETE(request: NextRequest) {
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
const productCount = await prisma.product.count({ where: { typeId: id } })
if (productCount > 0) {
return NextResponse.json(
{ error: `Cannot delete: ${productCount} product(s) use this product type` },
{ status: 409 }
)
}
await prisma.productType.delete({ where: { id } })
return NextResponse.json({ success: true })
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { saveImage, deleteImageFile } from '@/lib/storage'
import { saveImage, deleteImageFile, validateImageMagicBytes } from '@/lib/storage'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
@@ -26,6 +26,10 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Only JPEG, PNG and WebP images are allowed' }, { status: 400 })
}
const isValidImage = await validateImageMagicBytes(file, file.type)
if (!isValidImage) {
return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
}
+3 -3
View File
@@ -16,8 +16,8 @@ export async function GET(request: NextRequest) {
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const search = searchParams.get('search')
const status = searchParams.get('status')
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
const parsed = productSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message || 'Invalid input', details: parsed.error.errors },
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ status: 400 }
)
}
+2 -2
View File
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const status = searchParams.get('status')
const skip = (page - 1) * limit
+15
View File
@@ -2,6 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
const ALLOWED_SETTING_KEYS = [
'site_name',
'site_description',
'support_email',
'currency',
'tax_rate',
'footer_copyright',
'footer_links',
'favicon_url',
] as const
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
@@ -40,6 +51,10 @@ export async function POST(request: NextRequest) {
if (!key) return NextResponse.json({ error: 'Key is required' }, { status: 400 })
if (!ALLOWED_SETTING_KEYS.includes(key as (typeof ALLOWED_SETTING_KEYS)[number])) {
return NextResponse.json({ error: 'Invalid setting key' }, { status: 400 })
}
const setting = await prisma.siteSettings.upsert({
where: { key },
update: { value: value as object },
@@ -3,8 +3,9 @@ import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
import { validateImageMagicBytes } from '@/lib/storage'
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/jpeg', 'image/webp']
const MAX_SIZE = 1 * 1024 * 1024 // 1MB
const FAVICON_URL = '/uploads/branding/favicon.png'
@@ -23,7 +24,11 @@ export async function POST(req: NextRequest) {
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, SVG o WebP)' }, { status: 400 })
return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, JPEG o WebP)' }, { status: 400 })
}
const isValidImage = await validateImageMagicBytes(file, file.type)
if (!isValidImage) {
return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
import { changePasswordSchema } from '@/lib/validate'
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const user = await getCurrentUser()
@@ -9,6 +10,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const ip =
request.headers.get('x-forwarded-for') ??
request.headers.get('x-real-ip') ??
'unknown'
const { limited } = await checkRateLimit(ip)
if (limited) {
return NextResponse.json(
{ error: 'Too many attempts. Please try again later.' },
{ status: 429 }
)
}
let body: unknown
try {
body = await request.json()
@@ -28,6 +42,7 @@ export async function POST(request: NextRequest) {
const valid = await verifyPassword(currentPassword, user.passwordHash)
if (!valid) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
}
+9 -25
View File
@@ -6,33 +6,16 @@ import {
setSessionCookie,
} from '@/lib/auth'
import { loginSchema } from '@/lib/validate'
// Simple in-memory rate limiter
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
function checkRateLimit(ip: string): boolean {
const now = Date.now()
const windowMs = 15 * 60 * 1000 // 15 minutes
const maxAttempts = 10
const record = loginAttempts.get(ip)
if (!record || record.resetAt < now) {
loginAttempts.set(ip, { count: 1, resetAt: now + windowMs })
return true
}
if (record.count >= maxAttempts) {
return false
}
record.count++
return true
}
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') || 'unknown'
const ip =
request.headers.get('x-forwarded-for') ??
request.headers.get('x-real-ip') ??
'unknown'
if (!checkRateLimit(ip)) {
const { limited } = await checkRateLimit(ip)
if (limited) {
return NextResponse.json(
{ error: 'Too many login attempts. Please try again later.' },
{ status: 429 }
@@ -58,11 +41,13 @@ export async function POST(request: NextRequest) {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
const valid = await verifyPassword(password, user.passwordHash)
if (!valid) {
await recordAttempt(ip)
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
}
@@ -75,7 +60,6 @@ export async function POST(request: NextRequest) {
email: user.email,
name: user.name,
role: user.role,
mustChangePassword: user.mustChangePassword,
},
})
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth'
export async function GET() {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ user: null }, { status: 401 })
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustChangePassword: user.mustChangePassword,
},
})
}
+2 -2
View File
@@ -3,8 +3,8 @@ import { prisma } from '@/lib/prisma'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
const category = searchParams.get('category')
const search = searchParams.get('search')
+3 -3
View File
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
try {
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
console.error('Webhook signature verification failed:', err)
console.error('Webhook signature verification failed:', err instanceof Error ? err.message : String(err))
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
currency: order.currency,
})
} catch (emailErr) {
console.error('Failed to send confirmation email:', emailErr)
console.error('Failed to send confirmation email:', emailErr instanceof Error ? emailErr.message : String(emailErr))
}
}
@@ -116,7 +116,7 @@ export async function POST(request: NextRequest) {
console.log(`Unhandled event type: ${event.type}`)
}
} catch (err) {
console.error('Error processing webhook:', err)
console.error('Error processing webhook:', err instanceof Error ? err.message : String(err))
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
+10 -6
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { Navbar } from '@/components/storefront/Navbar'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
interface CartItem {
productId: string
@@ -19,20 +20,23 @@ export default function CheckoutPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const router = useRouter()
const { user, isLoading: userLoading } = useUser()
useEffect(() => {
if (userLoading) return
if (!user) {
router.push('/login?redirect=/checkout')
return
}
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
if (stored.length === 0) {
router.push('/cart')
return
}
setCart(stored)
const user = localStorage.getItem('user')
if (!user) {
router.push('/login?redirect=/checkout')
}
}, [router])
}, [router, user, userLoading])
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
+4 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css'
import { prisma } from '@/lib/prisma'
import { Footer } from '@/components/storefront/Footer'
import { UserProvider } from '@/context/UserContext'
export async function generateMetadata(): Promise<Metadata> {
try {
@@ -23,7 +24,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="it">
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
{children}
<UserProvider>
{children}
</UserProvider>
<Footer />
</body>
</html>
+5 -3
View File
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
export default function LoginPage() {
return (
@@ -23,6 +24,7 @@ function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect') || '/'
const { refreshUser } = useUser()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -43,11 +45,11 @@ function LoginForm() {
return
}
localStorage.setItem('user', JSON.stringify(data.user))
const refreshedUser = await refreshUser()
if (data.user.mustChangePassword) {
if (refreshedUser?.mustChangePassword) {
router.push('/admin/change-password')
} else if (data.user.role === 'ADMIN' || data.user.role === 'OWNER') {
} else if (refreshedUser?.role === 'ADMIN' || refreshedUser?.role === 'OWNER') {
router.push('/admin')
} else {
router.push(redirect)
+3 -1
View File
@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
import { useUser } from '@/context/UserContext'
export default function RegisterPage() {
const [name, setName] = useState('')
@@ -14,6 +15,7 @@ export default function RegisterPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const { refreshUser } = useUser()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -34,7 +36,7 @@ export default function RegisterPage() {
return
}
localStorage.setItem('user', JSON.stringify(data.user))
await refreshUser()
router.push('/')
} catch {
setError('Something went wrong. Please try again.')
+3 -12
View File
@@ -3,31 +3,22 @@
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useUser } from '@/context/UserContext'
export function Navbar() {
const [cartCount, setCartCount] = useState(0)
const [user, setUser] = useState<{ name?: string; email: string; role: string } | null>(null)
const { user, refreshUser } = useUser()
const router = useRouter()
useEffect(() => {
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
setCartCount(count)
const userData = localStorage.getItem('user')
if (userData) {
try {
setUser(JSON.parse(userData))
} catch {
// ignore
}
}
}, [])
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' })
localStorage.removeItem('user')
setUser(null)
await refreshUser()
router.push('/')
router.refresh()
}
+60
View File
@@ -0,0 +1,60 @@
'use client'
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
interface User {
id: string
email: string
name: string
role: string
mustChangePassword: boolean
}
interface UserContextValue {
user: User | null
isLoading: boolean
setUser: (user: User | null) => void
refreshUser: () => Promise<User | null>
}
const UserContext = createContext<UserContextValue | null>(null)
export function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const refreshUser = useCallback(async (): Promise<User | null> => {
try {
const res = await fetch('/api/auth/me')
if (res.ok) {
const data = await res.json()
setUser(data.user)
return data.user
} else {
setUser(null)
return null
}
} catch {
setUser(null)
return null
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
refreshUser()
}, [refreshUser])
return (
<UserContext.Provider value={{ user, isLoading, setUser, refreshUser }}>
{children}
</UserContext.Provider>
)
}
export function useUser(): UserContextValue {
const ctx = useContext(UserContext)
if (!ctx) throw new Error('useUser must be used within a UserProvider')
return ctx
}
+25
View File
@@ -0,0 +1,25 @@
import { prisma } from '@/lib/prisma'
const MAX_ATTEMPTS = 10
const WINDOW_MS = 15 * 60 * 1000 // 15 minutes
export async function checkRateLimit(key: string): Promise<{ limited: boolean; remaining: number }> {
const windowStart = new Date(Date.now() - WINDOW_MS)
const count = await prisma.loginAttempt.count({
where: { key, createdAt: { gte: windowStart } },
})
if (count >= MAX_ATTEMPTS) {
return { limited: true, remaining: 0 }
}
return { limited: false, remaining: MAX_ATTEMPTS - count }
}
export async function recordAttempt(key: string): Promise<void> {
await prisma.loginAttempt.create({ data: { key } })
// Clean up old records (keep last 24h only)
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000)
await prisma.loginAttempt.deleteMany({ where: { createdAt: { lt: cutoff } } })
}
+17
View File
@@ -16,6 +16,23 @@ export async function saveImage(
return `/uploads/${productId}/${filename}`
}
const IMAGE_MAGIC_BYTES: Record<string, (buf: Buffer) => boolean> = {
'image/jpeg': (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
'image/png': (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47,
'image/webp': (b) =>
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
'image/x-icon': (b) => b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00,
}
export async function validateImageMagicBytes(file: File, declaredType: string): Promise<boolean> {
const checker = IMAGE_MAGIC_BYTES[declaredType]
if (!checker) return false
const arrayBuffer = await file.slice(0, 12).arrayBuffer()
const buf = Buffer.from(arrayBuffer)
return checker(buf)
}
export async function deleteImageFile(url: string): Promise<void> {
const filePath = path.join(process.cwd(), 'public', url)
await unlink(filePath)
+8
View File
@@ -4,6 +4,14 @@ import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
response.headers.set('x-pathname', request.nextUrl.pathname)
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com; frame-ancestors 'none'"
)
return response
}
+4 -4
View File
@@ -3,9 +3,9 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ecommerce
POSTGRES_PASSWORD: ecommerce_password
POSTGRES_DB: ecommerce
POSTGRES_USER: ${POSTGRES_USER:-ecommerce}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ecommerce_password}
POSTGRES_DB: ${POSTGRES_DB:-ecommerce}
volumes:
- ./data/db:/var/lib/postgresql/data
healthcheck:
@@ -24,7 +24,7 @@ services:
condition: service_healthy
env_file: .env
environment:
DATABASE_URL: postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
DATABASE_URL: postgresql://${POSTGRES_USER:-ecommerce}:${POSTGRES_PASSWORD:-ecommerce_password}@db:5432/${POSTGRES_DB:-ecommerce}
expose:
- "3000"
volumes: