diff --git a/.env.sample b/.env.sample index e94748a2e..65926e57f 100644 --- a/.env.sample +++ b/.env.sample @@ -15,7 +15,7 @@ POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=db POSTGRES_USER=pguser -POSTGRES_PASSWORD= +POSTGRES_PASSWORD=pgpassword ANTHROPIC_API_KEY=none ANTHROPIC_MAX_TOKENS=0 diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml index e85a9b167..67b743fb5 100644 --- a/.forgejo/workflows/buildAndTest.yml +++ b/.forgejo/workflows/buildAndTest.yml @@ -27,13 +27,13 @@ jobs: build: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 steps: - name: Clone yavsc run: | set -e cd /src - git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src + git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src cd _src if [ -n "${GITHUB_REF:-}" ]; then git fetch origin "$GITHUB_REF" @@ -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..." @@ -53,14 +53,5 @@ jobs: run: | echo "🚀 Lancement des tests..." cd /src/_src && dotnet test \ - --report-xunit \ - --report-xunit-html \ - --report-xunit-html-filename "test-results.html" - TEST_EXIT_CODE=$? - echo "Test exit code: $TEST_EXIT_CODE" - if [ $TEST_EXIT_CODE -ne 0 ]; then - echo "❌ Tests failed with exit code $TEST_EXIT_CODE" - exit $TEST_EXIT_CODE - else - echo "✅ All tests passed successfully." - fi + --verbosity normal \ + --filter="Category!=Platform-Android" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index de61bd43d..7b17bba42 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -52,7 +52,7 @@ jobs: release: runs-on: docker container: - image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 + image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2 steps: - name: Clone du repo au tag demandé env: @@ -69,7 +69,7 @@ jobs: cd /src if [[ ! -d _src/.git ]]; then - git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src + git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src fi cd _src diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 52d500e18..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,3 +0,0 @@ -# Conventions du projet - -- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer. diff --git a/.github/workflows/docker-publish-backend.yml b/.github/workflows/docker-publish-backend.yml index 2d84b767f..d8466bdb8 100644 --- a/.github/workflows/docker-publish-backend.yml +++ b/.github/workflows/docker-publish-backend.yml @@ -8,7 +8,8 @@ on: jobs: production-deploy: - runs-on: ubuntu-26.04 + runs-on: ubuntu-latest + steps: # 1. Récupération du code - name: Check out the repo @@ -24,24 +25,8 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: 10.0.x - - name: Workloads restore - run: dotnet workload restore - name: Test - env: - TEST_FAILED: "" - run: make test || export TEST_FAILED=$? - - uses: actions/upload-artifact@v7 - with: - name: my-artifact - path: test-reports/**/* - - name: Fail if tests failed - if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }} - run: exit $TEST_FAILED - + run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android" # 4. Build et Push de l'image de production finale - name: Build and push production image uses: docker/build-push-action@v7 diff --git a/.gitignore b/.gitignore index 57f13079f..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 - -# Exclude test reports (local test report, not for the repo) -/test-reports -/doc/**/.*.md 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 87cf40dbb..d5088f01d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,9 +22,11 @@ }, { "name": "Android Debug", - "type": "maui", - "request": "launch", - "project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj" + "type": "mono", + "preLaunchTask": "run-debug-android", + "request": "attach", + "address": "localhost", + "port": 55555 }, { "name": "Android Attach - Debug", @@ -41,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/settings.json b/.vscode/settings.json index e6f2aee1d..83a17ae38 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -45,10 +45,5 @@ ] } }, - "dotnet.defaultSolution": "yavsc.sln", - "axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln", - "chat.tools.terminal.autoApprove": { - "dotnet build": true, - "dotnet test": true - } + "dotnet.defaultSolution": "yavsc.sln" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0b0bb63a5..71384cd8c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,17 +1,27 @@ { "version": "2.0.0", "isRoot": true, - + "problemMatcher": [ + { + "owner": "dotnet", + "fileLocation": ["relative", "${workspaceFolder}"], + "source": "dotnet", + "pattern": { + "regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + } + ], "tasks": [ { - "label": "build-debug-android", + "label": "run-debug-android", "command": "dotnet", - "detail": "Build the PostIt Android project in debug mode", "type": "shell", - "runOptions": { - - }, - "problemMatcher": ["$msCompile"], "options": { "cwd": "${workspaceFolder}/src/PostIt/PostIt.Android", "env": { @@ -33,7 +43,6 @@ "label": "build", "command": "dotnet", "type": "process", - "problemMatcher": ["$msCompile"], "args": ["build", "/property:GenerateFullPaths=true"], "group": "build", "isBuildCommand": true, @@ -93,34 +102,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", @@ -128,7 +109,6 @@ "isTestCommand": false, "isBackground": true, "command": "dotnet", - "problemMatcher": ["$msCompile"], "args": ["build", "/property:GenerateFullPaths=true"], "options": { "cwd": "src/PostIt/PostIt.Desktop" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3320ee74f..3f8253c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,464 +1,5 @@ # Changelog -## [1.0.8-rc25] - unstable - -### Added - -* [Yavsc.Api] Notification du performer à la création d'un rendez-vous - depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org. -* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature - client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt - et transmise par e-mail et SignalR. -* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me` - pour exposer les notifications publiques et ciblées à PostIt sur la - Home page ; les notifications d'administration restent filtrées. -* [PostIt] Affichage des notifications sur la page d'accueil, avec - actualisation, indicateur de chargement et message d'erreur. Les - notifications publiques sont accessibles sans connexion et la liste - est rechargée lors d'un changement de session. - -### Changed - -* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ; - les liens provisoires des nouveaux billets sont résolus au premier - enregistrement. Save conserve l'édition ouverte et est mis en valeur - lorsque le titre, le corps ou les pièces jointes sont modifiés. - -* [Projet] Instructions Copilot persistantes : mise à jour du changelog - par défaut à chaque commit, avec justification explicite des exceptions. - -### Fixed - -* [PostIt.Tests] Les tests du bandeau de session utilisent le bandeau - réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché. - Les fenêtres sont fermées après chaque test et les quatre boutons - de navigation sont vérifiés. - -* [PostIt] Les liens Markdown des pièces jointes utilisent désormais - le même filtre de nom que l'upload serveur, avant l'encodage URL. - Le libellé conserve le nom original, notamment ses accents. - -* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés - dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour - éviter les fragments d'URL et permettre le téléchargement via les - fichiers statiques, notamment pour les noms accentués. - -* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont - désormais enregistrés côté serveur pour les requêtes authentifiées : - le middleware de télémétrie est maintenant branché après - l'authentification, ce qui permet de comptabiliser `ApiCalls` et - `BandwidthMb` au lieu d'enregistrer des zéros. - -## [1.0.8-rc24] - unstable - -### Added - -* [PostIt] Reconnection automatique aux salons de discussion au reconnect - -### Changed - -* [PostIt] La liste des billets et l'éditeur Markdown se partagent - l'espace disponible et défilent séparément ; un séparateur permet - de redimensionner les deux zones. - -### Fixed - -* [PostIt] Défilement des articles longs en édition et en aperçu : - suppression de la limite de hauteur qui masquait le contenu Markdown - et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless - couvrent les barres automatiques, le redimensionnement et le - défilement indépendant de la liste. - -## [1.0.8-rc23] - unstable - -### Added - -* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la - liste, mis à jour lors de la publication ou du retour en brouillon. -* [PostIt] Gestion native du compte inspirée de `/Manage` : modification - du nom d'utilisateur, du nom complet et de l'adresse, téléversement - d'avatar, confirmation e-mail, préférence de courriel mensuel, - création ou changement de mot de passe et activation/désactivation - de l'authentification à deux facteurs. -* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des - comptes bancaires, dissociation des connexions externes et suppression - du compte avec confirmation du nom d'utilisateur. -* [PostIt] Configuration du profil professionnel et des activités, - navigation vers les cercles et les publications depuis le compte. -* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche - dans les métadonnées du profil et consultation de la consommation - de ressources. -* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de - ressources, API de synthèse et migration `ResourceUsageRecords`. -* [Tests] Couverture des parcours profil et professionnel, de l'API de - consommation de ressources et du renouvellement des jetons : - rotation à usage unique, appels simultanés, réponses 401 concurrentes - et rejet d'un jeton de renouvellement révoqué. - -### Changed - -* [PostIt] Réorganisation de la page de compte en sections de profil, - sécurité et préférences ; actions de modification désactivées tant - que le profil n'est pas chargé ou qu'une opération est en cours. -* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark` - dans les pages de profil et de configuration professionnelle. -* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par - PostIt, avec l'état de sécurité, les connexions externes, les comptes - bancaires et les compteurs du compte. - -### Fixed - -* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores - EF nécessaires à `UserManager` : leur absence - empêchait l'activation du contrôleur de compte et provoquait une 500. -* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton - ne contient pas d'identifiant utilisateur ou que le compte n'existe - plus, au lieu d'une exception serveur. -* [PostIt] Synchronisation de tous les chemins de renouvellement des - jetons, y compris après une 401 et au démarrage : les appels - concurrents ne réutilisent plus le même `refresh_token`. Un rejet - `invalid_grant` efface la session et invite à se reconnecter. -* [PostIt] Effacement des informations obsolètes après un échec de - rechargement du profil et normalisation des valeurs de calendrier - lors de la sauvegarde. -* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel - lors de son enregistrement. -* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir - d'un résumé agrégé : opérations dédiées avec contrôle de propriété - et validation de longueur des champs. - -### Known limitations - -* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des - crédits ne sont pas encore disponibles dans l'interface native. - -## [1.0.8-rc22] - unstable - -### Fixed - -User experience login into PostIt - -## [1.0.8-rc21] - unstable - -### Fixed - -User experience editing the blog article. - -## [1.0.8-rc20] - unstable - -Chat SignalR fonctionnel de bout en bout (connexion, salons multiples, -envoi/réception de messages), enregistreur audio et localisation dans -PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx -apprêté pour WebSocket. - -### Added - -* [PostIt] Interface de chat : page `ChatPage` (connexion au hub, - saisie du nom de salon, liste des messages et des membres présents), - entrée depuis la page d'accueil. -* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage` - (Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec - envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en - focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`). -* [PostIt] Modèle multi-salles côté client : `JoinedRooms`, - `SelectedRoom` (salle en focus), messages et utilisateurs indexés par - salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la - salle active ; un `Join` sur une salle déjà jointe ne fait que - changer le focus. -* [PostIt] Enregistreur audio (`AudioRecorder`, interface - `IPCMProcessor`) et ressources de localisation - (`Resources.{resx,en-EN,pt-PT}`). -* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à - chaud ; encore KO). -* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade - WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et - `proxy_buffering off` — sans quoi la connexion SignalR tombait au - premier silence ou l'upgrade échouait derrière nginx. - -### Changed - -* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et - enrichi d'une commande d'action optionnelle (`CheckCommand` + - `CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut - propose désormais l'action contextuelle (Connecter / Déconnecter / - Reconnecter / Vérifier la connexion). -* [PostIt] `ChatViewModel` suit le cycle de vie complet de la - connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de - `WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un - `IsConnected` optimiste. -* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` : - `ChatRoomInfo` n'expose que des champs publics, que System.Text.Json - ne sérialise pas par défaut — le client recevait des objets vides - (`Name` null, `Users` vide) et la liste des salons joints n'était - jamais alimentée. -* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName` - en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le - relire depuis un dictionnaire jamais alimenté. -* [PostIt.Android] `network_security_config.xml` déplacé sous - `Resources/xml/` (chemin canonique Android). - -### Fixed - -* [Yavsc.Server] `KeyNotFoundException` dans - `HubConnectionManager.OnConnected` à chaque connexion SignalR : - `ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique - d'un `SetUserName` disparu lors d'une refactorisation. L'exception - dans `OnConnectedAsync` faisait fermer la connexion par le hub, et - PostIt affichait « Déconnecté (The server closed the connection…) » - immédiatement après la connexion. -* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de - `ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à - la racine du service, pas sous le préfixe versionné). -* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"` - sur une grille à 3 rangées (rangée hors limites, bandeau invisible). - -## [1.0.8-rc19] - unstable - -[PostIt] Blogs UX - -### Fixed - -La saisie du billet est maintenant plutôt possible, -en petit écran. - -## [1.0.8-rc18] - unstable - -* [PostIt] Page "About" avec lines vers le dépôt et les issues Forgejo - -* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout -(arbre de stockage indexé par le login, pas par le `sub`), boutons -d'action des listes réparés (binding `$parent`), revendication `name` -enfin présente dans les access tokens (branchement du `ProfileService`), -et négociation HTTP/3 côté client PostIt. - - -### Added - -* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc : - `DefaultRequestVersion = 3.0`, `DefaultVersionPolicy = - RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`, - avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/ - desktop sans libmsquic). -* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via - `NavigationPage` + `Window.Show()` et vérifie que chaque bouton de - ligne (Télécharger / Supprimer) a sa `Command` liée, est armé - (`CanExecute`) et déclenche l'appel API au clic. -* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` : - mint un vrai JWT via le token endpoint IdentityServer (password grant, - `openid profile`) et asserte que l'access token porte `name`=login et - que `GetUserName()` résout vers ce login. -* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` : - découple `sub` et login, téléverse sous le login, et asserte que - `GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après - le `sub` n'existe pas). - -### Changed - -* [Yavsc.Org] Branchement du `ProfileService` custom - (`AddProfileService()` après - `AddAspNetIdentity()`). La classe était du code mort : - IdentityServer8 retombait sur son profile service par défaut, qui - n'émet que les claims listés dans les `UserClaims` des API resources. - `name` n'en faisait jamais partie, donc tout access token était sans - `name` et `GetUserName()` résolvait à `null` sur les hosts ressources - (Blogs/Api) — cause du 500 sur l'upload MyFiles et du - `User.Identity.Name` injoignable. Le service émet désormais - `name`=`user.UserName`, `email` et les rôles pour tout client dont - les `AllowedScopes` contiennent `profile`, indépendamment du caller. -* [Yavsc.Server] `ServiceExtensions` : `NameClaimType = - JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms - courts de claims tels qu'émis). -* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise - `GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour - la racine de stockage par utilisateur. -* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ; - 1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client - de test ; réensemencement des identity resources openid/profile dans - `ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store - in-memory partagé et les laissait absentes → `invalid_scope`). -* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel - (défaut = subject) pour refléter la forme production où - `sub` (GUID) ≠ `name` (login). -* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour - sérialiser avec le build d'hôte qui mute les registres statiques - `BillingService`/`Config` (race parallèle pré-existante). - -### Fixed - -* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous - `{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de - stockage perso est indexé par le login partout ailleurs - (`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`, - le endpoint de téléchargement et les avatars utilisent - `user.UserName`). Une fois l'upload corrigé, les fichiers - atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait - sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt - « Mes fichiers » qui ne listait rien. `GetDir` résout désormais - l'utilisateur en base et liste sous `user.UserName`. - `UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds` - interpole toujours par `uid`. -* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au - clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach - sur les listes de pièces jointes de `BillingQueryDetailsPage` et - `EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd` - cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel, - pas un contrôle — donc résolvait à `null` et le `Button` restait - désactivé (grisé) et inerte au clic. Corrigé en - `$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe - déjà en vigueur dans `CirclesPage` / `EstimateListPage`). -* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient - pas sélectionnables/copiables : le texte était un `TextBlock` (non - sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`. -* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`) - renvoyait un 40x alors que le fichier était bien supprimé du disque. - `DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient - `FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length` - lève `FileNotFoundException` une fois le fichier disparu — le `catch` - du contrôleur transformait cette exception en 400, d'où le message - perçu « le fichier n'existe pas sur le serveur » tandis que la - suppression avait réussi. La taille est désormais capturée avant la - suppression. -* PostIt default settings - -## [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 -signé par les deux parties (fournisseur et client) puis téléchargé en PDF -ou TeX depuis PostIt, avec les signatures dessinées en vecteur. - -### Added - -* [PostIt] Pad de signature sur la page de validation d'un devis : on valide - ou on refuse en joignant une signature (côté fournisseur ou client), capturée - au format filaire PostIt (strokes) et envoyée sur l'acceptation. -* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` : - autorisation selon le rôle (fournisseur/client/admin), signature optionnelle - persistée sur l'estimate avec horodatage `ProviderValidationDate` / - `ClientValidationDate` selon le côté signataire. -* [Yavsc.Api] `QueryStatus.Accepted` éclaté en `ProAccepted` / `ClientAccepted` - pour distinguer l'acceptation de chaque partie. -* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et - `estimate.pdf` (clé par id de query). Le flux de génération TeX/PDF est - rapatrié de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`, - `BillViewComponent`, modèles Bill/Estimate, `_ViewImports` ; `Program.cs` - câble `AddControllersWithViews` + `SiteSettings`). -* [Yavsc.Api] Signatures du devis dessinées en TikZ vectoriel - (`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client - côte à côte dans deux minipages ; plus de dépendance à un PNG de signature. -* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` / - `GetEstimatePdfAsync` ; boutons « Télécharger PDF » / « Télécharger TeX » - dans PostIt sur la page de validation du devis et le détail de commande, - avec un helper `StorageProvider` d'enregistrement de fichier. -* [PostIt] La liste des devis se rafraîchit au retour de navigation (pop-back), - de sorte qu'un devis validé quitte immédiatement la liste du signataire. -* [PostIt] Renommage des titres de liste : « Devis en attente de signature - fournisseur » / « Devis en attente de signature client ». -* [Yavsc.Abstract] `SignatureType` déplacé dans `Yavsc.Abstract.Billing`. -* [CLI] Option `-c configuration-file.json` au lancement. -* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline - signature → TikZ → PDF (devis signé des deux côtés), assertant que le PDF - existe au chemin de sortie attendu et que seul le PDF persiste (aux/log - nettoyés) ; skip propre si `lualatex` est absent. -* [README] Dépendances système au runtime pour la génération PDF : - `texlive-binaries` (lualatex), `texlive-luatex` (luaotfload), - `texlive-pictures` (tikz), `texlive-fonts-extra` (bera). - -### Changed - -* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre désormais sur - `ProviderValidationDate` : un devis signé par le fournisseur quitte sa liste - « en attente ». -* [Yavsc.Api] La génération PDF compile via le `lualatex` système, le TeX fourni - sur l'entrée standard (`/dev/stdin`), le PDF écrit directement dans le - répertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyés - afin que seul le `.pdf` persiste. - -### Fixed - -* [Yavsc.Api] `Emergency stop` à la génération PDF : `lualatex` sans argument - fichier lit stdin en mode terminal et s'arrête après la première ligne d'un - document multi-lignes. Corrigé en passant `/dev/stdin` comme nom de fichier - d'entrée — stdin est alors traité comme un fichier régulier et le document - multi-lignes compile. C'était la cause racine de l'échec en production, pas - `bera` ni `luaotfload` (problèmes réels mais séparés, comblés côté paquets). -* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif - `Site.Bills` (`"bills"`) était imbriqué quand il servait à la fois de - `-output-directory` et de répertoire de travail ; résolu en chemin absolu. -* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model` - nuls) : reconstruit sur le `ControllerContext` du contrôleur ; `IViewEngine` - résolu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine` - unique). -* [Yavsc.Api] `Layout = "null"` (chaîne) dans les modèles TeX cherchait - `null.cshtml` ; corrigé en `Layout = null` (null C#). -* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadapté au LaTeX) remplacé par - `lualatex`. -* [Yavsc.Api] Les diagnostics lualatex étaient perdus (écrits dans le `.log` - qu'on supprimait) ; stdout/stderr sont désormais capturés dans le message - d'erreur, et le `.log` est conservé en cas d'échec. - ## [1.0.8-rc15] - unstable ### Added @@ -828,10 +369,6 @@ the ACL now comes along with the article, the same user-visible switch without a schema change. [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD -[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16 -[1.0.8-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17 -[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18 -[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 [1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 diff --git a/Directory.Packages.props b/Directory.Packages.props index b0b055b98..f1be93f91 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,71 +4,53 @@ 8.1.0-pazofrc007 - - - + + - + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - + + + + - + + + + + - - - - - - - - - - - - - - - - - - - - - diff --git a/Dockerfile b/Dockerfile index 8ebbb73f0..795b70ab3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,24 +24,41 @@ # ===================================================================== # syntax=docker/dockerfile:1.7 -ARG BUILD_ENV_TAG=debian13-dotnet10-android36-jdk21-v2 +ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1 FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env WORKDIR /src +# (1) Manifest + fichiers de projet pour le cache de restore +COPY *.sln ./ +COPY Directory.*.props ./ +COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/ +COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/ +COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/ +COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/ +COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/ +COPY src/cli/*.csproj ./src/cli/ +COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/ +COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/ +COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/ +COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/ +COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ + +# (2) Tout le code source COPY . . +# (4) Restore RUN dotnet restore -# Build des trois services web principaux +# (5) Build des trois services web principaux RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly -# Build APK Android (utilisé par le workflow docker-publish-android). +# (6) Build APK Android (utilisé par le workflow docker-publish-android). ARG ANDROID_TARGET_RID=android-arm64 RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID} -# Publish des trois services web (les artifacts sont consommés par +# (7) Publish des trois services web (les artifacts sont consommés par # les stages publish-org / publish-api / publish-blogs ci-dessous). RUN mkdir -p /app/publish RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org diff --git a/Dockerfile.backend b/Dockerfile.backend index 46c9a982e..a4e9a54a9 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,12 +1,36 @@ # Utilisation de l'image globale pour le build et l'exécution -FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env +FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env WORKDIR /src + # 1. Copie de la solution +COPY *.sln ./ +COPY Directory.*.props ./ + +# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration +COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/ +COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/ +COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/ +COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/ +COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/ +COPY src/cli/*.csproj ./src/cli/ +COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/ + +# Projets associés à PostIt +COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/ +COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/ +COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/ +COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/ + +# 4. Copie de l'intégralité du code source COPY . . -RUN dotnet workload restore + # 4. Restauration des dépendances pour tous les projets RUN dotnet restore + +# 5. Copie de la totalité du code source (filtrée par votre .dockerignore) +COPY . . + # 6. Compilation du serveur Web principal RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly diff --git a/Makefile b/Makefile index 04efba0be..d99fe5e5e 100644 --- a/Makefile +++ b/Makefile @@ -5,43 +5,19 @@ DESTDIR=/tmp/yavsc APP_PATH=srv/www/yavsc APP_FULL_PATH=$(DESTDIR)/$(APP_PATH) +include .env + all: dotnet build --nologo clean: dotnet clean -c $(CONFIG) -brutal_clean: - @find -name "obj" -exec rm -rf {} \; - @find -name "bin" -exec rm -rf {} \; - src/Yavsc/bin/output/wwwroot: dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish test: - ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \ - --report-xunit-html \ - --results-directory "test-reports" - -test-postit: - ASPNETCORE_ENVIRONMENT=Development dotnet test \ - --report-xunit \ - --report-xunit-html \ - --results-directory "test-reports" \ - --report-xunit-html-filename "postit-test-results.html" \ - --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj - -backend-tests: Blogs-backend-test Org-backend-test Api-backend-test -Api-backend-test: -Blogs-backend-test: -Org-backend-test: -%-backend-test: - ASPNETCORE_ENVIRONMENT=Development dotnet test \ - --report-xunit \ - --report-xunit-html \ - --results-directory "test-reports" \ - --report-xunit-html-filename "$*-backend-test-results.html" \ - --project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj + ASPNETCORE_ENVIRONMENT=Development dotnet test watch: dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj diff --git a/README.md b/README.md index 79e711258..549348f4a 100644 --- a/README.md +++ b/README.md @@ -61,40 +61,9 @@ make test ### les services et l'API -#### Dépendances système au runtime - -La génération des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`, -`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande -système `lualatex` : le TeX est fourni sur l'entrée standard et le PDF est écrit -directement dans le répertoire des factures (`Site.Bills`). `lualatex` doit donc -être installé et opérationnel sur l'hôte qui exécute l'API — typiquement le service -systemd `yavscApi`. - -Sous Debian/Ubuntu : - -```bash -sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra -``` - -- `texlive-binaries` fournit l'exécutable `lualatex` (collection basic). -- `texlive-luatex` fournit les paquets de support chargés au démarrage de lualatex, - notamment `luaotfload`. Sans lui, lualatex démarre mais s'arrête sur - `module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF. - (Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers - `texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.) -- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures - du client et du fournisseur en bas du devis (vecteur, pas de PNG). -- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis. - Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found` - (`Emergency stop`, aucun PDF). - -Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF -generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet -de compiler le document hors-ligne. - ### La Prod -`cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`. +`cd srv/Yavsc` : `make pushInProd CONFIGURATION=Release`. puis, pour une première installation `make install_service`. @@ -232,12 +201,6 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d * ~/Views/Home/Index.cshtml * ~/Views/Home/About.cshtml -# 👥 Crédits & Co-auteurs - -Ce projet a été développé de manière hybride : -- **Conception, architecture et revue de code :** Paul Schneider -- **Assistance au code et optimisation :** IA générative (Gemini, Google, Claude (Anthropic), https://qoder.com/ (ex-Windsurf)) - # Remerciements Photo de Dewang Guptasur Unsplash diff --git a/contrib/Makefile b/contrib/Makefile index 5ccf773a1..4e4ef4df6 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -1,32 +1,23 @@ -DESTDIR= -DOTNET_FRAMEWORK=net10.0 APP_PROJECT_NAMES=Org Blogs Api SLNDIR=.. -APP_INSTALL_ENV=preprod -APP_INSTALL_PREFIX=preprod- -include .$(APP_INSTALL_ENV).env +include $(SLNDIR)/.env +include .env -reinstall: copy-binaries - @for project in $(APP_PROJECT_NAMES); do \ - echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ - sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done - @echo "💯 backend started" +generated/: + @mkdir -p $@ -reinstall-prod: - make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production +generated/yavscOrg.service: +generated/yavscBlogs.service: +generated/yavscApi.service: -generated/$(APP_INSTALL_PREFIX)yavscOrg.service: -generated/$(APP_INSTALL_PREFIX)yavscBlogs.service: -generated/$(APP_INSTALL_PREFIX)yavscApi.service: - -generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLNDIR)/.env +generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env @cat template.service | APP_NAME="$*" \ DESTDIR="$(DESTDIR)" \ HTTP_HOST="$(HTTP_HOST)" \ - HTTP_PORT="$($*_PORT)" \ - APPDIR="$(APPDIR)" \ - APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ + HTTP_PORT="$*_$(HTTP_PORT)" \ + BASEAPPDIR="$(BASEAPPDIR)" \ + APP_SYSLOG_ID="yavsc$*" \ POSTGRES_HOST="$(POSTGRES_HOST)" \ POSTGRES_PORT="$(POSTGRES_PORT)" \ POSTGRES_DB="$(POSTGRES_DB)" \ @@ -43,83 +34,11 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN envsubst > $@ @echo Created service file: $@ -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$* - -install-services: install-service-Org install-service-Blogs install-service-Api +copy-services: copy-service-Org copy-service-Blogs copy-service-Api +copy-service-Org: /etc/systemd/system/yavscOrg.service +copy-service-Blogs: /etc/systemd/system/yavscBlogs.service +copy-service-Api: /etc/systemd/system/yavscApi.service copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services @for project in $(APP_PROJECT_NAMES); \ @@ -133,36 +52,39 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \ sync ; \ ls $${BINDIR}/appsettings* 2>&1 ; \ - sudo cp -a $${BINDIR}/* $(DESTDIR)$(APPDIR) ; \ + sudo cp -a $${BINDIR}/* $(BASEAPPDIR) ; \ done - @sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR) - @sync + @sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR) -$(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)yavsc%.service +/etc/systemd/system/yavsc%.service: generated/yavsc%.service sudo cp $^ $@ sudo chown root:root $@ build_publish_%: clean_publish_dir_% @ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj +build_publish: build_publish_Org build_publish_Blogs build_publish_Api + clean_publish_dir_%: @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish -install: copy-binaries copy-services +install: build_publish copy-binaries copy-services @sudo systemctl daemon-reload @for project in $(APP_PROJECT_NAMES); \ do \ - echo "🖒 enable $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ - sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$${project} ; \ - echo "🚀 start $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ - sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; \ + sudo systemctl enable yavsc$${project} ; \ + sudo systemctl start yavsc$${project} ; \ done +reinstall: copy-binaries + @sync + @for project in $(APP_PROJECT_NAMES); do \ + sudo systemctl start yavsc$${project} ; done + stop-services: @for project in $(APP_PROJECT_NAMES); \ do \ - echo "🔥 Stopping $(APP_INSTALL_PREFIX)yavsc$${project}" ; \ - sudo systemctl stop $(APP_INSTALL_PREFIX)yavsc$${project} ; \ + sudo systemctl stop yavsc$${project} ; \ done $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish @@ -171,7 +93,7 @@ $(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_ showConfig: @echo CONFIGURATION: $(CONFIGURATION) - @echo APPDIR: $(APPDIR) + @echo BASEAPPDIR: $(BASEAPPDIR) showApiLogs: @sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log @@ -185,5 +107,3 @@ showBlogsLogs: clean: @rm -rf generated -generated/: - @mkdir -p $@ 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 6f8fa02bc..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" - ], - "UserFilesRoot": "files" - }, - "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 c330f5f32..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, - "UserFilesRoot": "files", - "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.service b/contrib/template.service index 3959c7151..b075c4da1 100644 --- a/contrib/template.service +++ b/contrib/template.service @@ -10,10 +10,10 @@ RestartSec=5s Type=simple User=yavsc Group=yavsc -WorkingDirectory=$APPDIR -ExecStart=$APPDIR/Yavsc.$APP_NAME +WorkingDirectory=$DESTDIR +ExecStart=$DESTDIR/Yavsc.$APP_NAME Restart=always -Environment="HOME=$APPDIR" +Environment="HOME=$DESTDIR" Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS" Environment="ASPNETCORE_Environment=$ENV" @@ -29,6 +29,8 @@ Environment="ASPNETCORE_Smtp__Password=\"$ASPNETCORE_Smtp__Password\"" CapabilityBoundingSet=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE +StandardOutput=syslog +StandardError=syslog SyslogIdentifier=$APP_SYSLOG_ID [Install] diff --git a/contrib/template.vhost b/contrib/template.vhost deleted file mode 100644 index b4c9c139e..000000000 --- a/contrib/template.vhost +++ /dev/null @@ -1,81 +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; - } - - # SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout - # long, sinon la connexion tombe au premier silence ou l'upgrade échoue. - # Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui - # exigerait une directive map {} globale hors de ce vhost) : SignalR - # négocie toujours un upgrade sur ce chemin, et la négociation initiale - # (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet - # en-tête côté Kestrel. - location /chathub { - proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - 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 3600s; - proxy_buffering off; - } - include server-status.conf; -} diff --git a/contrib/yavsc-org b/contrib/yavsc-org new file mode 100644 index 000000000..dc65aa9fe --- /dev/null +++ b/contrib/yavsc-org @@ -0,0 +1,54 @@ + + +server { + if ($host = yavsc.pschneider.fr) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + + listen 80; + server_name yavsc.pschneider.fr; + location / { + root /srv/www/yavsc/wwwroot; + dav_methods PUT; + + proxy_pass http://127.0.0.1:3003; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection keep-alive; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + +} + +server { + listen 443 ssl; + server_name yavsc.pschneider.fr; + ssl_certificate /etc/letsencrypt/live/yavsc.pschneider.fr-0001/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/yavsc.pschneider.fr-0001/privkey.pem; # managed by Certbot + # Configure the SignalR Endpoint + location / { + # App server url + proxy_pass http://localhost:3003; + + proxy_cache off; + # WebSockets were implemented after http/1.0 + proxy_http_version 1.1; + + # Configuration for ServerSentEvents + proxy_buffering off; + + # Configuration for LongPolling or if your KeepAliveInterval is longer than 60 seconds + proxy_read_timeout 100s; + + 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; + } +} + diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index b1bf83cf7..70f3f2fdc 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -126,20 +126,6 @@ le DI est construit. Ordre, dans cet ordre : > à cette table, *ne pas* ajouter de registration pour > `Settings` ailleurs que la ligne `AddSingleton(settings)`. -## Défilement de la page des billets - -La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête -et les commandes restent visibles, tandis que la liste des billets et -l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet -de modifier cette répartition. La liste et l'article défilent séparément. - -Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ; -l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter -la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir -dépasser le viewport pour que les barres automatiques s'activent. -Ne pas placer cette grille dans un `StackPanel` vertical ou un -`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée. - ## Navigation Le host de navigation est un `NavigationPage x:Name="NavRoot"` diff --git a/docker-compose.yaml b/docker-compose.yaml index ea08e9009..f478dcc9f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,7 +26,7 @@ services: dockerfile: Dockerfile target: web-runtime args: - BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 + BUILD_ENV_TAG: debian12-dotnet10-android36-v1 secrets: - source: yavsc_appsettings target: yavsc_appsettings @@ -53,7 +53,9 @@ services: depends_on: db: condition: service_healthy - network_mode: host + networks: + - yavsc-internal + - yavsc-public # volumes: # # Indispensable pour que Yavsc.Org (IdentityServer8) démarre. # # Sans ce montage, 'docker compose up' échoue côté web avec @@ -71,7 +73,7 @@ services: dockerfile: Dockerfile target: api-runtime args: - BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 + BUILD_ENV_TAG: debian12-dotnet10-android36-v1 secrets: - source: yavsc_appsettings target: yavsc_appsettings @@ -88,7 +90,9 @@ services: depends_on: db: condition: service_healthy - network_mode: host + networks: + - yavsc-internal + - yavsc-public # volumes: # - /etc/letsencrypt:/etc/letsencrypt:ro @@ -101,7 +105,7 @@ services: dockerfile: Dockerfile target: blogs-runtime args: - BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2 + BUILD_ENV_TAG: debian12-dotnet10-android36-v1 secrets: - source: yavsc_appsettings target: yavsc_appsettings @@ -118,7 +122,9 @@ services: depends_on: db: condition: service_healthy - network_mode: host + networks: + - yavsc-internal + - yavsc-public # volumes: # - /etc/letsencrypt:/etc/letsencrypt:ro diff --git a/global.json b/global.json deleted file mode 100644 index 9009caf0b..000000000 --- a/global.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "test": { - "runner": "Microsoft.Testing.Platform" - } -} diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index a353e4b3a..e8804a8bf 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -4,36 +4,28 @@ true - 12.1.3 + 12.1.1 - - + - - - - + + - - - - - - - - + + + - + - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index f8fd5a0fd..549100804 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -2,7 +2,6 @@ using Android.App; using Android.Content; using Android.Content.PM; -using Android.Views; using AndroidX.Core.Provider; using AndroidX.Emoji2.Text; using Avalonia.Android; @@ -16,9 +15,7 @@ namespace PostIt.Android; Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, - WindowSoftInputMode = SoftInput.AdjustResize, - ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode) - ] + ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] public class MainActivity : AvaloniaMainActivity { /// diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 18bfec459..a6d04cb33 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -11,8 +11,8 @@ 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 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 @@ -27,14 +27,4 @@ - - - - - - - + \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 256555df1..8793aae8a 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -1,10 +1,6 @@  - + diff --git a/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml b/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml deleted file mode 100644 index 6d2c97559..000000000 --- a/src/PostIt/PostIt.Android/Resources/xml/network_security_config.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - 10.0.2.2 - - diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj index f2b2a2bbf..ff2f3f2b8 100644 --- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj +++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj @@ -6,8 +6,8 @@ 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 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj index 46df5025f..36ed1251e 100644 --- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj +++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj @@ -7,8 +7,8 @@ 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 + 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf + 1.1.0-feat-sign-estimate.1 app.manifest diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index 1c5aa0c27..0de3bd69e 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -1,7 +1,6 @@ using System; using Avalonia; using PostIt.Services; -using PostIt.CLIOptions; namespace PostIt.Desktop; @@ -13,8 +12,6 @@ sealed class Program [STAThread] public static void Main(string[] args) { - CLIOptionHandler.HandleArgs(args); - // Short-circuit 2nd-instance launches (OS handing us the // postit://callback URL) BEFORE Avalonia spins up a window. // If we let Avalonia initialise, the new MainWindow flashes diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs index 741b3863c..112c11752 100644 --- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs +++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs @@ -73,14 +73,6 @@ internal sealed class RecordingYavscApiClient : YavscApiClient public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) { _recorder.Calls.Add((method, path, contentFactory)); - if (typeof(T) == typeof(BlogPostDto)) - return Task.FromResult((T)(object)new BlogPostDto - { - Id = 42, - Title = "Mon premier billet", - AuthorId = "tester", - Article = "Contenu du billet de test.", - }); return Task.FromResult(default(T)!); } } diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs index c9d7d3aae..946d3d28e 100644 --- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs +++ b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs @@ -20,13 +20,8 @@ namespace PostIt.Tests; /// public class BlogAttachmentLinkTests { - [Theory] - [InlineData("note.txt", "note.txt")] - [InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF", - "INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")] - [InlineData("rapport final.pdf", "rapport%20final.pdf")] - public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority( - string originalName, string urlName) + [Fact] + public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority() { const string authority = "https://idp.example.test"; @@ -58,59 +53,27 @@ public class BlogAttachmentLinkTests vm.DraftTitle = "Après"; vm.DraftArticle = "Contenu modifié."; vm.DraftAttachments.Add(new BlogUploadFile( - originalName, + "note.txt", System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")); - var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}"; - Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle); - Assert.Empty(recorder.Calls); - var draftBeforeSave = vm.DraftArticle; - await vm.SaveAsync(); - var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); + // The update branch issues two PUTs: the multipart one + // carrying the file, then — because TryAppendAttachmentLinks + // appended a link — a JSON one carrying the final article. + var linkPut = recorder.Calls.LastOrDefault( + c => c.method == HttpMethod.Put); Assert.NotEqual(default, linkPut); var sent = Assert.IsType>(linkPut.body); var multipartContent = Assert.IsType(sent()); - var sentStringContent = Assert.IsType(multipartContent.First()); + var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt"; + var sentStringContent = multipartContent.First() as StringContent ; var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken); - using var document = System.Text.Json.JsonDocument.Parse(sentString); - var article = document.RootElement.GetProperty("article").GetString(); - Assert.Contains($"- [{originalName}]({expectedUrl})", article); + Assert.Contains($"- [note.txt]({expectedUrl})", sentString); // The regression shape: a relative, authority-less link. - Assert.DoesNotContain("](/files/", article); - Assert.Equal(draftBeforeSave, vm.DraftArticle); - Assert.NotNull(vm.SelectedPost); - Assert.False(vm.IsDraftModified); - } - - [Fact] - public async Task New_post_resolves_only_the_provisional_link_at_first_save() - { - var recorder = new CallRecorder(); - var api = new RecordingYavscApiClient(recorder); - var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"), - new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } }); - vm.DraftTitle = "Nouveau billet"; - vm.DraftArticle = "Texte conservé."; - vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 })); - - Assert.Contains("postit-attachment:", vm.DraftArticle); - Assert.Empty(recorder.Calls); - await vm.SaveAsync(); - - Assert.Equal("Nouveau billet", vm.DraftTitle); - Assert.Contains("Texte conservé.", vm.DraftArticle); - Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)", - vm.DraftArticle); - Assert.DoesNotContain("postit-attachment:", vm.DraftArticle); - Assert.Equal(42, vm.SelectedPost?.Id); - Assert.Empty(vm.DraftAttachments); - Assert.False(vm.IsDraftModified); - Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post); - Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put); + Assert.DoesNotContain("](/files/", sentString); } } diff --git a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs index 31c82387d..db70e25fa 100644 --- a/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateEditionPageViewModelTests.cs @@ -1,5 +1,4 @@ using System.Net.Http; -using PostIt.Models; using PostIt.ViewModels; using Yavsc; using Yavsc.Api.Client; @@ -23,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] @@ -145,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); @@ -216,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); @@ -231,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) { @@ -263,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; } @@ -273,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/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs index eefd59f20..6158b4132 100644 --- a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs @@ -11,13 +11,12 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); await vm.InitializeAsync(); Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); - Assert.Equal("Devis en attente de signature client", vm.Title); + Assert.Equal("Mes devis à valider", vm.Title); Assert.Equal(2, vm.Estimates.Count); // Most recent first Assert.Equal(2, vm.Estimates[0].Id); @@ -29,13 +28,12 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); await vm.InitializeAsync(); Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); - Assert.Equal("Devis en attente de signature fournisseur", vm.Title); + Assert.Equal("Mes devis en attente", vm.Title); Assert.Equal(2, vm.Estimates.Count); } @@ -44,8 +42,7 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi(); var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); await vm.InitializeAsync(); @@ -66,8 +63,7 @@ public class EstimateListPageViewModelTests { var api = new StubEstimateListApi { Empty = true }; var client = new EstimateApiClient(api, "https://business.example/api/v1/"); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front); + var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); await vm.InitializeAsync(); diff --git a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs deleted file mode 100644 index 02a6c64bd..000000000 --- a/src/PostIt/PostIt.Tests/EstimateValidationPageViewModelTests.cs +++ /dev/null @@ -1,255 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using PostIt.Controls; -using PostIt.ViewModels; -using Yavsc.Api.Client; -using Yavsc.Models.Billing; - -namespace PostIt.Tests; - -/// -/// Verifies wires the two -/// signature pads (provider + client) into the right -/// call. The view (XAML, rendering) -/// is not tested here; the VM is driven through the same -/// test hooks the signature tests use, -/// and the API client through a recording fake -/// . The test host's -/// TestApp makes GoBackAsync a no-op, so the success paths -/// run to completion without navigation side effects. -/// -public class EstimateValidationPageViewModelTests -{ - private static EstimateDto SampleEstimate(long? commandId = 42) => new() - { - Id = 7, - CommandId = commandId, - CommandType = "Rdv", - Title = "Devis plomberie", - Description = "Réparation", - ClientId = "bob", - OwnerId = "alice", - }; - - private static void DrawOneStroke(SignaturePadControl pad) - { - pad.AppendPointForTest(100, 200); - pad.AppendPointForTest(300, 400); - pad.SealStrokeForTest(); - } - - [Fact] - public async Task Validate_calls_accept_with_strokes_and_command_id() - { - var api = new RecordingApi(); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Provider, front); - - var proPad = new SignaturePadControl(); - var clientPad = new SignaturePadControl(); - vm.Attach(proPad, clientPad); - DrawOneStroke(proPad); // provider signs on their writable pad - - await vm.ValidateAsync(); - - Assert.Equal(HttpMethod.Post, api.LastMethod); - Assert.Contains("front/query/accept", api.LastPath); - Assert.Contains("billingCode=Rdv", api.LastPath); - Assert.Contains("queryId=42", api.LastPath); - var body = Assert.IsType(api.LastBody); - Assert.NotNull(body.Strokes); - Assert.NotEmpty(body.Strokes); - Assert.Equal(10_000, body.CoordinateMax); - // The provider perspective declares the Pro side, so the - // server stores the signature under the provider's author - // regardless of role inference. - Assert.Equal(SignatureType.Pro, body.SignatureType); - } - - [Fact] - public async Task Validate_from_client_perspective_declares_client_side() - { - var api = new RecordingApi(); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Client, front); - - var cliPad = new SignaturePadControl(); - vm.Attach(new SignaturePadControl(), cliPad); - DrawOneStroke(cliPad); // client signs on their writable pad - - await vm.ValidateAsync(); - - var body = Assert.IsType(api.LastBody); - Assert.Equal(SignatureType.Client, body.SignatureType); // Client side - } - - [Fact] - public async Task Reject_calls_reject_endpoint_with_no_body() - { - var api = new RecordingApi(); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Client, front); - - vm.Attach(new SignaturePadControl(), new SignaturePadControl()); - - await vm.RejectAsync(); - - Assert.Equal(HttpMethod.Post, api.LastMethod); - Assert.Contains("front/query/reject", api.LastPath); - Assert.Contains("queryId=42", api.LastPath); - Assert.Null(api.LastBody); - } - - [Fact] - public async Task Validate_without_signature_warns_and_does_not_call() - { - var api = new RecordingApi(); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Provider, front); - - vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads - - await vm.ValidateAsync(); // no throw: returns before GoBack - - Assert.Null(api.LastMethod); - Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void Validate_command_gates_on_signature_and_command() - { - var api = new RecordingApi(); - var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - var vm = new EstimateValidationPageViewModel( - SampleEstimate(commandId: null), EstimateListPerspective.Provider, front); - - var proPad = new SignaturePadControl(); - vm.Attach(proPad, new SignaturePadControl()); - - // No CommandId ⇒ cannot validate even after signing. - DrawOneStroke(proPad); - Assert.False(vm.ValidateCommand.CanExecute(null)); - - var vm2 = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Provider, front); - var proPad2 = new SignaturePadControl(); - vm2.Attach(proPad2, new SignaturePadControl()); - Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet - DrawOneStroke(proPad2); - Assert.True(vm2.ValidateCommand.CanExecute(null)); - } - - [Fact] - public void RoleLabel_reflects_perspective() - { - var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); - var pro = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Provider, front); - var cli = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Client, front); - - Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase); - Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void Only_the_author_pad_is_writable() - { - var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); - - var proVm = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Provider, front); - var proPad = new SignaturePadControl(); - var cliPad = new SignaturePadControl(); - proVm.Attach(proPad, cliPad); - Assert.False(proPad.IsReadOnly); // provider writes the Pro pad - Assert.True(cliPad.IsReadOnly); - - var cliVm = new EstimateValidationPageViewModel( - SampleEstimate(), EstimateListPerspective.Client, front); - var proPad2 = new SignaturePadControl(); - var cliPad2 = new SignaturePadControl(); - cliVm.Attach(proPad2, cliPad2); - Assert.True(proPad2.IsReadOnly); - Assert.False(cliPad2.IsReadOnly); // client writes the Client pad - } - - [Fact] - public void Load_existing_signatures_renders_both_sides_in_their_pads() - { - var proStrokes = new[] { 1, 1000, 1000 }; - var cliStrokes = new[] { 1, 9000, 9000 }; - var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); - var estimate = SampleEstimate(); - estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes }; - estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes }; - - // Client perspective: both pads show the stored signatures, but - // only the client pad is writable. - var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front); - var proPad = new SignaturePadControl(); - var cliPad = new SignaturePadControl(); - vm.Attach(proPad, cliPad); - - Assert.Equal(proStrokes, proPad.Snapshot().Strokes); - Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes); - Assert.True(vm.HasProSignature); - Assert.True(vm.HasClientSignature); - } - - [Fact] - public void Load_existing_pro_signature_renders_in_pro_pad_only() - { - var strokes = new[] { 1, 5000, 5000 }; - var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/"); - var estimate = SampleEstimate(); - estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 }; - var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front); - - var proPad = new SignaturePadControl(); - var cliPad = new SignaturePadControl(); - vm.Attach(proPad, cliPad); - - Assert.Equal(strokes, proPad.Snapshot().Strokes); - Assert.True(cliPad.Snapshot().IsEmpty); - Assert.True(vm.HasProSignature); - Assert.False(vm.HasClientSignature); - } - - private sealed class RecordingApi : IYavscApiClient - { - public HttpClient Http { get; } = new(); - public HttpMethod? LastMethod { get; private set; } - public string? LastPath { get; private set; } - public object? LastBody { get; private set; } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - LastMethod = method; - LastPath = path; - LastBody = body; - return Task.FromResult(default(T)!); - } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - LastMethod = method; - LastPath = path; - LastBody = body; - return Task.CompletedTask; - } - - public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) - => CallAsync(method, path, (object?)null, ct); - - public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) - => CallAsync(method, path, (object?)null, ct); - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs b/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs deleted file mode 100644 index 02f6ae149..000000000 --- a/src/PostIt/PostIt.Tests/FrontOfficeApiClientTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Net.Http; -using Yavsc.Api.Client; - -namespace PostIt.Tests; - -/// -/// Verifies issues the right -/// api/v1/front/query/accept / .../reject requests with -/// the PostIt signature payload, using a recording fake -/// (no network). -/// -public class FrontOfficeApiClientTests -{ - private static QueryAcceptanceRequestDto SampleBody() => new() - { - Strokes = new[] { 2, 100, 200, 300, 400 }, - CoordinateMax = 10_000, - CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc), - }; - - [Fact] - public async Task Accept_posts_to_accept_endpoint_with_signature_body() - { - var api = new RecordingApi(); - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - var body = SampleBody(); - await client.AcceptQueryAsync("Rdv", 42, body); - - Assert.Equal(HttpMethod.Post, api.LastMethod); - Assert.Equal( - "https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42", - api.LastPath); - Assert.Same(body, api.LastBody); - } - - [Fact] - public async Task Reject_posts_to_reject_endpoint_with_no_body() - { - var api = new RecordingApi(); - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - await client.RejectQueryAsync("Rdv", 42); - - Assert.Equal(HttpMethod.Post, api.LastMethod); - Assert.Equal( - "https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42", - api.LastPath); - Assert.Null(api.LastBody); - } - - [Fact] - public async Task Accept_returns_deserialized_response() - { - var api = new RecordingApi - { - Response = new QueryAcceptanceResponseDto - { - QueryId = 42, - Status = "Accepted", - Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" }, - }, - }; - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody()); - - Assert.Equal(42, response.QueryId); - Assert.Equal("Accepted", response.Status); - Assert.NotNull(response.Signature); - Assert.Equal("Pro", response.Signature!.Type); - } - - [Fact] - public async Task Accept_rejects_invalid_arguments() - { - var api = new RecordingApi(); - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - await Assert.ThrowsAsync(() => client.AcceptQueryAsync("", 42, SampleBody())); - await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 0, SampleBody())); - await Assert.ThrowsAsync(() => client.AcceptQueryAsync("Rdv", 42, null!)); - await Assert.ThrowsAsync(() => client.RejectQueryAsync("", 42)); - await Assert.ThrowsAsync(() => client.RejectQueryAsync("Rdv", 0)); - } - - [Fact] - public async Task GetEstimatePdf_gets_estimate_pdf_endpoint() - { - var api = new RecordingApi(); - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - await client.GetEstimatePdfAsync(42); - - Assert.Equal(HttpMethod.Get, api.LastMethod); - Assert.Equal( - "https://business.example/api/v1/front/query/42/estimate.pdf", - api.LastPath); - } - - [Fact] - public async Task GetEstimateTex_gets_estimate_tex_endpoint() - { - var api = new RecordingApi(); - var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/"); - - await client.GetEstimateTexAsync(42); - - Assert.Equal(HttpMethod.Get, api.LastMethod); - Assert.Equal( - "https://business.example/api/v1/front/query/42/estimate.tex", - api.LastPath); - } - - private sealed class RecordingApi : IYavscApiClient - { - public HttpClient Http { get; } = new(); - public HttpMethod? LastMethod { get; private set; } - public string? LastPath { get; private set; } - public object? LastBody { get; private set; } - public QueryAcceptanceResponseDto? Response { get; set; } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - LastMethod = method; - LastPath = path; - LastBody = body; - - var response = Response ?? new QueryAcceptanceResponseDto(); - return Task.FromResult((T)(object)response); - } - - public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) - { - LastMethod = method; - LastPath = path; - LastBody = body; - return Task.CompletedTask; - } - - public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default) - => CallAsync(method, path, (object?)null, ct); - - 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) - { - LastMethod = method; - LastPath = path; - LastBody = null; - return Task.FromResult(Array.Empty()); - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs b/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs deleted file mode 100644 index 667ea599a..000000000 --- a/src/PostIt/PostIt.Tests/HomePageNotificationsTests.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System.Net.Http; -using Avalonia.Controls; -using Avalonia.Headless.XUnit; -using Avalonia.VisualTree; -using PostIt.Services; -using PostIt.ViewModels; -using PostIt.Views; -using Yavsc.Abstract.Models.Messaging; -using Yavsc.Api.Client; - -namespace PostIt.Tests; - -public class HomePageNotificationsTests -{ - [Fact] - public async Task Refresh_loads_public_notifications_from_the_business_host() - { - var transport = new NotificationTransport(); - var vm = CreateViewModel(transport); - transport.Response = Task.FromResult(new List { Note(1, "Information") }); - - await vm.RefreshNotificationsAsync(); - - Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath); - Assert.True(transport.WasAnonymous); - Assert.Equal("Information", Assert.Single(vm.Notifications).title); - Assert.False(vm.IsLoadingNotifications); - Assert.False(vm.HasNoNotifications); - Assert.Null(vm.NotificationsError); - } - - [Fact] - public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed() - { - var transport = new NotificationTransport(); - var vm = CreateViewModel(transport); - vm.Notifications.Add(Note(1, "Old")); - await vm.RefreshNotificationsAsync(); - - Assert.Empty(vm.Notifications); - Assert.True(vm.HasNoNotifications); - - transport.Response = Task.FromException>(new HttpRequestException("Server unavailable")); - await vm.RefreshNotificationsAsync(); - - Assert.Empty(vm.Notifications); - Assert.False(vm.HasNoNotifications); - Assert.Contains("Server unavailable", vm.NotificationsError); - Assert.False(vm.IsLoadingNotifications); - - transport.Response = Task.FromResult(new List { Note(2, "Recovered") }); - await vm.RefreshNotificationsAsync(); - Assert.Null(vm.NotificationsError); - Assert.Equal(2, Assert.Single(vm.Notifications).Id); - } - - [Fact] - public async Task Refresh_ignores_an_older_response_after_a_newer_refresh() - { - var transport = new NotificationTransport(); - var vm = CreateViewModel(transport); - var oldResponse = new TaskCompletionSource>(); - transport.Response = oldResponse.Task; - var oldLoad = vm.RefreshNotificationsAsync(); - Assert.True(vm.IsLoadingNotifications); - - transport.Response = Task.FromResult(new List { Note(2, "Current") }); - await vm.RefreshNotificationsAsync(); - oldResponse.SetResult(new List { Note(1, "Stale") }); - await oldLoad; - - Assert.Equal(2, Assert.Single(vm.Notifications).Id); - Assert.False(vm.IsLoadingNotifications); - } - - [AvaloniaFact] - public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data() - { - var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json"); - var settings = new Settings(); - var store = new TokenStore(tokenPath); - store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", - DateTimeOffset.UtcNow.AddHours(1), null)); - await using var api = new YavscApiClient(settings, store); - try - { - var session = new SessionStatusViewModel { Api = api }; - var transport = new NotificationTransport(); - var vm = CreateViewModel(transport, session); - session.Refresh(); - Assert.False(transport.WasAnonymous); - Assert.EndsWith("/notifications/me", transport.LastPath); - var privateResponse = new TaskCompletionSource>(); - transport.Response = privateResponse.Task; - var privateLoad = vm.RefreshNotificationsAsync(); - Assert.False(transport.WasAnonymous); - Assert.EndsWith("/notifications/me", transport.LastPath); - - vm.Notifications.Add(Note(9, "Private")); - transport.Response = Task.FromResult(new List { Note(2, "Public") }); - session.SetError("Disconnected"); - Assert.Equal(2, Assert.Single(vm.Notifications).Id); - Assert.True(transport.WasAnonymous); - - privateResponse.SetResult(new List { Note(1, "Late private data") }); - await privateLoad; - Assert.Equal(2, Assert.Single(vm.Notifications).Id); - } - finally - { - File.Delete(tokenPath); - } - } - - [AvaloniaFact] - public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies() - { - var transport = new NotificationTransport - { - Response = Task.FromResult(new List { Note(1, "Notification title") }) - }; - var vm = CreateViewModel(transport); - var page = new HomePage { DataContext = vm }; - var nav = new NavigationPage(); - var window = new Window { Content = nav, Width = 320, Height = 450 }; - try - { - window.Show(); - await nav.PushAsync(page); - window.UpdateLayout(); - - Assert.Single(vm.Notifications); - var texts = page.GetVisualDescendants().OfType().ToArray(); - Assert.Contains(texts, t => t.Text == "Notification title" - && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); - Assert.Contains(texts, t => t.Text == "Notification body" - && t.TextWrapping == Avalonia.Media.TextWrapping.Wrap); - var refresh = Assert.Single(page.GetVisualDescendants().OfType - Annuler - - - \ No newline at end of file diff --git a/src/Yavsc.Org/Views/Blogspot/Create.cshtml b/src/Yavsc.Org/Views/Blogspot/Create.cshtml new file mode 100644 index 000000000..6831b0461 --- /dev/null +++ b/src/Yavsc.Org/Views/Blogspot/Create.cshtml @@ -0,0 +1,88 @@ +@model BlogPostEditViewModel + +@{ + ViewBag.Title = "Blog post edition"; +} + +@section header { + + + + + + @{ await Html.RenderPartialAsync("_FSScriptsPartial"); } + +} + +

Blog post

+ + +
+ + +

@Model.Title

+ +
@Model.Article
+ +
+
+
+ +
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ @await Component.InvokeAsync("CirclesControl", Model) +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+
+
+@await Component.InvokeAsync(name: "Directory",arguments: new {subdir="."}) +
+ @{ await Html.RenderPartialAsync("_PostFilesPartial"); } +
+ diff --git a/src/Yavsc.Org/Views/Blogspot/Details.cshtml b/src/Yavsc.Org/Views/Blogspot/Details.cshtml index 0b07b0d94..dc0bea8a8 100644 --- a/src/Yavsc.Org/Views/Blogspot/Details.cshtml +++ b/src/Yavsc.Org/Views/Blogspot/Details.cshtml @@ -1,235 +1,105 @@ @model BlogPost @{ - ViewBag.Title = Model.Title; + ViewBag.Title=Model.Title; } -@section header { +@section scripts { + + + } -@section scripts { - -} - -
-
- @if (!string.IsNullOrEmpty(Model.Photo)) - { - @Model.Title - } -
-

@Model.Title

- -
-
- @Model.Article +
+
+
+ +

@Model.Title

+ + @Html.DisplayFor(m=>m.Author) + @Html.DisplayFor(m=>m.Article) +
- @if (Model.Comments != null) - { - foreach (var comment in Model.Comments.Where(c => c.ParentId == null)) - { + @if (Model.Comments!=null) { + foreach (var comment in Model.Comments.Where(c=>c.ParentId==null)) { @await Component.InvokeAsync("Comment", new { id = comment.Id }) } }
- - @if (User.GetUserId() != null) - { -
-
- - - - - -
+@if (User.GetUserId()!=null) { +
+
+ + + + +
+ +
} - else - { -

Vous devez être identifié pour commenter.

+ else { +

Vous devez être identifié pour commenter.

+ } + + @if ((await AuthorizationService.AuthorizeAsync(User, Model, new EditPermission())).Succeeded) { + Edit } - Back to List -
\ No newline at end of file + diff --git a/src/Yavsc.Org/Views/Blogspot/Edit.cshtml b/src/Yavsc.Org/Views/Blogspot/Edit.cshtml new file mode 100644 index 000000000..4645d45db --- /dev/null +++ b/src/Yavsc.Org/Views/Blogspot/Edit.cshtml @@ -0,0 +1,120 @@ +@model BlogPostEditViewModel +@{ + ViewBag.Title = "Blog post edition"; +} + +@section header { + + + + + + + + @{ await Html.RenderPartialAsync("_FSScriptsPartial"); } + +} + +

Blog post

+ + +
+ + + + + +

@Model.Title

+ +
+
+ +
+ @Html.HiddenFor(m=>m.Id) + +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ @await Component.InvokeAsync("CirclesControl", Model) +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+
+
+@await Component.InvokeAsync("Directory","") + + +
+ +
+ @{ await Html.RenderPartialAsync("_PostFilesPartial"); } +
+ diff --git a/src/Yavsc.Org/Views/Blogspot/Index.cshtml b/src/Yavsc.Org/Views/Blogspot/Index.cshtml index 9f29a9417..1582041cf 100644 --- a/src/Yavsc.Org/Views/Blogspot/Index.cshtml +++ b/src/Yavsc.Org/Views/Blogspot/Index.cshtml @@ -1,155 +1,92 @@ + @model IEnumerable @{ ViewBag.Title = "Blogs, l'index"; - - // Pagination reflects the Index(string id, int skip, int take) - // call profile. The service applies Skip/Take server-side, so - // the view only needs the current page params to build Prev/Next - // links — it does not receive a total count. - int currentSkip = Context.Request.Query.TryGetValue("skip", out var skipVal) && int.TryParse(skipVal, out var s) ? s : 0; - int currentTake = Context.Request.Query.TryGetValue("take", out var takeVal) && int.TryParse(takeVal, out var t) ? t : 25; - - var posts = Model.ToList(); - bool hasPrev = currentSkip > 0; - bool hasNext = posts.Count >= currentTake; - - int prevSkip = Math.Max(0, currentSkip - currentTake); - int nextSkip = currentSkip + currentTake; - - int maxSummaryLen = 256; } @section header { + +} +@section scripts { + }

@ViewBag.Title

@ViewBag.StatusMessage

- -@if (!posts.Any()) -{ -

Néant

+@if (User.IsSignedIn()) { +

+ Create a new article +

} -else -{ -
- @foreach (var post in posts) - { - var canEdit = (await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded; -
- @if (!string.IsNullOrEmpty(post.Photo)) - { - - @post.Title - - } -
-

- @post.Title -

-
- - @post.Article - -
- -
- Details - @if (canEdit) - { - Delete - } -
-
-
- } -
- -} \ No newline at end of file +
+@{ + int maxTextLen = 75; + foreach (var post in Model) { +
+ + + +
+

@post.Title

+
+
+ + @post.Article + @Html.DisplayFor(m => post.Author) + + posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm") + @if ((post.DateModified - post.DateCreated).Minutes > 0){  + @:- Modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm") + } + +
+
+ @if ((await AuthorizationService.AuthorizeAsync(User, post, new ReadPermission())).Succeeded) + { + Details + } + else + { + Details + } + @if ((await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded) + { + Edit + + Delete + + } +
+
+ } +} +
+ +@if(Model.Count()==0){

Néant

} diff --git a/src/Yavsc.Org/Views/Blogspot/Title.cshtml b/src/Yavsc.Org/Views/Blogspot/Title.cshtml index 9421a5b01..7bdf0468c 100644 --- a/src/Yavsc.Org/Views/Blogspot/Title.cshtml +++ b/src/Yavsc.Org/Views/Blogspot/Title.cshtml @@ -3,24 +3,28 @@

@ViewBag.StatusMessage

- +

+ Poster au même titre +

+
+ @foreach (var item in Model) { -
- @((item.Article?.Length > 256) ? item.Article.Substring(0, 256) + " ..." : item.Article) + @((item.Article?.Length > 256) ? item.Article.Substring(0, 256) + " ..." : item.Article) (@item.Author.UserName , posté le @item.DateCreated.ToString("dddd d MMM yyyy à H:mm") - @if ((item.DateModified - item.DateCreated).Minutes > 0){ + @if ((item.DateModified - item.DateCreated).Minutes > 0){  @:- Modifié le @item.DateModified.ToString("dddd d MMM yyyy à H:mm") }) +
    @if ((await AuthorizationService.AuthorizeAsync(User, item, new ReadPermission())).Succeeded) {
  • @@ -28,7 +32,8 @@
  • } @if ((await AuthorizationService.AuthorizeAsync(User, item, new EditPermission())).Succeeded) { - +
  • Edit +
  • Delete
  • } diff --git a/src/Yavsc.Org/Views/HairCutCommand/CGV.cshtml b/src/Yavsc.Org/Views/HairCutCommand/CGV.cshtml index 08d01cf63..1428ee674 100644 --- a/src/Yavsc.Org/Views/HairCutCommand/CGV.cshtml +++ b/src/Yavsc.Org/Views/HairCutCommand/CGV.cshtml @@ -1,13 +1,13 @@ -@{ +@{ ViewBag.Title = "Conditions Générales de Vente"; }

    @ViewBag.Title

    - + Le paiement intervient à la commande, quelque soit la date d'intervention. -Vous pouvez annuler votre commande depuis la [liste de vos commande en cours](/HairCutCommand/), - -Vous pouvez demander le remboursement d'un paiement, dans le cadre d'une réclamation sur l'execution +Vous pouvez annuler votre commande depuis la [liste de vos commande en cours](/HairCutCommand/), + +Vous pouvez demander le remboursement d'un paiement, dans le cadre d'une réclamation sur l'execution de votre commande, en nous contactant via courrier éléctronique ou postal, [que vour retrouverez sur la page de contact](/Home/Contact), votre demande sera rapidement traitée. - + diff --git a/src/Yavsc.Org/Views/Home/About.cshtml b/src/Yavsc.Org/Views/Home/About.cshtml index 746968be8..39062cb22 100755 --- a/src/Yavsc.Org/Views/Home/About.cshtml +++ b/src/Yavsc.Org/Views/Home/About.cshtml @@ -7,12 +7,12 @@ **Version de Development** - -## L'objectif + +== L'objectif Cette application est construite pour mettre en relation des artistes du domaine musical avec leur public. -## Le fonctionnement +== Le fonctionnement Les utilisateurs du site sont soit artiste, soit client, soit administrateur. ils ont tous droit à leur blog. Pour les artistes, c'est un moyen de promouvoir leur activité. @@ -45,7 +45,7 @@ Une fois sa prestation associée exécutée, les paiements relatifs sont effectu Pour un contrat exécuté et non honoré par le client, le processus de poursuite en recouvrement est engagé, sinon, le contrat est archivé, des attestations de paiement sont disponibles pour l'artiste et la facture est marquée payée, puis repostée au client. -## Pour l'artiste +=== Pour l'artiste L'artiste choisit plusieurs paramètres qui vont faire son profil : @@ -55,7 +55,7 @@ L'artiste choisit plusieurs paramètres qui vont faire son profil : * Un agenda Google optionnel, à consulter pour établir sa disponibilité * Des paramètres supplémentaires en fonctions de son type d'activité par exemple, pour les ensembles, leur taille, le cas échéant, leur répertoire ou des indications sur le style de leur musique) -## Pour le client +=== Pour le client Il choisit un lieu et une date pour déclarer un événement avenir (il peut bien-sûr en programmer autant qu'il le veut). @@ -63,19 +63,11 @@ Il peut, parcourir les publications des artistes, et entamer, sur la base d'un d Il a accès à la connaissance des journées connues comme libres des artistes par le système. -## La confidentialité +== La confidentialité -À l'exception d'adresses postales pour rendez-vous, -transmises avec accord explicite, -et dans le cadre d'établissement d'un devis, -aucune adresse postale, -aucune adresse e-mail, -ni aucun numéro de téléphone ne sont transmis, -ni aux clients, ni aux artistes. -Seul le serveur a accès à ces informations. +À aucun moment, aucune adresse postale, aucune adresse e-mail ni aucun numéro de téléphone ne sont transmis ni aux clients, ni aux artistes. Seul le système a accès à ces informations. De plus, le droit de retrait est permanent et sa mise en oeuvre immédiate. -Les artistes comme les clients peuvent demander leur désinscription, qui désactive immédiatement les publications associées à leurs informations, -et programme la suppression complète de ces dites informations dans les quinze jours à compter de la demande, sauf demande contradictoire. +Les artistes comme les clients peuvent demander leur désinscription, qui désactive immédiatement les publications associées à leurs informations, et programme la suppression complète de ces dites informations dans les quinze jours à compter de la demande, sauf demande contradictoire. L'opération est annulable, jusqu'à deux semaines après sa programmation. - + \ No newline at end of file diff --git a/src/Yavsc.Org/Views/Home/About.pt.cshtml b/src/Yavsc.Org/Views/Home/About.pt.cshtml index a805999ce..487a1ab36 100755 --- a/src/Yavsc.Org/Views/Home/About.pt.cshtml +++ b/src/Yavsc.Org/Views/Home/About.pt.cshtml @@ -5,7 +5,7 @@

    @SiteSettings.Value.Title - objetivo

    - + = O objetivo Esta aplicação é construída para conectar artistas @@ -87,51 +87,51 @@ que imediatamente desativa as publicações associadas às suas informações, e planeia a eliminação completa desta informação no prazo de quinze dias do aplicativo, a menos que seja um pedido contraditório. A operação é anulável até duas semanas após a sua programação. - + -Este é o meu site perso, uma configuração de _Yavsc_ (outro negócio muito pequeno). +Este é o meu site perso, uma configuração de _Yavsc_ (outro negócio muito pequeno). * [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) * [licença: GNU GPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) Outras instalações: - - + + * [Coiffure](http://coiffure.pschneider.fr) * [ZicMoove](http://linkmuse.pschneider.fr) * [Yavsc](http://yavsc.pschneider.fr) - + - + Yet Another Very Small Company ... * [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) * [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) - + - -## Yet Another Very Small Company : + +## Yet Another Very Small Company : * [README](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/README.md) * [license: GNU FPL v3](https://forgejo.pschneider.fr/notazof/yavsc/blob/vnext/LICENSE) En production: - + * [Lua](https://lua.pschneider.fr) * [Yavsc](https://yavsc.pschneider.fr) - + - + Você está no site da ordem em cabeleireiro em casa de Soraya Boudjouraf, um ás de cabeleireiro, que trabalha na região de Paris. @@ -139,11 +139,11 @@ Ao validar um formulário de pedido aqui, é para ela que você notifica sua sol Você pode [Deixe-lhe o seu número de telefone] (/ HairCutCommand / HairCut? ActivityCode = Brush & performerId = 1bd841ab-c305-4971-940d-7ddca818310c) e / ou detalhes sobre o seu pedido, -ela vai ligar de volta. +ela vai ligar de volta. - + ## Este é um site de desenvolvimento. Este recurso só diz respeito ao desenvolvimento do software que o implementa. @@ -156,7 +156,7 @@ A "pré-produção" exibe os seguintes sites: * [Yavsc](https://yavsc.pschneider.fr) * [Lua](https://lua.pschneider.fr) - +

    @Model

    diff --git a/src/Yavsc.Org/Views/Home/AboutAccess.cshtml b/src/Yavsc.Org/Views/Home/AboutAccess.cshtml index 6aaec7ff9..05331032b 100644 --- a/src/Yavsc.Org/Views/Home/AboutAccess.cshtml +++ b/src/Yavsc.Org/Views/Home/AboutAccess.cshtml @@ -3,13 +3,13 @@ }

    @ViewBag.Title

    - + # Blog et fichiers utilisateurs Par défaut, un envoi à destination du serveur est privé, visible seulement pour son possesseur (et l'administration). -L'utilisateur peu publier chacun de ces derniers à l'adresse : +L'utilisateur peu publier chacun de ces derniers à l'adresse : * de tout le monde, * ou d'une liste de cercles d'utilisateurs, @@ -17,8 +17,8 @@ L'utilisateur peu publier chacun de ces derniers à l'adresse : Quand il s'agit d'un fichier, son adresse permanente relative à la racine du serveur web est: `/UserFiles/(Nom Utilisateur)/(cheminVersLeFichier)` -Les restrictions d'accès à ce fichier sont celles associées à tous les fichiers du même dossier. -Il n'y a pas de regle assiciée à chaque fichier individuellement, +Les restrictions d'accès à ce fichier sont celles associées à tous les fichiers du même dossier. +Il n'y a pas de regle assiciée à chaque fichier individuellement, elles appartiennent au dossier qui les contient. Les billet de blog sont regroupés par utilisateur, à l'Url @@ -35,6 +35,6 @@ Et ils possèdent une addresse permanente de la forme : - + diff --git a/src/Yavsc.Org/Views/Home/AboutMarkdown.cshtml b/src/Yavsc.Org/Views/Home/AboutMarkdown.cshtml index f39aaf536..f025c111e 100644 --- a/src/Yavsc.Org/Views/Home/AboutMarkdown.cshtml +++ b/src/Yavsc.Org/Views/Home/AboutMarkdown.cshtml @@ -3,7 +3,7 @@ }

    @ViewBag.Title

    - + Quelques extensions à un Markdown de base : * les video et audio: `![video: Ep. 28 - La France en commun - Une invitation à écrire un nouveau projet d'émancipation humaine](/images/testVideo.mp4)` @@ -16,4 +16,4 @@ Quelques extensions à un Markdown de base : * le tag "Titre d'article": "#1_great_title" * le tag "User" = "@@(John Doe)" ou "@@jdoe" - + diff --git a/src/Yavsc.Org/Views/Home/CGV.cshtml b/src/Yavsc.Org/Views/Home/CGV.cshtml index 08d01cf63..1428ee674 100644 --- a/src/Yavsc.Org/Views/Home/CGV.cshtml +++ b/src/Yavsc.Org/Views/Home/CGV.cshtml @@ -1,13 +1,13 @@ -@{ +@{ ViewBag.Title = "Conditions Générales de Vente"; }

    @ViewBag.Title

    - + Le paiement intervient à la commande, quelque soit la date d'intervention. -Vous pouvez annuler votre commande depuis la [liste de vos commande en cours](/HairCutCommand/), - -Vous pouvez demander le remboursement d'un paiement, dans le cadre d'une réclamation sur l'execution +Vous pouvez annuler votre commande depuis la [liste de vos commande en cours](/HairCutCommand/), + +Vous pouvez demander le remboursement d'un paiement, dans le cadre d'une réclamation sur l'execution de votre commande, en nous contactant via courrier éléctronique ou postal, [que vour retrouverez sur la page de contact](/Home/Contact), votre demande sera rapidement traitée. - + diff --git a/src/Yavsc.Org/Views/Home/Index.cshtml b/src/Yavsc.Org/Views/Home/Index.cshtml index a18b0727b..f867b2bfc 100755 --- a/src/Yavsc.Org/Views/Home/Index.cshtml +++ b/src/Yavsc.Org/Views/Home/Index.cshtml @@ -2,6 +2,8 @@ @{ ViewBag.Title = @Localizer["Page d'accueil"]; + int i=0; + bool multipleact = Model.Count()>1; } diff --git a/src/Yavsc.Org/Views/Home/Privacy.cshtml b/src/Yavsc.Org/Views/Home/Privacy.cshtml index a9a6e7cf4..34ae18037 100644 --- a/src/Yavsc.Org/Views/Home/Privacy.cshtml +++ b/src/Yavsc.Org/Views/Home/Privacy.cshtml @@ -2,15 +2,15 @@ ViewBag.Title = Localizer["Home"]; } - -# La confidentialité + += La confidentialité +:hide-uri-scheme: -À aucun moment, aucune adresse postale, -aucune adresse e-mail ni aucun numéro de téléphone -ne sont transmis à personne. Seul le système - et son link:Contact[possesseur] ont accès - à ces informations. +xref:Contact[Get Report] -De plus, le droit de retrait est permanent -et sa [mise en oeuvre est immédiate](/Account/Delete). - +À aucun moment, aucune adresse postale, aucune adresse e-mail ni aucun numéro de téléphone +ne sont transmis à personne. Seul le système et son link:Contact[possesseur] ont accès à ces informations. + +De plus, le droit de retrait est permanent et sa mise en oeuvre link:/Account/Delete[immédiate]. + + \ No newline at end of file diff --git a/src/Yavsc.Org/Views/Home/Todo.cshtml b/src/Yavsc.Org/Views/Home/Todo.cshtml index c63500479..448251a48 100755 --- a/src/Yavsc.Org/Views/Home/Todo.cshtml +++ b/src/Yavsc.Org/Views/Home/Todo.cshtml @@ -4,14 +4,14 @@

    @ViewBag.Title

    Linkmuse(trox) - + Les tags. La librairie, les lives. - + @{ ViewBag.Title = @Localizer["TODO"]; }

    @ViewBag.Title

    Faster, stronger, shorter - - + + diff --git a/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_pdf.cshtml b/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_pdf.cshtml new file mode 100644 index 000000000..f49fad802 --- /dev/null +++ b/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_pdf.cshtml @@ -0,0 +1,21 @@ +@using System.Reflection +@using System.IO +@using Microsoft.Extensions.WebEncoders +@using System.Diagnostics +@using System.Text +@using Yavsc.Helpers +@model Yavsc.ViewModels.Gen.PdfGenerationViewModel + +@{ + ViewBag.Settings.GenerateEstimatePdf(Model); +} +@if (Model.Generated) { +
    + @(Model.BaseFileName).pdf was generated +
    +} else { +
    + @Model.GenerationErrorMessage +

    Something went wrong ...

    +
    +} diff --git a/src/Yavsc.Api/Views/Shared/Bill_tex.cshtml b/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_tex.cshtml similarity index 99% rename from src/Yavsc.Api/Views/Shared/Bill_tex.cshtml rename to src/Yavsc.Org/Views/Shared/Components/Bill/Bill_tex.cshtml index f0a3d2ad3..353d124e0 100644 --- a/src/Yavsc.Api/Views/Shared/Bill_tex.cshtml +++ b/src/Yavsc.Org/Views/Shared/Components/Bill/Bill_tex.cshtml @@ -3,7 +3,7 @@ @using System.Globalization @model IBillable @{ - Layout = null; + Layout = "null"; var pro = ViewBag.PerformerProfile; var from = ViewBag.Performer; var to = ViewBag.Client; diff --git a/src/Yavsc.Org/Views/Shared/Components/Bill/Default.cshtml b/src/Yavsc.Org/Views/Shared/Components/Bill/Default.cshtml new file mode 100644 index 000000000..f2d0e0163 --- /dev/null +++ b/src/Yavsc.Org/Views/Shared/Components/Bill/Default.cshtml @@ -0,0 +1,33 @@ + +@model IBillable +
    +@if (ViewBag.BillFileInfo.Exists) { +La facture au format Pdf +} else { + +
    +
    + + +
    +
    +
    +
    +} +
    diff --git a/src/Yavsc.Org/Views/Shared/Components/Comment/Default.cshtml b/src/Yavsc.Org/Views/Shared/Components/Comment/Default.cshtml index e83ea14c6..6dfec029c 100644 --- a/src/Yavsc.Org/Views/Shared/Components/Comment/Default.cshtml +++ b/src/Yavsc.Org/Views/Shared/Components/Comment/Default.cshtml @@ -1,30 +1,16 @@ @model Comment -
    -
    - @Model.Article -
    - - — @(Model.Author?.UserName ?? "Anonyme"), - posté le @Model.DateCreated.ToString("dddd d MMM yyyy à H:mm") - - @if (User.GetUserId() != null) - { - - } +
    + @Model.Article + + @if (Model.Children!=null) {
    - @if (Model.Children != null) - { - foreach (var comment in Model.Children) + @foreach (var comment in Model.Children) { - @await this.Component.InvokeAsync("Comment", new { id = comment.Id }) + @await this.Component.InvokeAsync("Comment", new { id = comment.Id}); } - }
    -
    \ No newline at end of file +} +
    + diff --git a/src/Yavsc.Org/Views/Shared/Components/Estimate/Default.cshtml b/src/Yavsc.Org/Views/Shared/Components/Estimate/Default.cshtml new file mode 100644 index 000000000..b9a1ab26d --- /dev/null +++ b/src/Yavsc.Org/Views/Shared/Components/Estimate/Default.cshtml @@ -0,0 +1,13 @@ + +@model Estimate + diff --git a/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_pdf.cshtml b/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_pdf.cshtml new file mode 100644 index 000000000..bef19b35a --- /dev/null +++ b/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_pdf.cshtml @@ -0,0 +1,21 @@ +@using System.Reflection +@using System.IO +@using Microsoft.Extensions.WebEncoders +@using System.Diagnostics +@using System.Text +@using Yavsc.Helpers +@model Yavsc.ViewModels.Gen.PdfGenerationViewModel + +@{ + SiteSettings.Value.GenerateEstimatePdf(Model); +} +@if (Model.Generated) { +
    + @(Model.BaseFileName).pdf was generated +
    +} else { +
    + @Model.GenerationErrorMessage +

    Something went wrong ...

    +
    +} diff --git a/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml b/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_tex.cshtml similarity index 72% rename from src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml rename to src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_tex.cshtml index bbe4326b4..4c35d4bdd 100644 --- a/src/Yavsc.Api/Views/Shared/Estimate_tex.cshtml +++ b/src/Yavsc.Org/Views/Shared/Components/Estimate/Estimate_tex.cshtml @@ -3,7 +3,7 @@ @using System.Globalization @model Estimate @{ - Layout = null; + Layout = "null"; var pro = Model.Query.PerformerProfile; var from = Model.Query.PerformerProfile.Performer; var to = Model.Query.Client; @@ -11,6 +11,7 @@ var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX(); var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address); var isestimate = !ViewBag.AsBill; + var prosign = new FileInfo($"{ViewBag.BillsDir}/estimate-prosign-{Model.Id}.png"); } \documentclass[french,11pt]{article} @@ -24,7 +25,6 @@ \usepackage{graphicx} \usepackage{fancyhdr} \usepackage{fp} -\usepackage{tikz} \def\TVA{20} % Taux de la TVA @@ -49,7 +49,7 @@ \global\let\TotalHT\TotalHT \global\let\TotalTVA\TotalTVA \global\let\TotalTTC\TotalTTC - \cr + \cr \hline \textbf{Total} & & & \TotalHT } @@ -77,12 +77,12 @@ % Infos Client \def\ClientNom{@TeXHelpers.ToTeX(to.UserName)} % Nom du client -\def\ClientAdresse{ +\def\ClientAdresse{ % Adresse du client @PostalAddress @if (PostalAddress!=null) {@PostalAddress} @if (!string.IsNullOrWhiteSpace(to.PhoneNumber)) {@TeXHelpers.ToTeX(to.PhoneNumber)\\ - } + } E-mail: @TeXHelpers.ToTeX(to.Email) } @@ -105,7 +105,7 @@ \renewcommand{\headrulewidth}{0pt} \cfoot{ @TeXHelpers.ToTeX(from.UserName) @if (proaddrm!=null) { - @proaddrm } \newline - \small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) } + \small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) } } } @@ -113,10 +113,10 @@ % Logo de la société @if (from.Avatar != null) { -\includegraphics[height=60pt, viewport=0 0 128 128]{@(ViewBag.AvatarsDir)/@(from.UserName).png} +\includegraphics[height=60pt]{@(ViewBag.AvatarsDir)/@(from.UserName).png} -} else { -%\includegraphics[height=60pt, viewport=0 0 128 128]{logo.png} +} else { +%\includegraphics{logo.png} } % Nom et adresse de la société @@ -170,12 +170,12 @@ \begin{center} \begin{tabular}{|c c c c|} @if (!string.IsNullOrWhiteSpace(bi.BankCode) && !string.IsNullOrWhiteSpace(bi.WicketCode) - && !string.IsNullOrWhiteSpace(bi.AccountNumber) ) { + && !string.IsNullOrWhiteSpace(bi.AccountNumber) ) { \hline \textbf{Code banque} & \textbf{Code guichet} & \textbf{N° de Compte} & \textbf{Clé RIB} \\ @bi.BankCode & @bi.WicketCode & @bi.AccountNumber & @bi.BankedKey \\ } - @if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) { + @if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) { \hline \textbf{IBAN N°} & \multicolumn{3}{|l|}{ @bi.IBAN } \\ \hline \textbf{Code BIC} & \multicolumn{3}{|l|}{ @bi.BIC } @@ -188,46 +188,19 @@ } -@{ - // Captured signatures, if any. The Estimate.Signatures collection - // is loaded by the controller; SignaturePro/SignatureClient project - // the row by type. We draw them as vector TikZ at the foot of the - // devis (see TeXHelpers.SignatureToTikz), so there is no PNG to - // produce or \includegraphics to fail on. - var fr = CultureInfo.CreateSpecificCulture("fr-FR"); - var proSig = Model.SignaturePro; - var cliSig = Model.SignatureClient; +@if (!(Model.ProviderValidationDate==null)) { + +\begin{flushright} + @(new HtmlString(isestimate?"Devis validé":"Facture validée")) le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy")) +\end{flushright} +@if (prosign.Exists) { + +\begin{center} + \hspace{263pt} + \includegraphics[height=60pt]{@(ViewBag.BillsDir)/estimate-prosign-@(Model.Id).png} +\end{center} + } - -\vspace{2em} -\noindent -\begin{minipage}[t]{0.45\textwidth} - \textbf{Signature du fournisseur}\\ -@if (Model.ProviderValidationDate != default(DateTime)) { -\small Validé le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy", fr))\\ + } -@if (proSig != null && proSig.Strokes != null && proSig.Strokes.Length > 0) { -\begin{tikzpicture} - \useasboundingbox (0,0) rectangle (5,2.5); - @proSig.SignatureToTikz(5, 2.5) -\end{tikzpicture} -} else { -\rule[-2.2cm]{5cm}{0.4pt} -} -\end{minipage}\hfill -\begin{minipage}[t]{0.45\textwidth} - \textbf{Signature du client}\\ -@if (Model.ClientValidationDate != default(DateTime)) { -\small Validé le @TeXHelpers.ToTeX(Model.ClientValidationDate.ToString("dddd dd MMMM yyyy", fr))\\ -} -@if (cliSig != null && cliSig.Strokes != null && cliSig.Strokes.Length > 0) { -\begin{tikzpicture} - \useasboundingbox (0,0) rectangle (5,2.5); - @cliSig.SignatureToTikz(5, 2.5) -\end{tikzpicture} -} else { -\rule[-2.2cm]{5cm}{0.4pt} -} -\end{minipage} - \end{document} diff --git a/src/Yavsc.Org/Views/Shared/_Layout.cshtml b/src/Yavsc.Org/Views/Shared/_Layout.cshtml index 653ffae0a..9515ac2b4 100644 --- a/src/Yavsc.Org/Views/Shared/_Layout.cshtml +++ b/src/Yavsc.Org/Views/Shared/_Layout.cshtml @@ -31,7 +31,7 @@

    @((n.click_action == null) ? Localizer["Fermer"] : Localizer[n.click_action]) - @n.body + @n.body } } diff --git a/src/Yavsc.Org/Views/Shared/_Nav.cshtml b/src/Yavsc.Org/Views/Shared/_Nav.cshtml index 6d5e7bb78..385884fc0 100644 --- a/src/Yavsc.Org/Views/Shared/_Nav.cshtml +++ b/src/Yavsc.Org/Views/Shared/_Nav.cshtml @@ -2,8 +2,8 @@ style="background-image: url('@SiteSettings.Value.Banner'); background-position: 0%; background-attachment: fixed;"> - + Yavsc @SiteSettings.Value.Title