fix(security): close IDOR on computers/components update & destroy
- Scope update/destroy queries by userId (Computers & Components), matching the pattern already used in index/show/edit - Add missing auth.authenticate() calls on destroy actions - handleComponents now requires userId and scopes component lookups to the current user, preventing cross-user component takeover - handleComponents rejects attaching more than one component of the same 'unique' type to a computer (new InvalidComponentSelectionException, 422) - handlePictures scopes picture lookups by computerId - destroy(Computer) now detaches its components (computerId = null) instead of losing them to cascade delete, and removes the computer's uploads dir - Switch store/update actions to request.validateUsing() for consistency - Fix relative redirects to absolute paths (/computers, /computers/:id) - Untrack .env.production and add it to .gitignore (secrets must not be committed); local file kept on disk
This commit is contained in:
1 parent
ec6734f491
commit
3cc189c534
7 files changed
+417
-24
No files matched your search
@@ -9,6 +9,7 @@ dist
|
|||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.production
|
||||||
.env.production.local
|
.env.production.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test
|
.env.test
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Plan de remédiation — PC Builder
|
||||||
|
|
||||||
|
Basé sur la revue de code du 2026-07-23. Objectif : corriger les failles de
|
||||||
|
sécurité critiques avant toute mise en production, puis assainir la base du
|
||||||
|
code (majeur), puis traiter les points mineurs/qualité.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — CRITIQUE (bloquant avant prod)
|
||||||
|
|
||||||
|
### 1.1 IDOR sur `update`/`destroy` (Computers & Components)
|
||||||
|
|
||||||
|
**Problème** : `computers_controller.ts` (`update` L92-103, `destroy` L105-110)
|
||||||
|
et `components_controller.ts` (`destroy` L77-82) chargent la ressource via
|
||||||
|
`findOrFail(id)` sans filtrer par `userId`. `destroy` des deux controllers
|
||||||
|
n'authentifie même pas l'utilisateur (`auth.authenticate()` absent). N'importe
|
||||||
|
quel utilisateur connecté peut modifier/supprimer les PC ou composants d'un
|
||||||
|
autre utilisateur.
|
||||||
|
|
||||||
|
**Remédiation** :
|
||||||
|
- `computers_controller.ts#update` : ajouter `auth.authenticate()`, remplacer
|
||||||
|
`Computer.findOrFail(computerId)` par
|
||||||
|
`Computer.query().where('user_id', user.id).andWhere('id', computerId).firstOrFail()`
|
||||||
|
(même pattern que `show`/`edit`).
|
||||||
|
- `computers_controller.ts#destroy` : ajouter `auth.authenticate()`, même
|
||||||
|
pattern de query scopée par `user_id`.
|
||||||
|
- `components_controller.ts#destroy` : ajouter `auth.authenticate()`, remplacer
|
||||||
|
`Component.findOrFail(componentId)` par une query scopée
|
||||||
|
`where('userId', user.id).andWhere('id', componentId).firstOrFail()`.
|
||||||
|
- Vérifier les routes (`start/routes.ts`) : `middleware.auth()` est déjà
|
||||||
|
appliqué sur les groupes `/computers` et `/components`, donc l'utilisateur
|
||||||
|
est authentifiable dans le contexte — juste manquant l'appel explicite +
|
||||||
|
le filtre.
|
||||||
|
|
||||||
|
**Fichiers impactés** :
|
||||||
|
- `app/controllers/computers_controller.ts`
|
||||||
|
- `app/controllers/components_controller.ts`
|
||||||
|
|
||||||
|
### 1.2 IDOR transitif via `handleComponents` / `handlePictures`
|
||||||
|
|
||||||
|
**Problème** : même après la 1.1, `component_service.ts#handleComponents`
|
||||||
|
(L11, L18) et `picture_service.ts#handlePictures` (L22, L47) font
|
||||||
|
`findOrFail(id)` sans vérifier que le composant/l'image appartient à
|
||||||
|
l'utilisateur courant. Un attaquant authentifié peut poster un `componentsId`
|
||||||
|
contenant l'ID d'un composant appartenant à un autre utilisateur pour
|
||||||
|
se l'approprier (`component.computerId = computer.id`).
|
||||||
|
|
||||||
|
**Remédiation** :
|
||||||
|
- `handleComponents(componentsId, computer, userId)` : ajouter le paramètre
|
||||||
|
`userId`, et scoper les requêtes `Component.query().where('userId', userId).andWhere('id', id).firstOrFail()`
|
||||||
|
au lieu de `Component.findOrFail(id)`. Si un ID demandé n'appartient pas à
|
||||||
|
l'utilisateur, lever une erreur explicite (404 ou 403) plutôt que d'échouer
|
||||||
|
silencieusement.
|
||||||
|
- `handlePictures` : scoper `ComputerPicture.findOrFail(picture.id)` par
|
||||||
|
`computerId` du computer courant (`.where('computerId', computer.id)`), pour
|
||||||
|
empêcher de manipuler des images d'un autre computer/utilisateur.
|
||||||
|
- Mettre à jour les appels dans `computers_controller.ts#store` et `#update`
|
||||||
|
pour passer `user.id`.
|
||||||
|
|
||||||
|
**Fichiers impactés** :
|
||||||
|
- `app/services/component_service.ts`
|
||||||
|
- `app/services/picture_service.ts`
|
||||||
|
- `app/controllers/computers_controller.ts` (appels à `handleComponents`)
|
||||||
|
|
||||||
|
### 1.3 Secrets commités : `.env.production`
|
||||||
|
|
||||||
|
**Problème** : `.env.production` est suivi par git et contient `APP_KEY`,
|
||||||
|
`DB_PASSWORD`, `DB_USER`, `DB_DATABASE` en clair (commit `c822872`). Le CI
|
||||||
|
(`.gitea/workflows/build.yml`) le lit et le déploie tel quel sur le NAS.
|
||||||
|
|
||||||
|
**Remédiation** (à faire avec le user — actions sensibles) :
|
||||||
|
1. **Révoquer/régénérer immédiatement** les secrets réels côté NAS :
|
||||||
|
nouveau `APP_KEY` (`node ace generate:key`), nouveau mot de passe MySQL,
|
||||||
|
éventuellement recréer l'utilisateur DB.
|
||||||
|
2. Ajouter `.env.production` au `.gitignore` (garder uniquement
|
||||||
|
`.env.production.example` comme gabarit, déjà présent et sans valeurs
|
||||||
|
réelles).
|
||||||
|
3. Retirer le fichier du suivi git : `git rm --cached .env.production`.
|
||||||
|
4. Purger l'historique git du secret (`git filter-repo` ou BFG Repo-Cleaner)
|
||||||
|
— **à valider avec l'utilisateur** car cela réécrit l'historique partagé
|
||||||
|
(impact sur les autres clones/branches).
|
||||||
|
5. Migrer la distribution des secrets de prod vers les **Secrets Gitea Actions**
|
||||||
|
(comme `secrets.NASPASS`) plutôt qu'un fichier committé : injecter les
|
||||||
|
variables via `${{ secrets.APP_KEY }}`, `${{ secrets.DB_PASSWORD }}`, etc.,
|
||||||
|
et générer `.env` à la volée dans le job de déploiement.
|
||||||
|
|
||||||
|
**Fichiers impactés** :
|
||||||
|
- `.gitignore`
|
||||||
|
- `.gitea/workflows/build.yml`
|
||||||
|
- `.env.production` (suppression du suivi)
|
||||||
|
- Historique git (opération destructive, confirmation utilisateur requise)
|
||||||
|
|
||||||
|
### 1.4 Secrets en ligne de commande dans le pipeline CI (NASPASS)
|
||||||
|
|
||||||
|
**Problème** : `.gitea/workflows/build.yml` L46-56 utilise `sshpass -p` et
|
||||||
|
`sudo -S` avec le secret NAS directement en argument de commande shell —
|
||||||
|
risque d'exposition dans les logs/process list.
|
||||||
|
|
||||||
|
**Remédiation** :
|
||||||
|
- Remplacer l'authentification par mot de passe par une **clé SSH dédiée**
|
||||||
|
stockée en secret Gitea (`secrets.NAS_SSH_KEY`), utilisée via
|
||||||
|
`ssh -i <(echo "$NAS_SSH_KEY")` ou `webfactory/ssh-agent`-like action.
|
||||||
|
- Configurer un accès `sudo` sans mot de passe restreint aux commandes
|
||||||
|
nécessaires (`visudo` avec `NOPASSWD` limité aux scripts `setup-db.sh`,
|
||||||
|
`docker compose`, `docker exec`) plutôt que de faire transiter le mot de
|
||||||
|
passe utilisateur via `sudo -S`.
|
||||||
|
- À défaut de refonte complète immédiate, au minimum masquer la commande
|
||||||
|
(`set +x` explicite, éviter `-x` en debug) et documenter le risque résiduel.
|
||||||
|
|
||||||
|
**Fichiers impactés** :
|
||||||
|
- `.gitea/workflows/build.yml`
|
||||||
|
- Configuration NAS (hors dépôt — à faire manuellement)
|
||||||
|
|
||||||
|
### 1.5 Pas de contrainte d'unicité applicative pour les composants "uniques"
|
||||||
|
|
||||||
|
**Problème** : `ComponentType.unique` (ex: un seul CPU par PC) n'est vérifié
|
||||||
|
nulle part côté serveur. Un POST direct sur `/computers` ou `/computers/:id`
|
||||||
|
avec plusieurs composants du même type "unique" dans `componentsId` est accepté.
|
||||||
|
|
||||||
|
**Remédiation** :
|
||||||
|
- Dans `handleComponents`, après résolution des composants à ajouter, grouper
|
||||||
|
par `typeId` (en préchargeant `type.unique`) et rejeter (erreur 422/exception
|
||||||
|
métier) si plus d'un composant du même type "unique" seraient attachés au
|
||||||
|
computer simultanément.
|
||||||
|
- Ajouter un test automatisé couvrant ce cas (voir Phase 4).
|
||||||
|
|
||||||
|
**Fichiers impactés** :
|
||||||
|
- `app/services/component_service.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — MAJEUR (avant la prochaine itération fonctionnelle)
|
||||||
|
|
||||||
|
### 2.1 Redirections relatives fragiles
|
||||||
|
|
||||||
|
**Problème** : `computers_controller.ts#store` (L46) fait
|
||||||
|
`response.redirect('computers/')` et `#update` (L102) fait
|
||||||
|
`response.redirect(`${computer.id}`)` — chemins relatifs incohérents avec
|
||||||
|
le reste du code (`/components`, `/auth/login`, etc.).
|
||||||
|
|
||||||
|
**Remédiation** : remplacer par des chemins absolus explicites :
|
||||||
|
- `store` → `response.redirect('/computers')`
|
||||||
|
- `update` → `response.redirect(`/computers/${computer.id}`)`
|
||||||
|
|
||||||
|
### 2.2 Incohérence `request.all()` vs `request.validateUsing()`
|
||||||
|
|
||||||
|
**Problème** : `components_controller.ts` et `computers_controller.ts`
|
||||||
|
utilisent `request.all()` + `xxxValidator.validate(data)` au lieu du helper
|
||||||
|
standard `request.validateUsing(validator)` utilisé par
|
||||||
|
`session_controller.ts`/`new_account_controller.ts`. Risque d'incohérence
|
||||||
|
sur le flash des erreurs de validation Inertia (`inputErrorsBag`).
|
||||||
|
|
||||||
|
**Remédiation** : uniformiser tous les controllers sur
|
||||||
|
`await request.validateUsing(xxxValidator)`, supprimer les appels manuels à
|
||||||
|
`.validate()` sur `request.all()`.
|
||||||
|
|
||||||
|
**Fichiers impactés** :
|
||||||
|
- `app/controllers/computers_controller.ts` (store, update)
|
||||||
|
- `app/controllers/components_controller.ts` (store, update)
|
||||||
|
|
||||||
|
### 2.3 Validateurs d'inscription dupliqués et incohérents
|
||||||
|
|
||||||
|
**Problème** : `app/validators/Auth/register.ts` (`registerValidator`) n'est
|
||||||
|
utilisé nulle part (code mort) ; `new_account_controller.ts` utilise
|
||||||
|
`signupValidator` de `app/validators/user.ts`. Les deux ont des règles
|
||||||
|
différentes sur `name` (obligatoire minLength 3 vs nullable sans contrainte).
|
||||||
|
|
||||||
|
**Remédiation** :
|
||||||
|
- Supprimer `app/validators/Auth/register.ts` (code mort), ou le fusionner
|
||||||
|
si une seconde route d'inscription est prévue.
|
||||||
|
- Décider de la règle métier définitive sur `name` (obligatoire ou non) dans
|
||||||
|
`signupValidator` et l'appliquer.
|
||||||
|
|
||||||
|
### 2.4 Formulaire `computers/create.vue` ne se soumet pas via Enter / pas d'état de chargement
|
||||||
|
|
||||||
|
**Problème** : le bouton "Create" est un `<Link method="post">` externe au
|
||||||
|
`<form>` (pas de `@submit.prevent`), contrairement à `components/create.vue`
|
||||||
|
qui utilise `useForm` + `@submit.prevent` correctement.
|
||||||
|
|
||||||
|
**Remédiation** : refactorer `computers/create.vue` pour utiliser
|
||||||
|
`useForm()` d'Inertia avec `@submit.prevent="form.post('/computers')"`,
|
||||||
|
afficher `form.processing` sur le bouton (état de chargement), harmoniser
|
||||||
|
avec le pattern de `components/create.vue`.
|
||||||
|
|
||||||
|
### 2.5 `origin` fantôme dans `components/edit.vue`
|
||||||
|
|
||||||
|
**Problème** : la page lit `props.origin` et le renvoie dans le payload,
|
||||||
|
mais `components_controller.ts#edit` ne le partage jamais dans
|
||||||
|
`inertia.render(...)`.
|
||||||
|
|
||||||
|
**Remédiation** : soit supprimer complètement `origin` du formulaire et du
|
||||||
|
type de props si la fonctionnalité est abandonnée, soit l'implémenter
|
||||||
|
proprement si un besoin réel existe (ex: retour à la page d'origine après
|
||||||
|
modification). À trancher avec l'utilisateur — proposition : suppression
|
||||||
|
(simplification), sauf avis contraire.
|
||||||
|
|
||||||
|
### 2.6 Suppression d'un `Computer` : cascade destructive sur les composants + fichiers orphelins
|
||||||
|
|
||||||
|
**Problème** : `destroy` d'un `Computer` supprime en cascade DB ses
|
||||||
|
`Component` liés au lieu de les détacher (`computerId = null`), ce qui fait
|
||||||
|
perdre définitivement les composants de l'utilisateur. De plus, les fichiers
|
||||||
|
images physiques (`uploads/computer_X/...`) ne sont jamais nettoyés lors
|
||||||
|
d'un `destroy` de computer (seulement lors d'un retrait explicite via update).
|
||||||
|
|
||||||
|
**Remédiation** :
|
||||||
|
- Dans `computers_controller.ts#destroy`, avant `computer.delete()` :
|
||||||
|
détacher les composants (`UPDATE components SET computer_id = NULL WHERE computer_id = ?`)
|
||||||
|
et supprimer physiquement le dossier `uploads/computer_${id}` (+ les lignes
|
||||||
|
`computer_pictures`, qui elles peuvent rester en cascade DB puisque les
|
||||||
|
fichiers sont supprimés en même temps).
|
||||||
|
- Vérifier/adapter la contrainte `onDelete('CASCADE')` sur `components.computer_id`
|
||||||
|
dans la migration si on souhaite un `SET NULL` réellement porté par la DB
|
||||||
|
plutôt qu'un traitement applicatif (nécessiterait une migration corrective).
|
||||||
|
|
||||||
|
**Fichiers impactés** :
|
||||||
|
- `app/controllers/computers_controller.ts`
|
||||||
|
- Éventuellement une nouvelle migration si `onDelete` doit changer.
|
||||||
|
|
||||||
|
### 2.7 Duplication `ComponentsViewer.vue` / `ComputersViewer.vue`
|
||||||
|
|
||||||
|
**Remédiation** : extraire un composant générique `FilterDropdown.vue`
|
||||||
|
(props : liste d'options `{id, label}`, `v-model` sur l'état de filtre,
|
||||||
|
slot ou prop pour le libellé du bouton) réutilisé dans les deux vues, pour
|
||||||
|
éliminer la duplication du markup dropdown + logique `toggleDropdown`.
|
||||||
|
|
||||||
|
### 2.8 Composants "widgets" orphelins et fonctionnalité galerie incomplète
|
||||||
|
|
||||||
|
**Problème** : `ComponentEditor.vue`, `ComputerComponentsTable.vue`,
|
||||||
|
`SimpleGallery.vue`, `AuthErrorAlert.vue` ne sont référencés par aucune page
|
||||||
|
active. La gestion de galerie photo (`SimpleGallery.vue` + `picture_service.ts`)
|
||||||
|
n'est appelée par aucun formulaire actuel (`computers/create.vue`/`edit.vue`
|
||||||
|
n'ont pas de champ pictures).
|
||||||
|
|
||||||
|
**Remédiation** — décision à prendre avec l'utilisateur :
|
||||||
|
- **Option A (simplification)** : supprimer ces composants orphelins et le
|
||||||
|
code backend de gestion d'images s'ils ne sont plus dans la roadmap.
|
||||||
|
- **Option B (finaliser la fonctionnalité)** : intégrer `SimpleGallery.vue`
|
||||||
|
dans `computers/create.vue`/`edit.vue` pour rendre l'upload d'images
|
||||||
|
réellement fonctionnel, et réintégrer `ComponentEditor.vue` (gestion
|
||||||
|
propre des composants uniques via select) à la place des checkboxes brutes
|
||||||
|
actuelles — ce qui réglerait aussi une partie du problème 1.5 côté UX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — MINEUR (qualité, hygiène)
|
||||||
|
|
||||||
|
### 3.1 Sécurité de configuration
|
||||||
|
- `config/session.ts` L45 : remplacer `secure: false` par `secure: app.inProduction`.
|
||||||
|
- `config/shield.ts` L12 : activer une CSP de base (`csp.enabled: true`)
|
||||||
|
avec des directives minimales (`default-src 'self'`, autoriser les sources
|
||||||
|
Vite en dev).
|
||||||
|
|
||||||
|
### 3.2 Typage frontend
|
||||||
|
- Remplacer les `any` dans `computers/*.vue` et `components/*.vue` par les
|
||||||
|
types réels (`Computer`, `Component`, `ComputerState`, `ComponentType`
|
||||||
|
importés depuis `#models/*` comme le fait déjà `ComponentsViewer.vue`).
|
||||||
|
- Corriger/aligner `inertia/pages/types/UITypes.ts` avec le schéma backend
|
||||||
|
réel (`typeId` au lieu de `componentTypeId`, retirer `componentStateId`
|
||||||
|
inexistant) ou supprimer ce fichier si les vrais modèles Lucid suffisent.
|
||||||
|
|
||||||
|
### 3.3 Encodage UTF-8 cassé (mojibake)
|
||||||
|
- Corriger l'encodage dans `ComponentsViewer.vue`, `ComponentEditor.vue`,
|
||||||
|
`ComputerComponentsTable.vue`, `TableComponent.vue` (caractères `├®`, `Ôé¼`
|
||||||
|
etc. à réécrire en UTF-8 propre : é, à, ô, €).
|
||||||
|
|
||||||
|
### 3.4 Accessibilité
|
||||||
|
- Corriger les doublons d'`id` HTML dans `ComputersViewer.vue` (L82/94,
|
||||||
|
deux fois `id="used"`).
|
||||||
|
- Ajouter `aria-expanded`/`aria-haspopup` sur les boutons de dropdown filtres.
|
||||||
|
- Remplacer `CustomSelect.vue` par un `<select>` natif stylé, ou ajouter les
|
||||||
|
rôles ARIA manquants (`role="listbox"`, navigation clavier).
|
||||||
|
|
||||||
|
### 3.5 Nettoyage divers
|
||||||
|
- Remplacer `console.warn` dans `computer.ts#cost` par un logger applicatif
|
||||||
|
ou un comportement documenté.
|
||||||
|
- Déplacer/supprimer `MIGRATION_PAGES_SUMMARY.md`, `MIGRATION_COMPLETE.md`,
|
||||||
|
`MIGRATION_CHECKLIST.md` (artefacts temporaires) vers `docs/` ou les
|
||||||
|
supprimer une fois la migration validée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Tests de non-régression
|
||||||
|
|
||||||
|
Pour valider les correctifs de sécurité (Phase 1), ajouter des tests
|
||||||
|
fonctionnels Japa :
|
||||||
|
- Un utilisateur A ne peut pas `PUT`/`DELETE` un computer/component
|
||||||
|
appartenant à l'utilisateur B (attend 404/403).
|
||||||
|
- Un utilisateur ne peut pas attacher via `componentsId` un composant
|
||||||
|
appartenant à un autre utilisateur.
|
||||||
|
- Impossible d'attacher deux composants du même type "unique" à un même
|
||||||
|
computer.
|
||||||
|
- Après suppression d'un computer, ses composants redeviennent disponibles
|
||||||
|
(`computerId = null`) au lieu d'être supprimés.
|
||||||
|
|
||||||
|
**Fichiers** : `tests/functional/computers.spec.ts`,
|
||||||
|
`tests/functional/components.spec.ts` (à créer si absents).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordre d'exécution proposé
|
||||||
|
|
||||||
|
1. **Phase 1** entièrement (sécurité bloquante), avec confirmation
|
||||||
|
utilisateur explicite avant toute réécriture d'historique git (1.3) ou
|
||||||
|
changement de config NAS (1.4).
|
||||||
|
2. **Phase 4** (tests) en parallèle de la Phase 1 pour prouver les correctifs.
|
||||||
|
3. **Phase 2** (majeur), avec un point de décision utilisateur sur 2.5 et 2.8
|
||||||
|
(garder/supprimer/finaliser des fonctionnalités).
|
||||||
|
4. **Phase 3** (mineur) en dernier, en petits commits séparés par thème
|
||||||
|
(config sécurité / typage / encodage / accessibilité / nettoyage fichiers).
|
||||||
|
|
||||||
|
## Points nécessitant une décision explicite de l'utilisateur avant exécution
|
||||||
|
|
||||||
|
- **1.3** : confirmer la réécriture de l'historique git pour purger le secret,
|
||||||
|
et confirmer que les secrets réels (APP_KEY, DB_PASSWORD) seront bien
|
||||||
|
régénérés côté NAS avant/juste après.
|
||||||
|
- **1.4** : confirmer l'accès pour reconfigurer l'authentification SSH/sudo
|
||||||
|
sur le NAS (hors du dépôt, nécessite un accès serveur).
|
||||||
|
- **2.5** : garder, implémenter ou supprimer le champ `origin`.
|
||||||
|
- **2.8** : supprimer les composants orphelins (Option A) ou finaliser la
|
||||||
|
fonctionnalité galerie/éditeur de composants uniques (Option B).
|
||||||
@@ -18,9 +18,8 @@ export default class ComponentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async store({ request, response, auth }: HttpContext) {
|
async store({ request, response, auth }: HttpContext) {
|
||||||
const data = request.all()
|
|
||||||
const user = await auth.authenticate()
|
const user = await auth.authenticate()
|
||||||
const { quantity, ...componentData } = await storeComponentValidator.validate(data)
|
const { quantity, ...componentData } = await request.validateUsing(storeComponentValidator)
|
||||||
await ComponentType.findOrFail(componentData.typeId)
|
await ComponentType.findOrFail(componentData.typeId)
|
||||||
for (let i = 0; i < quantity; i++) {
|
for (let i = 0; i < quantity; i++) {
|
||||||
await Component.create({
|
await Component.create({
|
||||||
@@ -59,9 +58,8 @@ export default class ComponentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update({ params, auth, request, response }: HttpContext) {
|
async update({ params, auth, request, response }: HttpContext) {
|
||||||
const data = request.all()
|
|
||||||
const componentData = await updateComponentValidator.validate(data)
|
|
||||||
const user = await auth.authenticate()
|
const user = await auth.authenticate()
|
||||||
|
const componentData = await request.validateUsing(updateComponentValidator)
|
||||||
const component = await Component.query()
|
const component = await Component.query()
|
||||||
.where('userId', user.id)
|
.where('userId', user.id)
|
||||||
.andWhere('id', params.id)
|
.andWhere('id', params.id)
|
||||||
@@ -74,9 +72,13 @@ export default class ComponentsController {
|
|||||||
return response.redirect('/components')
|
return response.redirect('/components')
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy({ params, response }: HttpContext) {
|
async destroy({ params, auth, response }: HttpContext) {
|
||||||
|
const user = await auth.authenticate()
|
||||||
const componentId = params.id
|
const componentId = params.id
|
||||||
const component = await Component.findOrFail(componentId)
|
const component = await Component.query()
|
||||||
|
.where('userId', user.id)
|
||||||
|
.andWhere('id', componentId)
|
||||||
|
.firstOrFail()
|
||||||
await component.delete()
|
await component.delete()
|
||||||
return response.redirect('/components')
|
return response.redirect('/components')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
import { existsSync, rmSync } from 'node:fs'
|
||||||
import Computer from '#models/computer'
|
import Computer from '#models/computer'
|
||||||
import ComputerState from '#models/computer_state'
|
import ComputerState from '#models/computer_state'
|
||||||
import Component from '#models/component'
|
import Component from '#models/component'
|
||||||
@@ -33,17 +34,17 @@ export default class ComputersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async store({ auth, request, response }: HttpContext) {
|
async store({ auth, request, response }: HttpContext) {
|
||||||
const data = request.all()
|
|
||||||
const user = await auth.authenticate()
|
const user = await auth.authenticate()
|
||||||
const { pictures, componentsId, ...computerData } = await storeComputerValidator.validate(data)
|
const { pictures, componentsId, ...computerData } =
|
||||||
|
await request.validateUsing(storeComputerValidator)
|
||||||
await ComputerState.findOrFail(computerData.stateId)
|
await ComputerState.findOrFail(computerData.stateId)
|
||||||
const computer = await Computer.create({
|
const computer = await Computer.create({
|
||||||
...computerData,
|
...computerData,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
})
|
})
|
||||||
await handleComponents(componentsId as number[], computer)
|
await handleComponents(componentsId as number[], computer, user.id)
|
||||||
await handlePictures(pictures as Image[], request, computer)
|
await handlePictures(pictures as Image[], request, computer)
|
||||||
return response.redirect('computers/')
|
return response.redirect('/computers')
|
||||||
}
|
}
|
||||||
|
|
||||||
async show({ auth, inertia, params }: HttpContext) {
|
async show({ auth, inertia, params }: HttpContext) {
|
||||||
@@ -89,22 +90,36 @@ export default class ComputersController {
|
|||||||
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: computer.serialize(), availableComponents: availableComponents.map((c) => c.serialize()), states: states.map((s) => s.serialize()) })
|
||||||
}
|
}
|
||||||
|
|
||||||
async update({ params, request, response }: HttpContext) {
|
async update({ auth, params, request, response }: HttpContext) {
|
||||||
|
const user = await auth.authenticate()
|
||||||
const computerId = params.id
|
const computerId = params.id
|
||||||
const { pictures, componentsId, ...computerData } = await updateComputerValidator.validate(
|
const { pictures, componentsId, ...computerData } = await request.validateUsing(
|
||||||
request.all()
|
updateComputerValidator
|
||||||
)
|
)
|
||||||
const computer = await Computer.findOrFail(computerId)
|
const computer = await Computer.query()
|
||||||
|
.where('user_id', user.id)
|
||||||
|
.andWhere('id', computerId)
|
||||||
|
.firstOrFail()
|
||||||
computer.merge(computerData)
|
computer.merge(computerData)
|
||||||
await computer.save()
|
await computer.save()
|
||||||
await handleComponents(componentsId as number[], computer)
|
await handleComponents(componentsId as number[], computer, user.id)
|
||||||
await handlePictures(pictures as Image[], request, computer)
|
await handlePictures(pictures as Image[], request, computer)
|
||||||
return response.redirect(`${computer.id}`)
|
return response.redirect(`/computers/${computer.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async destroy({ params, response }: HttpContext) {
|
async destroy({ auth, params, response }: HttpContext) {
|
||||||
|
const user = await auth.authenticate()
|
||||||
const computerId = params.id
|
const computerId = params.id
|
||||||
const computer = await Computer.findOrFail(computerId)
|
const computer = await Computer.query()
|
||||||
|
.where('user_id', user.id)
|
||||||
|
.andWhere('id', computerId)
|
||||||
|
.firstOrFail()
|
||||||
|
|
||||||
|
const uploadsDir = `uploads/computer_${computer.id}`
|
||||||
|
await computer.related('components').query().update({ computerId: null })
|
||||||
|
if (existsSync(uploadsDir)) {
|
||||||
|
rmSync(uploadsDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
await computer.delete()
|
await computer.delete()
|
||||||
return response.redirect('/computers')
|
return response.redirect('/computers')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Exception } from '@adonisjs/core/exceptions'
|
||||||
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a component selection is invalid, either because:
|
||||||
|
* - the referenced component does not belong to the current user, or
|
||||||
|
* - more than one component of the same "unique" type is being attached
|
||||||
|
* to the same computer at once.
|
||||||
|
*/
|
||||||
|
export default class InvalidComponentSelectionException extends Exception {
|
||||||
|
static status = 422
|
||||||
|
static code = 'E_INVALID_COMPONENT_SELECTION'
|
||||||
|
|
||||||
|
handle(error: this, { response }: HttpContext) {
|
||||||
|
return response.status(error.status).send({ errors: [{ message: error.message }] })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,53 @@
|
|||||||
import Component from '#models/component'
|
import Component from '#models/component'
|
||||||
import Computer from '#models/computer'
|
import Computer from '#models/computer'
|
||||||
|
import InvalidComponentSelectionException from '#exceptions/invalid_component_selection_exception'
|
||||||
|
|
||||||
async function handleComponents(componentsId: number[], computer: Computer) {
|
async function handleComponents(componentsId: number[], computer: Computer, userId: number) {
|
||||||
const currentComponents = await computer.related('components').query()
|
const currentComponents = await computer.related('components').query()
|
||||||
const currentComponentsIds = currentComponents.map((component) => component.id)
|
const currentComponentsIds = currentComponents.map((component) => component.id)
|
||||||
const componentsToAdd = componentsId.filter((id) => !currentComponentsIds.includes(id))
|
const componentsToAdd = componentsId.filter((id) => !currentComponentsIds.includes(id))
|
||||||
const componentsToRemove = currentComponentsIds.filter((id) => !componentsId.includes(id))
|
const componentsToRemove = currentComponentsIds.filter((id) => !componentsId.includes(id))
|
||||||
|
|
||||||
|
const componentsToAddRecords = await Promise.all(
|
||||||
|
componentsToAdd.map((id: number) =>
|
||||||
|
Component.query().where('userId', userId).andWhere('id', id).preload('type').firstOrFail()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure at most one component of each "unique" type ends up attached to
|
||||||
|
// the computer once the additions/removals are applied.
|
||||||
|
const remainingComponentIds = currentComponentsIds.filter(
|
||||||
|
(id) => !componentsToRemove.includes(id)
|
||||||
|
)
|
||||||
|
const remainingComponents = remainingComponentIds.length
|
||||||
|
? await Component.query().whereIn('id', remainingComponentIds).preload('type')
|
||||||
|
: []
|
||||||
|
const finalComponents = [...remainingComponents, ...componentsToAddRecords]
|
||||||
|
|
||||||
|
const uniqueTypeCounts = new Map<number, number>()
|
||||||
|
for (const component of finalComponents) {
|
||||||
|
if (!component.type.unique) continue
|
||||||
|
const count = (uniqueTypeCounts.get(component.type.id) ?? 0) + 1
|
||||||
|
uniqueTypeCounts.set(component.type.id, count)
|
||||||
|
if (count > 1) {
|
||||||
|
throw new InvalidComponentSelectionException(
|
||||||
|
`Only one component of type "${component.type.name}" can be attached to a computer at a time.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
componentsToAdd.map(async (id: number) => {
|
componentsToAddRecords.map(async (component) => {
|
||||||
const component = await Component.findOrFail(id)
|
|
||||||
component.computerId = computer.id
|
component.computerId = computer.id
|
||||||
await component.save()
|
await component.save()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
componentsToRemove.map(async (id: number) => {
|
componentsToRemove.map(async (id: number) => {
|
||||||
const component = await Component.findOrFail(id)
|
const component = await Component.query()
|
||||||
|
.where('userId', userId)
|
||||||
|
.andWhere('id', id)
|
||||||
|
.firstOrFail()
|
||||||
component.computerId = null
|
component.computerId = null
|
||||||
await component.save()
|
await component.save()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ async function handlePictures(
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
pictures.map(async (picture: Image, index: number) => {
|
pictures.map(async (picture: Image, index: number) => {
|
||||||
if ('id' in picture) {
|
if ('id' in picture) {
|
||||||
const existingPicture = await ComputerPicture.findOrFail(picture.id)
|
const existingPicture = await ComputerPicture.query()
|
||||||
|
.where('computerId', computer.id)
|
||||||
|
.andWhere('id', picture.id)
|
||||||
|
.firstOrFail()
|
||||||
existingPicture.order = picture.order
|
existingPicture.order = picture.order
|
||||||
await existingPicture.save()
|
await existingPicture.save()
|
||||||
} else {
|
} else {
|
||||||
@@ -44,7 +47,10 @@ async function handlePictures(
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
imagesToRemove.map(async (id: number) => {
|
imagesToRemove.map(async (id: number) => {
|
||||||
const picture = await ComputerPicture.findOrFail(id)
|
const picture = await ComputerPicture.query()
|
||||||
|
.where('computerId', computer.id)
|
||||||
|
.andWhere('id', id)
|
||||||
|
.firstOrFail()
|
||||||
const filePath = `uploads/computer_${computer.id}/${picture.link.split('/').pop()}`
|
const filePath = `uploads/computer_${computer.id}/${picture.link.split('/').pop()}`
|
||||||
if (existsSync(filePath)) {
|
if (existsSync(filePath)) {
|
||||||
unlinkSync(filePath)
|
unlinkSync(filePath)
|
||||||
|
|||||||
Reference in new issue
Block a user