diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index 7cf15ce77..cf4100eda 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -44,7 +44,7 @@ jobs: - name: Secret scan run: | echo "🔍 Scanning for secrets..." - cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n10 \ + cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts main..HEAD \ - name: Build run: | echo "🏗️ Building the solution..." diff --git a/.gitignore b/.gitignore index 056cf69d2..c1b184a87 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ DataDir/ *.log +# gitleaks scan output (local secret-scan report, not for the repo) +/gitleaks-report.json +/test-report.html +/test-report.xml + + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..168ff1424 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.24.2 + hooks: + - id: gitleaks + diff --git a/.vscode/launch.json b/.vscode/launch.json index d5088f01d..f3411003c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -43,9 +43,15 @@ }, { "name": "Yavsc Org", - "type": "dotnet", + "type": "coreclr", "request": "launch", - "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", + "program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "cwd": "${workspaceFolder}/src/Yavsc.Org", + "preLaunchTask": "build-web-yavsc-org" + }, { "name": "Yavsc Blogs", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index cbd3a744d..15ee3405b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -89,6 +89,34 @@ }, "isBackground": true }, + { + "label": "build-web-yavsc-org", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": ["build"], + "options": { + "cwd": "src/Yavsc.Org" + }, + "group": { + "kind": "build" + }, + "isBackground": true + }, + { + "label": "build-web-yavsc-blogs", + "type": "process", + "problemMatcher": ["$msCompile"], + "command": "dotnet", + "args": ["build"], + "options": { + "cwd": "src/Yavsc.Blogs" + }, + "group": { + "kind": "build" + }, + "isBackground": true + }, { "label": "dotnet: build-postit-desktop", "type": "process", diff --git a/CHANGELOG.md b/CHANGELOG.md index 85139db1b..885b95854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # Changelog +## [1.0.8-rc17] - unstable + +Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt + +API), système de génération `contrib` (vhosts nginx HTTP/3 edge + +appsettings jq + QUIC logiciel Kestrel), et correction du bug de +production où `IsPublished` disparaissait du payload de liste du blog API. + +### Added + +* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion + des fichiers de l'utilisateur, entrée depuis la page d'accueil. +* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis + le détail de demande et la page d'édition de devis. +* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis + (`EstimateApiController`) et les demandes front-office + (`FrontOfficeApiController`). +* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes + d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`. +* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`, + `BlogAttachedFile`, `AttachFileRequest` ; migration EF Core + `UserFileAttachments`. +* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge + (`template.vhost`, `QUIC_OPTS`), appsettings générés via jq + (`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des + secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et + `README.nginx.md`. +* [Tests] Projet `Yavsc.Server.Tests` : tests du service + `BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:` + par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`). +* [Tests] `IBlogPostWireShapeTests` (forme du wire blog), + `EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`, + `FileSystemApiTests` (pièces jointes et stockage perso). +* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`). + +### Changed + +* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en + production sur le vhost Org. La preprod et la prod partageant la même + IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) — + serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser + la config par nginx. +* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL + provient de la variable d'environnement + `YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en + source. + +### Fixed + +* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload + de la liste du blog API. Le endpoint `GetBlogspot` retourne + `IEnumerable` sérialisé par System.Text.Json d'après le type + déclaré (l'interface), qui omet les propriétés du type concret — + `IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur + `IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet, + y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13). + Corrigé en déclarant `IsPublished` sur `IBlogPost`. +* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de + `CommentsController` (Index/Details/…) atterrissaient sur la même route + `POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une + `SwaggerGeneratorException` (conflit method/path) au chargement de + `/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur + les actions scaffoldées. + ## [1.0.8-rc16] - unstable Flux de signature et de génération de devis : un devis peut désormais être diff --git a/contrib/Makefile b/contrib/Makefile index 4ba6059df..deb55bcc6 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -24,7 +24,7 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN @cat template.service | APP_NAME="$*" \ DESTDIR="$(DESTDIR)" \ HTTP_HOST="$(HTTP_HOST)" \ - HTTP_PORT="$*_$(HTTP_PORT)" \ + HTTP_PORT="$($*_PORT)" \ APPDIR="$(APPDIR)" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ POSTGRES_HOST="$(POSTGRES_HOST)" \ @@ -45,6 +45,76 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service +# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) --- +# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api} +# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr) +# +# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx +# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement +# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré). +NGINX_TEMPLATE := template.vhost +NGINX_OUT_DIR := generated/nginx +NGINX_HOSTS := Org Blogs Api +DOMAIN_ROOT ?= pschneider.fr +# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's +# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env). +BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http) +# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs +nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1))) +# domaine complet : . +# prod Org -> yavsc.pschneider.fr +# preprod Org -> preprod-yavsc.pschneider.fr +nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT) + +# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port +# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne +# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est +# vide sur tous les vhosts (sinon nginx refuse la config : duplicate +# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org). +$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env + @cat $(NGINX_TEMPLATE) \ + | DOMAIN="$(call nginx_domain,$*)" \ + BACKEND_SCHEME="$(BACKEND_SCHEME)" \ + HTTP_HOST="$(HTTP_HOST)" \ + BACKEND_PORT="$($*_PORT)" \ + QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \ + envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@ + @echo Created nginx vhost: $@ + +generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS)) + +generate-nginx-prod: + make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= + +$(NGINX_OUT_DIR): + @mkdir -p $@ + +# --- appsettings (générés depuis template.appsettings-*.json) --- +# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json +# +# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les +# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password. +# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un +# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel +# contient un \). jq lit le secret depuis l'environnement (export) -> +# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd. +# Les templates actuels sont issus de la preprod (préfixe preprod-, ports +# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe). +export ASPNETCORE_Smtp__Password +APPSETTINGS_OUT_DIR := generated/appsettings +APPSETTINGS_HOSTS := org api blogs + +$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env + @jq --arg domain "$(DOMAIN_ROOT)" \ + 'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \ + template.appsettings-$*.json > $@ + @echo Created appsettings: $@ + +generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS))) + +$(APPSETTINGS_OUT_DIR): + @mkdir -p $@ + install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service sudo systemctl daemon-reload sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$* diff --git a/contrib/README.nginx.md b/contrib/README.nginx.md new file mode 100644 index 000000000..9564d4ffe --- /dev/null +++ b/contrib/README.nginx.md @@ -0,0 +1,134 @@ +# Vhosts nginx — génération par `make generate-nginx` + +Les vhosts nginx sont **générés** à l'image des unités de service systemd +(`template.service` + `envsubst` → `generated/*.service`) : un template +unique `contrib/template.vhost` est substitué par `envsubst` vers +`contrib/generated/nginx/`, qui est **gitignoré**. + +## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`) + +nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec +h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`). +Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les +`template.appsettings-*.json`) est une capacité complémentaire — non +joignable par les clients derrière nginx (nginx OSS = upstream h1.1 +uniquement), mais disponible si Kestrel est un jour exposé en direct. + +Directives générées (par vhost) : +```nginx +listen 443 ssl; listen [::]:443 ssl; http2 on; +listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on; +add_header Alt-Svc 'h3=":443"; ma=86400' always; +ssl_protocols TLSv1.3; +``` +`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle +`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts +confondus). + +## Prérequis côté host + +1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;` + exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`, + `listen ... quic` suffit à activer h3) : + ```bash + nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide + ``` + Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon, + backports ou `nginx-full`. +2. **UDP 443 ouvert** : + ```bash + sudo ufw allow 443/udp + sudo ufw status | grep '443/udp' # → ALLOW + ``` +3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le + host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t` + échoue sur le bind IPv6). +4. Certbot / certs Let's Encrypt déjà en place (chemins + `/etc/letsencrypt/live//{fullchain,privkey}.pem` référencés). +5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` — + sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux). + +## Génération + +```bash +cd contrib + +# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api} +make generate-nginx + +# Production -> generated/nginx/yavsc{Org,Blogs,Api} +make generate-nginx-prod +# ou manuellement : +make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= +``` + +Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env` +(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend +`https` en prod, `http` en preprod. + +| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod | +|-------|--------------|-------------|-------------------------|------------------------| +| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` | +| Api | api | non | `https://localhost:87` | `http://localhost:93` | +| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` | + +Domaines : `.pschneider.fr` +(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`). + +## Déploiement sur le host + +```bash +sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg +sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg +# (idem yavscApi, yavscBlogs) +sudo nginx -t && sudo systemctl reload nginx +``` + +## Vérification (sur le host) + +```bash +# 1. Module h3 présent +nginx -V 2>&1 | grep -o 'http_v3_module' + +# 2. UDP 443 ouvert +sudo ufw status | grep '443/udp' + +# 3. En-tête Alt-Svc annoncé +curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443" + +# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3) +curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3' + +# 5. Fallback h2 et h1 toujours fonctionnels +curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n' +curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n' +``` +Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et +les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools → +Network → colonne « Protocole » → `h3` après mise à niveau. + +## Comment ça marche (template + envsubst) + +`contrib/template.vhost` contient les placeholders `$DOMAIN`, +`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle +`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue +via `envsubst` avec une **allowlist** : + +```makefile +envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS' +``` + +Cette allowlist limite la substitution aux 5 variables citées, de sorte +que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`, +`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans +allowlist, `envsubst` détruirait ces variables nginx. + +## Hors périmètre + +- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal + systemd + nginx). +- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que + HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`. +- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2 + côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP, + relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3). \ No newline at end of file diff --git a/contrib/template.appsettings-api.json b/contrib/template.appsettings-api.json new file mode 100644 index 000000000..b364eeef1 --- /dev/null +++ b/contrib/template.appsettings-api.json @@ -0,0 +1,33 @@ +{ + "Site": { + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": ["api"], + "CorsAllowedOrigins": [ + "https://preprod-blogs.$DOMAIN" + ], + "Bills": "bills", + "TempDir": "temp" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:92" + }, + "Https": { + "Url": "https://localhost:93", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.appsettings-blogs.json b/contrib/template.appsettings-blogs.json new file mode 100644 index 000000000..f522feaca --- /dev/null +++ b/contrib/template.appsettings-blogs.json @@ -0,0 +1,40 @@ +{ + "Site": { + "Title": "Yavsc Blogs", + "Description": "A collection of blogs about software development, technology, and programming.", + "Keywords": "software development, technology, programming, coding, blogs", + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": ["blogs"], + "CorsAllowedOrigins": [ + "https://preprod-api.$DOMAIN" + ], + "Blog": "blog" + }, + "ConnectionStrings": { + "YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Authentication": "Debug" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:90" + }, + "Https": { + "Url": "https://localhost:91", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.appsettings-org.json b/contrib/template.appsettings-org.json new file mode 100644 index 000000000..76203d9e9 --- /dev/null +++ b/contrib/template.appsettings-org.json @@ -0,0 +1,74 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information" + } + }, + "AllowedHosts": "*", + "Site": { + "Title": "yavsc", + "Slogan": "Yavsc!", + "StyleSheet": "/css/default.css", + "Authority": "https://preprod-yavsc.$DOMAIN", + "Audience": [ + "blogs", + "api" + ], + "ExternalUrl": "https://preprod-yavsc.$DOMAIN", + "ApiUrl": "https://preprod-api.$DOMAIN", + "CorsAllowedOrigins": [ + "https://preprod-blogs.$DOMAIN", + "https://preprod-api.$DOMAIN" + ], + "Banner": "/images/banner-dewang.jpg", + "Owner": { + "Name": "Paul Schneider", + "EMail": "paul@pschneider.fr" + }, + "Admin": { + "Name": "Paul Schneider", + "EMail": "paul@pschneider.fr" + }, + "Quota": 200000000, + "Blog": "blog", + "Avatars": "avatars", + "Bills": "bills", + "TempDir": "temp", + "DataDir": "data", + "GitRepository": "git" + }, + "Smtp": { + "Host": "mercure.pschneider.fr", + "Port": 465, + "SenderName": "Paul Schneider", + "SenderEmail": "paul@pschneider.fr", + "UserName": "paul", + "Password": "$SMTP_PASSWORD" + }, + "DataProtection": { + "Keys": { + "Dir": "DataProtection" + }, + "RSAParamFile": "RSA-Params.json", + "ExpiresInHours": 168 + }, + "Moderation": { + "AutoApproveThreshold": 0.7, + "AutoRejectThreshold": 0.9 + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:88" + }, + "Https": { + "Url": "https://localhost:89", + "Certificate": { + "Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem", + "KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem" + }, + "Protocols": "Http1AndHttp2AndHttp3" + } + } + } +} diff --git a/contrib/template.vhost b/contrib/template.vhost new file mode 100644 index 000000000..ea2c2cb6d --- /dev/null +++ b/contrib/template.vhost @@ -0,0 +1,61 @@ +# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER. +# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT +# +# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un +# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443 +# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel, +# cf. template.appsettings-*.json — capacité, non joignable derrière nginx). +# +# Variables substituées par envsubst (allowlist) : +# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS +# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production +# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport +# une seule fois par addr:port (443/udp) — or preprod et prod partagent la +# même IP, donc seule la prod (Org) l'active. +# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont +# préservées (hors allowlist). + +# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges +# ACME ; ne pas servir l'app en HTTP clair.) +server { + listen 80; + listen [::]:80; + server_name $DOMAIN; + return 301 https://$host$request_uri; +} + +server { + # 1. TCP : HTTPS classique + HTTP/2 + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + + # 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS). + listen 443 quic$QUIC_OPTS; + listen [::]:443 quic$QUIC_OPTS; + http3 on; + + server_name $DOMAIN; + + # 3. Certificats Let's Encrypt (Certbot). + ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot + # QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP : + # `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.) + ssl_protocols TLSv1.3; + + # 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur. + add_header Alt-Svc 'h3=":443"; ma=86400' always; + + location / { + proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 100s; + proxy_buffering off; + } + include server-status.conf; +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 46702b3a8..14516758d 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -11,7 +11,7 @@ false 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index bf967849b..f2b2a2bbf 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,7 +6,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 12e9a8143..46df5025f 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,7 +7,7 @@ enable 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs index db70e25fa..17efd391b 100644 --- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -22,7 +22,8 @@ public class EstimateEditionPageViewModelTests private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null) { var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - return new EstimateEditionPageViewModel(query ?? SampleQuery(), client); + var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/"); + return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient); } [Fact] @@ -143,10 +144,8 @@ public class EstimateEditionPageViewModelTests await vm.SendCommand.ExecuteAsync(null); - Assert.Equal("https://business.example/api/v1/estimate", api.LastPath); - Assert.Equal(HttpMethod.Post, api.LastMethod); - - var payload = Assert.IsType(api.LastBody); + var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); + var payload = Assert.IsType(postCall.Body); Assert.Equal(42, payload.CommandId); Assert.Equal("cli-1", payload.ClientId); Assert.Equal("Brush", payload.CommandType); @@ -216,7 +215,8 @@ public class EstimateEditionPageViewModelTests await vm.SendCommand.ExecuteAsync(null); - var payload = Assert.IsType(api.LastBody); + var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); + var payload = Assert.IsType(postCall.Body); Assert.Equal(2, payload.Bill.Count); Assert.Equal(-5m, payload.Bill[1].UnitaryCost); Assert.True(vm.HasSent); @@ -230,11 +230,20 @@ public class EstimateEditionPageViewModelTests public object? LastBody { get; private set; } public Exception? Failure { get; init; } - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + /// Historique complet des appels (le plus récent en dernier). + public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new(); + + private void Record(HttpMethod method, string path, object? body) { LastMethod = method; LastPath = path; LastBody = body; + Calls.Add((method, path, body)); + } + + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + { + Record(method, path, body); if (Failure is not null) { @@ -253,9 +262,7 @@ public class EstimateEditionPageViewModelTests public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { - LastMethod = method; - LastPath = path; - LastBody = body; + Record(method, path, body); return Task.CompletedTask; } @@ -265,6 +272,12 @@ public class EstimateEditionPageViewModelTests public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) => CallAsync(method, path, (object?)null, ct); + public Task DownloadAsync(HttpMethod method, string path, CancellationToken ct = default) + { + Record(method, path, null); + return Task.FromResult(Array.Empty()); + } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj index d4d16c29e..c00ea086f 100644 --- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj +++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj @@ -8,7 +8,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 2b243f2d5..9616e7089 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -22,6 +22,7 @@ public static class ServiceCollectionHelpers var api = new YavscApiClient(settings, tokenStore); var client = new BlogApiClient(api, settings.BlogsApiUrl); + var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); @@ -65,6 +66,7 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -77,6 +79,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(billingClient); services.AddSingleton(estimateClient); services.AddSingleton(frontClient); + services.AddSingleton(userFilesClient); services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); services.AddSingleton(); diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj index 98dcbe480..9cdf71299 100644 --- a/src/PostIt/PostIt/PostIt.csproj +++ b/src/PostIt/PostIt/PostIt.csproj @@ -5,7 +5,7 @@ latest 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index ca9a7ff65..687a7cbd2 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -56,6 +56,7 @@ public class ViewLocator : IDataTemplate EstimateEditionPageViewModel => services.GetRequiredService(), EstimateListPageViewModel => services.GetRequiredService(), EstimateValidationPageViewModel => services.GetRequiredService(), + MyFilesViewModel => services.GetRequiredService(), null => new TextBlock { Text = "No view for " }, _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } }; diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs index dbd84e5c2..f1b05b749 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs @@ -132,14 +132,18 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV { var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); var frontClient = app.ServiceProvider?.GetRequiredService(); + var fsClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( Activity, Performer, Form, _billingClient, frontClient, + fsClient, details, - IsReadOnly); + IsReadOnly, + canAttach: !IsReadOnly); + await vm.InitializeAsync().ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true); } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) diff --git a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs index 9ab1b42fd..c9f122dce 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -15,8 +16,17 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt { private readonly BillingApiClient _billingClient; private readonly FrontOfficeApiClient _frontClient; + private readonly UserFilesApiClient _fsClient; private readonly BillingQueryDetailsDto _details; + /// + /// Vrai lorsque l'utilisateur courant est le client de la demande + /// et peut y attacher/détacher des fichiers de son espace perso. + /// Le fournisseur consulte et télécharge les pièces mais n'attache + /// pas (il joint ses documents au devis, côté EstimateEditionPage). + /// + public bool CanAttach { get; } + public ActivityInfo Activity { get; } public ActivityUserDisplayItem Performer { get; } public CommandFormSummary Form { get; } @@ -52,6 +62,10 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt [ObservableProperty] public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); + /// Pièces jointes rattachées à la demande (par référence). + [ObservableProperty] + public partial ObservableCollection Attachments { get; set; } = new(); + public override bool CanNavigateNext { get => false; @@ -70,20 +84,27 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt CommandFormSummary form, BillingApiClient billingClient, FrontOfficeApiClient frontClient, + UserFilesApiClient fsClient, BillingQueryDetailsDto details, - bool isReadOnly) + bool isReadOnly, + bool canAttach) { Activity = activity ?? throw new ArgumentNullException(nameof(activity)); Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Form = form ?? throw new ArgumentNullException(nameof(form)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); + _fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient)); _details = details ?? throw new ArgumentNullException(nameof(details)); IsReadOnly = isReadOnly; + CanAttach = canAttach; this.SetInfoStatus("Details de commande charges."); } + /// Constructeur pour le designer Avalonia. + public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { } + [RelayCommand] private async Task OpenEditorAsync() { @@ -134,6 +155,110 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt await app.GoBackAsync().ConfigureAwait(true); } + /// Charge les pièces jointes de la demande. + public async Task InitializeAsync() + { + await LoadQueryAttachmentsAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task LoadQueryAttachmentsAsync() + { + try + { + var list = await _frontClient + .GetQueryAttachmentsAsync(BillingCode, Id) + .ConfigureAwait(true); + Attachments.Clear(); + if (list is not null) + foreach (var a in list) Attachments.Add(a); + } + catch (Exception ex) + { + // Non bloquant : les pièces jointes sont un complément. + this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}"); + } + } + + /// + /// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi + /// est rattaché à la demande par référence via + /// POST front/query/{id}/attachments. Réservé au client + /// (). + /// + [RelayCommand] + private async Task AddQueryAttachmentAsync() + { + if (!CanAttach) return; + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var billingCode = BillingCode; + var queryId = Id; + var picker = new MyFilesViewModel(_fsClient, async fileId => + { + try + { + await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true); + await LoadQueryAttachmentsAsync().ConfigureAwait(true); + this.SetInfoStatus("Pièce jointe ajoutée à la demande."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec de l'attachement : {ex.Message}"); + } + }); + await picker.InitializeAsync().ConfigureAwait(true); + await app.PushPageAsync(picker).ConfigureAwait(true); + } + + [RelayCommand] + private async Task DetachQueryFileAsync(AttachmentDto? attachment) + { + if (attachment is null || !CanAttach) return; + IsBusy = true; + try + { + await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true); + Attachments.Remove(attachment); + this.SetInfoStatus("Pièce jointe détachée de la demande."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du détachement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment) + { + if (attachment is null) return; + IsBusy = true; + try + { + var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true); + var name = System.IO.Path.GetFileName(attachment.Path ?? "file"); + var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true); + this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + /// /// Download the estimate linked to this command as a LaTeX source /// (GET api/v1/front/query/{Id}/estimate.tex). The estimate is diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs index 8f007dc08..1f1e4b4be 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs @@ -25,6 +25,7 @@ namespace PostIt.ViewModels; public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel { private readonly EstimateApiClient _estimateClient; + private readonly UserFilesApiClient _fsClient; private readonly BillingQuerySummaryDto _query; public long QueryId => _query.Id; @@ -68,6 +69,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus [ObservableProperty] public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); + /// Identifiant du devis, renseigné après l'envoi. + [ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))] + public partial long? EstimateId { get; set; } + + /// Pièces jointes rattachées au devis (par référence). + [ObservableProperty] + public partial ObservableCollection Attachments { get; set; } = new(); + public decimal Total => Lines.Sum(line => line.LineTotal); public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}"; @@ -86,10 +95,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus protected set { _ = value; } } - public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient) + public EstimateEditionPageViewModel( + BillingQuerySummaryDto query, + EstimateApiClient estimateClient, + UserFilesApiClient fsClient) { _query = query ?? throw new ArgumentNullException(nameof(query)); _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); + _fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient)); EstimateDescription = query.Description ?? string.Empty; Lines.CollectionChanged += OnLinesCollectionChanged; @@ -98,6 +111,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée."); } + /// Constructeur pour le designer Avalonia. + public EstimateEditionPageViewModel() : this(null!, null!, null!) { } + [RelayCommand] private void AddLine() { @@ -141,9 +157,11 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true); HasSent = true; + EstimateId = created.Id; OnPropertyChanged(nameof(SendLabel)); this.SetInfoStatus( $"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée."); + await LoadAttachmentsAsync().ConfigureAwait(true); } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) { @@ -171,6 +189,106 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus await app.GoBackAsync().ConfigureAwait(true); } + /// + /// Charge les pièces jointes rattachées au devis courant + /// (GET estimate/{id}/attachments). + /// + [RelayCommand] + private async Task LoadAttachmentsAsync() + { + if (EstimateId is null || _estimateClient is null) return; + try + { + var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true); + Attachments.Clear(); + if (list is not null) + foreach (var a in list) Attachments.Add(a); + } + catch (Exception ex) + { + this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}"); + } + } + + private bool CanAddAttachment() => EstimateId is not null && !IsBusy; + + /// + /// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi + /// est rattaché au devis par référence via + /// POST estimate/{id}/attachments. + /// + [RelayCommand(CanExecute = nameof(CanAddAttachment))] + private async Task AddAttachmentAsync() + { + if (EstimateId is null) return; + + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var estimateId = EstimateId.Value; + var picker = new MyFilesViewModel(_fsClient, async fileId => + { + try + { + await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true); + await LoadAttachmentsAsync().ConfigureAwait(true); + this.SetInfoStatus("Pièce jointe ajoutée au devis."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec de l'attachement : {ex.Message}"); + } + }); + await picker.InitializeAsync().ConfigureAwait(true); + await app.PushPageAsync(picker).ConfigureAwait(true); + } + + [RelayCommand] + private async Task DetachFileAsync(AttachmentDto? attachment) + { + if (attachment is null || EstimateId is null) return; + IsBusy = true; + try + { + await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true); + Attachments.Remove(attachment); + this.SetInfoStatus("Pièce jointe détachée du devis."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du détachement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task DownloadAttachmentAsync(AttachmentDto? attachment) + { + if (attachment is null) return; + IsBusy = true; + try + { + var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true); + var name = System.IO.Path.GetFileName(attachment.Path ?? "file"); + var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true); + this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + internal EstimateDto BuildPayload() { return new EstimateDto diff --git a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs index 75a5bdc9a..7fd84fd88 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs @@ -155,6 +155,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi .ConfigureAwait(true); var (activity, performer, form) = BuildNavigationContext(SelectedQuery); var frontClient = app.ServiceProvider?.GetRequiredService(); + var fsClient = app.ServiceProvider?.GetRequiredService(); var vm = new BillingQueryDetailsPageViewModel( activity, @@ -162,9 +163,12 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi form, _billingClient, frontClient, + fsClient, details, - isReadOnly: false); + isReadOnly: false, + canAttach: false); + await vm.InitializeAsync().ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true); } catch (Exception ex) @@ -243,7 +247,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi throw new InvalidOperationException("Application PostIt indisponible."); } - var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient); + var fsClient = app.ServiceProvider?.GetRequiredService(); + if (fsClient is null) + { + throw new InvalidOperationException("Client fichiers indisponible."); + } + + var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient); await app.PushPageAsync(vm).ConfigureAwait(true); } diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs index 9d466b26c..2c138a27d 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -35,12 +35,14 @@ public class HomePageViewModel : ViewModelBase OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client)); OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); + OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync); } public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenProviderRequests { get; } public IAsyncRelayCommand OpenClientEstimates { get; } public IAsyncRelayCommand OpenProviderEstimates { get; } + public IAsyncRelayCommand OpenMyFiles { get; } private async Task OpenActivitiesAsync() { @@ -101,6 +103,29 @@ public class HomePageViewModel : ViewModelBase await app.PushPageAsync(vm); } + /// + /// Ouvre la page « Mes fichiers » (espace de stockage personnel), + /// servie par le host Blogs via api/v1/fs. + /// + private async Task OpenMyFilesAsync() + { + var app = (App?)Application.Current; + if (app is null) + { + throw new InvalidOperationException("Application PostIt indisponible."); + } + + var fsClient = app.ServiceProvider?.GetRequiredService(); + if (fsClient is null) + { + throw new InvalidOperationException("Client fichiers indisponible."); + } + + var vm = new MyFilesViewModel(fsClient); + await vm.InitializeAsync(); + await app.PushPageAsync(vm); + } + /// /// Avalonia designer constructor. Builds a self-contained VM /// with a freshly-constructed Settings so the XAML preview can diff --git a/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs new file mode 100644 index 000000000..cc4b46828 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Helpers; +using Yavsc.Api.Client; + +namespace PostIt.ViewModels; + +/// +/// Gestion de l'espace de stockage personnel (« My Files ») : +/// navigation dans l'arbre, téléversement, suppression, téléchargement. +/// Sert aussi de **sélecteur** pour attacher un fichier à un devis +/// (fournisseur) ou à une demande (client) par référence : lorsque +/// est vrai, la sélection d'un fichier invoque +/// avec l'identifiant +/// puis revient à la page précédente. +/// +public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel +{ + private readonly UserFilesApiClient _fs; + private readonly Action? _onFilePicked; + + /// + /// Sous-répertoire courant, relatif à la racine de l'espace perso + /// (chaîne vide à la racine). Servi tel quel à GET/POST fs/{subdir}. + /// + [ObservableProperty] + public partial string CurrentPath { get; set; } = string.Empty; + + public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath) + ? "Mes fichiers" + : $"Mes fichiers / {CurrentPath}"; + + public bool IsPickerMode => _onFilePicked is not null; + + public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath); + + [ObservableProperty] + public partial ObservableCollection Files { get; set; } = new(); + + [ObservableProperty] + public partial ObservableCollection SubDirectories { get; set; } = new(); + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial string StatusMessage { get; set; } = "Prêt."; + + [ObservableProperty] + public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); + + public override bool CanNavigateNext { get => false; protected set { _ = value; } } + public override bool CanNavigatePrevious { get => true; protected set { _ = value; } } + + public MyFilesViewModel(UserFilesApiClient fs, Action? onFilePicked = null) + { + _fs = fs ?? throw new ArgumentNullException(nameof(fs)); + _onFilePicked = onFilePicked; + } + + /// Constructeur pour le designer Avalonia. + public MyFilesViewModel() : this(null!) { } + + public async Task InitializeAsync() + { + await RefreshAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task RefreshAsync() + { + IsBusy = true; + try + { + var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true); + Files.Clear(); + SubDirectories.Clear(); + if (dir?.Files is not null) + foreach (var f in dir.Files) Files.Add(f); + if (dir?.SubDirectories is not null) + foreach (var d in dir.SubDirectories) SubDirectories.Add(d); + this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s)."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Erreur de listage : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task OpenDirAsync(DirEntryDto? dir) + { + if (dir is null) return; + CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty); + OnPropertyChanged(nameof(PathLabel)); + OnPropertyChanged(nameof(CanGoUp)); + await RefreshAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task UpAsync() + { + if (!CanGoUp) return; + CurrentPath = ParentPath(CurrentPath); + OnPropertyChanged(nameof(PathLabel)); + OnPropertyChanged(nameof(CanGoUp)); + await RefreshAsync().ConfigureAwait(true); + } + + /// Téléversement déclenché par le code-behind (picker OS). + public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection uploads) + { + if (uploads is null || uploads.Count == 0) return; + IsBusy = true; + try + { + var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true); + this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s)."); + await RefreshAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléversement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task DeleteFileAsync(FileEntryDto? file) + { + if (file is null) return; + IsBusy = true; + try + { + await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true); + this.SetInfoStatus($"Fichier supprimé."); + await RefreshAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec de la suppression : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task DownloadFileAsync(FileEntryDto? file) + { + if (file is null || file.Id is null) return; + IsBusy = true; + try + { + var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true); + var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true); + this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé."); + } + catch (Exception ex) + { + this.SetErrorStatus($"Échec du téléchargement : {ex.Message}"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private async Task PickFileAsync(FileEntryDto? file) + { + if (file is null || file.Id is null) + { + this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord."); + return; + } + + var callback = _onFilePicked; + if (callback is null) + { + // Mode gestion : le téléchargement est l'action par défaut. + await DownloadFileAsync(file).ConfigureAwait(true); + return; + } + + callback(file.Id.Value); + await GoBackAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task BackAsync() + { + await GoBackAsync().ConfigureAwait(true); + } + + private static async Task GoBackAsync() + { + var app = (App?)Application.Current; + if (app is null) throw new InvalidOperationException("Application PostIt indisponible."); + await app.GoBackAsync().ConfigureAwait(true); + } + + private static string CombinePath(string current, string name) + { + if (string.IsNullOrWhiteSpace(current)) return name; + return $"{current}/{name}"; + } + + private static string ParentPath(string current) + { + if (string.IsNullOrWhiteSpace(current)) return string.Empty; + var idx = current.LastIndexOf('/'); + return idx < 0 ? string.Empty : current[..idx]; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml index 904f17350..4cbce9a20 100644 --- a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml +++ b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml @@ -1,6 +1,7 @@ + + + + + + /// public string ContentType { get; set; } + + /// + /// Propriétaire du fichier dans l'espace de stockage personnel. + /// Renseigné pour les fichiers téléversés via api/v1/fs + /// (référencés par / + /// ). Reste null pour les + /// pièces jointes de blog historiques (), + /// qui ne sont pas adressées par propriétaire. + /// + public string? OwnerId { get; set; } + + [ForeignKey("OwnerId")] + public virtual ApplicationUser? Owner { get; set; } } [PrimaryKey(nameof(FileId), nameof(PostId))] diff --git a/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs b/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs index 63627c116..602cc4bf0 100644 --- a/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs +++ b/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs @@ -43,11 +43,29 @@ namespace Yavsc.Server.Models.FileSystem } public string DestDir { get; set; } - + public string FileName { get; set; } public bool Overridden { get; set; } public bool QuotaOffense { get; set; } + + /// + /// Identifiant de la ligne + /// créée à l'enregistrement du fichier dans l'espace perso + /// (api/v1/fs). Nul si aucune métadonnée n'a été persistée. + /// + public long? FileId { get; set; } + + /// + /// Taille du fichier reçu (octets), pour le reflet côté métadonnées. + /// + public long Length { get; set; } + + /// + /// Type MIME du fichier reçu. + /// + public string ContentType { get; set; } + public string FullName { get => Path.Combine(DestDir, FileName); } } diff --git a/src/Yavsc.Server/Yavsc.Server.csproj b/src/Yavsc.Server/Yavsc.Server.csproj index b291847c0..61c426ec0 100644 --- a/src/Yavsc.Server/Yavsc.Server.csproj +++ b/src/Yavsc.Server/Yavsc.Server.csproj @@ -9,7 +9,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index 830377ca2..dda2c21c8 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -14,7 +14,7 @@ --> 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/src/cli/cli.csproj b/src/cli/cli.csproj index 39ef7660f..7b95a7e68 100644 --- a/src/cli/cli.csproj +++ b/src/cli/cli.csproj @@ -7,7 +7,7 @@ true 1.1.0.0 1.1.0.0 - 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e 1.1.0-estimate.1 diff --git a/yavsc.sln b/yavsc.sln index 6085a85fd..d7cffd18f 100644 --- a/yavsc.sln +++ b/yavsc.sln @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Server.Tests", "src\Yavsc.Server.Tests\Yavsc.Server.Tests.csproj", "{899B7240-729A-48BC-A993-300A6CCFCBEA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -243,6 +245,18 @@ Global {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.ActiveCfg = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.Build.0 = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.ActiveCfg = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.Build.0 = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.Build.0 = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.ActiveCfg = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.Build.0 = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.ActiveCfg = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -265,5 +279,6 @@ Global {59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE} {49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F} + {899B7240-729A-48BC-A993-300A6CCFCBEA} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} EndGlobalSection EndGlobal