From d82234482b2f90b94e9eb26235872c3e764623b7 Mon Sep 17 00:00:00 2001 From: AV77web Date: Fri, 3 Jul 2026 17:43:45 +0200 Subject: [PATCH] aggiunti: in validation i constraints, middleware, operatore, primitive; aggiunti anche db/pool.ts middleware/auth.ts types/index.ts config/cors.ts e auth/config.ts --- src/auth/config.ts | 81 ++++++++++++ src/config/cors.ts | 28 +++++ src/db/pool.ts | 13 ++ src/middleware/auth.ts | 39 ++++++ src/types/auth.d.ts | 22 ++++ src/types/index.ts | 8 ++ src/validation/constraints.ts | 43 +++++++ src/validation/middleware.ts | 21 ++++ src/validation/operatore.ts | 224 ++++++++++++++++++++++++++++++++++ src/validation/primitives.ts | 198 ++++++++++++++++++++++++++++++ 10 files changed, 677 insertions(+) create mode 100644 src/auth/config.ts create mode 100644 src/config/cors.ts create mode 100644 src/db/pool.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/types/auth.d.ts create mode 100644 src/types/index.ts create mode 100644 src/validation/constraints.ts create mode 100644 src/validation/middleware.ts create mode 100644 src/validation/operatore.ts create mode 100644 src/validation/primitives.ts diff --git a/src/auth/config.ts b/src/auth/config.ts new file mode 100644 index 0000000..e0af0c4 --- /dev/null +++ b/src/auth/config.ts @@ -0,0 +1,81 @@ +import type { ExpressAuthConfig } from "@auth/express"; +import Credentials from "@auth/express/providers/credentials"; +import bcrypt from "bcryptjs"; +import type { RowDataPacket } from "mysql2"; +import pool from "../db/pool.js"; +import type { Operatore } from "../types/index.js"; +import { validateLoginInput } from "../validation/operatore.js"; + +const isProduction = process.env.NODE_ENV === "production"; + +const crossOriginCookieOptions = { + httpOnly: true, + sameSite: "none" as const, + secure: true, + path: "/", +}; + +export const authConfig: ExpressAuthConfig = { + basePath: "/api/auth", + trustHost: true, + secret: process.env.AUTH_SECRET, + useSecureCookies: isProduction, + session: { strategy: "jwt" }, + pages: { signIn: "/api/auth/error" }, + cookies: isProduction + ? { + sessionToken: { options: crossOriginCookieOptions }, + csrfToken: { options: { ...crossOriginCookieOptions, httpOnly: false } }, + callbackUrl: { options: crossOriginCookieOptions }, + } + : undefined, + providers: [ + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const validation = validateLoginInput(credentials ?? {}); + + if (!validation.success) return null; + + const { email, password } = validation.data; + + const [rows] = await pool.execute( + "SELECT OperatoreID, Nome, Cognome, Email, PasswordHash, Admin FROM operatore WHERE Email = ?", + [email] + ); + + const operatore = rows[0] as Operatore | undefined; + if (!operatore) return null; + + const valid = await bcrypt.compare(password, operatore.PasswordHash); + if (!valid) return null; + + return { + id: String(operatore.OperatoreID), + email: operatore.Email, + name: `${operatore.Nome} ${operatore.Cognome}`, + admin: operatore.Admin, + }; + }, + }), + ], + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.admin = user.admin; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + session.user.id = token.id as string; + session.user.admin = token.admin as boolean; + } + return session; + }, + }, +}; diff --git a/src/config/cors.ts b/src/config/cors.ts new file mode 100644 index 0000000..b1ec67f --- /dev/null +++ b/src/config/cors.ts @@ -0,0 +1,28 @@ +import cors from "cors"; + +const allowedOrigins = [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "https://prenotazionifrontend.andreavillari.it", + process.env.FRONTEND_URL, +].filter(Boolean) as string[]; + +export const corsMiddleware = cors({ + origin(origin, callback) { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + console.warn(`CORS: origin non consentita: ${origin}`); + callback(null, false); + }, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "Cookie", + "X-Requested-With", + "X-Auth-Return-Redirect", + ], +}); diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..384b8cc --- /dev/null +++ b/src/db/pool.ts @@ -0,0 +1,13 @@ +import mysql from "mysql2/promise"; + +const pool = mysql.createPool({ + host: process.env.DB_HOST ?? "127.0.0.1", + port: Number(process.env.DB_PORT ?? 3307), + database: process.env.DB_NAME ?? "prenotazioni", + user: process.env.DB_USER ?? "prenotazioni_user", + password: process.env.DB_PASSWORD ?? "", + waitForConnections: true, + connectionLimit: 10, +}); + +export default pool; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..3ba8a40 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,39 @@ +import { getSession } from "@auth/express"; +import type { NextFunction, Request, Response } from "express"; +import { authConfig } from "../auth/config.js"; + +export async function requireAuth(req: Request, res: Response, next: NextFunction) { + const session = await getSession(req, authConfig); + if (!session?.user?.id) { + res.status(401).json({ error: "Autenticazione richiesta" }); + return; + } + req.user = { + id: Number(session.user.id), + email: session.user.email, + name: session.user.name, + admin: session.user.admin, + }; + next(); +} + +export function requireAdmin(req: Request, res: Response, next: NextFunction) { + if (!req.user?.admin) { + res.status(403).json({ error: "Operazione riservata agli amministratori" }); + return; + } + next(); +} + +declare global { + namespace Express { + interface Request { + user?: { + id: number; + email: string; + name: string; + admin: boolean; + }; + } + } +} diff --git a/src/types/auth.d.ts b/src/types/auth.d.ts new file mode 100644 index 0000000..d629603 --- /dev/null +++ b/src/types/auth.d.ts @@ -0,0 +1,22 @@ +import type {} from "./index.js"; + +declare module "@auth/core/types" { + interface User { + admin?: boolean; + } + interface Session { + user: { + id: string; + email: string; + name: string; + admin: boolean; + }; + } +} + +declare module "@auth/core/jwt" { + interface JWT { + id?: string; + admin?: boolean; + } +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..6bf764a --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,8 @@ +export interface Operatore { + OperatoreID: number; + Nome: string; + Cognome: string; + Email: string; + PasswordHash: string; + Admin: boolean; +} diff --git a/src/validation/constraints.ts b/src/validation/constraints.ts new file mode 100644 index 0000000..50dc2cc --- /dev/null +++ b/src/validation/constraints.ts @@ -0,0 +1,43 @@ +//========================================= +// File: constraints.ts +// Vincoli derivati da initdb/01_schema.sql. +// author: "villari.andrea@libero.it" +// version: "1.0.0 2026-06-18" +//========================================= + +export const OPERATORE_CONSTRAINTS = { + nome: { maxLength: 100, required: true }, + cognome: { maxLength: 100, required: true }, + email: { maxLength: 255, required: true }, + password: { minLength: 8, maxLength: 72, required: true }, + } as const; + + export const CLIENTE_CONSTRAINTS = { + nome: { maxLength: 100, required: true }, + cognome: { maxLength: 100, required: true }, + telefono: { maxLength: 30, required: true }, + email: { maxLength: 255, required: true }, + note: { required: false }, + } as const; + + export const CAMPO_SPORTIVO_CONSTRAINTS = { + nome: { maxLength: 100, required: true }, + tipo: { allowedValues: ["tennis", "padel", "calcetto"], required: true }, + coperto: { required: true }, + prezzoOrario: { required: true, min: 0.01 }, + attivo: { required: true }, + } as const; + + export const PRENOTAZIONE_CONSTRAINTS = { + data: { required: true }, + oraInizio: { required: true }, + oraFine: { required: true }, + stato: { + allowedValues: ["prenotata", "confermata", "annullata", "completata"], + required: true, + }, + codicePrenotazione: { maxLength: 8, required: true }, + clienteId: { required: true }, + campoId: { required: true }, + } as const; + \ No newline at end of file diff --git a/src/validation/middleware.ts b/src/validation/middleware.ts new file mode 100644 index 0000000..94fa95d --- /dev/null +++ b/src/validation/middleware.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from "express"; +import type { ValidationResult } from "./primitives.js"; + +export function validateBody( + validator: (body: Record) => ValidationResult +): RequestHandler { + return (req, res, next) => { + const result = validator(req.body as Record); + + if (!result.success) { + res.status(400).json({ + error: "Validazione fallita", + fields: result.errors, + }); + return; + } + + res.locals.validatedBody = result.data; + next(); + }; +} diff --git a/src/validation/operatore.ts b/src/validation/operatore.ts new file mode 100644 index 0000000..4b0033a --- /dev/null +++ b/src/validation/operatore.ts @@ -0,0 +1,224 @@ +//============================================== +// File: operatore.ts +// file per la validazione e il login operatore. +// author: "villari.andrea@libero.it" +// version: "1.0.0 2026-06-18" +//============================================== + +import { OPERATORE_CONSTRAINTS } from "./constraints.js"; +import { + buildResult, + normalizeBoolean, + normalizeEmail, + normalizeString, + validateBoolean, + validateEmailFormat, + validateMaxLength, + validateMinLength, + validateRequired, + type FieldErrors, + type ValidationResult, +} from "./primitives.js"; + +export interface LoginInput { + email: string; + password: string; +} + +export interface OperatoreCreateInput { + nome: string; + cognome: string; + email: string; + password: string; + admin: boolean; +} + +export type OperatoreUpdateInput = Partial; + +export function normalizeLoginInput(raw: Record): LoginInput { + return { + email: normalizeEmail(raw.email), + password: raw.password?.toString() ?? "", + }; +} + +export function validateLoginInput( + raw: Record +): ValidationResult { + const data = normalizeLoginInput(raw); + const errors: FieldErrors = {}; + + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + OPERATORE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + validateRequired(errors, "password", data.password, "Password obbligatoria"); + validateMaxLength( + errors, + "password", + data.password, + OPERATORE_CONSTRAINTS.password.maxLength, + "Password" + ); + + return buildResult(data, errors); +} + +function validateOperatoreProfileFields( + data: { + nome: string; + cognome: string; + email: string; + password?: string; + admin?: boolean; + }, + errors: FieldErrors, + options: { requirePassword: boolean } +): void { + validateRequired(errors, "nome", data.nome, "Nome obbligatorio"); + validateMaxLength( + errors, + "nome", + data.nome, + OPERATORE_CONSTRAINTS.nome.maxLength, + "Nome" + ); + validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio"); + validateMaxLength( + errors, + "cognome", + data.cognome, + OPERATORE_CONSTRAINTS.cognome.maxLength, + "Cognome" + ); + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + OPERATORE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + + if (options.requirePassword) { + validateRequired(errors, "password", data.password ?? "", "Password obbligatoria"); + } + + if (data.password) { + validateMinLength( + errors, + "password", + data.password, + OPERATORE_CONSTRAINTS.password.minLength, + "Password" + ); + validateMaxLength( + errors, + "password", + data.password, + OPERATORE_CONSTRAINTS.password.maxLength, + "Password" + ); + } +} + +export function validateOperatoreCreate( + raw: Record +): ValidationResult { + const data: OperatoreCreateInput = { + nome: normalizeString(raw.nome), + cognome: normalizeString(raw.cognome), + email: normalizeEmail(raw.email), + password: raw.password?.toString() ?? "", + admin: normalizeBoolean(raw.admin, false), + }; + const errors: FieldErrors = {}; + + validateOperatoreProfileFields(data, errors, { requirePassword: true }); + + return buildResult(data, errors); +} + +export function validateOperatoreUpdate( + raw: Record +): ValidationResult { + const errors: FieldErrors = {}; + const data: OperatoreUpdateInput = {}; + + if ("nome" in raw) { + data.nome = normalizeString(raw.nome); + validateRequired(errors, "nome", data.nome, "Nome obbligatorio"); + validateMaxLength( + errors, + "nome", + data.nome, + OPERATORE_CONSTRAINTS.nome.maxLength, + "Nome" + ); + } + + if ("cognome" in raw) { + data.cognome = normalizeString(raw.cognome); + validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio"); + validateMaxLength( + errors, + "cognome", + data.cognome, + OPERATORE_CONSTRAINTS.cognome.maxLength, + "Cognome" + ); + } + + if ("email" in raw) { + data.email = normalizeEmail(raw.email); + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + OPERATORE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + } + + if ("password" in raw) { + const password = raw.password?.toString() ?? ""; + if (password) { + data.password = password; + validateMinLength( + errors, + "password", + password, + OPERATORE_CONSTRAINTS.password.minLength, + "Password" + ); + validateMaxLength( + errors, + "password", + password, + OPERATORE_CONSTRAINTS.password.maxLength, + "Password" + ); + } + } + + if ("admin" in raw) { + validateBoolean(errors, "admin", raw.admin, "Admin"); + if (!errors.admin) { + data.admin = normalizeBoolean(raw.admin, false); + } + } + + if (Object.keys(data).length === 0) { + errors._form = "Nessun campo da aggiornare"; + } + + return buildResult(data, errors); +} diff --git a/src/validation/primitives.ts b/src/validation/primitives.ts new file mode 100644 index 0000000..8883590 --- /dev/null +++ b/src/validation/primitives.ts @@ -0,0 +1,198 @@ +//========================================== +// File: primitives.ts +// Funzioni di validazione dei dati prmitivi +// author: "villari.andrea@libero.it" +// version: "1.0.0 2026-06-18" +//========================================== + +export type FieldErrors = Record; + +export type ValidationResult = + | { success: true; data: T } + | { success: false; errors: FieldErrors }; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function normalizeString(value: unknown): string { + return value?.toString().trim() ?? ""; +} + +export function normalizeEmail(value: unknown): string { + return normalizeString(value).toLowerCase(); +} + +export function normalizeBoolean(value: unknown, defaultValue = false): boolean { + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + return defaultValue; +} + +export function normalizeDecimal(value: unknown): number { + if (typeof value === "number") return value; + const normalized = value?.toString().trim().replace(",", ".") ?? ""; + if (!normalized) return Number.NaN; + return Number(normalized); +} + +export function normalizePositiveInt(value: unknown): number { + if (typeof value === "number") return value; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : Number.NaN; +} + +export function normalizeTime(value: unknown): string { + const str = normalizeString(value); + if (!str) return ""; + if (/^\d{2}:\d{2}$/.test(str)) return `${str}:00`; + return str; +} + +export function validateRequired( + errors: FieldErrors, + field: string, + value: string, + message: string +): void { + if (!value) errors[field] = message; +} + +export function validateMaxLength( + errors: FieldErrors, + field: string, + value: string, + maxLength: number, + label: string +): void { + if (value.length > maxLength) { + errors[field] = `${label} deve contenere al massimo ${maxLength} caratteri`; + } +} + +export function validateMinLength( + errors: FieldErrors, + field: string, + value: string, + minLength: number, + label: string +): void { + if (value && value.length < minLength) { + errors[field] = `${label} deve contenere almeno ${minLength} caratteri`; + } +} + +export function validateEmailFormat( + errors: FieldErrors, + field: string, + value: string +): void { + if (value && !EMAIL_REGEX.test(value)) { + errors[field] = "Formato email non valido"; + } +} + +export function validateEnum( + errors: FieldErrors, + field: string, + value: string, + allowedValues: readonly string[], + label: string +): void { + if (value && !allowedValues.includes(value)) { + errors[field] = `${label} non valido`; + } +} + +export function validateBoolean( + errors: FieldErrors, + field: string, + value: unknown, + label: string +): void { + if (value === undefined || value === null || value === "") return; + if ( + value !== true && + value !== false && + value !== "true" && + value !== "false" + ) { + errors[field] = `${label} non valido`; + } +} + +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const TIME_REGEX = /^\d{2}:\d{2}:\d{2}$/; + +export function validateDate( + errors: FieldErrors, + field: string, + value: string, + label: string +): void { + if (!value) return; + if (!DATE_REGEX.test(value)) { + errors[field] = `${label} non valida`; + return; + } + const parsed = new Date(`${value}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + errors[field] = `${label} non valida`; + } +} + +export function validateTimeFormat( + errors: FieldErrors, + field: string, + value: string, + label: string +): void { + if (!value) return; + if (!TIME_REGEX.test(value)) { + errors[field] = `${label} non valida`; + } +} + +export function validatePositiveInt( + errors: FieldErrors, + field: string, + value: number, + label: string, + required = true +): void { + if (Number.isNaN(value) || value <= 0) { + errors[field] = required ? `${label} obbligatorio` : `${label} non valido`; + } +} + +export function validateTimeRange( + errors: FieldErrors, + startField: string, + endField: string, + start: string, + end: string +): void { + if (!start || !end) return; + if (start >= end) { + errors[endField] = + "L'ora di fine deve essere successiva all'ora di inizio"; + } +} + +export function validateDecimalPositive( + errors: FieldErrors, + field: string, + value: unknown, + label: string, + min = 0.01 +): void { + if (value === undefined || value === null || value === "") return; + const num = typeof value === "number" ? value : Number(value); + if (Number.isNaN(num) || num < min) { + errors[field] = `${label} deve essere almeno ${min}`; + } +} + +export function buildResult(data: T, errors: FieldErrors): ValidationResult { + return Object.keys(errors).length > 0 + ? { success: false, errors } + : { success: true, data }; +}