16 Commits

24 changed files with 1724 additions and 887 deletions

6
.gitignore vendored
View File

@@ -51,3 +51,9 @@ docker-compose.override.yml
coverage/
.tmp/
.cache/
/src/generated/prisma
/src/generated/prisma
/src/generated/prisma

View File

1913
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,37 @@
{
"name": "bo_liste",
"version": "1.0.0",
"description": "Back Office de l'application des listes",
"description": "BO pour la liste application",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "ts-node-dev --respawn --transpile-only src/core/server.ts",
"dev": "tsx src/server.ts",
"build": "tsc",
"start": "node dist/core/server.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
"start": "node dist/server.js"
},
"repository": {
"type": "git",
"url": "git@gitea.raffiskender.com:raffi/bo_listes.git"
"url": "bo_liste"
},
"author": "Raffi",
"license": "ISC",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/jwt": "^10.0.0",
"@prisma/client": "^7.5.0",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"argon2": "^0.44.0",
"fastify": "^5.8.4"
"fastify": "^5.8.4",
"fastify-plugin": "^5.1.0",
"nodemailer": "^8.0.4",
"pg": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.5.0",
"prisma": "^7.5.0",
"ts-node-dev": "^2.0.0",
"@types/nodemailer": "^7.0.11",
"prisma": "^7.6.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
}
}

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

45
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,45 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model ActionToken {
id String @id
userId String
token String @unique
type String
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id
email String @unique
passwordHash String
displayName String
tokenVersion Int @default(0)
isConfirmed Boolean @default(false)
isGoogleUser Boolean @default(false)
avatar String
createdAt DateTime @default(now())
publicKey String?
encryptedPrivateKey String?
ActionToken ActionToken[]
UserPreference UserPreference?
}
model UserPreference {
id String @id
userId String @unique
language String @default("fr")
theme String @default("light")
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

27
src/app.ts Normal file
View File

@@ -0,0 +1,27 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import jwt from '@fastify/jwt'
import prismaPlugin from './plugins/prisma.js'
import mailerPlugin from './plugins/mailer.js'
import authRoutes from './routes/auth.js'
import userRoutes from './routes/users.js'
import errorHandler from './plugins/errorHandler.js'
export default function buildApp() {
const app = Fastify({ logger: true })
app.register(errorHandler)
app.register(cookie)
app.register(jwt, {
secret: process.env.JWT_SECRET!,
})
app.register(prismaPlugin)
app.register(mailerPlugin)
app.register(authRoutes, { prefix: '/api' })
app.register(userRoutes, { prefix: '/api' })
app.get('/health', async () => ({ status: 'ok' }))
return app
}

29
src/errors/AppError.ts Normal file
View File

@@ -0,0 +1,29 @@
export class AppError extends Error {
constructor(
public readonly code: string,
public readonly statusCode: number,
message: string
) {
super(message)
this.name = 'AppError'
}
}
// Erreurs prédéfinies
export const Errors = {
// registration errors
EMAIL_TAKEN: new AppError('EMAIL_TAKEN', 409, 'Cette adresse email est déjà utilisée.'),
PASSWORD_TOO_WEAK: new AppError('PASSWORD_TOO_WEAK', 400, 'Le mot de passe doit contenir au moins 8 caractères.'),
INVALID_CREDENTIALS: new AppError('INVALID_CREDENTIALS', 401, 'Email ou mot de passe incorrect.'),
VALIDATION_ERROR: (message: string) => new AppError('VALIDATION_ERROR', 400, message),
//Action token errors
INVALID_TOKEN: new AppError('INVALID_TOKEN', 400, 'Invalid or already used token'),
TOKEN_EXPIRED: new AppError('TOKEN_EXPIRED', 400, 'Token has expired'),
ALREADY_CONFIRMED: new AppError('ALREADY_CONFIRMED', 400, 'User is already confirmed'),
//Auth errors
UNAUTHORIZED: new AppError('UNAUTHORIZED', 401, 'Non authentifié'),
USER_NOT_FOUND: new AppError('USER_NOT_FOUND', 404, 'Utilisateur introuvable'),
}

View File

@@ -0,0 +1,19 @@
import { FastifyRequest, FastifyReply } from 'fastify'
import { verifyAuthToken } from '../services/authToken.service.js'
import { Errors } from '../errors/AppError.js'
export async function verifyAuth(request: FastifyRequest, reply: FastifyReply) {
const token = request.cookies['authToken']
console.log('token reçu:', token)
if (!token) throw Errors.UNAUTHORIZED
try {
const payload = await verifyAuthToken(request.server, request.server.prisma, token)
console.log('payload:', payload)
request.user = payload
} catch (e) {
console.log('erreur:', e)
throw Errors.UNAUTHORIZED
}
}

View File

@@ -0,0 +1,34 @@
import fp from 'fastify-plugin'
import { FastifyInstance } from 'fastify'
import { ZodError } from 'zod'
import { AppError } from '../errors/AppError.js'
export default fp(async (fastify: FastifyInstance) => {
fastify.setErrorHandler((error, request, reply) => {
// Erreur Zod
if (error instanceof ZodError) {
return reply.status(400).send({
error: 'VALIDATION_ERROR',
details: error.issues.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
})
}
// Erreur métier
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: error.code,
message: error.message,
})
}
// Erreur inconnue
fastify.log.error(error)
return reply.status(500).send({
error: 'INTERNAL_ERROR',
message: 'Une erreur interne est survenue.',
})
})
})

22
src/plugins/mailer.ts Normal file
View File

@@ -0,0 +1,22 @@
import fp from 'fastify-plugin'
import { FastifyInstance } from 'fastify'
import nodemailer, { Transporter } from 'nodemailer'
declare module 'fastify' {
interface FastifyInstance {
mailer: Transporter
}
}
export default fp(async (fastify: FastifyInstance) => {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
fastify.decorate('mailer', transporter)
})

24
src/plugins/prisma.ts Normal file
View File

@@ -0,0 +1,24 @@
import fp from 'fastify-plugin'
import { FastifyInstance } from 'fastify'
import { PrismaClient } from '../generated/prisma/client.js'
import { PrismaPg } from '@prisma/adapter-pg'
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient
}
}
export default fp(async (fastify: FastifyInstance) => {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
})
const prisma = new PrismaClient({ adapter })
fastify.decorate('prisma', prisma)
fastify.addHook('onClose', async () => {
await prisma.$disconnect()
})
})

50
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,50 @@
import { FastifyInstance } from 'fastify'
import { RegisterSchema, LoginSchema } from '../schemas/auth.schema.js'
import { registerUser, loginUser, logoutUser } from '../services/auth.service.js'
import { signAuthToken } from '../services/authToken.service.js'
import { verifyAuth } from '../middleware/verifyAuth.js'
export default async function authRoutes(fastify: FastifyInstance) {
fastify.post('/auth/register', async (request, reply) => {
const body = RegisterSchema.parse(request.body)
const lang = request.headers['accept-language']
?.split(',')[0]
.split('-')[0] as 'fr' | 'en'
const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr'
const { user } = await registerUser(fastify.prisma, fastify.mailer, body, validLang)
const token = signAuthToken(fastify, { userId: user.id, tokenVersion: 0 })
reply.setCookie('authToken', token, {
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return reply.status(201).send({ user })
})
fastify.post('/auth/login', async (request, reply) => {
const body = LoginSchema.parse(request.body)
const { user } = await loginUser(fastify.prisma, body)
const token = signAuthToken(fastify, { userId: user.id, tokenVersion: user.tokenVersion })
reply.setCookie('authToken', token, {
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
path: '/',
})
return reply.status(200).send({ user })
})
fastify.post('/auth/logout', { preHandler: verifyAuth }, async (request, reply) => {
await logoutUser(fastify.prisma, request.user.userId)
reply.clearCookie('authToken', { path: '/' })
return reply.status(200).send({ message: 'Déconnecté avec succès' })
})
}

42
src/routes/users.ts Normal file
View File

@@ -0,0 +1,42 @@
import { FastifyInstance } from 'fastify'
import { Errors } from '../errors/AppError'
import { confirmEmail } from '../services/user.service'
import { verifyAuth } from '../middleware/verifyAuth'
import { UpdateDisplayNameSchema } from '../schemas/user.schema.js'
import { updateDisplayName } from '../services/user.service.js'
export default async function userRoutes(fastify: FastifyInstance) {
fastify.get('/users', async (request, reply) => {
const users = await fastify.prisma.user.findMany({
select: {
id: true,
email: true,
displayName: true,
isConfirmed: true,
isGoogleUser: true,
avatar: true,
createdAt: true,
},
})
return users
})
fastify.get('/user/confirm', async (request, reply) => {
const { token } = request.query as { token?: string }
if (!token) {
throw Errors.INVALID_TOKEN
}
const result = await confirmEmail(fastify.prisma, token)
return reply.status(200).send(result)
})
fastify.patch('/user/display-name', { preHandler: verifyAuth }, async (request, reply) => {
const body = UpdateDisplayNameSchema.parse(request.body)
const user = await updateDisplayName(fastify.prisma, request.user.userId, body)
return reply.status(200).send({ user })
})
}

View File

@@ -0,0 +1,21 @@
import { z } from 'zod'
export const RegisterSchema = z.object({
email: z.email({ error: 'Adresse email invalide.' }),
password: z
.string()
.regex(/^.{8,22}$/, { error: 'Le mot de passe doit contenir entre 8 et 22 caractères.' })
.regex(/[^A-Za-z0-9]/, { error: 'Le mot de passe doit contenir au moins un caractère spécial.' })
.regex(/[A-Z]/, { error: 'Le mot de passe doit contenir au moins une majuscule.' })
.regex(/[a-z]/, { error: 'Le mot de passe doit contenir au moins une minuscule.' })
.regex(/\d/, { error: 'Le mot de passe doit contenir au moins un chiffre.' }),
})
export type RegisterInput = z.infer<typeof RegisterSchema>
export const LoginSchema = z.object({
email: z.email({ error: 'Adresse email invalide.' }),
password: z.string().min(1, { error: 'Le mot de passe est requis.' }),
})
export type LoginInput = z.infer<typeof LoginSchema>

View File

@@ -0,0 +1,7 @@
import { z } from 'zod'
export const UpdateDisplayNameSchema = z.object({
displayName: z.string().min(2).max(32),
})
export type UpdateDisplayNameInput = z.infer<typeof UpdateDisplayNameSchema>

15
src/server.ts Normal file
View File

@@ -0,0 +1,15 @@
import 'dotenv/config'
import buildApp from './app.js'
const app = buildApp()
const start = async () => {
try {
await app.listen({ port: 1234, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}
}
start()

View File

@@ -0,0 +1,51 @@
import crypto from 'crypto'
import { PrismaClient } from '../generated/prisma/client.js'
import { Errors } from '../errors/AppError.js'
function generateToken(length = 32): string {
return crypto.randomBytes(length).toString('hex')
}
function generateActionTokenExpiry(minutes = 1440): Date {
const date = new Date()
date.setMinutes(date.getMinutes() + minutes)
return date
}
export async function createActionToken(
prisma: PrismaClient,
userId: string,
type: string,
expiryMinutes = 1440
): Promise<string> {
const token = generateToken()
await prisma.actionToken.create({
data: {
id: crypto.randomUUID(),
userId,
token,
type,
expiresAt: generateActionTokenExpiry(expiryMinutes),
},
})
return token
}
export async function consumeActionToken(
prisma: PrismaClient,
token: string,
type: string
) {
const actionToken = await prisma.actionToken.findUnique({ where: { token } })
if (!actionToken || actionToken.type !== type) throw Errors.INVALID_TOKEN
if (actionToken.used) throw Errors.INVALID_TOKEN
if (actionToken.expiresAt < new Date()) throw Errors.INVALID_TOKEN
await prisma.actionToken.update({
where: { token },
data: { used: true },
})
return actionToken
}

View File

@@ -0,0 +1,78 @@
import argon2 from 'argon2'
import crypto from 'crypto'
import { PrismaClient } from '../generated/prisma/client.js'
import { Transporter } from 'nodemailer'
import { RegisterInput, LoginInput } from '../schemas/auth.schema.js'
import { sendConfirmationMail } from './mail.service.js'
import { createActionToken } from './actionToken.service.js'
import { Errors } from '../errors/AppError.js'
export async function registerUser(
prisma: PrismaClient,
mailer: Transporter,
input: RegisterInput,
lang: 'fr' | 'en' = 'fr'
) {
const existing = await prisma.user.findUnique({ where: { email: input.email } })
if (existing) throw Errors.EMAIL_TAKEN
const passwordHash = await argon2.hash(input.password)
const displayName = input.email.split('@')[0]
const user = await prisma.user.create({
data: {
id: crypto.randomUUID(),
email: input.email,
passwordHash,
displayName,
avatar: '',
},
select: {
id: true,
email: true,
displayName: true,
isConfirmed: true,
createdAt: true,
},
})
const confirmToken = await createActionToken(prisma, user.id, 'email-confirm', 1440)
await sendConfirmationMail(mailer, user.email, confirmToken, lang)
await prisma.userPreference.create({
data: {
id: crypto.randomUUID(),
userId: user.id,
language: lang,
theme: 'light',
},
})
return { user }
}
export async function loginUser(prisma: PrismaClient, input: LoginInput) {
const user = await prisma.user.findUnique({ where: { email: input.email } })
if (!user) throw Errors.INVALID_CREDENTIALS
const valid = await argon2.verify(user.passwordHash, input.password)
if (!valid) throw Errors.INVALID_CREDENTIALS
return {
user: {
id: user.id,
email: user.email,
displayName: user.displayName,
isConfirmed: user.isConfirmed,
createdAt: user.createdAt,
tokenVersion: user.tokenVersion,
},
}
}
export async function logoutUser(prisma: PrismaClient, userId: string) {
await prisma.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } },
})
}

View File

@@ -0,0 +1,30 @@
import { FastifyInstance } from 'fastify'
import { PrismaClient } from '../generated/prisma/client.js'
export interface JwtPayload {
userId: string
tokenVersion: number
}
export function signAuthToken(fastify: FastifyInstance, payload: JwtPayload): string {
return fastify.jwt.sign(payload, { expiresIn: '7d' })
}
export async function verifyAuthToken(
fastify: FastifyInstance,
prisma: PrismaClient,
token: string
): Promise<JwtPayload> {
const payload = fastify.jwt.verify<JwtPayload>(token)
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { tokenVersion: true },
})
if (!user || user.tokenVersion !== payload.tokenVersion) {
throw new Error('TOKEN_REVOKED')
}
return payload
}

View File

@@ -0,0 +1,51 @@
import { Transporter } from 'nodemailer'
type Lang = 'fr' | 'en'
const templates = {
fr: (url: string) => ({
subject: 'Merci de confirmer votre e-mail',
html: `
<html><body>
<p>Bienvenue sur le gestionnaire de listes !</p>
<p>
Pour pouvoir utiliser le site, vous devez confirmer votre adresse mail sous 7 jours en cliquant sur
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
ce lien
</a>.
</p>
</body></html>
`,
}),
en: (url: string) => ({
subject: 'Please confirm your email address',
html: `
<html><body>
<p>Welcome to the list manager!</p>
<p>
To start using the site, please confirm your email address within 7 days by clicking
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
this link
</a>.
</p>
</body></html>
`,
}),
}
export async function sendConfirmationMail(
mailer: Transporter,
email: string,
token: string,
lang: Lang = 'fr'
): Promise<void> {
const url = `${process.env.FRONT_URL}/${lang}/confirm?token=${token}`
const { subject, html } = templates[lang](url)
await mailer.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject,
html,
})
}

View File

@@ -0,0 +1,60 @@
import { PrismaClient } from '../generated/prisma/client.js'
import { Errors } from '../errors/AppError.js'
import { UpdateDisplayNameInput } from '../schemas/user.schema.js'
export async function confirmEmail(prisma: PrismaClient, token: string) {
const actionToken = await prisma.actionToken.findUnique({
where: { token },
})
if (!actionToken || actionToken.type !== 'email-confirm' || actionToken.used) {
throw Errors.INVALID_TOKEN
}
if (actionToken.expiresAt < new Date()) {
throw Errors.TOKEN_EXPIRED
}
const user = await prisma.user.findUnique({
where: { id: actionToken.userId },
select: { isConfirmed: true },
})
if (user?.isConfirmed) {
throw Errors.ALREADY_CONFIRMED
}
await prisma.$transaction([
prisma.user.update({
where: { id: actionToken.userId },
data: { isConfirmed: true },
}),
prisma.actionToken.delete({
where: { id: actionToken.id },
}),
])
return { success: true }
}
export async function updateDisplayName(
prisma: PrismaClient,
userId: string,
input: UpdateDisplayNameInput
) {
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) throw Errors.USER_NOT_FOUND
return await prisma.user.update({
where: { id: userId },
data: { displayName: input.displayName },
select: {
id: true,
email: true,
displayName: true,
avatar: true,
isConfirmed: true,
createdAt: true,
},
})
}

27
src/types/fastify.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
import { PrismaClient } from '../generated/prisma/client.js'
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient
}
interface FastifyRequest {
user: {
userId: string
tokenVersion: number
}
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: {
userId: string
tokenVersion: number
}
user: {
userId: string
tokenVersion: number
}
}
}

View File

@@ -1,18 +1,15 @@
{
"compilerOptions": {
"rootDir": "./src",
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"module": "CommonJS",
"target": "ES2020",
"types": ["node"],
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}