Compare commits

...

4 commits

Author SHA1 Message Date
Nathan Lamy
1082f29143 feat: prepare latex server-side 2025-07-30 18:24:52 +02:00
Nathan Lamy
cc63e16d9c feat: new register flow 2025-07-30 17:44:30 +02:00
Nathan Lamy
5598b40d66 fix: logout doesn't require auth 2025-07-30 13:16:47 +02:00
Nathan Lamy
a6b8669e3d feat: add logout 2025-07-30 12:58:48 +02:00
6 changed files with 118 additions and 39 deletions

View file

@ -9,7 +9,7 @@ import User from '#models/user'
@inject()
export default class AuthController {
constructor(private authService: AuthService) { }
constructor(private authService: AuthService) {}
// POST /auth/request
async requestLogin({ request, response, captcha }: HttpContext) {
@ -31,7 +31,6 @@ export default class AuthController {
const expiresIn = '15 minutes'
const payload = await this.authService.generateCode(email, expiresIn)
// Send email
await mail
.send((message) => {
message
@ -62,7 +61,6 @@ export default class AuthController {
})
}
// Find user by id
const user = await User.findBy('email', email)
if (!user) {
// If the user does not exist, return a token for registration
@ -75,53 +73,86 @@ export default class AuthController {
}
}
// Perform login
await auth.use('web').login(user, true) // true for remember me
return {
success: true,
user
}
}
// GET /auth/autocomplete
async listNames({ request }: HttpContext) {
const { className } = request.qs()
if (!className) {
return {
success: false,
message: 'Veuillez spécifier une classe',
}
}
return User.query()
.select('firstName', 'lastName')
.where('className', className)
.orderBy('lastName', 'asc')
.then((users) => {
return {
success: true,
data: users.map((user) => ({
value: `${user.firstName}::${user.lastName}`,
label: user.fullName,
})),
}
})
}
// POST /auth/register
async register({ request, response, auth }: HttpContext) {
const { firstName, lastName, className, token } = await request.validateUsing(registerValidator)
const { name, className, token } = await request.validateUsing(registerValidator)
// Validate token
const { success, email } = this.authService.validateToken(token)
if (!success || !email) {
const [firstName, lastName] = name.split('::')
if (!success || !email || !firstName || !lastName) {
return response.badRequest({
success: false,
message: 'Votre lien de connexion est invalide ou a expiré.',
})
}
// Check if user already exists
const existingUser = await User.findBy('email', email)
if (existingUser) {
// If user already exists, perform login
await auth.use('web').login(existingUser, true) // true for remember me
return {
success: true,
user: existingUser,
}
const user = await User.query()
.where('firstName', firstName)
.where('lastName', lastName)
.where('className', className)
.first()
if (!user) {
return response.badRequest({
success: false,
message: 'Utilisateur non trouvé. Veuillez vérifier vos informations.',
})
}
// TODO: Check if className is allowed (else redirect for account giving)
if (user.email && user.email.toLowerCase() !== email.toLowerCase()) {
return response.badRequest({
success: false,
message:
"L'email associé à votre compte ne correspond pas à celui utilisé pour la connexion.",
})
}
user.email = email.toLowerCase() // Update email if necessary
await user.save()
// TODO: Rewrite user creation (NEVER CREATE USER - use string similarity)
// Create new user
const user = await User.create({
firstName,
lastName,
className,
email
})
// Perform login
await auth.use('web').login(user, true) // true for remember me
return {
success: true,
user
}
}
// POST /auth/logout
async logout({ auth }: HttpContext) {
await auth.use('web').logout()
return {
success: true,
}
}

View file

@ -43,13 +43,17 @@ export default class CollesController {
.preload('subject')
.preload('room')
.first()
// TODO: Include BJID and BJSecret !
if (!colle) {
return response.notFound({ message: 'Colle not found' })
}
return {
success: true,
data: colle,
data: {
...colle.serialize(),
bjid: colle.bjid,
bjsecret: colle.bjsecret,
},
}
}
@ -67,6 +71,14 @@ export default class CollesController {
return response.badRequest({ message: 'Invalid date format' })
}
// Prepare the content and comment for rendering
if (payload.comment) {
payload.comment = this.service.prepareHtmlForRendering(payload.comment)
}
if (payload.content) {
payload.content = this.service.prepareHtmlForRendering(payload.content)
}
const colleData = {
studentId: student.id,
examinerId: examiner.id,

View file

@ -11,10 +11,10 @@ export default class User extends BaseModel {
@column()
declare className: string
@column()
@column({ serializeAs: null})
declare firstName: string
@column()
@column({ serializeAs: null})
declare lastName: string
@computed()
@ -22,7 +22,7 @@ export default class User extends BaseModel {
return `${this.firstName} ${this.lastName}`
}
@column()
@column({ serializeAs: null })
declare email: string
@column.dateTime({ autoCreate: true })

View file

@ -92,4 +92,45 @@ export class ColleService {
favoriteColles,
}
}
// Pre-process HTML content to render LaTeX
private removeTrailingLines(htmlString: string) {
return htmlString.replace(/(<br\s*\/?>\s*)+$/gi, '').trim()
}
private extractLatexImages(html: string) {
const imgRegex = /<img[^>]+src="(https:\/\/latex\.codecogs\.com\/gif\.latex\?(=?.*?))"[^>]*>/g
let parts = []
let latexMatches: string[] = []
let lastIndex = 0
html.replace(imgRegex, (match, _, latex, index) => {
parts.push(html.slice(lastIndex, index)) // Add HTML before image
latexMatches.push(decodeURIComponent(latex)) // Extract and decode LaTeX
lastIndex = index + match.length
return ''
})
parts.push(html.slice(lastIndex)) // Add remaining HTML after last image
return { parts, latexMatches }
}
prepareHtmlForRendering(html: string) {
const strippedHtml = this.removeTrailingLines(html)
const { parts, latexMatches } = this.extractLatexImages(strippedHtml)
const outputHtml = parts
.map((part, i) => {
if (!latexMatches[i]) {
return part
}
return `${part}$$${latexMatches[i]}$$`
})
.join('')
// Remove all "\," from string
const regex = /\\,/g
return outputHtml.replace(regex, ' ')
}
}

View file

@ -19,16 +19,9 @@ export const verifyCodeValidator = vine.compile(
})
)
function toTitleCase(value: string) {
return value.replace(/\w\S*/g, (txt) => {
return txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase()
})
}
export const registerValidator = vine.compile(
vine.object({
firstName: vine.string().minLength(2).maxLength(50).trim().transform(toTitleCase),
lastName: vine.string().minLength(2).maxLength(50).trim().toUpperCase(),
name: vine.string().minLength(2).maxLength(50).trim(),
className: vine.string().minLength(2).maxLength(50),
token: vine.string(),
})

View file

@ -21,6 +21,8 @@ router.group(() => {
router.post('/auth/request', [AuthController, 'requestLogin']).use(authThrottle)
router.post('/auth/verify', [AuthController, 'verifyCode']).use(throttle)
router.post('/auth/register', [AuthController, 'register']).use(throttle)
router.post('/auth/logout', [AuthController, 'logout'])
router.get('/auth/autocomplete', [AuthController, 'listNames']).use(throttle)
// TODO: Magic link login
// router.get('/auth/magic-link', 'AuthController.magicLink').use(throttle)
// router.get('/auth/listen', 'AuthController.listen')