ajout app

This commit is contained in:
2024-04-17 20:22:30 +02:00
parent cc017cfc5e
commit f9d05a2fd3
8025 changed files with 729805 additions and 0 deletions

View File

@@ -0,0 +1,315 @@
import { ErrorCode, newError } from '@/infra/error'
import { client } from '@/infra/postgres'
export type User = {
id: string
fullName: string
username: string
email: string
passwordHash: string
refreshTokenValue?: string
refreshTokenExpiry?: string
resetPasswordToken?: string
emailConfirmationToken?: string
isEmailConfirmed: boolean
emailUpdateToken?: string
emailUpdateValue?: string
picture?: string
createTime: string
updateTime?: string
}
export type InsertOptions = {
id: string
fullName?: string
username?: string
email?: string
passwordHash?: string
refreshTokenValue?: string
refreshTokenExpiry?: string
resetPasswordToken?: string
emailConfirmationToken?: string
isEmailConfirmed?: boolean
picture?: string
createTime?: string
updateTime?: string
}
export type UpdateOptions = {
id: string
fullName?: string
username?: string
email?: string
passwordHash?: string
refreshTokenValue?: string
refreshTokenExpiry?: string
resetPasswordToken?: string
emailConfirmationToken?: string
isEmailConfirmed?: boolean
emailUpdateToken?: string
emailUpdateValue?: string
picture?: string
createTime?: string
updateTime?: string
}
export interface UserRepo {
findByID(id: string): Promise<User>
findByUsername(username: string): Promise<User>
findByEmail(email: string): Promise<User>
findByRefreshTokenValue(refreshTokenValue: string): Promise<User>
findByResetPasswordToken(resetPasswordToken: string): Promise<User>
findByEmailConfirmationToken(emailConfirmationToken: string): Promise<User>
findByEmailUpdateToken(emailUpdateToken: string): Promise<User>
findByPicture(picture: string): Promise<User>
isUsernameAvailable(username: string): Promise<boolean>
insert(data: InsertOptions): Promise<User>
update(data: UpdateOptions): Promise<User>
delete(id: string): Promise<void>
}
class UserRepoImpl {
async findByID(id: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE id = $1`,
[id]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with id=${id} not found`,
})
}
return this.mapRow(rows[0])
}
async findByUsername(username: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE username = $1`,
[username]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with username=${username} not found`,
})
}
return this.mapRow(rows[0])
}
async findByEmail(email: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE email = $1`,
[email]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with email=${email} not found`,
})
}
return this.mapRow(rows[0])
}
async findByRefreshTokenValue(refreshTokenValue: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE refresh_token_value = $1`,
[refreshTokenValue]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with refresh_token_value=${refreshTokenValue} not found`,
})
}
return this.mapRow(rows[0])
}
async findByResetPasswordToken(resetPasswordToken: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE reset_password_token = $1`,
[resetPasswordToken]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with reset_password_token=${resetPasswordToken} not found`,
})
}
return this.mapRow(rows[0])
}
async findByEmailConfirmationToken(
emailConfirmationToken: string
): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE email_confirmation_token = $1`,
[emailConfirmationToken]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with email_confirmation_token=${emailConfirmationToken} not found`,
})
}
return this.mapRow(rows[0])
}
async findByEmailUpdateToken(emailUpdateToken: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE email_update_token = $1`,
[emailUpdateToken]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with email_update_token=${emailUpdateToken} not found`,
})
}
return this.mapRow(rows[0])
}
async findByPicture(picture: string): Promise<User> {
const { rowCount, rows } = await client.query(
`SELECT * FROM "user" WHERE picture = $1`,
[picture]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.ResourceNotFound,
error: `User with picture=${picture} not found`,
})
}
return this.mapRow(rows[0])
}
async isUsernameAvailable(username: string): Promise<boolean> {
const { rowCount } = await client.query(
`SELECT * FROM "user" WHERE username = $1`,
[username]
)
return rowCount === 0
}
async insert(data: InsertOptions): Promise<User> {
const { rowCount, rows } = await client.query(
`INSERT INTO "user" (
id,
full_name,
username,
email,
password_hash,
refresh_token_value,
refresh_token_expiry,
reset_password_token,
email_confirmation_token,
is_email_confirmed,
picture,
create_time
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[
data.id,
data.fullName,
data.username,
data.email,
data.passwordHash,
data.refreshTokenValue,
data.refreshTokenExpiry,
data.resetPasswordToken,
data.emailConfirmationToken,
data.isEmailConfirmed || false,
data.picture,
new Date().toISOString(),
]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.InternalServerError,
error: `Inserting user with id=${data.id} failed`,
})
}
return this.mapRow(rows[0])
}
async update(data: UpdateOptions): Promise<User> {
const entity = await this.findByID(data.id)
if (!entity) {
throw newError({
code: ErrorCode.InternalServerError,
error: `User with id=${data.id} not found`,
})
}
Object.assign(entity, data)
entity.updateTime = new Date().toISOString()
const { rowCount, rows } = await client.query(
`UPDATE "user"
SET
full_name = $1,
username = $2,
email = $3,
password_hash = $4,
refresh_token_value = $5,
refresh_token_expiry = $6,
reset_password_token = $7,
email_confirmation_token = $8,
is_email_confirmed = $9,
email_update_token = $10,
email_update_value = $11,
picture = $12,
update_time = $13
WHERE id = $14
RETURNING *`,
[
entity.fullName,
entity.username,
entity.email,
entity.passwordHash,
entity.refreshTokenValue,
entity.refreshTokenExpiry,
entity.resetPasswordToken,
entity.emailConfirmationToken,
entity.isEmailConfirmed,
entity.emailUpdateToken,
entity.emailUpdateValue,
entity.picture,
new Date().toISOString(),
entity.id,
]
)
if (rowCount < 1) {
throw newError({
code: ErrorCode.InternalServerError,
error: `Inserting user with id=${data.id} failed`,
})
}
return this.mapRow(rows[0])
}
async delete(id: string): Promise<void> {
await client.query('DELETE FROM "user" WHERE id = $1', [id])
}
private mapRow(row: any): User {
return {
id: row.id,
fullName: row.full_name,
username: row.username,
email: row.email,
passwordHash: row.password_hash,
refreshTokenValue: row.refresh_token_value,
refreshTokenExpiry: row.refresh_token_expiry,
resetPasswordToken: row.reset_password_token,
emailConfirmationToken: row.email_confirmation_token,
isEmailConfirmed: row.is_email_confirmed,
emailUpdateToken: row.email_update_token,
emailUpdateValue: row.email_update_value,
picture: row.picture,
createTime: row.create_time,
updateTime: row.update_time,
}
}
}
const userRepo: UserRepo = new UserRepoImpl()
export default userRepo

View File

@@ -0,0 +1,183 @@
import fs from 'fs/promises'
import os from 'os'
import { NextFunction, Router, Response } from 'express'
import { body, validationResult } from 'express-validator'
import multer from 'multer'
import passport from 'passport'
import { parseValidationError } from '@/infra/error'
import { PassportRequest } from '@/infra/passport-request'
import {
deleteUser,
getUser,
updateFullName,
updatePicture,
updatePassword,
UserDeleteOptions,
UserUpdateFullNameOptions,
UserUpdatePasswordOptions,
deletePicture,
UserUpdateEmailRequestOptions,
UserUpdateEmailConfirmationOptions,
updateEmailRequest,
updateEmailConfirmation,
} from './service'
const router = Router()
router.get(
'/',
passport.authenticate('jwt', { session: false }),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
res.json(await getUser(req.user.id))
} catch (err) {
next(err)
}
}
)
router.post(
'/update_full_name',
passport.authenticate('jwt', { session: false }),
body('fullName').isString().notEmpty().trim().escape().isLength({ max: 255 }),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(
await updateFullName(req.user.id, req.body as UserUpdateFullNameOptions)
)
} catch (err) {
next(err)
}
}
)
router.post(
'/update_email_request',
passport.authenticate('jwt', { session: false }),
body('email').isEmail().isLength({ max: 255 }),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(
await updateEmailRequest(
req.user.id,
req.body as UserUpdateEmailRequestOptions
)
)
} catch (err) {
next(err)
}
}
)
router.post(
'/update_email_confirmation',
passport.authenticate('jwt', { session: false }),
body('token').isString().notEmpty().trim(),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(
await updateEmailConfirmation(
req.body as UserUpdateEmailConfirmationOptions
)
)
} catch (err) {
next(err)
}
}
)
router.post(
'/update_password',
passport.authenticate('jwt', { session: false }),
body('currentPassword').notEmpty(),
body('newPassword').isStrongPassword(),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
res.json(
await updatePassword(req.user.id, req.body as UserUpdatePasswordOptions)
)
} catch (err) {
if (err === 'invalid_password') {
res.status(400).json({ error: err })
return
} else {
next(err)
}
}
}
)
router.post(
'/update_picture',
passport.authenticate('jwt', { session: false }),
multer({
dest: os.tmpdir(),
limits: { fileSize: 3000000, fields: 0, files: 1 },
}).single('file'),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const user = await updatePicture(
req.user.id,
req.file.path,
req.file.mimetype
)
await fs.rm(req.file.path)
res.json(user)
} catch (err) {
next(err)
}
}
)
router.post(
'/delete_picture',
passport.authenticate('jwt', { session: false }),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
res.json(await deletePicture(req.user.id))
} catch (err) {
next(err)
}
}
)
router.delete(
'/',
passport.authenticate('jwt', { session: false }),
body('password').isString().notEmpty(),
async (req: PassportRequest, res: Response, next: NextFunction) => {
try {
const result = validationResult(req)
if (!result.isEmpty()) {
throw parseValidationError(result)
}
await deleteUser(req.user.id, req.body as UserDeleteOptions)
res.sendStatus(200)
} catch (err) {
if (err === 'invalid_password') {
res.status(400).json({ error: err })
return
} else {
next(err)
}
}
}
)
export default router

View File

@@ -0,0 +1,191 @@
import fs from 'fs/promises'
import { ErrorCode, newError } from '@/infra/error'
import { hashPassword, verifyPassword } from '@/infra/password'
import search, { USER_SEARCH_INDEX } from '@/infra/search'
import userRepo, { User } from '@/user/repo'
import { newHyphenlessUuid } from '@/infra/id'
import { sendTemplateMail } from '@/infra/mail'
import { getConfig } from '@/config/config'
export type UserDTO = {
id: string
fullName: string
picture: string
email: string
username: string
pendingEmail?: string
}
export type UserUpdateEmailRequestOptions = {
email: string
}
export type UserUpdateEmailConfirmationOptions = {
token: string
}
export type UserUpdateFullNameOptions = {
fullName: string
}
export type UserUpdatePasswordOptions = {
currentPassword: string
newPassword: string
}
export type UserDeleteOptions = {
password: string
}
export async function getUser(id: string): Promise<UserDTO> {
return mapEntity(await userRepo.findByID(id))
}
export async function getByPicture(picture: string): Promise<UserDTO> {
return mapEntity(await userRepo.findByPicture(picture))
}
export async function updateFullName(
id: string,
options: UserUpdateFullNameOptions
): Promise<UserDTO> {
let user = await userRepo.findByID(id)
user = await userRepo.update({ id: user.id, fullName: options.fullName })
await search.index(USER_SEARCH_INDEX).updateDocuments([
{
...user,
fullName: user.fullName,
},
])
return mapEntity(user)
}
export async function updateEmailRequest(
id: string,
options: UserUpdateEmailRequestOptions
): Promise<UserDTO> {
let user = await userRepo.findByID(id)
if (options.email === user.email) {
user = await userRepo.update({
id: user.id,
emailUpdateToken: null,
emailUpdateValue: null,
})
return mapEntity(user)
} else {
let usernameUnavailable = false
try {
await userRepo.findByUsername(options.email)
usernameUnavailable = true
} catch {
// Ignored
}
if (usernameUnavailable) {
throw newError({ code: ErrorCode.UsernameUnavailable })
}
user = await userRepo.update({
id: user.id,
emailUpdateToken: newHyphenlessUuid(),
emailUpdateValue: options.email,
})
try {
await sendTemplateMail('email-update', options.email, {
'EMAIL': options.email,
'UI_URL': getConfig().publicUIURL,
'TOKEN': user.emailUpdateToken,
})
return mapEntity(user)
} catch (error) {
await userRepo.update({
id,
emailUpdateToken: null,
emailUpdateValue: null,
})
throw newError({ code: ErrorCode.InternalServerError, error })
}
}
}
export async function updateEmailConfirmation(
options: UserUpdateEmailConfirmationOptions
) {
let user = await userRepo.findByEmailUpdateToken(options.token)
user = await userRepo.update({
id: user.id,
email: user.emailUpdateValue,
username: user.emailUpdateValue,
emailUpdateToken: null,
emailUpdateValue: null,
})
await search.index(USER_SEARCH_INDEX).updateDocuments([
{
...user,
email: user.email,
username: user.email,
emailUpdateToken: null,
emailUpdateValue: null,
},
])
return mapEntity(user)
}
export async function updatePassword(
id: string,
options: UserUpdatePasswordOptions
): Promise<UserDTO> {
let user = await userRepo.findByID(id)
if (verifyPassword(options.currentPassword, user.passwordHash)) {
user = await userRepo.update({
id: user.id,
passwordHash: hashPassword(options.newPassword),
})
return mapEntity(user)
} else {
throw newError({ code: ErrorCode.PasswordValidationFailed })
}
}
export async function updatePicture(
id: string,
path: string,
contentType: string
): Promise<UserDTO> {
const picture = await fs.readFile(path, { encoding: 'base64' })
const { id: userId } = await userRepo.findByID(id)
const user = await userRepo.update({
id: userId,
picture: `data:${contentType};base64,${picture}`,
})
return mapEntity(user)
}
export async function deletePicture(id: string): Promise<UserDTO> {
let user = await userRepo.findByID(id)
user = await userRepo.update({ id: user.id, picture: null })
return mapEntity(user)
}
export async function deleteUser(id: string, options: UserDeleteOptions) {
const user = await userRepo.findByID(id)
if (verifyPassword(options.password, user.passwordHash)) {
await userRepo.delete(user.id)
await search.index(USER_SEARCH_INDEX).deleteDocuments([user.id])
} else {
throw newError({ code: ErrorCode.InvalidPassword })
}
}
export function mapEntity(entity: User): UserDTO {
const user: UserDTO = {
id: entity.id,
email: entity.email,
username: entity.username,
fullName: entity.fullName,
picture: entity.picture,
pendingEmail: entity.emailUpdateValue,
}
Object.keys(user).forEach(
(index) => !user[index] && user[index] !== undefined && delete user[index]
)
return user
}