Compare commits
10
Commits
d958a4b9a5
...
43a3efc94f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43a3efc94f | ||
|
|
e18bc8fbda | ||
|
|
5654964d09 | ||
|
|
8cf038443f | ||
|
|
d4b3398de5 | ||
|
|
f4eedaffe2 | ||
|
|
45a50dc906 | ||
|
|
fcfa0707a1 | ||
|
|
0395a78008 | ||
|
|
2a6c3a1222 |
+3
-2
@@ -1,8 +1,8 @@
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
DATABASE_URL=postgresql://ecommerce:ecommerce_password@db:5432/ecommerce
|
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_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_SECRET_KEY=sk_test_placeholder
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
STRIPE_WEBHOOK_SECRET=whsec_placeholder
|
||||||
SMTP_HOST=mailpit
|
SMTP_HOST=mailpit
|
||||||
@@ -10,3 +10,4 @@ SMTP_PORT=1025
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=noreply@localhost
|
SMTP_FROM=noreply@localhost
|
||||||
|
POSTGRES_PASSWORD=ecommerce_password
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
localhost {
|
localhost {
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
}
|
||||||
handle /uploads/* {
|
handle /uploads/* {
|
||||||
root * /srv
|
root * /srv
|
||||||
file_server
|
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");
|
||||||
@@ -255,3 +255,11 @@ model AuditLog {
|
|||||||
metadata Json?
|
metadata Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LoginAttempt {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String // IP address or identifier
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([key, createdAt])
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from 'next/link'
|
|||||||
import { Navbar } from '@/components/storefront/Navbar'
|
import { Navbar } from '@/components/storefront/Navbar'
|
||||||
import { Badge } from '@/components/ui/Badge'
|
import { Badge } from '@/components/ui/Badge'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,13 +36,14 @@ export default function OrdersPage() {
|
|||||||
function OrdersContent() {
|
function OrdersContent() {
|
||||||
const [orders, setOrders] = useState<Order[]>([])
|
const [orders, setOrders] = useState<Order[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { user, isLoading: userLoading } = useUser()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const success = searchParams.get('success')
|
const success = searchParams.get('success')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem('user')
|
if (userLoading) return
|
||||||
if (!stored) {
|
if (!user) {
|
||||||
router.push('/login?redirect=/account/orders')
|
router.push('/login?redirect=/account/orders')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Navbar } from '@/components/storefront/Navbar'
|
import { Navbar } from '@/components/storefront/Navbar'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
interface User {
|
|
||||||
id: string
|
|
||||||
email: string
|
|
||||||
name?: string
|
|
||||||
role: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const { user, isLoading } = useUser()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem('user')
|
if (!isLoading && !user) {
|
||||||
if (!stored) {
|
|
||||||
router.push('/login?redirect=/account')
|
router.push('/login?redirect=/account')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
try {
|
}, [isLoading, user, router])
|
||||||
setUser(JSON.parse(stored))
|
|
||||||
} catch {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
if (!user) return null
|
if (isLoading || !user) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
export default function ChangePasswordPage() {
|
||||||
const [currentPassword, setCurrentPassword] = useState('')
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
@@ -14,6 +15,7 @@ export default function ChangePasswordPage() {
|
|||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { user, setUser } = useUser()
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -41,12 +43,9 @@ export default function ChangePasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
// Update localStorage to remove mustChangePassword flag
|
// Update context to remove mustChangePassword flag
|
||||||
const userStr = localStorage.getItem('user')
|
if (user) {
|
||||||
if (userStr) {
|
setUser({ ...user, mustChangePassword: false })
|
||||||
const user = JSON.parse(userStr)
|
|
||||||
user.mustChangePassword = false
|
|
||||||
localStorage.setItem('user', JSON.stringify(user))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => router.push('/admin'), 1500)
|
setTimeout(() => router.push('/admin'), 1500)
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const id = searchParams.get('id')
|
const id = searchParams.get('id')
|
||||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
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 } })
|
await prisma.category.delete({ where: { id } })
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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() {
|
async function requireAdmin() {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
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 })
|
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({
|
const order = await prisma.order.update({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
data: { status: status as 'PENDING' | 'PAID' | 'CANCELLED' | 'REFUNDED' | 'FULFILLED' },
|
data: { status: status as 'PENDING' | 'PAID' | 'CANCELLED' | 'REFUNDED' | 'FULFILLED' },
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const page = parseInt(searchParams.get('page') || '1')
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||||
const status = searchParams.get('status')
|
const status = searchParams.get('status')
|
||||||
|
|
||||||
const skip = (page - 1) * limit
|
const skip = (page - 1) * limit
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const id = searchParams.get('id')
|
const id = searchParams.get('id')
|
||||||
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 })
|
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 } })
|
await prisma.productType.delete({ where: { id } })
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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 ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
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)) {
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
return NextResponse.json({ error: 'Only JPEG, PNG and WebP images are allowed' }, { status: 400 })
|
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) {
|
if (file.size > MAX_SIZE) {
|
||||||
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
|
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const page = parseInt(searchParams.get('page') || '1')
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||||
const search = searchParams.get('search')
|
const search = searchParams.get('search')
|
||||||
const status = searchParams.get('status')
|
const status = searchParams.get('status')
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const parsed = productSchema.safeParse(body)
|
const parsed = productSchema.safeParse(body)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
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 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const page = parseInt(searchParams.get('page') || '1')
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||||
const status = searchParams.get('status')
|
const status = searchParams.get('status')
|
||||||
|
|
||||||
const skip = (page - 1) * limit
|
const skip = (page - 1) * limit
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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() {
|
async function requireAdmin() {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
|
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 (!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({
|
const setting = await prisma.siteSettings.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
update: { value: value as object },
|
update: { value: value as object },
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { mkdir, writeFile } from 'fs/promises'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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 MAX_SIZE = 1 * 1024 * 1024 // 1MB
|
||||||
const FAVICON_URL = '/uploads/branding/favicon.png'
|
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 (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
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) {
|
if (file.size > MAX_SIZE) {
|
||||||
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
|
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 { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
|
import { getCurrentUser, verifyPassword, hashPassword } from '@/lib/auth'
|
||||||
import { changePasswordSchema } from '@/lib/validate'
|
import { changePasswordSchema } from '@/lib/validate'
|
||||||
|
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
@@ -9,6 +10,19 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
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
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -28,6 +42,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const valid = await verifyPassword(currentPassword, user.passwordHash)
|
const valid = await verifyPassword(currentPassword, user.passwordHash)
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
await recordAttempt(ip)
|
||||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
|
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,33 +6,16 @@ import {
|
|||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
import { loginSchema } from '@/lib/validate'
|
import { loginSchema } from '@/lib/validate'
|
||||||
|
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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(
|
return NextResponse.json(
|
||||||
{ error: 'Too many login attempts. Please try again later.' },
|
{ error: 'Too many login attempts. Please try again later.' },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
@@ -58,11 +41,13 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { email } })
|
const user = await prisma.user.findUnique({ where: { email } })
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
await recordAttempt(ip)
|
||||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.passwordHash)
|
const valid = await verifyPassword(password, user.passwordHash)
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
|
await recordAttempt(ip)
|
||||||
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +60,6 @@ export async function POST(request: NextRequest) {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
mustChangePassword: user.mustChangePassword,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ import { prisma } from '@/lib/prisma'
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const page = parseInt(searchParams.get('page') || '1')
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1') || 1)
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get('limit') || '20') || 20))
|
||||||
const category = searchParams.get('category')
|
const category = searchParams.get('category')
|
||||||
const search = searchParams.get('search')
|
const search = searchParams.get('search')
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
|
event = constructWebhookEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
|
||||||
} catch (err) {
|
} 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 })
|
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest) {
|
|||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
})
|
})
|
||||||
} catch (emailErr) {
|
} 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}`)
|
console.log(`Unhandled event type: ${event.type}`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 })
|
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { Navbar } from '@/components/storefront/Navbar'
|
import { Navbar } from '@/components/storefront/Navbar'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
interface CartItem {
|
interface CartItem {
|
||||||
productId: string
|
productId: string
|
||||||
@@ -19,20 +20,23 @@ export default function CheckoutPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { user, isLoading: userLoading } = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userLoading) return
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login?redirect=/checkout')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
|
const stored = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||||
if (stored.length === 0) {
|
if (stored.length === 0) {
|
||||||
router.push('/cart')
|
router.push('/cart')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setCart(stored)
|
setCart(stored)
|
||||||
|
}, [router, user, userLoading])
|
||||||
const user = localStorage.getItem('user')
|
|
||||||
if (!user) {
|
|
||||||
router.push('/login?redirect=/checkout')
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { Footer } from '@/components/storefront/Footer'
|
import { Footer } from '@/components/storefront/Footer'
|
||||||
|
import { UserProvider } from '@/context/UserContext'
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
try {
|
try {
|
||||||
@@ -23,7 +24,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<html lang="it">
|
<html lang="it">
|
||||||
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
|
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
|
||||||
|
<UserProvider>
|
||||||
{children}
|
{children}
|
||||||
|
</UserProvider>
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
|||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
@@ -23,6 +24,7 @@ function LoginForm() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const redirect = searchParams.get('redirect') || '/'
|
const redirect = searchParams.get('redirect') || '/'
|
||||||
|
const { refreshUser } = useUser()
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -43,11 +45,11 @@ function LoginForm() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('user', JSON.stringify(data.user))
|
const refreshedUser = await refreshUser()
|
||||||
|
|
||||||
if (data.user.mustChangePassword) {
|
if (refreshedUser?.mustChangePassword) {
|
||||||
router.push('/admin/change-password')
|
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')
|
router.push('/admin')
|
||||||
} else {
|
} else {
|
||||||
router.push(redirect)
|
router.push(redirect)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
@@ -14,6 +15,7 @@ export default function RegisterPage() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { refreshUser } = useUser()
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -34,7 +36,7 @@ export default function RegisterPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('user', JSON.stringify(data.user))
|
await refreshUser()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch {
|
} catch {
|
||||||
setError('Something went wrong. Please try again.')
|
setError('Something went wrong. Please try again.')
|
||||||
|
|||||||
@@ -3,31 +3,22 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useUser } from '@/context/UserContext'
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [cartCount, setCartCount] = useState(0)
|
const [cartCount, setCartCount] = useState(0)
|
||||||
const [user, setUser] = useState<{ name?: string; email: string; role: string } | null>(null)
|
const { user, refreshUser } = useUser()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
const cart = JSON.parse(localStorage.getItem('cart') || '[]')
|
||||||
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
|
const count = cart.reduce((sum: number, item: { quantity: number }) => sum + item.quantity, 0)
|
||||||
setCartCount(count)
|
setCartCount(count)
|
||||||
|
|
||||||
const userData = localStorage.getItem('user')
|
|
||||||
if (userData) {
|
|
||||||
try {
|
|
||||||
setUser(JSON.parse(userData))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' })
|
await fetch('/api/auth/logout', { method: 'POST' })
|
||||||
localStorage.removeItem('user')
|
await refreshUser()
|
||||||
setUser(null)
|
|
||||||
router.push('/')
|
router.push('/')
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 } } })
|
||||||
|
}
|
||||||
@@ -16,6 +16,23 @@ export async function saveImage(
|
|||||||
return `/uploads/${productId}/${filename}`
|
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> {
|
export async function deleteImageFile(url: string): Promise<void> {
|
||||||
const filePath = path.join(process.cwd(), 'public', url)
|
const filePath = path.join(process.cwd(), 'public', url)
|
||||||
await unlink(filePath)
|
await unlink(filePath)
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import type { NextRequest } from 'next/server'
|
|||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const response = NextResponse.next()
|
const response = NextResponse.next()
|
||||||
response.headers.set('x-pathname', request.nextUrl.pathname)
|
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
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -3,9 +3,9 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ecommerce
|
POSTGRES_USER: ${POSTGRES_USER:-ecommerce}
|
||||||
POSTGRES_PASSWORD: ecommerce_password
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ecommerce_password}
|
||||||
POSTGRES_DB: ecommerce
|
POSTGRES_DB: ${POSTGRES_DB:-ecommerce}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/db:/var/lib/postgresql/data
|
- ./data/db:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
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:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user