From 94f89be605d6009a48fb3e122d4cc5dbcf4ea9c4 Mon Sep 17 00:00:00 2001 From: Kevin L'Hereec Date: Thu, 23 Jul 2026 11:10:37 +0200 Subject: [PATCH] chore: harden config, strengthen frontend typing, fix UTF-8 mojibake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security config (3.1): - config/session.ts: cookie.secure now follows app.inProduction instead of being hardcoded to false - config/shield.ts: enable CSP with baseline directives compatible with Vite dev server and Inertia (self + @viteDevUrl/@viteHmrUrl) Frontend typing (3.2): - Replace 'any' props with concrete types (Computer, Component, ComponentType, ComputerState) across computers/*.vue and components/*.vue, using the existing inertia/pages/types/UITypes.ts definitions - Fix UITypes.ts: export ComponentType/ComputerState, switch from interface to type (interfaces are never assignable to Inertia's ComponentProps index signature), drop the stale componentTypeId/componentStateId fields that didn't match the actual Lucid schema (real field is typeId, no component state exists) - Add an asPageProp() boundary cast in the two controllers: Lucid's serialize() returns a loosely typed ModelObject that isn't structurally assignable to the concrete page prop shapes (and the inertia/** tree is excluded from the backend tsconfig, so real types can't be imported directly across that boundary) UTF-8 mojibake fixes (3.3): - Repair broken accented characters (├®/├á/├┤ -> é/à/ô, Ôé¼ -> €) left over from a bad encoding during the GWT->Inertia migration, across ComponentEditor.vue, ComputerComponentsTable.vue, SimpleGallery.vue, TableComponent.vue, isAdminMode.ts and the custom directives Misc cleanup (3.5): - computer.ts: replace console.warn with the app logger (includes computerId) when .cost is accessed without preloading components --- app/controllers/components_controller.ts | 28 ++++++++++++++--- app/controllers/computers_controller.ts | 31 ++++++++++++++++--- app/models/computer.ts | 6 +++- config/session.ts | 3 +- config/shield.ts | 11 +++++-- inertia/composables/isAdminMode.ts | 4 +-- inertia/directives/v-delete-confirm.ts | 10 +++--- inertia/directives/v-price.ts | 6 ++-- inertia/directives/v_validation_error.ts | 6 ++-- inertia/pages/components/create.vue | 3 +- inertia/pages/components/show.vue | 3 +- inertia/pages/computers/edit.vue | 9 +++--- inertia/pages/computers/index.vue | 3 +- inertia/pages/computers/show.vue | 3 +- inertia/pages/types/UITypes.ts | 7 ++--- inertia/pages/widgets/ComponentEditor.vue | 4 +-- .../pages/widgets/ComputerComponentsTable.vue | 8 ++--- inertia/pages/widgets/SimpleGallery.vue | 2 +- inertia/pages/widgets/TableComponent.vue | 4 +-- 19 files changed, 103 insertions(+), 48 deletions(-) diff --git a/app/controllers/components_controller.ts b/app/controllers/components_controller.ts index b2014ab..fea3739 100644 --- a/app/controllers/components_controller.ts +++ b/app/controllers/components_controller.ts @@ -5,16 +5,34 @@ import { updateComponentValidator } from '#validators/Components/update' import Component from '#models/component' import ComponentType from '#models/component_type' +/** + * Lucid's `serialize()` returns a loosely typed `ModelObject` + * (`{ [key: string]: any }`). It is not structurally assignable to the + * concrete prop shapes declared by the Vue pages (see + * `inertia/pages/types/UITypes.ts`), which live in a separate TypeScript + * project (the `inertia/**` tree is excluded from the backend tsconfig). + * Since the controller fully controls which columns/relations are + * preloaded and serialized, this cast documents that the runtime shape + * matches what the page expects at this boundary. + */ +function asPageProp(value: unknown): T { + return value as T +} + export default class ComponentsController { async index({ inertia, auth }: HttpContext) { const user = await auth.authenticate() const components = await Component.query().preload('type').where('userId', user.id) - return inertia.render('components/index', { components: components.map((c) => c.serialize()) }) + return inertia.render('components/index', { + components: asPageProp(components.map((c) => c.serialize())), + }) } async create({ inertia }: HttpContext) { const types = await ComponentType.all() - return inertia.render('components/create', { types: types.map((t) => t.serialize()) }) + return inertia.render('components/create', { + types: asPageProp(types.map((t) => t.serialize())), + }) } async store({ request, response, auth }: HttpContext) { @@ -39,7 +57,7 @@ export default class ComponentsController { .where('userId', user.id) .andWhere('id', componentId) .firstOrFail() - return inertia.render('components/show', { component: component.serialize() }) + return inertia.render('components/show', { component: asPageProp(component.serialize()) }) } async edit({ params, auth, inertia }: HttpContext) { @@ -52,8 +70,8 @@ export default class ComponentsController { .andWhere('id', componentId) .firstOrFail() return inertia.render('components/edit', { - component: component.serialize(), - types: types.map((t) => t.serialize()), + component: asPageProp(component.serialize()), + types: asPageProp(types.map((t) => t.serialize())), }) } diff --git a/app/controllers/computers_controller.ts b/app/controllers/computers_controller.ts index d70b228..ffb2bd3 100644 --- a/app/controllers/computers_controller.ts +++ b/app/controllers/computers_controller.ts @@ -9,6 +9,20 @@ import { handlePictures } from '#services/picture_service' import type { Image } from '#types/UItypes' import { updateComputerValidator } from '#validators/Computers/update' +/** + * Lucid's `serialize()` returns a loosely typed `ModelObject` + * (`{ [key: string]: any }`). It is not structurally assignable to the + * concrete prop shapes declared by the Vue pages (see + * `inertia/pages/types/UITypes.ts`), which live in a separate TypeScript + * project (the `inertia/**` tree is excluded from the backend tsconfig). + * Since the controller fully controls which columns/relations are + * preloaded and serialized, this cast documents that the runtime shape + * matches what the page expects at this boundary. + */ +function asPageProp(value: unknown): T { + return value as T +} + export default class ComputersController { async index({ auth, inertia }: HttpContext) { const user = await auth.authenticate() @@ -20,7 +34,9 @@ export default class ComputersController { picturesQuery.where('order', 1) }) - return inertia.render('computers/index', { computers: computers.map((c) => c.serialize()) }) + return inertia.render('computers/index', { + computers: asPageProp(computers.map((c) => c.serialize())), + }) } async create({ auth, inertia }: HttpContext) { @@ -30,7 +46,10 @@ export default class ComputersController { .where('userId', user.id) .whereNull('computerId') .preload('type') - return inertia.render('computers/create', { components: components.map((c) => c.serialize()), states: states.map((s) => s.serialize()) }) + return inertia.render('computers/create', { + components: asPageProp(components.map((c) => c.serialize())), + states: asPageProp(states.map((s) => s.serialize())), + }) } async store({ auth, request, response }: HttpContext) { @@ -61,7 +80,7 @@ export default class ComputersController { .where('user_id', user.id) .andWhere('id', computerId) .firstOrFail() - return inertia.render('computers/show', { computer: computer.serialize() }) + return inertia.render('computers/show', { computer: asPageProp(computer.serialize()) }) } async edit({ params, inertia, auth }: HttpContext) { @@ -87,7 +106,11 @@ export default class ComputersController { 'id', computer.components.map((c) => c.id) ) - return inertia.render('computers/edit', { computer: computer.serialize(), availableComponents: availableComponents.map((c) => c.serialize()), states: states.map((s) => s.serialize()) }) + return inertia.render('computers/edit', { + computer: asPageProp(computer.serialize()), + availableComponents: asPageProp(availableComponents.map((c) => c.serialize())), + states: asPageProp(states.map((s) => s.serialize())), + }) } async update({ auth, params, request, response }: HttpContext) { diff --git a/app/models/computer.ts b/app/models/computer.ts index 6a6d282..f74e8f4 100644 --- a/app/models/computer.ts +++ b/app/models/computer.ts @@ -1,6 +1,7 @@ import { DateTime } from 'luxon' import { BaseModel, belongsTo, column, computed, hasMany } from '@adonisjs/lucid/orm' import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' +import logger from '@adonisjs/core/services/logger' import ComputerState from '#models/computer_state' import ComputerPicture from '#models/computer_picture' import Component from '#models/component' @@ -52,7 +53,10 @@ export default class Computer extends BaseModel { @computed() get cost(): number { if (!this.$preloaded.components) { - console.warn('Components not preloaded, cost may be incorrect') + logger.warn( + { computerId: this.id }, + 'Computer.cost accessed without preloading "components"; returning 0' + ) return 0 } return this.components.reduce((total, component) => total + Number(component.price), 0) diff --git a/config/session.ts b/config/session.ts index f887717..cc34262 100644 --- a/config/session.ts +++ b/config/session.ts @@ -1,4 +1,5 @@ import env from '#start/env' +import app from '@adonisjs/core/services/app' import { defineConfig, stores } from '@adonisjs/session' const sessionConfig = defineConfig({ @@ -42,7 +43,7 @@ const sessionConfig = defineConfig({ /** * Send cookies only over HTTPS in production. */ - secure: false, + secure: app.inProduction, /** * Cross-site policy for cookie sending. diff --git a/config/shield.ts b/config/shield.ts index 37067c6..deff8bc 100644 --- a/config/shield.ts +++ b/config/shield.ts @@ -9,12 +9,19 @@ const shieldConfig = defineConfig({ /** * Enable the Content-Security-Policy header. */ - enabled: false, + enabled: true, /** * Per-resource CSP directives. */ - directives: {}, + directives: { + defaultSrc: [`'self'`, '@viteDevUrl'], + connectSrc: [`'self'`, '@viteHmrUrl'], + scriptSrc: [`'self'`, '@viteDevUrl'], + styleSrc: [`'self'`, '@viteDevUrl', `'unsafe-inline'`], + imgSrc: [`'self'`, 'data:'], + fontSrc: [`'self'`, 'data:'], + }, /** * Report violations without blocking resources. diff --git a/inertia/composables/isAdminMode.ts b/inertia/composables/isAdminMode.ts index d3d1a0f..66d582f 100644 --- a/inertia/composables/isAdminMode.ts +++ b/inertia/composables/isAdminMode.ts @@ -1,9 +1,9 @@ import { ref, watch } from 'vue' -// Cr├®er une r├®f├®rence pour isAdminActive +// Créer une référence pour isAdminActive const isAdminActive = ref(JSON.parse(localStorage.getItem('isAdminActive') || 'false')) -// Surveiller les changements de isAdminActive et mettre ├á jour localStorage +// Surveiller les changements de isAdminActive et mettre à jour localStorage watch(isAdminActive, (newValue) => { localStorage.setItem('isAdminActive', JSON.stringify(newValue)) }) diff --git a/inertia/directives/v-delete-confirm.ts b/inertia/directives/v-delete-confirm.ts index 6a537f8..38ab483 100644 --- a/inertia/directives/v-delete-confirm.ts +++ b/inertia/directives/v-delete-confirm.ts @@ -1,7 +1,7 @@ import { createApp, DirectiveBinding, h, ObjectDirective } from 'vue' import DeleteConfirmationModal from '~/pages/widgets/DeleteConfirmationModal.vue' -// Interface pour ├®tendre HTMLElement avec _handleClick +// Interface pour étendre HTMLElement avec _handleClick interface HTMLElementWithHandleClick extends HTMLElement { _handleClick?: (event: Event) => void } @@ -9,8 +9,8 @@ interface HTMLElementWithHandleClick extends HTMLElement { const deleteConfirmDirective: ObjectDirective = { mounted(el: HTMLElementWithHandleClick, binding: DirectiveBinding) { const handleClick = (event: Event) => { - event.stopPropagation() // Emp├¬cher la propagation de l'├®v├®nement - event.preventDefault() // Emp├¬cher l'action par d├®faut + event.stopPropagation() // Empêcher la propagation de l'événement + event.preventDefault() // Empêcher l'action par défaut const modalApp = createApp({ data() { @@ -20,7 +20,7 @@ const deleteConfirmDirective: ObjectDirective = { confirm() { this.show = false document.body.removeChild(modalContainer) - binding.value() // Ex├®cuter la fonction de suppression + binding.value() // Exécuter la fonction de suppression }, cancel() { this.show = false @@ -42,7 +42,7 @@ const deleteConfirmDirective: ObjectDirective = { } el.addEventListener('click', handleClick) - el._handleClick = handleClick // Stocker la r├®f├®rence pour la suppression ult├®rieure + el._handleClick = handleClick // Stocker la référence pour la suppression ultérieure }, unmounted(el: HTMLElementWithHandleClick) { if (el._handleClick) { diff --git a/inertia/directives/v-price.ts b/inertia/directives/v-price.ts index 5c43786..e92bad9 100644 --- a/inertia/directives/v-price.ts +++ b/inertia/directives/v-price.ts @@ -6,7 +6,7 @@ const formatPrice = (value: string): string => { return Number.isNaN(numberValue) ? '' : numberValue.toFixed(2).replace('.', ',') } -// Interface pour ├®tendre HTMLInputElement avec _handleBlur +// Interface pour étendre HTMLInputElement avec _handleBlur interface HTMLInputElementWithHandleBlur extends HTMLInputElement { _handleBlur?: () => void } @@ -15,10 +15,10 @@ const priceDirective: ObjectDirective = { mounted(el: HTMLInputElementWithHandleBlur) { const handleBlur = () => { el.value = formatPrice(el.value) - el.dispatchEvent(new Event('input')) // Pour mettre ├á jour le v-model + el.dispatchEvent(new Event('input')) // Pour mettre à jour le v-model } el.addEventListener('blur', handleBlur) - el._handleBlur = handleBlur // Stocker la r├®f├®rence pour la suppression ult├®rieure + el._handleBlur = handleBlur // Stocker la référence pour la suppression ultérieure }, unmounted(el: HTMLInputElementWithHandleBlur) { if (el._handleBlur) { diff --git a/inertia/directives/v_validation_error.ts b/inertia/directives/v_validation_error.ts index b51bd68..0811a1e 100644 --- a/inertia/directives/v_validation_error.ts +++ b/inertia/directives/v_validation_error.ts @@ -33,7 +33,7 @@ const validationErrorDirective: ObjectDirective = { const vnode = createVNode(ValidationError, { message: errorMessage }) render(vnode, container) - // Force reflow pour d├®clencher la transition + // Force reflow pour déclencher la transition void container.offsetWidth container.classList.remove('w-0', 'opacity-0') container.classList.add('w-full', 'opacity-100') @@ -77,7 +77,7 @@ const validationErrorDirective: ObjectDirective = { const vnode = createVNode(ValidationError, { message: errorMessage }) render(vnode, newContainer) - // Force reflow pour d├®clencher la transition + // Force reflow pour déclencher la transition void newContainer.offsetWidth newContainer.classList.remove('w-0', 'opacity-0') newContainer.classList.add('w-100', 'opacity-100') @@ -92,7 +92,7 @@ const validationErrorDirective: ObjectDirective = { container.classList.remove('transition-all', 'duration-2500', 'ease-in-out') // Supprimez les classes de transition pour la disparition container.classList.add('w-0', 'opacity-0') - // Supprimez le conteneur imm├®diatement sans d├®lai + // Supprimez le conteneur immédiatement sans délai render(null, container) container.remove() ;(el as any)._validationErrorContainer = null diff --git a/inertia/pages/components/create.vue b/inertia/pages/components/create.vue index cfd39c3..e6fbafc 100644 --- a/inertia/pages/components/create.vue +++ b/inertia/pages/components/create.vue @@ -3,8 +3,9 @@ import { reactive } from 'vue' import { Link } from '@adonisjs/inertia/vue' import { useForm } from '@inertiajs/vue3' import CustomSelect from '~/pages/widgets/CustomSelect.vue' +import type { ComponentType } from '~/pages/types/UITypes' -defineProps<{ types: any[]; errors?: Record }>() +defineProps<{ types: ComponentType[]; errors?: Record }>() const form = reactive({ name: '', price: 0, diff --git a/inertia/pages/components/show.vue b/inertia/pages/components/show.vue index 170cc56..b90a177 100644 --- a/inertia/pages/components/show.vue +++ b/inertia/pages/components/show.vue @@ -1,7 +1,8 @@