diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index cf4100eda..7cf15ce77 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 main..HEAD \ + cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n10 \ - name: Build run: | echo "🏗️ Building the solution..." diff --git a/.gitignore b/.gitignore index c1b184a87..056cf69d2 100644 --- a/.gitignore +++ b/.gitignore @@ -43,9 +43,3 @@ 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 deleted file mode 100644 index 168ff1424..000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -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 f3411003c..d5088f01d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -43,15 +43,9 @@ }, { "name": "Yavsc Org", - "type": "coreclr", + "type": "dotnet", "request": "launch", - "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" - + "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", }, { "name": "Yavsc Blogs", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 15ee3405b..cbd3a744d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -89,34 +89,6 @@ }, "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 885b95854..85139db1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,68 +1,5 @@ # 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 deb55bcc6..4ba6059df 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="$($*_PORT)" \ + HTTP_PORT="$*_$(HTTP_PORT)" \ APPDIR="$(APPDIR)" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ POSTGRES_HOST="$(POSTGRES_HOST)" \ @@ -45,76 +45,6 @@ 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 deleted file mode 100644 index 9564d4ffe..000000000 --- a/contrib/README.nginx.md +++ /dev/null @@ -1,134 +0,0 @@ -# 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 deleted file mode 100644 index b364eeef1..000000000 --- a/contrib/template.appsettings-api.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index f522feaca..000000000 --- a/contrib/template.appsettings-blogs.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "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 deleted file mode 100644 index 76203d9e9..000000000 --- a/contrib/template.appsettings-org.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "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 deleted file mode 100644 index ea2c2cb6d..000000000 --- a/contrib/template.vhost +++ /dev/null @@ -1,61 +0,0 @@ -# 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 14516758d..46702b3a8 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index f2b2a2bbf..bf967849b 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 46df5025f..12e9a8143 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs index 17efd391b..db70e25fa 100644 --- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -22,8 +22,7 @@ public class EstimateEditionPageViewModelTests private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null) { var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/"); - return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient); + return new EstimateEditionPageViewModel(query ?? SampleQuery(), client); } [Fact] @@ -144,8 +143,10 @@ public class EstimateEditionPageViewModelTests await vm.SendCommand.ExecuteAsync(null); - 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("https://business.example/api/v1/estimate", api.LastPath); + Assert.Equal(HttpMethod.Post, api.LastMethod); + + var payload = Assert.IsType(api.LastBody); Assert.Equal(42, payload.CommandId); Assert.Equal("cli-1", payload.ClientId); Assert.Equal("Brush", payload.CommandType); @@ -215,8 +216,7 @@ public class EstimateEditionPageViewModelTests await vm.SendCommand.ExecuteAsync(null); - 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); + var payload = Assert.IsType(api.LastBody); Assert.Equal(2, payload.Bill.Count); Assert.Equal(-5m, payload.Bill[1].UnitaryCost); Assert.True(vm.HasSent); @@ -230,20 +230,11 @@ public class EstimateEditionPageViewModelTests public object? LastBody { get; private set; } public Exception? Failure { get; init; } - /// 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) + public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { 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) { @@ -262,7 +253,9 @@ public class EstimateEditionPageViewModelTests public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { - Record(method, path, body); + LastMethod = method; + LastPath = path; + LastBody = body; return Task.CompletedTask; } @@ -272,12 +265,6 @@ 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 c00ea086f..d4d16c29e 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 9616e7089..2b243f2d5 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -22,7 +22,6 @@ 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); @@ -66,7 +65,6 @@ public static class ServiceCollectionHelpers services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); // ViewModels services.AddSingleton(settings); @@ -79,7 +77,6 @@ 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 9cdf71299..98dcbe480 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index 687a7cbd2..ca9a7ff65 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -56,7 +56,6 @@ 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 f1b05b749..dbd84e5c2 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueriesPageViewModel.cs @@ -132,18 +132,14 @@ 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, - canAttach: !IsReadOnly); - await vm.InitializeAsync().ConfigureAwait(true); + IsReadOnly); 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 c9f122dce..9ab1b42fd 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/BillingQueryDetailsPageViewModel.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; using Avalonia; @@ -16,17 +15,8 @@ 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; } @@ -62,10 +52,6 @@ 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; @@ -84,27 +70,20 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt CommandFormSummary form, BillingApiClient billingClient, FrontOfficeApiClient frontClient, - UserFilesApiClient fsClient, BillingQueryDetailsDto details, - bool isReadOnly, - bool canAttach) + bool isReadOnly) { 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() { @@ -155,110 +134,6 @@ 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 1f1e4b4be..8f007dc08 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/EstimateEditionPageViewModel.cs @@ -25,7 +25,6 @@ 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; @@ -69,14 +68,6 @@ 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"}"; @@ -95,14 +86,10 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus protected set { _ = value; } } - public EstimateEditionPageViewModel( - BillingQuerySummaryDto query, - EstimateApiClient estimateClient, - UserFilesApiClient fsClient) + public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient) { _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; @@ -111,9 +98,6 @@ 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() { @@ -157,11 +141,9 @@ 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) { @@ -189,106 +171,6 @@ 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 7fd84fd88..75a5bdc9a 100644 --- a/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Activity/ProviderOngoingRequestsPageViewModel.cs @@ -155,7 +155,6 @@ 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, @@ -163,12 +162,9 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi form, _billingClient, frontClient, - fsClient, details, - isReadOnly: false, - canAttach: false); + isReadOnly: false); - await vm.InitializeAsync().ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true); } catch (Exception ex) @@ -247,13 +243,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi throw new InvalidOperationException("Application PostIt indisponible."); } - var fsClient = app.ServiceProvider?.GetRequiredService(); - if (fsClient is null) - { - throw new InvalidOperationException("Client fichiers indisponible."); - } - - var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient); + var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient); 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 2c138a27d..9d466b26c 100644 --- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs @@ -35,14 +35,12 @@ 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() { @@ -103,29 +101,6 @@ 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 deleted file mode 100644 index cc4b46828..000000000 --- a/src/PostIt/PostIt/ViewModels/MyFiles/MyFilesViewModel.cs +++ /dev/null @@ -1,229 +0,0 @@ -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 4cbce9a20..904f17350 100644 --- a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml +++ b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml @@ -1,7 +1,6 @@ - - - - - - /// 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 602cc4bf0..63627c116 100644 --- a/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs +++ b/src/Yavsc.Server/Models/FileSystem/FileReceivedInfo.cs @@ -43,29 +43,11 @@ 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 61c426ec0..b291847c0 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 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 dda2c21c8..830377ca2 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/src/cli/cli.csproj b/src/cli/cli.csproj index 7b95a7e68..39ef7660f 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+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d 1.1.0-estimate.1 diff --git a/yavsc.sln b/yavsc.sln index d7cffd18f..6085a85fd 100644 --- a/yavsc.sln +++ b/yavsc.sln @@ -41,8 +41,6 @@ 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 @@ -245,18 +243,6 @@ 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 @@ -279,6 +265,5 @@ 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