Compare commits

..
Author SHA1 Message Date
94d8eb538c
CHANGELOG: add 1.0.8-rc17 section
All checks were successful
Forgejo Release / release (push) Successful in 9m46s
Dotnet build and test / build (pull_request) Successful in 10m42s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 18:42:01 +01:00
daf5949392
contrib: gate QUIC reuseport on production + Org only
reuseport is legal only once per addr:port (443/udp). preprod and prod
share the same IP today, so enabling it on preprod-Org clashed with
prod-Org and made nginx refuse the config. QUIC_OPTS now resolves to
" reuseport" only when APP_INSTALL_ENV=production AND the host is Org;
preprod and the Api/Blogs vhosts get a plain `listen 443 quic;`.

Also drop the stray space before `;` when QUIC_OPTS is empty by moving
the separator into the QUIC_OPTS value (`listen 443 quic$QUIC_OPTS;`).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 18:32:44 +01:00
2c8be78175
presentation 2026-09-25 17:23:27 +01:00
e1ce67a76e
laucnh my Debug session 2026-09-25 17:23:15 +01:00
384f6bc300
IBlogPost porte IsPublished : le endpoint liste ne le lâche plus sur le wire
Le blog API (Yavsc.Blogs/Program.cs) sérialise en System.Text.Json
(.AddControllers() par défaut), et BlogApiController.GetBlogspot retourne
IEnumerable<IBlogPost>. STJ n'émet que les membres du type déclaré
(l'interface), donc IsPublished — qui n'était que sur les classes
concrètes BlogPost / BlogPostDto — était dropped du payload liste.
PostIt lisait alors IsPublished=false pour tout billet, y compris
publiés (cas prod /BlogSpot/Details/13).

Fix : déclarer IsPublished sur IBlogPost. BlogPost et BlogPostDto
l'implémentent déjà. Tests de wire-shape
(IBlogPostWireShapeTests) ajoutés pour verrouiller le contrat au
niveau du boundary.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 15:11:25 +01:00
a17a9cef9a
ApiWebServerFixture : connexion admin Npgsql depuis l'env, plus de mot de passe en source
Applique le même traitement que ServerServicesFixture : la chaîne de
connexion admin PostgreSQL se lit depuis YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION
au lieu d'être codée en dur (mot de passe dev 8*5idas retiré). Sans cette
variable, le chemin Npgsql échoue avec un message explicite plutôt que de
retomber silencieusement sur la base de dev locale.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 15:11:00 +01:00
ed3dc733d3
Projet de tests Yavsc.Server.Tests : BlogSpotService.Details
Ajoute un projet de tests service-level pour BlogSpotService.Details
(owner/public/sponsor/Administrator + ACL scrub + IsPublished + auteurs
de commentaires). Le fixture supporte SQLite (défaut) et PostgreSQL via
YAVSC_SERVER_TEST_DB_PROVIDER=npgsql, sur le modèle de
ApiWebServerFixture (migrations Yavsc.Org, base dédiée yavscServerTestDb,
reset par purge FK-ordonnée). La connexion admin Npgsql se lit depuis
YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION (aucun mot de passe en source).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 14:45:22 +01:00
5553f2f290
Génération contrib : vhosts nginx h3 edge + appsettings (jq) + QUIC logiciel Kestrel
Vhosts nginx (contrib/template.vhost + Makefile, envsubst allowlist) :
HTTP/3 edge per contrib/quic.md — listen 443 quic reuseport (Org only),
http2/http3 on, Alt-Svc, ssl_protocols TLSv1.3. Cibles generate-nginx /
generate-nginx-prod -> generated/nginx/ (gitignoré). Allowlist préserve
les variables nginx intrinsèques ($host, $scheme, ...).

Appsettings (contrib/template.appsettings-{org,api,blogs}.json + Makefile) :
génération via jq (et non envsubst) car le mot de passe SMTP contient un qui cassait le JSON. Substitue $DOMAIN (DOMAIN_ROOT) partout et injecte
$SMTP_PASSWORD depuis env (échappement JSON, secret non exposé en cmd).
Cible generate-appsettings -> generated/appsettings/.

QUIC logiciel : Kestrel:Endpoints:Https:Protocols = Http1AndHttp2AndHttp3
sur les 3 templates appsettings (capacité, non joignable derrière nginx).

Fix bonus : générateur systemd HTTP_PORT"$($*_PORT)" (était
"$*_$(HTTP_PORT)" -> port vide localhost:Org_).

Runbook contrib/README.nginx.md. Prérequis host : nginx http_v3_module,
UDP 443, libmsquic (Kestrel h3).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 13:31:56 +01:00
3fe7b8b46c
templates for appsettings 2026-09-25 13:27:00 +01:00
a793996b70
Fixes the swagger ui access
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m25s
2026-09-25 10:20:18 +01:00
8e3862ed26
Tests d'attachement (devis/demande) et de l'espace de stockage perso
Couverture des endpoints backend introduits pour la gestion des fichiers
utilisateur et des pièces jointes par référence.

Yavsc.Api.Test:
- EstimateAttachmentsApiTests : attachement de fichiers au devis
  (fournisseur uniquement, fichier devant appartenir au caller,
  idempotence du re-attach, détachement, listage ouvert aux deux
  parties + admin, tiers refusé, 404 sur devis inconnu).
- FrontOfficeQueryAttachmentsApiTests : attachement à la demande
  (client uniquement, fichier devant appartenir au caller, détachement,
  listage ouvert aux deux parties, tiers refusé).

Yavsc.Blogs.Tests:
- FileSystemApiTests : POST api/v1/fs crée une ligne UploadedFile
  propriétaire (avec FileId en retour) et écrit le fichier sur disque ;
  GET api/v1/fs/file/{fileId} autorise le propriétaire et une partie
  au devis rattaché (client/fournisseur), refuse un tiers, 404 sur id
  inconnu.

306/306 verts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 17:14:47 +01:00
70ab05522b
Gestion des fichiers utilisateur et pièces jointes (demande/devis)
Espace de stockage personnel géré depuis PostIt, avec attachement par
référence (sans duplication) des fichiers à la demande du client
(NominativeServiceCommand) et au devis du fournisseur (Estimate).

Backend:
- UploadedFile.OwnerId (FK ApplicationUser, nullable) ; les téléversements
  vers api/v1/fs créent/mettent à jour une ligne propriétaire.
- Tables de jointure EstimateAttachedFile et QueryAttachedFile (PK composite),
  migration UserFileAttachments.
- EstimateApiController: GET/POST/DELETE estimate/{id}/attachments
  (fournisseur uniquement, fichier devant appartenir au caller).
- FrontOfficeApiController: GET/POST/DELETE front/query/{id}/attachments
  (client uniquement).
- FileSystemApiController: GET fs/file/{fileId} sert les octets depuis le
  host Blogs (autorisation propriétaire ou partie au devis/demande via DB
  partagée) — le host API ne gère que les métadonnées, contrainte des
  conteneurs docker à disques distincts.

Client .NET:
- UserFilesApiClient (list/upload/delete/download) pointant vers BlogsApiUrl.
- DTOs UserDirectoryDto/FileEntryDto/AttachmentDto/FileReceivedDto/UploadFile.
- Méthodes d'attachement sur EstimateApiClient et FrontOfficeApiClient.

PostIt (Avalonia):
- Page « Mes fichiers » (naviguer/téléverser/télécharger/supprimer), servant
  aussi de sélecteur pour l'attachement.
- Section pièces jointes dans l'édition du devis (fournisseur) et le détail
  de la demande (client), avec ajout depuis My Files, détachement,
  téléchargement.
- Enregistrement DI + mapping ViewLocator + bouton d'accès.

Tests:
- EstimateEditionPageViewModelTests mis à jour (nouveau ctor fsClient,
  historique d'appels du stub pour les assertions post-envoi).

Nommage HTTP/méthodes en anglais, documentation en français.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:35:36 +01:00
eb58fc8862
Merge branch 'main' into feature/estimate
All checks were successful
Dotnet build and test / build (pull_request) Successful in 9m38s
2026-09-24 15:10:15 +01:00
040446c0b8
ignore scan reports
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-09-24 15:09:48 +01:00
bad09cc7af
test 2026-09-24 15:08:56 +01:00
2c36d880e1
Gitleaks integration
All checks were successful
Dotnet build and test / build (push) Successful in 11m52s
2026-09-24 14:50:56 +01:00
f0d262cb37
test 2026-09-24 14:50:55 +01:00
1cf3c689d8
Secret scan 2026-09-24 14:50:28 +01:00
fbf42cc1db
version update
All checks were successful
Forgejo Release / release (push) Successful in 8m17s
Dotnet build and test / build (pull_request) Successful in 6m2s
2026-09-22 18:39:19 +01:00
ba40698cc9
CHANGELOG: add 1.0.8-rc16 section
Describes the estimate signature + TeX/PDF generation release impact:
signature pad on validation, role-aware accept/reject with signatures,
ProAccepted/ClientAccepted split, estimate.tex/estimate.pdf endpoints
in Yavsc.Api, vector TikZ signatures, PostIt PDF/TeX download buttons,
list refresh + title renames, the lualatex stdin runtime, and the
Emergency stop / log-write / RenderViewToString / Layout fixes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:35:48 +01:00
35d784fdf1
Fix lualatex stdin PDF gen + unit test for signed estimate PDF
GenerateEstimatePdf passed /dev/stdin as the input filename so lualatex
treats stdin as a regular input file instead of the terminal — without
it, lualatex emergency-stops after the first line of a multi-line
document (the real root cause of the production "Emergency stop", not
bera/luaotfload which were separate host-package gaps already fixed).

EstimatePdf endpoint calls GenerateEstimatePdf directly and streams the
returned FileInfo, guarding on fo.Exists. QueryAcceptanceRequest is
extracted into its own file under ViewModels/FrontOffice.

Add EstimatePdfGenerationTests: unit tests for SignatureToTikz (vector
draws of both signatures) and GenerateEstimatePdf on a devis signed by
both parties — asserts the PDF exists at the expected DestDir path, is
non-empty, and that only the PDF persists (aux/log cleaned). Skips
cleanly when lualatex is absent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:33:47 +01:00
9c1f552bca
Draw estimate signatures as vector TikZ instead of PNG
The estimate foot now renders the provider and client signatures as
TikZ \draw/\fill paths straight from the captured stroke data, in two
side-by-side minipages with a \useasboundingbox so the boxes align
regardless of stroke coverage. A side that hasn't signed shows a
signature rule.

- TeXHelpers.SignatureToTikz walks the PostIt wire format
  ([k, x0,y0, ...] per stroke, coords in [0, CoordinateMax], y-down)
  and emits one \draw per stroke (round caps/joins, 1.5pt), flipping y
  so the signature is upright on paper; a single-point tap becomes a
  filled dot, mirroring the Avalonia round-capped Polyline.
- FrontOfficeApiController.LoadEstimateForRenderAsync includes
  Estimate.Signatures so SignaturePro/SignatureClient resolve.
- Estimate_tex.cshtml drops the \includegraphics of a PNG (which
  failed the build when the file was absent) and adds \usepackage{tikz}.
- README: document texlive-pictures (tikz) and texlive-fonts-extra
  (bera) as runtime deps; bera.sty missing is the likely cause of the
  remaining host Emergency stop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 16:45:05 +01:00
bbc37d3095
corrige la génération du Pdf 2026-09-22 16:34:51 +01:00
7caae103fe
PDF gen: capture lualatex stdout/stderr into the error message
The detailed LaTeX error (missing .sty, missing \includegraphics
target, …) was being lost: we only redirected stdin, and the engine
writes its diagnostics to the .log file we delete in cleanup. So a
generation failure surfaced only as the terse "Emergency stop" in the
journal, with no clue what actually failed.

Redirect stdout and stderr, and on non-zero exit include them in
GenerationErrorMessage so the caller sees the real engine diagnostic.
Keep the .log file on failure (only clean it on success) so it can be
inspected alongside.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:31:10 +01:00
f44dab0334
README: document lualatex runtime deps for PDF generation
texlive-binaries (lualatex) + texlive-luatex (luaotfload) are required
on the API host for estimate/bill PDF generation. Note that luaotfload
moved from texlive-latex-extra into texlive-luatex on recent Debian.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:28:52 +01:00
97d0584292
Estimate validation polish + PDF/TeX generation in Yavsc.Api
- List removal: GetOngoingEstimatesAsProvider filters on
  ProviderValidationDate so a provider's signed estimate leaves their
  list; PostIt refreshes the list on navigation pop-back.
- Title renames: "Devis en attente de signature fournisseur/client"
  on both the provider and client sides (HomePage, VM, tests).
- Move the estimate/bill TeX+PDF generation flow from Yavsc.Org into
  Yavsc.Api (TeXHelpers, PdfGenerationViewModel, BillViewComponent,
  Bill/Estimate templates, _ViewImports). Program.cs wires
  AddControllersWithViews + SiteSettings; FrontOfficeApiController
  injects IRazorViewEngine (cast to IViewEngine) with optional deps
  and exposes estimate.tex/estimate.pdf endpoints keyed by query id.
- PDF generation drives through the Estimate_pdf view (side-effect
  render) and compiles via the system lualatex, fed TeX on stdin (no
  intermediate .tex), PDF to the bills dir, aux artifacts cleaned so
  only the .pdf persists. Resolve the bills dir to an absolute path so
  lualatex's -output-directory and working directory agree regardless
  of the host cwd (fixes "I can't write on file estimate-1.log").
- PostIt: DownloadAsync on the client + GetEstimateTex/PdfAsync;
  Télécharger PDF/TeX buttons on the validation and command-detail
  pages via a StorageProvider save helper.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:22:01 +01:00
70a123b840
CLI options : -c configurqtion-file.json 2026-09-21 18:53:17 +01:00
adc27a006b
Reopen estimate: carry signature strokes on the estimate payload
Reopening an already-validated estimate showed an empty pad: the
strokes were stored server-side but never sent back. Embed them in the
estimate payload and load them straight into the pad, in the same wire
format PostIt captured — no separate fetch.

- Server: Signature inverse navs get STJ [JsonIgnore] (cycle-safe
  under System.Text.Json, matching ApplicationUser); the four estimate
  read endpoints Include(e => e.Signatures) so strokes ride the payload
- Client: EstimateDto.Signatures with EstimateSignatureDto (Strokes,
  Type=Pro/0|Client/1, CoordinateMax, CapturedAtUtc)
- PostIt: SignaturePadControl.Load(int[]) replaces the buffer and
  raises RedrawRequested; EstimateValidationPageViewModel.LoadExisting
  Signature() picks the perspective's side off the estimate and loads
  it on Attach
- Tests: server asserts GET estimate/{id} carries the strokes; PostIt
  VM tests cover load + side-by-perspective

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 18:03:08 +01:00
827fe6b41f
Split QueryStatus.Accepted into ProAccepted/ClientAccepted
Distinguish the two parties' acceptances: ProAccepted (provider) and
ClientAccepted (client) replace the single Accepted value for new
acceptances. Accepted=2 is retained as a legacy value so existing DB rows
keep their integer mapping.

- FrontOfficeApiController.AcceptQuery sets status by ActorRole
- BillingController provider-ongoing filters match the new values
- FrontOfficeController (MVC) dashboard counts use ProAccepted||Accepted
- PostIt: IsOngoingStatus, GetStatusRank (client-accepted ranks first for
  the pro), and status-badge switches (P/C glyphs) handle the new values
- Tests assert ProAccepted (provider) vs ClientAccepted (client)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:32 +01:00
36ffe4683c
PostIt: validate/refuse an estimate with a signature (pro or client)
Adds the client-side ability to open an estimate from the list, draw a
signature on a reusable SignaturePadView control, and accept or reject it
against /api/v1/front/query/accept|reject. The server resolves the actor
role (provider vs client) from the caller's identity, so the same page works
for either party.

- Yavsc.Api.Client: QueryAcceptanceDto + FrontOfficeApiClient (accept/reject)
- PostIt: extract SignaturePadView UserControl from SignaturePage; new
  EstimateValidationPage (VM + view) with Valider/Refuser/Effacer/Retour;
  wire OpenEstimate entry point from EstimateListPage; register client/page
  in DI + ViewLocator
- Tests: FrontOfficeApiClient + EstimateValidationPageViewModel unit tests;
  update EstimateListPageViewModelTests ctor

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:18 +01:00
2ad7a55e34
Add missing Account/Delete confirmation page
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m44s
The POST Delete(UnregisterViewModel) action and the GET Delete() action
both existed in AccountController, but Views/Account/Delete.cshtml was
missing — so GET /Account/Delete had no page to render. Add the
confirmation view (posts back to Delete with the user's UserId, with a
"Confirmer la suppression" button and an "Annuler" cancel link).

Add a smoke test: an authenticated GET /Account/Delete returns 200 and
the confirmation page body.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:31 +01:00
709846c58d
Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath
FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor
(provider vs client vs admin) from the query's PerformerId/ClientId and
authorize accordingly — a non-party caller is forbidden. Either party may
accept or reject. Acceptance may carry a PostIt signature, which is staged
on the query's linked estimate (Estimate.CommandId == queryId) and sets the
estimate's ProviderValidationDate / ClientValidationDate; a signature with
no linked estimate, or a signature on a reject, returns 400. Admin bypasses
authz but cannot sign.

The shared signature-staging logic (find-or-add the Signature row by
(EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted
into EstimateSignaturePersister, and BillingController.Sign now calls it.
The persister owns no disk I/O and no SaveChanges — the caller persists in
its own transaction.

Signature.Strokes is now the sole source of truth: drop the FilePath
property, remove the on-disk EstimateSignatureFileHelper (and its tests),
and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign
flow is independent of Signature.FilePath and is unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:18 +01:00
9aef10f281
make reinstall, better
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m56s
2026-09-21 14:58:40 +01:00
e28959b068
asciidoc => markdown 2026-09-21 14:39:25 +01:00
22c33c5388
Hide inserted estimates from client list; redirect anon to /signin
Estimates with status Inserted (provider hasn't worked on them yet)
were showing up in the client's "Mes devis à valider" list. The
asclient endpoint now requires ProviderValidationDate to be set, so
only estimates the provider has actually established and submitted
are visible to the client. The asprovider endpoint is unchanged
(providers still need to see incoming unprocessed requests).

Also fix the anonymous-access redirect on Yavsc.Org: the application
cookie's LoginPath defaulted to /Account/Login, which has no
controller. Anonymous hits to a restricted page now redirect to the
real /signin endpoint (with /signout and /Account/AccessDenied set
to match the existing controller routes).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 14:38:49 +01:00
60c10781f0
deploy in pre and prod : bug fixes
All checks were successful
Dotnet build and test / build (pull_request) Successful in 16m30s
2026-09-21 12:49:38 +01:00
625e062b4b
Fix white-on-white comment blocks in quartz theme
Quartz sets white body text on a coloured page background, but
--bs-tertiary-bg is near-white in its light variant. The .blogcomment
background now uses a semi-transparent black overlay so white text
stays legible, matching the dropdown hover fix.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:29:33 +01:00
1455ba5187
Restore tree-structured comments in Blog Details
The $.psc.blogcomment widget (from the removed wwwroot/js/comment.js)
provided reply-to-comment nesting. Rewrite Details.cshtml to drive the
same /api/v1/blogcomments endpoint with ParentId, delegating reply
clicks so they work across the whole nested tree.

Render comment metadata server-side in the Comment view component
template (the widget used to add it client-side) and Include Author so
nested comments get their author instead of relying on the request-scoped
identity map.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:21:33 +01:00
605d885917
Rewrite Blogspot Index view with skip/take pagination
Renders Prev/Next pager from the Index(string id, int skip, int take)
call profile, drops dead .sametitle JS, and replaces the redundant
ReadPermission branch and Minutes>0 modifié check.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:12:20 +01:00
efc2296858
The blogspot iis now in the PostIt app,
All checks were successful
Dotnet build and test / build (pull_request) Successful in 21m55s
no more creation/edition from here
2026-09-21 10:40:49 +01:00
6a69a20364
accessibility 2026-09-21 10:38:09 +01:00
f09c657183
reinstall-prod target 2026-09-21 10:37:39 +01:00
93fd4e61ad
code cleaup
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m25s
2026-09-20 21:06:02 +01:00
d5f11e83a4
reprod deployment 2026-09-20 18:35:27 +01:00
11ae66f85b
--stop-on-fail
All checks were successful
Dotnet build and test / build (pull_request) Successful in 11m44s
2026-09-20 14:12:08 +01:00
8d1348197d
test is ialready skipped when adb is not available
Some checks failed
Dotnet build and test / build (pull_request) Failing after 13m48s
2026-09-20 13:42:45 +01:00
483abb78ec
nuget referencezs updated
Some checks failed
Dotnet build and test / build (pull_request) Failing after 15m42s
2026-09-20 01:53:20 +01:00
b3c2674235
Use AvaloniaEdit 2026-09-20 00:21:07 +01:00
db5c202918
Publish, bug fix 2026-09-19 22:20:52 +01:00
1773c5aaea
refacto publication 2026-09-19 21:46:08 +01:00
67da921bce
Blog Post Id 2026-09-19 21:08:10 +01:00
fb0d9589cf Merge pull request 'Estimate managment from PostIt' (#53) from feat/sign-estimate into main
All checks were successful
Dotnet build and test / build (push) Successful in 14m55s
Reviewed-on: #53
2026-09-19 18:39:25 +01:00
c450bd7ff8
Updated
Some checks failed
Dotnet build and test / build (pull_request) Failing after 16m28s
2026-09-19 18:09:02 +01:00
827cf7d2a3
Org Markdown : Markdig
All checks were successful
Dotnet build and test / build (pull_request) Successful in 6m22s
2026-09-19 18:00:09 +01:00
44f0664a5d
optimisation of workflows : Skiasharp deps already installed
All checks were successful
Dotnet build and test / build (pull_request) Successful in 11m20s
Forgejo Release / release (push) Successful in 6m11s
2026-09-19 16:37:57 +01:00
a12830e49b
test(blogs): configure SiteSettings.Blog in MappedClaimsBlogsWebServerFixture
Some checks failed
Forgejo Release / release (push) Failing after 6m42s
Dotnet build and test / build (pull_request) Failing after 5m30s
BlogSpotService's constructor throws InvalidOperationException
("SiteSettings.Blog is not configured.") when IOptions<SiteSettings>.Blog
is null, so every request to BlogApiController 500'd resolving the
service — all 3 BlogApiMappedClaimsTests were failing with
InternalServerError.

The mapped-claims fixture never configured SiteSettings (the main
BlogsWebServerFixture does, pointing Blog at a per-run temp dir).
Mirror that: Configure<SiteSettings>(s => s.Blog = <temp dir>)
before AddScoped<BlogSpotService>(). These tests are JSON-only
(AttachFiles short-circuits), but the service must be constructible.

Verified: BlogApiMappedClaimsTests 3/3 green; full Yavsc.Blogs.Tests
37/37 green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 14:14:28 +01:00
2d8f92dde9
build: disable StaticWebAssets cache to stop MSB4018 race
DefineStaticWebAssets.ReadOrCreateCache opens the *.dswa.cache.json
files under obj/.../ via File.OpenRead with no try/catch, so under
parallel solution builds a sharing-violation / TOCTOU race on the cache
file escapes as MSB4018 and fails the whole build (seen on
Yavsc.Blogs during the rc15 solution build).

Set StaticWebAssetsCacheDefineStaticWebAssetsEnabled=false so
CacheManifestPath is empty and ReadOrCreateCache short-circuits to a
no-file-I/O path. The cache is a pure build-speed optimization; no
effect on output correctness, only on incremental rebuild speed of the
wwwroot-light web projects.

Verified: Yavsc.Api (transitive web deps) and Yavsc.Blogs.Tests build
clean, and zero *.dswa.cache.json files are produced.

Refs:
  https://source.dot.net/Microsoft.NET.Sdk.StaticWebAssets.Tasks/DefineStaticWebAssets.Cache.cs.html
  https://github.com/dotnet/sdk/issues/49039

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 13:53:25 +01:00
156b9c46e1
changelog: add 1.0.8-rc15 entry
Cover the 60 commits since 1.0.8-rc14: blog attachments + Markdown
preview, the Estimate pipeline (list/edition/validation + API + EF
migrations), provider ongoing requests, file-ACL and genericEstimate
migrations, POSTIT_SETTINGS_JSON, billing filtered by user, shared
YavscMessageSender, Gitleaks CI, and the test stabilization wave.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 12:56:02 +01:00
1a968346a4
preview the Markdown
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m16s
2026-09-18 19:45:35 +01:00
574616c786
+MarkdownViewer
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m45s
2026-09-18 13:58:31 +01:00
bc6bebea06
post the file
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-09-18 13:38:42 +01:00
9e8d00d78f
corrige la création du lien
All checks were successful
Dotnet build and test / build (pull_request) Successful in 15m51s
2026-09-18 12:13:00 +01:00
ac1ba2ee68
test: cover BlogApiClient.CreateMultipartContent shape + server round-trip
Expose CreateMultipartContent as public so the blogs backend tests can
assert on its wire shape directly and round-trip it through the live
BlogsWebServerFixture host. New CreateMultipartContentTests:
- shape: blog part is camelCase JSON with WhenWritingNull, one file
  part per attachment (filename, Content-Type, octet-stream fallback);
- round-trip: POST the built body to /api/v1/blogspot, assert 201
  Created + attachment row + file landed under BlogFilesRoot.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-18 11:39:45 +01:00
eebd46176d
-Config.Temp
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m53s
2026-09-18 11:13:44 +01:00
3bc4142383
refacto site settings
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m29s
2026-09-17 23:54:36 +01:00
3e7e76edf2
BlogAttachmentLinkTests
All checks were successful
Dotnet build and test / build (pull_request) Successful in 16m9s
2026-09-17 00:57:52 +01:00
96254b52cd
Supprime un faux message d'erreur
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m27s
2026-09-16 19:48:13 +01:00
d47dd0f4ba
better indications
All checks were successful
Dotnet build and test / build (pull_request) Successful in 10m36s
2026-09-16 18:53:17 +01:00
51a2a40c88
new defaults
All checks were successful
Dotnet build and test / build (pull_request) Successful in 21m36s
2026-09-16 18:23:52 +01:00
2b1c3d2a9b
code cleanup 2026-09-16 18:23:42 +01:00
ef9ee2fb87
fixes the upload 2026-09-16 18:23:27 +01:00
110ed279f5
Estimate lists
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m1s
2026-09-15 00:19:23 +01:00
48cacb74cb Merge pull request 'feat/files-control' (#52) from feat/files-control into main
Some checks failed
Dotnet build and test / build (push) Failing after 8m49s
Reviewed-on: #52
2026-09-14 10:20:31 +01:00
48ffe835df
install native dependencies
All checks were successful
Dotnet build and test / build (pull_request) Successful in 11m31s
2026-09-14 10:07:10 +01:00
2cd24dd383
fixes the pipeline
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m44s
2026-09-14 00:47:28 +01:00
c3c5494507
removes the ItemGroup.Using Include=Xunit
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-09-14 00:40:30 +01:00
75e1499bb1
build before testing (sunit not found by MSBuild task)
Some checks failed
Dotnet build and test / build (pull_request) Failing after 10m49s
2026-09-14 00:02:54 +01:00
0c5bd61991
Merge remote-tracking branch 'origin/main' into feat/files-control
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5m26s
2026-09-13 23:39:41 +01:00
2484876c5b
The Estimate is validated
Some checks failed
Dotnet build and test / build (pull_request) Failing after 7m28s
2026-09-13 23:29:06 +01:00
848f8be42f
Estimate UI
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5m20s
2026-09-13 18:54:27 +01:00
f70f487777
better and simpler UI 2026-09-13 17:51:42 +01:00
1af839e14e
POSTIT_SETTINGS_JSON env var
Some checks failed
Dotnet build and test / build (pull_request) Failing after 10m54s
2026-09-13 13:34:39 +01:00
f7e1f4a736
list only my billing codes
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m23s
2026-09-13 03:49:07 +01:00
7c565d3af9
take the scanning in account 2026-09-13 03:25:09 +01:00
9832d28173
use Sqlite 2026-09-13 03:22:22 +01:00
cbb59f019d
got saved a RdvQuery 2026-09-13 03:09:12 +01:00
67715f9d67
tests en vert
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5m36s
2026-09-12 17:31:18 +01:00
d92456e141
fixe tests
Some checks failed
Dotnet build and test / build (pull_request) Failing after 6m27s
2026-09-12 16:49:36 +01:00
5b48e0bfdb
GetOpenIdConfiguration_returns_ok against testing host 2026-09-12 14:30:50 +01:00
3836faf872
Use a shared YavscMessageSender 2026-09-12 13:57:25 +01:00
2397f4d3a8
Add provider ongoing requests flow and sort persistence 2026-09-12 13:29:49 +01:00
86091990c5
generic estimate 2026-09-12 12:59:44 +01:00
ddff760c41
fixes the tests
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m6s
2026-09-11 18:37:28 +01:00
344c38704b
MainPage => BlogsPage 2026-09-11 18:37:26 +01:00
af1a0e0bf9
Bad test, fix the test 2026-09-11 18:37:25 +01:00
c665a3e95d
Test the release, don't ignore test return code 2026-09-11 18:37:24 +01:00
3824a928fe
Bad test, fix the test
Some checks failed
Dotnet build and test / build (push) Failing after 7m26s
2026-09-11 13:42:29 +01:00
77e521b96d
Test the release, don't ignore test return code
Some checks failed
Dotnet build and test / build (push) Failing after 3m7s
2026-09-11 13:25:32 +01:00
91fc7f168b
implements a dico 2026-09-11 12:45:13 +01:00
3626be3d8b
We don't need these "release" branches
All checks were successful
Dotnet build and test / build (pull_request) Successful in 10m23s
2026-09-11 11:01:46 +01:00
7b842a9fe9
user the CommandBar
All checks were successful
Dotnet build and test / build (pull_request) Successful in 9m17s
2026-09-07 20:53:23 +01:00
b75d0add7d
db testing 2026-09-07 20:02:56 +01:00
555087232c
Removes an abusive assertion, that went wrong
All checks were successful
Dotnet build and test / build (pull_request) Successful in 10m58s
2026-09-07 19:16:23 +01:00
41d8fad8c3
Post Files 2026-09-07 15:41:33 +01:00
d8c0678462
code reorg 2026-09-07 14:39:53 +01:00
93426986f6
code format 2026-09-07 14:12:34 +01:00
ec87e8db15
scan the commit
All checks were successful
Dotnet build and test / build (pull_request) Successful in 6m30s
2026-09-07 13:20:50 +01:00
bb4792d8ae
Gitleaks integration
All checks were successful
Dotnet build and test / build (pull_request) Successful in 3m31s
2026-09-07 12:56:48 +01:00
f7d5b21d66
cleanup 2026-09-07 12:28:24 +01:00
e79fba7b66
retest
All checks were successful
Dotnet build and test / build (pull_request) Successful in 2m17s
2026-09-07 12:21:38 +01:00
d65f78d985
test
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-09-07 12:20:55 +01:00
7a4ca76957
picket scan
All checks were successful
Dotnet build and test / build (pull_request) Successful in 2m43s
2026-09-07 12:08:57 +01:00
c902852a24
Secret scan
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5s
2026-09-07 11:57:00 +01:00
38bc659a58
CircleAuthorizationToFile EF migration
All checks were successful
Dotnet build and test / build (pull_request) Successful in 6m15s
2026-09-07 11:02:41 +01:00
10fdcb288c
unique email handling 2026-09-07 10:58:20 +01:00
229971783a
code reorg 2026-09-07 10:30:45 +01:00
73 changed files with 8776 additions and 161 deletions

View file

@ -44,7 +44,7 @@ jobs:
- name: Secret scan - name: Secret scan
run: | run: |
echo "🔍 Scanning for secrets..." echo "🔍 Scanning for secrets..."
cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n10 \ cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts main..HEAD \
- name: Build - name: Build
run: | run: |
echo "🏗️ Building the solution..." echo "🏗️ Building the solution..."

6
.gitignore vendored
View file

@ -43,3 +43,9 @@ DataDir/
*.log *.log
# gitleaks scan output (local secret-scan report, not for the repo)
/gitleaks-report.json
/test-report.html
/test-report.xml

6
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,6 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks

10
.vscode/launch.json vendored
View file

@ -43,9 +43,15 @@
}, },
{ {
"name": "Yavsc Org", "name": "Yavsc Org",
"type": "dotnet", "type": "coreclr",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", "program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"cwd": "${workspaceFolder}/src/Yavsc.Org",
"preLaunchTask": "build-web-yavsc-org"
}, },
{ {
"name": "Yavsc Blogs", "name": "Yavsc Blogs",

28
.vscode/tasks.json vendored
View file

@ -89,6 +89,34 @@
}, },
"isBackground": true "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", "label": "dotnet: build-postit-desktop",
"type": "process", "type": "process",

View file

@ -1,5 +1,68 @@
# Changelog # Changelog
## [1.0.8-rc17] - unstable
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
API), système de génération `contrib` (vhosts nginx HTTP/3 edge +
appsettings jq + QUIC logiciel Kestrel), et correction du bug de
production où `IsPublished` disparaissait du payload de liste du blog API.
### Added
* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion
des fichiers de l'utilisateur, entrée depuis la page d'accueil.
* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis
le détail de demande et la page d'édition de devis.
* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis
(`EstimateApiController`) et les demandes front-office
(`FrontOfficeApiController`).
* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes
d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`.
* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`,
`BlogAttachedFile`, `AttachFileRequest` ; migration EF Core
`UserFileAttachments`.
* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge
(`template.vhost`, `QUIC_OPTS`), appsettings générés via jq
(`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des
secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et
`README.nginx.md`.
* [Tests] Projet `Yavsc.Server.Tests` : tests du service
`BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:`
par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`).
* [Tests] `IBlogPostWireShapeTests` (forme du wire blog),
`EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`,
`FileSystemApiTests` (pièces jointes et stockage perso).
* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`).
### Changed
* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en
production sur le vhost Org. La preprod et la prod partageant la même
IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) —
serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser
la config par nginx.
* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL
provient de la variable d'environnement
`YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en
source.
### Fixed
* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload
de la liste du blog API. Le endpoint `GetBlogspot` retourne
`IEnumerable<IBlogPost>` 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 ## [1.0.8-rc16] - unstable
Flux de signature et de génération de devis : un devis peut désormais être Flux de signature et de génération de devis : un devis peut désormais être

View file

@ -24,7 +24,7 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
@cat template.service | APP_NAME="$*" \ @cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \ DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \ HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$*_$(HTTP_PORT)" \ HTTP_PORT="$($*_PORT)" \
APPDIR="$(APPDIR)" \ APPDIR="$(APPDIR)" \
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
POSTGRES_HOST="$(POSTGRES_HOST)" \ POSTGRES_HOST="$(POSTGRES_HOST)" \
@ -45,6 +45,76 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service 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 : <prefix><subdomain>.<DOMAIN_ROOT>
# 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 install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$* sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*

134
contrib/README.nginx.md Normal file
View file

@ -0,0 +1,134 @@
# Vhosts nginx — génération par `make generate-nginx`
Les vhosts nginx sont **générés** à l'image des unités de service systemd
(`template.service` + `envsubst` → `generated/*.service`) : un template
unique `contrib/template.vhost` est substitué par `envsubst` vers
`contrib/generated/nginx/`, qui est **gitignoré**.
## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`)
nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec
h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`).
Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les
`template.appsettings-*.json`) est une capacité complémentaire — non
joignable par les clients derrière nginx (nginx OSS = upstream h1.1
uniquement), mais disponible si Kestrel est un jour exposé en direct.
Directives générées (par vhost) :
```nginx
listen 443 ssl; listen [::]:443 ssl; http2 on;
listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
ssl_protocols TLSv1.3;
```
`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle
`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts
confondus).
## Prérequis côté host
1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;`
exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`,
`listen ... quic` suffit à activer h3) :
```bash
nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide
```
Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon,
backports ou `nginx-full`.
2. **UDP 443 ouvert** :
```bash
sudo ufw allow 443/udp
sudo ufw status | grep '443/udp' # → ALLOW
```
3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le
host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t`
échoue sur le bind IPv6).
4. Certbot / certs Let's Encrypt déjà en place (chemins
`/etc/letsencrypt/live/<domain>/{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 : `<prefix><subdomain>.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).

View file

@ -0,0 +1,33 @@
{
"Site": {
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["api"],
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN"
],
"Bills": "bills",
"TempDir": "temp"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:92"
},
"Https": {
"Url": "https://localhost:93",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -0,0 +1,40 @@
{
"Site": {
"Title": "Yavsc Blogs",
"Description": "A collection of blogs about software development, technology, and programming.",
"Keywords": "software development, technology, programming, coding, blogs",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["blogs"],
"CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN"
],
"Blog": "blog"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Authentication": "Debug"
}
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:90"
},
"Https": {
"Url": "https://localhost:91",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -0,0 +1,74 @@
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"AllowedHosts": "*",
"Site": {
"Title": "yavsc",
"Slogan": "Yavsc!",
"StyleSheet": "/css/default.css",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": [
"blogs",
"api"
],
"ExternalUrl": "https://preprod-yavsc.$DOMAIN",
"ApiUrl": "https://preprod-api.$DOMAIN",
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN",
"https://preprod-api.$DOMAIN"
],
"Banner": "/images/banner-dewang.jpg",
"Owner": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Admin": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Quota": 200000000,
"Blog": "blog",
"Avatars": "avatars",
"Bills": "bills",
"TempDir": "temp",
"DataDir": "data",
"GitRepository": "git"
},
"Smtp": {
"Host": "mercure.pschneider.fr",
"Port": 465,
"SenderName": "Paul Schneider",
"SenderEmail": "paul@pschneider.fr",
"UserName": "paul",
"Password": "$SMTP_PASSWORD"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"Moderation": {
"AutoApproveThreshold": 0.7,
"AutoRejectThreshold": 0.9
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:88"
},
"Https": {
"Url": "https://localhost:89",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

61
contrib/template.vhost Normal file
View file

@ -0,0 +1,61 @@
# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER.
# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT
#
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un
# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443
# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel,
# cf. template.appsettings-*.json — capacité, non joignable derrière nginx).
#
# Variables substituées par envsubst (allowlist) :
# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS
# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production
# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport
# une seule fois par addr:port (443/udp) — or preprod et prod partagent la
# même IP, donc seule la prod (Org) l'active.
# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont
# préservées (hors allowlist).
# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges
# ACME ; ne pas servir l'app en HTTP clair.)
server {
listen 80;
listen [::]:80;
server_name $DOMAIN;
return 301 https://$host$request_uri;
}
server {
# 1. TCP : HTTPS classique + HTTP/2
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS).
listen 443 quic$QUIC_OPTS;
listen [::]:443 quic$QUIC_OPTS;
http3 on;
server_name $DOMAIN;
# 3. Certificats Let's Encrypt (Certbot).
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot
# QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP :
# `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.)
ssl_protocols TLSv1.3;
# 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur.
add_header Alt-Svc 'h3=":443"; ma=86400' always;
location / {
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 100s;
proxy_buffering off;
}
include server-status.conf;
}

View file

@ -11,7 +11,7 @@
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View file

@ -22,7 +22,8 @@ public class EstimateEditionPageViewModelTests
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null) private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{ {
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client); var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
} }
[Fact] [Fact]
@ -143,10 +144,8 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null); await vm.SendCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/estimate", api.LastPath); var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
Assert.Equal(HttpMethod.Post, api.LastMethod); var payload = Assert.IsType<EstimateDto>(postCall.Body);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(42, payload.CommandId); Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId); Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType); Assert.Equal("Brush", payload.CommandType);
@ -216,7 +215,8 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null); await vm.SendCommand.ExecuteAsync(null);
var payload = Assert.IsType<EstimateDto>(api.LastBody); var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
var payload = Assert.IsType<EstimateDto>(postCall.Body);
Assert.Equal(2, payload.Bill.Count); Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost); Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent); Assert.True(vm.HasSent);
@ -230,11 +230,20 @@ public class EstimateEditionPageViewModelTests
public object? LastBody { get; private set; } public object? LastBody { get; private set; }
public Exception? Failure { get; init; } public Exception? Failure { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default) /// <summary>Historique complet des appels (le plus récent en dernier).</summary>
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
{ {
LastMethod = method; LastMethod = method;
LastPath = path; LastPath = path;
LastBody = body; LastBody = body;
Calls.Add((method, path, body));
}
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (Failure is not null) if (Failure is not null)
{ {
@ -253,9 +262,7 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{ {
LastMethod = method; Record(method, path, body);
LastPath = path;
LastBody = body;
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -265,6 +272,12 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct); => CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -22,6 +22,7 @@ public static class ServiceCollectionHelpers
var api = new YavscApiClient(settings, tokenStore); var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl); var client = new BlogApiClient(api, settings.BlogsApiUrl);
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
@ -65,6 +66,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<EstimateEditionPage>(); services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>(); services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>(); services.AddTransient<EstimateValidationPage>();
services.AddTransient<MyFilesPage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
@ -77,6 +79,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton(estimateClient); services.AddSingleton(estimateClient);
services.AddSingleton(frontClient); services.AddSingleton(frontClient);
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding); services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();

View file

@ -5,7 +5,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -56,6 +56,7 @@ public class ViewLocator : IDataTemplate
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(), EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(), EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(), EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -132,14 +132,18 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
{ {
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>(); var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
Activity, Activity,
Performer, Performer,
Form, Form,
_billingClient, _billingClient,
frontClient, frontClient,
fsClient,
details, details,
IsReadOnly); IsReadOnly,
canAttach: !IsReadOnly);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -15,8 +16,17 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
{ {
private readonly BillingApiClient _billingClient; private readonly BillingApiClient _billingClient;
private readonly FrontOfficeApiClient _frontClient; private readonly FrontOfficeApiClient _frontClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQueryDetailsDto _details; private readonly BillingQueryDetailsDto _details;
/// <summary>
/// Vrai lorsque l'utilisateur courant est le client de la demande
/// et peut y attacher/détacher des fichiers de son espace perso.
/// Le fournisseur consulte et télécharge les pièces mais n'attache
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
/// </summary>
public bool CanAttach { get; }
public ActivityInfo Activity { get; } public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; } public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; } public CommandFormSummary Form { get; }
@ -52,6 +62,10 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
[ObservableProperty] [ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public override bool CanNavigateNext public override bool CanNavigateNext
{ {
get => false; get => false;
@ -70,20 +84,27 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
CommandFormSummary form, CommandFormSummary form,
BillingApiClient billingClient, BillingApiClient billingClient,
FrontOfficeApiClient frontClient, FrontOfficeApiClient frontClient,
UserFilesApiClient fsClient,
BillingQueryDetailsDto details, BillingQueryDetailsDto details,
bool isReadOnly) bool isReadOnly,
bool canAttach)
{ {
Activity = activity ?? throw new ArgumentNullException(nameof(activity)); Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form)); Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient)); _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
_details = details ?? throw new ArgumentNullException(nameof(details)); _details = details ?? throw new ArgumentNullException(nameof(details));
IsReadOnly = isReadOnly; IsReadOnly = isReadOnly;
CanAttach = canAttach;
this.SetInfoStatus("Details de commande charges."); this.SetInfoStatus("Details de commande charges.");
} }
/// <summary>Constructeur pour le designer Avalonia.</summary>
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
[RelayCommand] [RelayCommand]
private async Task OpenEditorAsync() private async Task OpenEditorAsync()
{ {
@ -134,6 +155,110 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>Charge les pièces jointes de la demande.</summary>
public async Task InitializeAsync()
{
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task LoadQueryAttachmentsAsync()
{
try
{
var list = await _frontClient
.GetQueryAttachmentsAsync(BillingCode, Id)
.ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
// Non bloquant : les pièces jointes sont un complément.
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
}
}
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché à la demande par référence via
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
/// (<see cref="CanAttach"/>).
/// </summary>
[RelayCommand]
private async Task AddQueryAttachmentAsync()
{
if (!CanAttach) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var billingCode = BillingCode;
var queryId = Id;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
{
if (attachment is null || !CanAttach) return;
IsBusy = true;
try
{
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée de la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
/// <summary> /// <summary>
/// Download the estimate linked to this command as a LaTeX source /// Download the estimate linked to this command as a LaTeX source
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is /// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is

View file

@ -25,6 +25,7 @@ namespace PostIt.ViewModels;
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly EstimateApiClient _estimateClient; private readonly EstimateApiClient _estimateClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQuerySummaryDto _query; private readonly BillingQuerySummaryDto _query;
public long QueryId => _query.Id; public long QueryId => _query.Id;
@ -68,6 +69,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
[ObservableProperty] [ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
/// <summary>Identifiant du devis, renseigné après l'envoi.</summary>
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))]
public partial long? EstimateId { get; set; }
/// <summary>Pièces jointes rattachées au devis (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public decimal Total => Lines.Sum(line => line.LineTotal); public decimal Total => Lines.Sum(line => line.LineTotal);
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}"; public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
@ -86,10 +95,14 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
protected set { _ = value; } protected set { _ = value; }
} }
public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient) public EstimateEditionPageViewModel(
BillingQuerySummaryDto query,
EstimateApiClient estimateClient,
UserFilesApiClient fsClient)
{ {
_query = query ?? throw new ArgumentNullException(nameof(query)); _query = query ?? throw new ArgumentNullException(nameof(query));
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
EstimateDescription = query.Description ?? string.Empty; EstimateDescription = query.Description ?? string.Empty;
Lines.CollectionChanged += OnLinesCollectionChanged; Lines.CollectionChanged += OnLinesCollectionChanged;
@ -98,6 +111,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée."); this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
} }
/// <summary>Constructeur pour le designer Avalonia.</summary>
public EstimateEditionPageViewModel() : this(null!, null!, null!) { }
[RelayCommand] [RelayCommand]
private void AddLine() private void AddLine()
{ {
@ -141,9 +157,11 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true); var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
HasSent = true; HasSent = true;
EstimateId = created.Id;
OnPropertyChanged(nameof(SendLabel)); OnPropertyChanged(nameof(SendLabel));
this.SetInfoStatus( this.SetInfoStatus(
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée."); $"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
await LoadAttachmentsAsync().ConfigureAwait(true);
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{ {
@ -171,6 +189,106 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>
/// Charge les pièces jointes rattachées au devis courant
/// (<c>GET estimate/{id}/attachments</c>).
/// </summary>
[RelayCommand]
private async Task LoadAttachmentsAsync()
{
if (EstimateId is null || _estimateClient is null) return;
try
{
var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}");
}
}
private bool CanAddAttachment() => EstimateId is not null && !IsBusy;
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché au devis par référence via
/// <c>POST estimate/{id}/attachments</c>.
/// </summary>
[RelayCommand(CanExecute = nameof(CanAddAttachment))]
private async Task AddAttachmentAsync()
{
if (EstimateId is null) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var estimateId = EstimateId.Value;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true);
await LoadAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée au devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachFileAsync(AttachmentDto? attachment)
{
if (attachment is null || EstimateId is null) return;
IsBusy = true;
try
{
await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée du devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
internal EstimateDto BuildPayload() internal EstimateDto BuildPayload()
{ {
return new EstimateDto return new EstimateDto

View file

@ -155,6 +155,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
.ConfigureAwait(true); .ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery); var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>(); var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
activity, activity,
@ -162,9 +163,12 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
form, form,
_billingClient, _billingClient,
frontClient, frontClient,
fsClient,
details, details,
isReadOnly: false); isReadOnly: false,
canAttach: false);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
catch (Exception ex) catch (Exception ex)
@ -243,7 +247,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
throw new InvalidOperationException("Application PostIt indisponible."); throw new InvalidOperationException("Application PostIt indisponible.");
} }
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient); var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }

View file

@ -35,12 +35,14 @@ public class HomePageViewModel : ViewModelBase
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client)); OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
} }
public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; } public IAsyncRelayCommand OpenProviderRequests { get; }
public IAsyncRelayCommand OpenClientEstimates { get; } public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; } public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; }
private async Task OpenActivitiesAsync() private async Task OpenActivitiesAsync()
{ {
@ -101,6 +103,29 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }
/// <summary>
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
/// servie par le host Blogs via <c>api/v1/fs</c>.
/// </summary>
private async Task OpenMyFilesAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var fsClient = app.ServiceProvider?.GetRequiredService<Yavsc.Api.Client.UserFilesApiClient>();
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new MyFilesViewModel(fsClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary> /// <summary>
/// Avalonia designer constructor. Builds a self-contained VM /// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can /// with a freshly-constructed Settings so the XAML preview can

View file

@ -0,0 +1,229 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Gestion de l'espace de stockage personnel (« My Files ») :
/// navigation dans l'arbre, téléversement, suppression, téléchargement.
/// Sert aussi de **sélecteur** pour attacher un fichier à un devis
/// (fournisseur) ou à une demande (client) par référence : lorsque
/// <see cref="IsPickerMode"/> est vrai, la sélection d'un fichier invoque
/// <see cref="_onFilePicked"/> avec l'identifiant <see cref="FileEntryDto.Id"/>
/// puis revient à la page précédente.
/// </summary>
public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly UserFilesApiClient _fs;
private readonly Action<long>? _onFilePicked;
/// <summary>
/// Sous-répertoire courant, relatif à la racine de l'espace perso
/// (chaîne vide à la racine). Servi tel quel à <c>GET/POST fs/{subdir}</c>.
/// </summary>
[ObservableProperty]
public partial string CurrentPath { get; set; } = string.Empty;
public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath)
? "Mes fichiers"
: $"Mes fichiers / {CurrentPath}";
public bool IsPickerMode => _onFilePicked is not null;
public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath);
[ObservableProperty]
public partial ObservableCollection<FileEntryDto> Files { get; set; } = new();
[ObservableProperty]
public partial ObservableCollection<DirEntryDto> SubDirectories { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public MyFilesViewModel(UserFilesApiClient fs, Action<long>? onFilePicked = null)
{
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
_onFilePicked = onFilePicked;
}
/// <summary>Constructeur pour le designer Avalonia.</summary>
public MyFilesViewModel() : this(null!) { }
public async Task InitializeAsync()
{
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task RefreshAsync()
{
IsBusy = true;
try
{
var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true);
Files.Clear();
SubDirectories.Clear();
if (dir?.Files is not null)
foreach (var f in dir.Files) Files.Add(f);
if (dir?.SubDirectories is not null)
foreach (var d in dir.SubDirectories) SubDirectories.Add(d);
this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s).");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur de listage : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task OpenDirAsync(DirEntryDto? dir)
{
if (dir is null) return;
CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task UpAsync()
{
if (!CanGoUp) return;
CurrentPath = ParentPath(CurrentPath);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
/// <summary>Téléversement déclenché par le code-behind (picker OS).</summary>
public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection<UploadFile> uploads)
{
if (uploads is null || uploads.Count == 0) return;
IsBusy = true;
try
{
var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true);
this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s).");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléversement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DeleteFileAsync(FileEntryDto? file)
{
if (file is null) return;
IsBusy = true;
try
{
await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true);
this.SetInfoStatus($"Fichier supprimé.");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de la suppression : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null) return;
IsBusy = true;
try
{
var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task PickFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null)
{
this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord.");
return;
}
var callback = _onFilePicked;
if (callback is null)
{
// Mode gestion : le téléchargement est l'action par défaut.
await DownloadFileAsync(file).ConfigureAwait(true);
return;
}
callback(file.Id.Value);
await GoBackAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task BackAsync()
{
await GoBackAsync().ConfigureAwait(true);
}
private static async Task GoBackAsync()
{
var app = (App?)Application.Current;
if (app is null) throw new InvalidOperationException("Application PostIt indisponible.");
await app.GoBackAsync().ConfigureAwait(true);
}
private static string CombinePath(string current, string name)
{
if (string.IsNullOrWhiteSpace(current)) return name;
return $"{current}/{name}";
}
private static string ParentPath(string current)
{
if (string.IsNullOrWhiteSpace(current)) return string.Empty;
var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx];
}
}

View file

@ -1,6 +1,7 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.BillingQueryDetailsPage" x:Class="PostIt.Views.BillingQueryDetailsPage"
x:DataType="vm:BillingQueryDetailsPageViewModel" x:DataType="vm:BillingQueryDetailsPageViewModel"
@ -80,6 +81,53 @@
</Grid> </Grid>
</Border> </Border>
<!-- Pièces jointes (par référence à l'espace perso du client) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddQueryAttachmentCommand}"
IsVisible="{Binding CanAttach}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DownloadQueryAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DetachQueryFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[vm:BillingQueryDetailsPageViewModel].CanAttach}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Expander Header="Metadonnees techniques" <Expander Header="Metadonnees techniques"
IsExpanded="False"> IsExpanded="False">
<Border BorderThickness="1" <Border BorderThickness="1"

View file

@ -1,6 +1,7 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateEditionPage" x:Class="PostIt.Views.EstimateEditionPage"
x:DataType="vm:EstimateEditionPageViewModel" x:DataType="vm:EstimateEditionPageViewModel"
@ -133,6 +134,52 @@
</Grid> </Grid>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Pièces jointes (par référence à l'espace perso du fournisseur) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
IsVisible="{Binding HasSent}">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddAttachmentCommand}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DownloadAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DetachFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>

View file

@ -34,5 +34,9 @@
Command="{Binding OpenProviderEstimates}" Command="{Binding OpenProviderEstimates}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes fichiers"
Command="{Binding OpenMyFiles}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel> </StackPanel>
</ContentPage> </ContentPage>

View file

@ -0,0 +1,90 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.MyFilesPage"
x:DataType="vm:MyFilesViewModel"
Header="Mes fichiers">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding PathLabel}"
FontSize="20"
FontWeight="Bold" />
<TextBlock Text="Espace de stockage personnel. Téléversez vos fichiers puis attachez-les à un devis ou une demande."
Opacity="0.7"
TextWrapping="Wrap" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
<Button Grid.Column="2"
Content="Remonter"
Command="{Binding UpCommand}"
IsEnabled="{Binding CanGoUp}" />
<Button Grid.Column="4"
Content="Téléverser…"
Click="Upload_Click"
HorizontalAlignment="Right" />
</Grid>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="10">
<ItemsControl ItemsSource="{Binding SubDirectories}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:DirEntryDto">
<Button Content="📁 {Binding Name}"
Command="{Binding $parent[vm:MyFilesViewModel].OpenDirCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Left"
Margin="0,0,0,4" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:FileEntryDto">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Name}"
VerticalAlignment="Center"
TextWrapping="NoWrap" />
<Button Grid.Column="1"
Content="Joindre"
Command="{Binding $parent[vm:MyFilesViewModel].PickFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[vm:MyFilesViewModel].IsPickerMode}" />
<Button Grid.Column="3"
Content="Télécharger"
Command="{Binding $parent[vm:MyFilesViewModel].DownloadFileCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="5"
Content="Supprimer"
Command="{Binding $parent[vm:MyFilesViewModel].DeleteFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,58 @@
using System;
using System.IO;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Interactivity;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class MyFilesPage : ContentPage
{
public MyFilesPage()
{
InitializeComponent();
}
private async void Upload_Click(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not MyFilesViewModel vm)
return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir des fichiers à téléverser dans Mes fichiers",
AllowMultiple = true,
});
if (files.Count == 0)
return;
var uploads = new List<Yavsc.Api.Client.UploadFile>(files.Count);
foreach (var file in files)
{
await using var stream = await file.OpenReadAsync();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
uploads.Add(new Yavsc.Api.Client.UploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
}
await vm.UploadAsync(uploads);
}
private static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}
}

View file

@ -15,5 +15,23 @@ namespace Yavsc.Blogspot
// BlogPost entity implements this getter by mapping its // BlogPost entity implements this getter by mapping its
// ApplicationUser navigation to a BlogPostAuthorDto. // ApplicationUser navigation to a BlogPostAuthorDto.
BlogPostAuthorDto? Author { get; } BlogPostAuthorDto? Author { get; }
/// <summary>
/// Whether this post is published. The server derives this
/// from the existence of a row in <c>BlogSpotPublication</c>
/// and serves it on the wire so clients (PostIt) can render
/// the current publication state without a follow-up request.
///
/// <para>This MUST be declared on the interface — not just on
/// the concrete <c>BlogPost</c> entity / <c>BlogPostDto</c> —
/// because the blog API's list endpoint returns
/// <c>IEnumerable&lt;IBlogPost&gt;</c>, and the blog API host
/// serialises with System.Text.Json, which emits only the
/// members of the declared (interface) type. When
/// <c>IsPublished</c> lived only on the concrete classes, STJ
/// dropped it from the list payload and PostIt read
/// <c>false</c> for every post, including published ones.</para>
/// </summary>
bool IsPublished { get; set; }
} }
} }

View file

@ -8,7 +8,17 @@
public DateTime CreationTime { get; set; } public DateTime CreationTime { get; set; }
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
/// <summary>
/// Identifiant de la ligne <c>UploadedFile</c> associée, quand le
/// fichier a été téléversé via <c>api/v1/fs</c> (donc référencé en
/// base). <c>null</c> pour un fichier présent sur disque sans ligne
/// de métadonnées (fichier antérieur à la fonctionnalité, ou non
/// encore backfillé). C'est cette valeur que le client lourd
/// (PostIt) envoie pour attacher le fichier à un devis/demande.
/// </summary>
public long? Id { get; set; }
} }

View file

@ -11,7 +11,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
namespace Yavsc.Api.Client;
/// <summary>
/// Représentation d'un répertoire de l'espace de stockage personnel,
/// renvoyée par <c>GET api/v1/fs/{subdir}</c> (host Blogs). Ne dépend pas
/// des helpers serveur : c'est un pur DTO de désérialisation.
/// </summary>
public sealed class UserDirectoryDto
{
public string? UserName { get; set; }
public string? SubPath { get; set; }
public FileEntryDto[] Files { get; set; } = Array.Empty<FileEntryDto>();
public DirEntryDto[] SubDirectories { get; set; } = Array.Empty<DirEntryDto>();
}
public sealed class FileEntryDto
{
/// <summary>Identifiant de la ligne UploadedFile, pour l'attachement par référence.</summary>
public long? Id { get; set; }
public string? Name { get; set; }
public long Size { get; set; }
public DateTime LastModified { get; set; }
}
public sealed class DirEntryDto
{
public string? Name { get; set; }
}
/// <summary>
/// Métadonnées d'une pièce jointe (fichier de l'espace perso rattaché à
/// un devis ou une demande), renvoyées par les endpoints d'attachement.
/// </summary>
public sealed class AttachmentDto
{
public long FileId { get; set; }
/// <summary>Chemin relatif dans l'espace perso du propriétaire.</summary>
public string? Path { get; set; }
public long Size { get; set; }
public string? ContentType { get; set; }
public string? OwnerId { get; set; }
}
/// <summary>
/// Métadonnées d'un fichier reçu après un téléversement multipart vers
/// <c>POST api/v1/fs/{subdir}</c>. Porte notamment l'identifiant
/// <see cref="FileId"/> de la ligne UploadedFile créée côté serveur.
/// </summary>
public sealed class FileReceivedDto
{
public long? FileId { get; set; }
public string? FileName { get; set; }
public long Length { get; set; }
public string? ContentType { get; set; }
public bool QuotaOffense { get; set; }
public bool Overridden { get; set; }
}
/// <summary>
/// Contenu d'un fichier à téléverser (nom, octets, type MIME), à l'image
/// de <see cref="BlogUploadFile"/> mais pour l'espace perso générique.
/// </summary>
public sealed record UploadFile(string FileName, byte[] Content, string? ContentType);

View file

@ -113,6 +113,41 @@ public sealed class EstimateApiClient
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct); return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
} }
/// <summary>
/// Liste les pièces jointes (fichiers de l'espace perso du fournisseur)
/// rattachées au devis <paramref name="id"/> par référence
/// (<c>GET estimate/{id}/attachments</c>).
/// </summary>
public Task<List<AttachmentDto>> GetAttachmentsAsync(long id, CancellationToken ct = default)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}/attachments"), ct: ct);
}
/// <summary>
/// Attache un fichier de l'espace perso du fournisseur au devis
/// <paramref name="id"/> par référence (<c>POST estimate/{id}/attachments</c>).
/// Réservé au fournisseur côté serveur.
/// </summary>
public Task AttachFileAsync(long id, long fileId, CancellationToken ct = default)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
return _api.CallAsync(HttpMethod.Post, Absolute($"{PathPrefix}/{id}/attachments"),
body: new { fileId }, ct: ct);
}
/// <summary>
/// Détache un fichier du devis <paramref name="id"/>
/// (<c>DELETE estimate/{id}/attachments/{fileId}</c>). Réservé au fournisseur.
/// </summary>
public Task DetachFileAsync(long id, long fileId, CancellationToken ct = default)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}/attachments/{fileId}"), ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString(); private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress() private Uri ResolveBusinessBaseAddress()

View file

@ -122,6 +122,48 @@ public sealed class FrontOfficeApiClient
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct); return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
} }
/// <summary>
/// Liste les pièces jointes (fichiers de l'espace perso du client)
/// rattachées à la demande <paramref name="queryId"/> par référence
/// (<c>GET front/query/{queryId}/attachments</c>).
/// </summary>
public Task<List<AttachmentDto>> GetQueryAttachmentsAsync(string billingCode, long queryId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute(path), ct: ct);
}
/// <summary>
/// Attache un fichier de l'espace perso du client à sa demande
/// (<c>POST front/query/{queryId}/attachments</c>). Réservé au client.
/// </summary>
public Task AttachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
return _api.CallAsync(HttpMethod.Post, Absolute(path), body: new { fileId }, ct: ct);
}
/// <summary>
/// Détache un fichier de la demande
/// (<c>DELETE front/query/{queryId}/attachments/{fileId}</c>). Réservé au client.
/// </summary>
public Task DetachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
var path = $"{PathPrefix}/query/{queryId}/attachments/{fileId}?billingCode={Uri.EscapeDataString(billingCode)}";
return _api.CallAsync(HttpMethod.Delete, Absolute(path), ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString(); private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress() private Uri ResolveBusinessBaseAddress()

View file

@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// Client HTTP de l'espace de stockage personnel
/// (<c>api/v1/fs</c>, servi par le host <c>Yavsc.Blogs</c>). Même forme
/// que <see cref="BlogApiClient"/> : le transport (URL de base, JSON,
/// Bearer, refresh silencieux sur 401) est délégué à
/// <see cref="IYavscApiClient"/> ; cette classe n'est qu'un mappeur
/// DTO↔chemin.
///
/// <para><b>Convention d'URL.</b> Le <c>pathPrefix</c> est relatif au
/// segment de version déjà porté par l'URL de base
/// (<c>…/api/v1/</c>) : <c>"fs"</c> résout en <c>…/api/v1/fs</c>, qui
/// correspond au <c>[Route(APIPrefix + "/fs")]</c> de
/// <c>FileSystemApiController</c>. Ne pas ré-inclure <c>api/</c>.</para>
/// </summary>
public sealed class UserFilesApiClient
{
private const string PathPrefix = "fs";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
public UserFilesApiClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
_baseAddress = new Uri(blogsBaseAddress);
_api.Http.BaseAddress = _baseAddress;
}
/// <summary>
/// Liste le contenu du sous-répertoire <paramref name="subdir"/> de
/// l'espace perso de l'utilisateur (<c>GET fs/{subdir}</c>).
/// </summary>
public Task<UserDirectoryDto> ListAsync(string? subdir = null, CancellationToken ct = default)
{
var path = string.IsNullOrWhiteSpace(subdir) ? PathPrefix : $"{PathPrefix}/{subdir.TrimStart('/')}";
return _api.CallAsync<UserDirectoryDto>(HttpMethod.Get, path, ct: ct);
}
/// <summary>
/// Téléverse des fichiers dans le sous-répertoire
/// <paramref name="subdir"/> (<c>POST fs/{subdir}</c> multipart).
/// Renvoie les métadonnées des fichiers reçus, dont l'identifiant
/// <see cref="FileReceivedDto.FileId"/> servant à l'attachement.
/// </summary>
public Task<List<FileReceivedDto>> UploadAsync(string subdir, IReadOnlyCollection<UploadFile> files, CancellationToken ct = default)
{
if (files is null || files.Count == 0)
throw new ArgumentException("At least one file is required.", nameof(files));
var path = string.IsNullOrWhiteSpace(subdir)
? PathPrefix
: $"{PathPrefix}/{subdir.TrimStart('/')}";
return _api.CallAsync<List<FileReceivedDto>>(HttpMethod.Post, path, () => CreateMultipartContent(files), ct);
}
/// <summary>
/// Supprime un fichier ou répertoire de l'espace perso
/// (<c>DELETE fs/{path}</c>).
/// </summary>
public Task DeleteAsync(string path, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path is required.", nameof(path));
return _api.CallAsync(HttpMethod.Delete, $"{PathPrefix}/{path.TrimStart('/')}", ct: ct);
}
/// <summary>
/// Télécharge une pièce jointe référencée par son identifiant
/// <see cref="FileReceivedDto.FileId"/> / <see cref="AttachmentDto.FileId"/>
/// (<c>GET fs/file/{fileId}</c>). Renvoie les octets bruts ; le serveur
/// (host Blogs) autorise l'appelant (propriétaire ou partie au
/// devis/demande auquel le fichier est rattaché).
/// </summary>
public Task<byte[]> DownloadFileAsync(long fileId, CancellationToken ct = default)
{
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
return _api.DownloadAsync(HttpMethod.Get, $"{PathPrefix}/file/{fileId}", ct);
}
/// <summary>
/// Construit le corps <c>multipart/form-data</c> d'un téléversement :
/// une part <c>file</c> par fichier (type MIME retombant sur
/// <c>application/octet-stream</c>). Exposé <c>public</c> pour les
/// tests backend qui assertent la forme filaire.
/// </summary>
public static HttpContent CreateMultipartContent(IReadOnlyCollection<UploadFile> files)
{
var content = new MultipartFormDataContent();
foreach (var file in files)
{
var fileContent = new ByteArrayContent(file.Content);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
content.Add(fileContent, "file", file.FileName);
}
return content;
}
}

View file

@ -17,7 +17,7 @@
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,303 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
/// <summary>
/// Integration tests for the estimate attachment endpoints
/// (<c>GET/POST/DELETE api/v1/estimate/{id}/attachments</c>), which
/// attach a file from the provider's personal storage to an estimate
/// **by reference** (<see cref="EstimateAttachedFile"/>).
///
/// <para>Coverage mirrors the authorization contract documented on the
/// controller: only the provider (<c>estimate.OwnerId</c>) may attach or
/// detach, the file must belong to the caller, and listing is open to
/// both parties (provider/client) plus admin. A third party is refused.
/// The download of the bytes themselves is served by the Blogs host and
/// exercised in <c>Yavsc.Blogs.Tests</c>; these tests cover only the
/// metadata/relationship endpoints that live on the API host.</para>
/// </summary>
[Collection("Yavsc Api")]
public sealed class EstimateAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public EstimateAttachmentsApiTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
/// <summary>
/// Reset the graph (seeding alice the provider and bob the client),
/// create an estimate owned by alice for bob, and seed two personal
/// <see cref="UploadedFile"/> rows: one owned by alice (the provider's
/// own file) and one owned by bob (a file that does NOT belong to the
/// provider, used to assert the ownership guard on attach).
/// </summary>
private (long estimateId, long aliceFileId, long bobFileId) Seed()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = new Estimate
{
ClientId = "bob",
OwnerId = "alice",
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis de test",
AttachedFiles = new List<string>(),
AttachedGraphics = new List<string>(),
Bill = new List<CommandLine>
{
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
},
};
db.Estimates.Add(estimate);
db.SaveChanges();
var aliceFile = new UploadedFile
{
OwnerId = "alice",
Path = "provider-doc.pdf",
ContentType = "application/pdf",
Length = 2048,
};
var bobFile = new UploadedFile
{
OwnerId = "bob",
Path = "client-note.txt",
ContentType = "text/plain",
Length = 64,
};
db.UploadedFiles.AddRange(aliceFile, bobFile);
db.SaveChanges();
return (estimate.Id, aliceFile.Id, bobFile.Id);
}
private int CountAttachments(long estimateId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.EstimateAttachedFiles.Count(a => a.EstimateId == estimateId);
}
[Fact]
public async Task Provider_attaching_own_file_creates_the_link()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
Assert.Equal(1, CountAttachments(estimateId));
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var link = Assert.Single(db.EstimateAttachedFiles.Where(a => a.EstimateId == estimateId));
Assert.Equal(aliceFileId, link.FileId);
}
[Fact]
public async Task Attaching_the_same_file_twice_is_idempotent()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
var second = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
Assert.Equal(1, CountAttachments(estimateId));
}
[Fact]
public async Task Non_provider_cannot_attach_to_an_estimate()
{
var (estimateId, aliceFileId, _) = Seed();
// bob is the client of the estimate, not the provider.
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(estimateId));
}
[Fact]
public async Task Provider_cannot_attach_a_file_they_do_not_own()
{
var (estimateId, _, bobFileId) = Seed();
// alice is the provider, but the file belongs to bob.
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(estimateId));
}
[Fact]
public async Task Provider_detaches_a_file()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(1, CountAttachments(estimateId));
var response = await http.DeleteAsync(
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(0, CountAttachments(estimateId));
}
[Fact]
public async Task Non_provider_cannot_detach()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
using var bob = NewClient("bob");
var response = await bob.DeleteAsync(
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(1, CountAttachments(estimateId));
}
[Fact]
public async Task Provider_lists_attachments()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
var response = await http.GetAsync(
$"/api/v1/estimate/{estimateId}/attachments",
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var item = Assert.Single(doc.RootElement.EnumerateArray());
Assert.Equal(aliceFileId, item.GetProperty("fileId").GetInt64());
Assert.Equal("provider-doc.pdf", item.GetProperty("path").GetString());
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
}
[Fact]
public async Task Client_can_list_attachments_as_a_party()
{
var (estimateId, aliceFileId, _) = Seed();
using var alice = NewClient("alice");
await alice.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
// bob is the client of the estimate: a party, may list.
using var bob = NewClient("bob");
var response = await bob.GetAsync(
$"/api/v1/estimate/{estimateId}/attachments",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Third_party_cannot_list_attachments()
{
var (estimateId, aliceFileId, _) = Seed();
using var alice = NewClient("alice");
await alice.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
// carol is neither the provider nor the client.
using var carol = NewClient("carol");
var response = await carol.GetAsync(
$"/api/v1/estimate/{estimateId}/attachments",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Listing_an_unknown_estimate_returns_404()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.GetAsync(
"/api/v1/estimate/999999/attachments",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -24,7 +24,6 @@ public sealed class ApiWebServerFixture : WebHostFixture
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER"; private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION"; private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb"; private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true";
protected override int HttpsPort => 5104; protected override int HttpsPort => 5104;
@ -168,25 +167,32 @@ public sealed class ApiWebServerFixture : WebHostFixture
private static string BuildAdminConnectionString() private static string BuildAdminConnectionString()
{ {
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar); var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
var source = string.IsNullOrWhiteSpace(configured) if (string.IsNullOrWhiteSpace(configured))
? DefaultDevelopmentConnectionString throw new InvalidOperationException(
: configured; $"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(source) var builder = new NpgsqlConnectionStringBuilder(configured)
{ {
Pooling = false, Pooling = false,
IncludeErrorDetail = true IncludeErrorDetail = true
}; };
if (string.IsNullOrWhiteSpace(configured)) // The admin connection targets the maintenance database
{ // (postgres) so we can CREATE/DROP the dedicated test database;
// whatever database the env var names is overridden here.
if (string.IsNullOrWhiteSpace(builder.Database))
builder.Database = "postgres"; builder.Database = "postgres";
}
else if (string.IsNullOrWhiteSpace(builder.Database))
{
builder.Database = "postgres";
}
return builder.ToString(); return builder.ToString();
} }

View file

@ -0,0 +1,256 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
/// <summary>
/// Integration tests for the query (demande) attachment endpoints
/// (<c>GET/POST/DELETE api/v1/front/query/{queryId}/attachments</c>),
/// which attach a file from the client's personal storage to their
/// <c>NominativeServiceCommand</c> **by reference**
/// (<see cref="QueryAttachedFile"/>).
///
/// <para>Coverage mirrors the controller contract: only the client
/// (the demande's <c>ClientId</c>) may attach or detach, the file must
/// belong to the caller, and listing is open to both parties
/// (client/provider) plus admin. A third party is refused.</para>
/// </summary>
[Collection("Yavsc Api")]
public sealed class FrontOfficeQueryAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public FrontOfficeQueryAttachmentsApiTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "bob", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
/// <summary>
/// Seed an Rdv query from bob (client) to alice (provider) and two
/// personal <see cref="UploadedFile"/> rows: one owned by bob (the
/// client's own file) and one owned by alice (a file that does NOT
/// belong to the client, used to assert the ownership guard).
/// </summary>
private (long queryId, long bobFileId, long aliceFileId) Seed()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
var query = new RdvQuery
{
ActivityCode = "dev",
ClientId = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "bob",
UserModified = "bob",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Demande de devis",
Status = QueryStatus.InProgress,
Description = "Demande en attente",
};
db.RdvQueries.Add(query);
db.SaveChanges();
var bobFile = new UploadedFile
{
OwnerId = "bob",
Path = "demande-photo.jpg",
ContentType = "image/jpeg",
Length = 4096,
};
var aliceFile = new UploadedFile
{
OwnerId = "alice",
Path = "provider-spec.pdf",
ContentType = "application/pdf",
Length = 1024,
};
db.UploadedFiles.AddRange(bobFile, aliceFile);
db.SaveChanges();
return (query.Id, bobFile.Id, aliceFile.Id);
}
private int CountAttachments(long queryId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.QueryAttachedFiles.Count(a => a.CommandId == queryId);
}
private string AttachUrl(long queryId) =>
$"/api/v1/front/query/{queryId}/attachments?billingCode=Rdv";
private string DetachUrl(long queryId, long fileId) =>
$"/api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=Rdv";
[Fact]
public async Task Client_attaching_own_file_creates_the_link()
{
var (queryId, bobFileId, _) = Seed();
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
Assert.Equal(1, CountAttachments(queryId));
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var link = Assert.Single(db.QueryAttachedFiles.Where(a => a.CommandId == queryId));
Assert.Equal(bobFileId, link.FileId);
}
[Fact]
public async Task Provider_cannot_attach_to_a_query()
{
var (queryId, bobFileId, _) = Seed();
// alice is the provider of the query; only the client attaches.
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(queryId));
}
[Fact]
public async Task Client_cannot_attach_a_file_they_do_not_own()
{
var (queryId, _, aliceFileId) = Seed();
// bob is the client, but the file belongs to alice.
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(queryId));
}
[Fact]
public async Task Client_detaches_a_file()
{
var (queryId, bobFileId, _) = Seed();
using var http = NewClient("bob");
await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
Assert.Equal(1, CountAttachments(queryId));
var response = await http.DeleteAsync(
DetachUrl(queryId, bobFileId),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(0, CountAttachments(queryId));
}
[Fact]
public async Task Provider_cannot_detach()
{
var (queryId, bobFileId, _) = Seed();
using var bob = NewClient("bob");
await bob.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
using var alice = NewClient("alice");
var response = await alice.DeleteAsync(
DetachUrl(queryId, bobFileId),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(1, CountAttachments(queryId));
}
[Fact]
public async Task Provider_lists_attachments_as_a_party()
{
var (queryId, bobFileId, _) = Seed();
using var bob = NewClient("bob");
await bob.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
// alice is the provider: a party, may list.
using var alice = NewClient("alice");
var response = await alice.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var item = Assert.Single(doc.RootElement.EnumerateArray());
Assert.Equal(bobFileId, item.GetProperty("fileId").GetInt64());
Assert.Equal("bob", item.GetProperty("ownerId").GetString());
}
[Fact]
public async Task Third_party_cannot_list_attachments()
{
var (queryId, bobFileId, _) = Seed();
using var bob = NewClient("bob");
await bob.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
using var carol = NewClient("carol");
var response = await carol.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json; using Newtonsoft.Json;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Billing; using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
@ -237,6 +238,96 @@ namespace Yavsc.Controllers
base.Dispose(disposing); base.Dispose(disposing);
} }
// GET: api/v1/estimate/{id}/attachments
//
// Liste les fichiers de l'espace perso du fournisseur rattachés au
// devis, par référence (UploadedFile). Lecture ouverte aux deux
// parties (fournisseur/client) du devis et aux admin, comme
// GetEstimate : la contrepartie doit pouvoir voir et télécharger
// les pièces (le téléchargement des octets reste servi par le host
// Blogs, qui autorise via cette même table de jointure).
[HttpGet("{id}/attachments")]
public IActionResult GetAttachments(long id)
{
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
if (estimate == null) return NotFound();
if (!UserIsAdminOrInThese(estimate.ClientId, estimate.OwnerId))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var attached = _context.EstimateAttachedFiles
.Where(a => a.EstimateId == id)
.Select(a => new
{
fileId = a.FileId,
path = a.File.Path,
size = a.File.Length,
contentType = a.File.ContentType,
ownerId = a.File.OwnerId
})
.ToList();
return Ok(attached);
}
// POST: api/v1/estimate/{id}/attachments { fileId }
//
// Attache un fichier de l'espace perso du fournisseur au devis, par
// référence. Réservé au fournisseur (estimate.OwnerId == caller) :
// seul le fournisseur constitue le devis et y joint ses documents
// (dont les documents à faire signer par le client, per README).
// Le fichier doit appartenir au fournisseur (UploadedFile.OwnerId).
[HttpPost("{id}/attachments")]
public IActionResult AttachFile(long id, [FromBody] AttachFileRequest body)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
if (estimate == null) return NotFound();
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
if (body == null || body.FileId <= 0)
return BadRequest(new { error = "fileId required" });
var file = _context.UploadedFiles.FirstOrDefault(f => f.Id == body.FileId);
if (file == null) return BadRequest(new { error = "file not found" });
if (file.OwnerId != uid)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var existing = _context.EstimateAttachedFiles
.FirstOrDefault(a => a.EstimateId == id && a.FileId == body.FileId);
if (existing != null) return Ok(new { estimateId = id, fileId = body.FileId });
_context.EstimateAttachedFiles.Add(new EstimateAttachedFile
{
EstimateId = id,
FileId = body.FileId
});
_context.SaveChanges(User.GetUserId());
return Ok(new { estimateId = id, fileId = body.FileId });
}
// DELETE: api/v1/estimate/{id}/attachments/{fileId}
//
// Détache un fichier du devis. Réservé au fournisseur.
[HttpDelete("{id}/attachments/{fileId}")]
public IActionResult DetachFile(long id, long fileId)
{
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
if (estimate == null) return NotFound();
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var link = _context.EstimateAttachedFiles
.FirstOrDefault(a => a.EstimateId == id && a.FileId == fileId);
if (link == null) return NotFound();
_context.EstimateAttachedFiles.Remove(link);
_context.SaveChanges(User.GetUserId());
return Ok(new { estimateId = id, fileId });
}
private bool EstimateExists(long id) private bool EstimateExists(long id)
{ {
return _context.Estimates.Count(e => e.Id == id) > 0; return _context.Estimates.Count(e => e.Id == id) > 0;

View file

@ -8,6 +8,7 @@ using Yavsc.Abstract.Workflow;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Billing; using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice; using Yavsc.ViewModels.FrontOffice;
@ -366,6 +367,95 @@ namespace Yavsc.ApiControllers
private static bool HasSignature(QueryAcceptanceRequest? body) private static bool HasSignature(QueryAcceptanceRequest? body)
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0; => body is not null && body.Strokes is not null && body.Strokes.Length > 0;
// GET: api/v1/front/query/{queryId}/attachments?billingCode=…
//
// Liste les fichiers de l'espace perso du client rattachés à la
// demande, par référence (UploadedFile). Lecture ouverte aux deux
// parties (client/fournisseur) de la demande et aux admin ; le
// téléchargement des octets est servi par le host Blogs.
[HttpGet("query/{queryId}/attachments")]
public async Task<IActionResult> GetQueryAttachments(
string billingCode, long queryId, CancellationToken token)
{
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
if (error is not null) return error;
if (query is null) return BadRequest(new { Error = "query not found" });
var attached = await dbContext.QueryAttachedFiles
.Where(a => a.CommandId == queryId)
.Select(a => new
{
fileId = a.FileId,
path = a.File.Path,
size = a.File.Length,
contentType = a.File.ContentType,
ownerId = a.File.OwnerId
})
.ToListAsync(token);
return Ok(attached);
}
// POST: api/v1/front/query/{queryId}/attachments?billingCode=… { fileId }
//
// Attache un fichier de l'espace perso du client à sa demande, par
// référence. Réservé au client (rôle Client) : c'est lui qui
// documente sa demande. Le fichier doit lui appartenir.
[HttpPost("query/{queryId}/attachments")]
public async Task<IActionResult> AttachQueryFile(
string billingCode, long queryId, [FromBody] AttachFileRequest? body, CancellationToken token)
{
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
if (error is not null) return error;
if (query is null) return BadRequest(new { Error = "query not found" });
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
if (body == null || body.FileId <= 0)
return BadRequest(new { error = "fileId required" });
var uid = User.GetUserId();
var file = await dbContext.UploadedFiles
.FirstOrDefaultAsync(f => f.Id == body.FileId, token);
if (file == null) return BadRequest(new { error = "file not found" });
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var existing = await dbContext.QueryAttachedFiles
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == body.FileId, token);
if (existing != null) return Ok(new { queryId, fileId = body.FileId });
dbContext.QueryAttachedFiles.Add(new QueryAttachedFile
{
CommandId = queryId,
FileId = body.FileId
});
await dbContext.SaveChangesAsync(User.GetUserId(), token);
return Ok(new { queryId, fileId = body.FileId });
}
// DELETE: api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=…
//
// Détache un fichier de la demande. Réservé au client.
[HttpDelete("query/{queryId}/attachments/{fileId}")]
public async Task<IActionResult> DetachQueryFile(
string billingCode, long queryId, long fileId, CancellationToken token)
{
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
if (error is not null) return error;
if (query is null) return BadRequest(new { Error = "query not found" });
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var link = await dbContext.QueryAttachedFiles
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == fileId, token);
if (link == null) return NotFound();
dbContext.QueryAttachedFiles.Remove(link);
await dbContext.SaveChangesAsync(User.GetUserId(), token);
return Ok(new { queryId, fileId });
}
} }
internal enum ActorRole { None, Provider, Client, Admin } internal enum ActorRole { None, Provider, Client, Admin }

View file

@ -7,7 +7,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,294 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogs.Tests.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Integration tests for the personal-storage file endpoints
/// (<c>api/v1/fs</c>) served by <c>FileSystemApiController</c> on the
/// Blogs host.
///
/// <para>Two concerns:</para>
/// <list type="number">
/// <item><description><b>Upload records metadata</b>: a multipart
/// <c>POST api/v1/fs</c> persists an <see cref="UploadedFile"/> row
/// owned by the caller and returns its <c>fileId</c>, so the client
/// can later attach that file to an estimate/query by
/// reference.</description></item>
/// <item><description><b>Download authorization</b>:
/// <c>GET api/v1/fs/file/{fileId}</c> serves the bytes from the
/// owner's personal space. The owner may always read; a party
/// (client or provider) of an estimate/query the file is attached
/// to may read; a third party is refused. The bytes live on the
/// Blogs host's disk — this is the cross-container reason the
/// download endpoint lives here and not on the API host.</description></item>
/// </list>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public FileSystemApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
return http;
}
private static string PhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName) =>
Path.Combine(fixture.BlogFilesRoot, owner, fileName);
/// <summary>Write a physical file into the owner's personal space,
/// so <c>GetFile</c> can stream it back. Creates the directory if
/// needed.</summary>
private static void WritePhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName, byte[] content)
{
var path = PhysicalFile(fixture, owner, fileName);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, content);
}
private long SeedUploadedFile(string ownerId, string path, string contentType, long length)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var file = new UploadedFile
{
OwnerId = ownerId,
Path = path,
ContentType = contentType,
Length = length,
};
db.UploadedFiles.Add(file);
db.SaveChanges();
return file.Id;
}
[Fact]
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
{
_fixture.ResetDatabase();
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
// once usage >= DiskQuota, and the controller skips the metadata
// row when QuotaOffense is set. A fresh user has quota 0.
_fixture.SeedUser("alice", u => u.DiskQuota = 10_000_000);
var payload = System.Text.Encoding.UTF8.GetBytes("hello personal storage");
using var form = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(payload);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
using var http = NewClient("alice");
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var item = Assert.Single(doc.RootElement.EnumerateArray());
var fileId = item.GetProperty("fileId").GetInt64();
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
Assert.Equal("note.txt", row.Path);
Assert.Equal("text/plain", row.ContentType);
Assert.Equal(payload.Length, row.Length);
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
"Expected the uploaded file on disk under the owner's personal root");
}
[Fact]
public async Task GetFile_owner_can_download_their_own_file()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var bytes = System.Text.Encoding.UTF8.GetBytes("owner payload");
WritePhysicalFile(_fixture, "alice", "owner-note.txt", bytes);
var fileId = SeedUploadedFile("alice", "owner-note.txt", "text/plain", bytes.Length);
using var http = NewClient("alice");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
Assert.Equal(bytes, downloaded);
}
[Fact]
public async Task GetFile_refuses_a_third_party()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("carol");
var bytes = System.Text.Encoding.UTF8.GetBytes("private to alice");
WritePhysicalFile(_fixture, "alice", "secret.txt", bytes);
var fileId = SeedUploadedFile("alice", "secret.txt", "text/plain", bytes.Length);
// carol is neither the owner nor a party to any estimate/query
// this file is attached to.
using var http = NewClient("carol");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
/// <summary>
/// Seed the graph needed to prove a party (the client of an
/// estimate the file is attached to) can download a file they do
/// not own: alice owns the file and is the provider; bob is the
/// client; the file is attached to the estimate by reference.
/// </summary>
private long SeedPartyDownloadGraph(out long estimateId, out string fileName)
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
fileName = "party-spec.pdf";
var bytes = System.Text.Encoding.UTF8.GetBytes("party payload");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// Location + PerformerProfile(alice): Estimate.OwnerId is a FK
// to PerformerProfile.PerformerId, so the provider profile must
// exist before the estimate can be inserted.
var location = new Location { Address = "1 rue du Test", Latitude = 48.8, Longitude = 2.3 };
db.Locations.Add(location);
db.SaveChanges();
db.Performers.Add(new PerformerProfile
{
PerformerId = "alice",
SIREN = "12345678901234",
OrganizationAddressId = location.Id,
ExerciseCountryCode = "fr",
AcceptNotifications = true,
AcceptPublicContact = true,
Active = true,
Rate = 5,
WebSite = "https://alice.dev",
});
db.SaveChanges();
var estimate = new Estimate
{
OwnerId = "alice",
ClientId = "bob",
CommandType = "Rdv",
Title = "Devis party test",
Description = "Devis",
AttachedFiles = new List<string>(),
AttachedGraphics = new List<string>(),
Bill = new List<CommandLine>
{
new() { Name = "Prestation", Description = "Prestation", Count = 1, UnitaryCost = 10m, Currency = "EUR" },
},
};
db.Estimates.Add(estimate);
db.SaveChanges();
estimateId = estimate.Id;
var file = new UploadedFile
{
OwnerId = "alice",
Path = fileName,
ContentType = "application/pdf",
Length = bytes.Length,
};
db.UploadedFiles.Add(file);
db.SaveChanges();
db.EstimateAttachedFiles.Add(new EstimateAttachedFile
{
FileId = file.Id,
EstimateId = estimate.Id,
});
db.SaveChanges();
// The bytes must exist on disk for GetFile to stream them.
WritePhysicalFile(_fixture, "alice", fileName, bytes);
return file.Id;
}
[Fact]
public async Task GetFile_party_client_of_an_attached_estimate_can_download()
{
var fileId = SeedPartyDownloadGraph(out _, out _);
// bob is the client of the estimate the file is attached to:
// a party, authorized to read even though alice owns the file.
using var http = NewClient("bob");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
Assert.Equal(System.Text.Encoding.UTF8.GetBytes("party payload"), downloaded);
}
[Fact]
public async Task GetFile_party_provider_of_an_attached_estimate_can_download()
{
var fileId = SeedPartyDownloadGraph(out _, out _);
// alice is the owner too, so this also exercises the owner path;
// the provider of an attached estimate is authorized regardless.
using var http = NewClient("alice");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetFile_unknown_id_returns_404()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
using var http = NewClient("alice");
var response = await http.GetAsync(
"/api/v1/fs/file/999999", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -0,0 +1,87 @@
using System.Text.Json;
using Yavsc.Blogspot;
using Yavsc.Models.Blog;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Wire-shape tests pinning that <see cref="IBlogPost.IsPublished"/>
/// is carried on the JSON the blog API list endpoint emits.
///
/// <para>The blog API host (<c>Yavsc.Blogs/Program.cs</c>) serialises
/// with System.Text.Json (the <c>.AddControllers()</c> default — no
/// <c>AddNewtonsoftJson</c>), and <c>BlogApiController.GetBlogspot</c>
/// returns <c>IEnumerable&lt;IBlogPost&gt;</c>. System.Text.Json emits
/// only the members of the <b>declared</b> type, so a property that
/// exists solely on the concrete <c>BlogPost</c> entity is dropped from
/// the payload. <c>IsPublished</c> used to be one such property: it was
/// on <c>BlogPost</c> and <c>BlogPostDto</c> but not on
/// <see cref="IBlogPost"/>, so the list response omitted it and PostIt
/// read <c>IsPublished == false</c> for every post — including
/// published ones.</para>
///
/// <para>These tests reproduce that serialisation path directly
/// (declared type <c>IEnumerable&lt;IBlogPost&gt;</c> through
/// <see cref="JsonSerializer.Serialize(object, Type)"/>) and assert
/// <c>isPublished</c> survives the round trip. They live in
/// <c>Yavsc.Blogs.Tests</c> — the assembly that owns the blog API
/// contract — so a regression at the wire boundary is caught here,
/// where it manifested in production (PostIt showing a published
/// post's status as <c>false</c>).</para>
/// </summary>
public sealed class IBlogPostWireShapeTests
{
private static readonly JsonSerializerOptions WebDefaults
= new(JsonSerializerDefaults.Web);
[Fact]
public void IsPublished_is_emitted_when_list_is_serialised_as_IEnumerable_IBlogPost()
{
var post = new BlogPost
{
Id = 13,
Title = "Published",
AuthorId = "alice",
Article = "x",
};
post.IsPublished = true;
// Declared type mirrors GetBlogspot's return: IEnumerable<IBlogPost>.
IEnumerable<IBlogPost> list = new List<BlogPost> { post };
// Serialize with the DECLARED type, the way ASP.NET Core's
// SystemTextJsonOutputFormatter hands context.ObjectType (the
// action return type) to JsonSerializer.Serialize.
var json = JsonSerializer.Serialize(list, typeof(IEnumerable<IBlogPost>),
WebDefaults);
using var doc = JsonDocument.Parse(json);
var element = doc.RootElement[0];
// Web defaults use camelCase, so the property is "isPublished".
Assert.True(element.TryGetProperty("isPublished", out var isPublished),
$"isPublished was dropped from the list payload. JSON: {json}");
Assert.True(isPublished.GetBoolean());
}
[Fact]
public void IsPublished_round_trips_through_BlogPostDto_on_the_client()
{
// The shape the client (PostIt) receives and deserialises with
// PropertyNameCaseInsensitive = true (cf. YavscApiClient).
var json = """
[{
"id": 13,
"title": "Published",
"article": "x",
"authorId": "alice",
"isPublished": true
}]
""";
var posts = JsonSerializer.Deserialize<List<BlogPostDto>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var post = Assert.Single(posts);
Assert.True(post.IsPublished);
}
}

View file

@ -9,7 +9,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -8,10 +8,14 @@ namespace Yavsc.Blogs.Controllers
{ {
using Yavsc.Attributes.Validation; using Yavsc.Attributes.Validation;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Models.Billing;
using Yavsc.Exceptions; using Yavsc.Exceptions;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Abstract.Helpers; using Yavsc.Abstract.Helpers;
using Yavsc.Server.Models.FileSystem; using Yavsc.Server.Models.FileSystem;
using Yavsc.ViewModels.UserFiles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
[Authorize,Route(APIPrefix + "/fs")] [Authorize,Route(APIPrefix + "/fs")]
@ -34,21 +38,55 @@ namespace Yavsc.Blogs.Controllers
this.siteSettings = siteSettings.Value; this.siteSettings = siteSettings.Value;
} }
// GET: api/v1/fs
[HttpGet()] [HttpGet()]
public IActionResult Get() public IActionResult Get()
{ {
return GetDir(null); return GetDir(null);
} }
// GET: api/v1/fs/{subdir}
// Liste les fichiers du sous-répertoire personnel de l'utilisateur,
// en enrichissant chaque entrée de l'identifiant de métadonnées
// UploadedFile.Id quand une ligne existe (cf. POST ci-dessous).
[HttpGet("{*subdir}")] [HttpGet("{*subdir}")]
public IActionResult GetDir([ValidRemoteUserFilePath] string subdir="") public IActionResult GetDir([ValidRemoteUserFilePath] string subdir="")
{ {
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState); if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
// _logger.LogInformation($"listing files from {User.Identity.Name}{subdir}"); var uid = User.GetUserId();
var files = FileSystemHelpers.GetUserFiles(siteSettings, User.GetUserId(), subdir); var files = FileSystemHelpers.GetUserFiles(siteSettings, uid, subdir);
EnrichWithFileIds(files, uid, subdir);
return Ok(files); return Ok(files);
} }
// Reporte l'UploadedFile.Id (quand il existe) sur chaque entrée
// retournée par la liste, pour que le client lourd puisse attacher
// le fichier à un devis/demande par référence.
void EnrichWithFileIds(UserDirectoryInfo dir, string ownerId, string subdir)
{
if (dir?.Files == null || dir.Files.Length == 0) return;
var relPaths = dir.Files
.Select(f => RelPath(subdir, f.Name))
.ToHashSet();
var byPath = dbContext.UploadedFiles
.Where(u => u.OwnerId == ownerId && relPaths.Contains(u.Path))
.ToDictionary(u => u.Path, u => u.Id);
foreach (var f in dir.Files)
{
var rel = RelPath(subdir, f.Name);
if (byPath.TryGetValue(rel, out var id)) f.Id = id;
}
}
// Chemin relatif au racine de l'espace perso de l'utilisateur,
// tel que stocké dans UploadedFile.Path (sans segment utilisateur).
static string RelPath(string subdir, string fileName)
{
// Normalize with '/' for cross-platform consistency in DB.
if (string.IsNullOrWhiteSpace(subdir)) return fileName;
return $"{subdir.TrimStart('/')}/{fileName}";
}
[HttpPost("{*subdir}")] [HttpPost("{*subdir}")]
public IActionResult Post([ValidRemoteUserFilePath] string subdir="") public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
{ {
@ -78,7 +116,38 @@ namespace Yavsc.Blogs.Controllers
foreach (var f in Request.Form.Files) foreach (var f in Request.Form.Files)
{ {
var item = user.ReceiveUserFile(destDir, f); var item = user.ReceiveUserFile(destDir, f);
if (!item.QuotaOffense)
{
// Persiste une ligne de métadonnées UploadedFile,
// propriétaire = l'utilisateur courant, chemin relatif
// à son espace perso. Upsert sur (OwnerId, Path) pour
// le re-téléversement (même fichier écrasé sur disque).
var rel = RelPath(subdir, item.FileName);
var existing = dbContext.UploadedFiles
.FirstOrDefault(u => u.OwnerId == uid && u.Path == rel);
if (existing == null)
{
var uploaded = new UploadedFile
{
OwnerId = uid,
Path = rel,
ContentType = f.ContentType,
Length = f.Length
};
dbContext.UploadedFiles.Add(uploaded);
dbContext.SaveChanges(User.GetUserId()); dbContext.SaveChanges(User.GetUserId());
item.FileId = uploaded.Id;
}
else
{
existing.ContentType = f.ContentType;
existing.Length = f.Length;
dbContext.SaveChanges(User.GetUserId());
item.FileId = existing.Id;
}
item.Length = f.Length;
item.ContentType = f.ContentType;
}
received.Add(item); received.Add(item);
_logger.LogInformation($"Received '{item.FileName}'."); _logger.LogInformation($"Received '{item.FileName}'.");
if (item.QuotaOffense) if (item.QuotaOffense)
@ -88,6 +157,62 @@ namespace Yavsc.Blogs.Controllers
return Ok(received); return Ok(received);
} }
// GET: api/v1/fs/file/{fileId}
// Télécharge une pièce jointe référencée par son UploadedFile.Id.
// Autorise : le propriétaire du fichier, ou toute partie (client /
// fournisseur) d'un devis ou d'une demande auquel ce fichier est
// rattaché via EstimateAttachedFile / QueryAttachedFile. Les octets
// sont lus depuis l'espace perso du propriétaire, sur le disque de
// ce host Blogs (le host API ne possède pas ces octets).
[HttpGet("file/{fileId:long}")]
public IActionResult GetFile(long fileId)
{
var file = dbContext.UploadedFiles.FirstOrDefault(u => u.Id == fileId);
if (file == null) return NotFound(new { error = "file not found" });
var uid = User.GetUserId();
if (!CanReadFile(file, uid))
return Forbid();
var ownerUserName = dbContext.Users
.Where(u => u.Id == file.OwnerId)
.Select(u => u.UserName)
.FirstOrDefault();
if (string.IsNullOrEmpty(ownerUserName))
return NotFound(new { error = "owner not found" });
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
var fi = new FileInfo(physicalPath);
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
var contentType = string.IsNullOrWhiteSpace(file.ContentType)
? "application/octet-stream" : file.ContentType;
var stream = fi.OpenRead();
return File(stream, contentType, fi.Name);
}
// Le propriétaire peut toujours lire son fichier ; sinon, il faut
// que le fichier soit rattaché à un devis ou une demande dont
// l'appelant est partie (client ou fournisseur). Les admin sont
// autorisés comme pour le reste du workflow.
bool CanReadFile(UploadedFile file, string uid)
{
if (User.IsInRole(Constants.AdminGroupName)) return true;
if (file.OwnerId == uid) return true;
if (dbContext.EstimateAttachedFiles.Any(a =>
a.FileId == file.Id &&
(a.Estimate.ClientId == uid || a.Estimate.OwnerId == uid)))
return true;
if (dbContext.QueryAttachedFiles.Any(a =>
a.FileId == file.Id &&
(a.Command.ClientId == uid || a.Command.PerformerId == uid)))
return true;
return false;
}
[Route("/api/fsc/addquota/{uname}/{len}")] [Route("/api/fsc/addquota/{uname}/{len}")]
[Authorize("AdministratorOnly")] [Authorize("AdministratorOnly")]
public IActionResult AddQuota(string uname, int len) public IActionResult AddQuota(string uname, int len)

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -11,7 +11,7 @@
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath> <RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Models; using Yavsc.Models;
@ -82,8 +83,17 @@ namespace Yavsc.Controllers
return CreatedAtRoute("GetComment", new { id = comment.Id }, new { id = comment.Id, dateCreated = comment.DateCreated }); return CreatedAtRoute("GetComment", new { id = comment.Id }, new { id = comment.Id, dateCreated = comment.DateCreated });
} }
// --- Actions MVC scaffoldées (vues CRUD) ---------------------
// Ces actions ne font pas partie de la surface d'API documentée
// par Swagger. Elles sont exclues de l'ApiExplorer : sinon, comme
// le contrôleur porte [Route("~/api/v1/blogcomments")], leurs
// [HttpPost] sans template propre atterrissent sur la même route
// "POST api/v1/blogcomments" que l'action Post ci-dessus, et
// Swashbuckle lève une SwaggerGeneratorException (conflicting
// method/path) au démarrage de /swagger.
// GET: Comments // GET: Comments
[HttpGet] [HttpGet]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var applicationDbContext = _context.Comment.Include(c => c.Post); var applicationDbContext = _context.Comment.Include(c => c.Post);
@ -91,6 +101,7 @@ namespace Yavsc.Controllers
} }
// GET: Comments/Details/5 // GET: Comments/Details/5
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Details(long? id) public async Task<IActionResult> Details(long? id)
{ {
if (id == null) if (id == null)
@ -109,6 +120,7 @@ namespace Yavsc.Controllers
// GET: Comments/Create (MVC form endpoint) // GET: Comments/Create (MVC form endpoint)
[HttpGet("form")] [HttpGet("form")]
[ApiExplorerSettings(IgnoreApi = true)]
public IActionResult Create() public IActionResult Create()
{ {
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title"); ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title");
@ -118,6 +130,7 @@ namespace Yavsc.Controllers
// POST: Comments/Create (MVC form endpoint) // POST: Comments/Create (MVC form endpoint)
[HttpPost("form")] [HttpPost("form")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Create(Comment comment) public async Task<IActionResult> Create(Comment comment)
{ {
comment.UserCreated = User.GetUserId(); comment.UserCreated = User.GetUserId();
@ -138,6 +151,7 @@ namespace Yavsc.Controllers
} }
// GET: Comments/Edit/5 // GET: Comments/Edit/5
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Edit(long? id) public async Task<IActionResult> Edit(long? id)
{ {
if (id == null) if (id == null)
@ -157,6 +171,7 @@ namespace Yavsc.Controllers
// POST: Comments/Edit/5 // POST: Comments/Edit/5
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Edit(Comment comment) public async Task<IActionResult> Edit(Comment comment)
{ {
if (ModelState.IsValid) if (ModelState.IsValid)
@ -171,6 +186,7 @@ namespace Yavsc.Controllers
// GET: Comments/Delete/5 // GET: Comments/Delete/5
[ActionName("Delete")] [ActionName("Delete")]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Delete(long? id) public async Task<IActionResult> Delete(long? id)
{ {
if (id == null) if (id == null)
@ -190,6 +206,7 @@ namespace Yavsc.Controllers
// POST: Comments/Delete/5 // POST: Comments/Delete/5
[HttpPost, ActionName("Delete")] [HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> DeleteConfirmed(long id) public async Task<IActionResult> DeleteConfirmed(long id)
{ {
Comment comment = await _context.Comment.SingleAsync(m => m.Id == id); Comment comment = await _context.Comment.SingleAsync(m => m.Id == id);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,148 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class UserFileAttachments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Path",
table: "UploadedFiles",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "UploadedFiles",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "OwnerId",
table: "UploadedFiles",
type: "text",
nullable: true);
migrationBuilder.CreateTable(
name: "EstimateAttachedFiles",
columns: table => new
{
FileId = table.Column<long>(type: "bigint", nullable: false),
EstimateId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EstimateAttachedFiles", x => new { x.FileId, x.EstimateId });
table.ForeignKey(
name: "FK_EstimateAttachedFiles_Estimates_EstimateId",
column: x => x.EstimateId,
principalTable: "Estimates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_EstimateAttachedFiles_UploadedFiles_FileId",
column: x => x.FileId,
principalTable: "UploadedFiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "QueryAttachedFiles",
columns: table => new
{
FileId = table.Column<long>(type: "bigint", nullable: false),
CommandId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_QueryAttachedFiles", x => new { x.FileId, x.CommandId });
table.ForeignKey(
name: "FK_QueryAttachedFiles_NominativeServiceCommands_CommandId",
column: x => x.CommandId,
principalTable: "NominativeServiceCommands",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_QueryAttachedFiles_UploadedFiles_FileId",
column: x => x.FileId,
principalTable: "UploadedFiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UploadedFiles_OwnerId",
table: "UploadedFiles",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_EstimateAttachedFiles_EstimateId",
table: "EstimateAttachedFiles",
column: "EstimateId");
migrationBuilder.CreateIndex(
name: "IX_QueryAttachedFiles_CommandId",
table: "QueryAttachedFiles",
column: "CommandId");
migrationBuilder.AddForeignKey(
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
table: "UploadedFiles",
column: "OwnerId",
principalTable: "AspNetUsers",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
table: "UploadedFiles");
migrationBuilder.DropTable(
name: "EstimateAttachedFiles");
migrationBuilder.DropTable(
name: "QueryAttachedFiles");
migrationBuilder.DropIndex(
name: "IX_UploadedFiles_OwnerId",
table: "UploadedFiles");
migrationBuilder.DropColumn(
name: "OwnerId",
table: "UploadedFiles");
migrationBuilder.AlterColumn<string>(
name: "Path",
table: "UploadedFiles",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "UploadedFiles",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
}
}

View file

@ -1394,6 +1394,21 @@ namespace Yavsc.Migrations
b.ToTable("Estimates"); b.ToTable("Estimates");
}); });
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
{
b.Property<long>("FileId")
.HasColumnType("bigint");
b.Property<long>("EstimateId")
.HasColumnType("bigint");
b.HasKey("FileId", "EstimateId");
b.HasIndex("EstimateId");
b.ToTable("EstimateAttachedFiles");
});
modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b => modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -1502,6 +1517,21 @@ namespace Yavsc.Migrations
b.UseTphMappingStrategy(); b.UseTphMappingStrategy();
}); });
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
{
b.Property<long>("FileId")
.HasColumnType("bigint");
b.Property<long>("CommandId")
.HasColumnType("bigint");
b.HasKey("FileId", "CommandId");
b.HasIndex("CommandId");
b.ToTable("QueryAttachedFiles");
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b => modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -1674,16 +1704,23 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ContentType") b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<long>("Length") b.Property<long>("Length")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<string>("OwnerId")
.HasColumnType("text");
b.Property<string>("Path") b.Property<string>("Path")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("OwnerId");
b.ToTable("UploadedFiles"); b.ToTable("UploadedFiles");
}); });
@ -3827,6 +3864,25 @@ namespace Yavsc.Migrations
b.Navigation("Query"); b.Navigation("Query");
}); });
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
{
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
.WithMany()
.HasForeignKey("EstimateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
.WithMany()
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Estimate");
b.Navigation("File");
});
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b => modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{ {
b.HasOne("Yavsc.Models.Workflow.Activity", "Context") b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
@ -3860,6 +3916,25 @@ namespace Yavsc.Migrations
b.Navigation("Regularization"); b.Navigation("Regularization");
}); });
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
{
b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command")
.WithMany()
.HasForeignKey("CommandId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
.WithMany()
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Command");
b.Navigation("File");
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b => modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{ {
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate") b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
@ -3952,6 +4027,15 @@ namespace Yavsc.Migrations
b.Navigation("Post"); b.Navigation("Post");
}); });
modelBuilder.Entity("Yavsc.Models.Blog.UploadedFile", b =>
{
b.HasOne("Yavsc.Models.ApplicationUser", "Owner")
.WithMany()
.HasForeignKey("OwnerId");
b.Navigation("Owner");
});
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b => modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
{ {
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post") b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")

View file

@ -2,7 +2,7 @@
style="background-image: url('@SiteSettings.Value.Banner'); style="background-image: url('@SiteSettings.Value.Banner');
background-position: 0%; background-position: 0%;
background-attachment: fixed;"> background-attachment: fixed;">
<a class="navbar-brand" href="/"> <a class="navbar-brand logo" href="/">
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" /> <img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" />
@SiteSettings.Value.Title @SiteSettings.Value.Title
</a> </a>

View file

@ -9,7 +9,7 @@
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,104 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.SignalR": "Debug",
"Microsoft.AspNetCore.Http.Connections": "Debug"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"YavscConnection": "*** via dotnet user-secrets ou variable d'environnement *** Server=[YOURSERVERNAME];Port=5432;Database=[YOURDBNAME];Username=[YOURDBUSERNAME];Password=[YOURDBPASSW];"
},
"Site": {
"Title": "yavsc",
"Slogan": "Yavsc!",
"StyleSheet": "/css/default.css",
"Authority": "https://[Your domaine name]",
"ExternalUrl": "https://[Your domaine name]",
"CorsAllowedOrigins": [
"https://your.api.url.com/api/v1",
"https://localhost:5003/api/v1",
"https://localhost:5005/api/v1"
],
"Audience": [ "api", "blogs"],
"Banner": "/images/banner-dewang.jpg",
"Owner": {
"Name": "[Site owner's name]",
"EMail": "[Site owner's e-mail address]"
},
"Admin": {
"Name": "[a name for a site admin]",
"EMail": "[an e-mail for a site admin]"
},
"Avatars": "avatars",
"Quota": 200000000,
"Bills": "bills",
"Blog": "blog",
"TempDir": "temp",
"DataDir": "data"
},
"Smtp": {
"Host": "[YOURSMTPHOST]",
"Port": 465,
"Username": "[YOURSMTPUSERNAME]",
"Password": "[YOURSMTPPASSWORD]"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"Authentication": {
"PayPal": {
"Mode": "[sandbox|production]",
"ClientId": "[Your PayPal REST API ClientId]",
"Secret": "[Your PayPal REST API Secret]"
},
"Google": {
"ApiKey": "[Your ApiKey]",
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]",
"ServiceAccount": {
"project_id": "[Yours]",
"private_key_id": "[Yours]",
"private_key": "-----[Yours]---\n",
"client_email": "[Yours]@appspot.gserviceaccount.com",
"client_id": "[Yours]",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www41.googleapis.com/robot/v1/[Yours].com"
}
},
"Facebook": {
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]"
},
"Twitter": {
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]"
}
},
"Moderation": {
"AutoApproveThreshold": 0.7,
"AutoRejectThreshold": 0.9
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:82"
},
"Https": {
"Url": "https://localhost:83",
"Certificate": {
"Path": "/etc/letsencrypt/live/[Your domaine name]/cert.pem",
"KeyPath": "/etc/letsencrypt/live/[Your domaine name]/privkey.pem"
}
}
}
}
}

View file

@ -5033,7 +5033,8 @@ fieldset:disabled .btn {
background-color: var(--bs-card-bg); background-color: var(--bs-card-bg);
background-clip: border-box; background-clip: border-box;
border: var(--bs-card-border-width) solid var(--bs-card-border-color); border: var(--bs-card-border-width) solid var(--bs-card-border-color);
border-radius: var(--bs-card-border-radius) border-radius: var(--bs-card-border-radius);
padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x);
} }
.card>hr { .card>hr {

View file

@ -0,0 +1,222 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Tests.Fixtures;
namespace Yavsc.Server.Tests;
/// <summary>
/// Service-level tests for <see cref="BlogSpotService.Details"/>.
///
/// <para><see cref="BlogSpotService.Details"/> loads a
/// <see cref="BlogPost"/> (with Author, Tags, Comments, ACL and
/// Publication navigations), runs a resource-based
/// <c>ReadPermission</c> authorization against it, scrubs the ACL for
/// non-owners, derives <see cref="BlogPost.IsPublished"/> from the
/// loaded <c>Publication</c> navigation, and eagerly loads each
/// comment's <c>Author</c>. These tests pin each of those behaviours
/// at the service layer against the real
/// <see cref="Yavsc.Services.PermissionHandler"/> (the public/owner/
/// sponsor/Administrator disjunction) and a real SQLite store — no
/// mocking.</para>
///
/// <para>Seeding and the service call resolve through separate DI
/// scopes so the DbContext that writes the seed rows is disposed
/// before the one <see cref="BlogSpotService.Details"/> reads through,
/// mirroring the per-request DbContext lifetime in production.</para>
/// </summary>
[Collection("Yavsc Server")]
public sealed class BlogSpotServiceDetailsTests : IClassFixture<ServerServicesFixture>
{
private readonly ServerServicesFixture _fixture;
public BlogSpotServiceDetailsTests(ServerServicesFixture fixture)
{
_fixture = fixture;
}
/// <summary>An authenticated principal whose <c>sub</c> claim is
/// <paramref name="userId"/>. <see cref="Yavsc.Server.Helpers.UserHelpers.GetUserId"/>
/// reads <c>sub</c> first, which is what the JwtBearer middleware
/// in production preserves (MapInboundClaims = false), and what
/// <see cref="Yavsc.Services.PermissionHandler"/> compares against
/// <see cref="BlogPost.AuthorId"/>.</summary>
private static ClaimsPrincipal Authenticated(string userId,
params Claim[] extra)
{
var claims = new List<Claim> { new("sub", userId) };
claims.AddRange(extra);
return new ClaimsPrincipal(
new ClaimsIdentity(claims, authenticationType: "Bearer"));
}
/// <summary>An unauthenticated principal: a
/// <see cref="ClaimsIdentity"/> with no authentication type has
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false. The
/// service still runs authorization — a published post's
/// <c>ReadPermission</c> succeeds purely on IsPublic, with no
/// identity required.</summary>
private static ClaimsPrincipal Anonymous()
=> new(new ClaimsIdentity());
/// <summary>The Microsoft-format role claim
/// <see cref="Yavsc.Server.Helpers.UserHelpers.IsInMsRole"/>
/// reads when checking the "Administrator" bypass in
/// <see cref="Yavsc.Services.PermissionHandler"/>.</summary>
private static Claim AdminRole()
=> new("http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
"Administrator");
[Fact]
public async Task Details_returns_the_post_for_its_owner_and_keeps_the_acl()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var circleId = _fixture.SeedCircle("alice", "alice-private");
var postId = _fixture.SeedBlogPost("alice", "Owner reads own draft");
_fixture.SeedAcl(postId, circleId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), postId));
Assert.Equal(postId, blog.Id);
Assert.Equal("Owner reads own draft", blog.Title);
// The owner is the one viewer ScrubAclForViewer keeps the ACL
// for — a non-owner would see an empty list.
Assert.Single(blog.ACL!);
// No BlogSpotPublication row was seeded, so IsPublished is
// derived from the (null) Publication navigation as false.
Assert.False(blog.IsPublished);
}
[Fact]
public async Task Details_serves_IsPublished_true_when_a_publication_row_exists()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var postId = _fixture.SeedBlogPost("alice", "Published post");
_fixture.Publish(postId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), postId));
// IsPublished is a wire-only value the service derives from
// the loaded Publication navigation (Details Includes it).
Assert.True(blog.IsPublished);
}
[Fact]
public async Task Details_lets_an_anonymous_viewer_read_a_published_post_and_scrubs_its_acl()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var circleId = _fixture.SeedCircle("alice", "alice-private");
var postId = _fixture.SeedBlogPost("alice", "Public post");
// The ACL is real, but a non-owner viewer must not see it.
_fixture.SeedAcl(postId, circleId);
_fixture.Publish(postId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Anonymous(), postId));
// ReadPermission succeeds purely on IsPublic (the publication
// row) — no identity needed.
Assert.Equal(postId, blog.Id);
Assert.True(blog.IsPublished);
// ScrubAclForViewer: the anonymous viewer is not the owner, so
// the ACL is replaced with an empty list rather than leaked.
Assert.Empty(blog.ACL!);
}
[Fact]
public async Task Details_denies_a_draft_to_a_non_owner_who_is_not_a_sponsor()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
var postId = _fixture.SeedBlogPost("alice", "Alice's draft");
// No publication row (not public), bob is not the owner, bob
// is not a member of any circle alice owns, bob is not an
// Administrator — every branch of ReadPermission fails.
await Assert.ThrowsAsync<AuthorizationFailureException>(() =>
_fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("bob"), postId)));
}
[Fact]
public async Task Details_lets_a_sponsor_read_a_draft_and_scrubs_its_acl()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
// Sponsorship = bob is a member of a circle alice owns.
var circleId = _fixture.SeedCircle("alice", "alice-friends");
_fixture.SeedCircleMember(circleId, "bob");
var postId = _fixture.SeedBlogPost("alice", "Draft for friends");
_fixture.SeedAcl(postId, circleId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("bob"), postId));
// ReadPermission succeeds on the IsSponsor branch.
Assert.Equal(postId, blog.Id);
Assert.False(blog.IsPublished);
// bob is not the owner, so the ACL is scrubbed even though he
// was granted read access via the circle.
Assert.Empty(blog.ACL!);
}
[Fact]
public async Task Details_lets_an_administrator_read_a_draft()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("root");
var postId = _fixture.SeedBlogPost("alice", "Draft, admin view");
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("root", AdminRole()), postId));
// The Administrator bypass in PermissionHandler short-circuits
// the public/owner/sponsor checks.
Assert.Equal(postId, blog.Id);
Assert.False(blog.IsPublished);
}
[Fact]
public async Task Details_loads_each_comment_author()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
var postId = _fixture.SeedBlogPost("alice", "Post with comments");
_fixture.SeedComment(postId, "bob", "Nice post!");
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), postId));
var comment = Assert.Single(blog.Comments);
// Details explicitly loads c.Author from _context.Users for
// every comment; the navigation must not be left null.
Assert.NotNull(comment.Author);
Assert.Equal("bob", comment.Author.UserName);
}
[Fact]
public async Task Details_throws_for_a_missing_post()
{
_fixture.ResetDatabase();
// The query uses SingleAsync, which throws
// InvalidOperationException when no row matches — the
// `if (blog == null) return null` guard below it is unreachable.
// Pin that current behaviour so a future switch to
// SingleOrDefaultAsync (which would make the guard live) is a
// deliberate, reviewed change rather than a silent one.
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), 424242)));
}
}

View file

@ -0,0 +1,452 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Npgsql;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Services;
namespace Yavsc.Server.Tests.Fixtures;
/// <summary>
/// Service-level test host for <see cref="BlogSpotService"/> and the
/// other Yavsc.Server services that take an
/// <see cref="ApplicationDbContext"/> + <see cref="IAuthorizationService"/>.
///
/// <para>Unlike <c>BlogsWebServerFixture</c> (in Yavsc.Blogs.Tests),
/// this fixture does <b>not</b> spin up Kestrel, JWT bearer, or MVC
/// controllers. <see cref="BlogSpotService.Details"/> takes the
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> as a parameter,
/// so the caller controls identity directly — there is no HTTP/auth
/// middleware in the path under test. Building only the DI graph the
/// service actually resolves keeps these tests fast and isolates the
/// service layer from the blog API's transport concerns, which
/// Yavsc.Blogs.Tests already covers end-to-end.</para>
///
/// <para><b>Database provider.</b> Mirrors
/// <c>ApiWebServerFixture</c>: SQLite <c>:memory:</c> by default, or
/// real PostgreSQL when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c> is
/// set. Production runs on Npgsql, so the Npgsql path — which applies
/// the real <c>Yavsc.Org</c> migrations against a dedicated test
/// database — is the one that catches provider-specific bugs (FK
/// enforcement, type mappings, migration drift). SQLite stays the
/// default so the suite runs out-of-the-box with no external server.</para>
///
/// <list type="bullet">
/// <item><description>SQLite: a named <c>:memory:</c> database
/// (Cache=Shared) backed by a single connection held open for the
/// fixture's lifetime, schema via <c>EnsureCreated</c>. Reset is
/// <c>EnsureDeleted</c> + <c>EnsureCreated</c>.</description></item>
/// <item><description>Npgsql: a dedicated database
/// <c>yavscServerTestDb</c> (created from the admin connection if it
/// does not exist), schema via <c>Migrate</c> using the
/// <c>Yavsc.Org</c> migrations assembly. Reset purges the blog-graph
/// tables in FK order — children before parents — so no DELETE
/// violates a constraint, instead of dropping the database (which
/// would force a full re-migration per test).</description></item>
/// <item><description>The real <see cref="BlogSpotService"/> and the
/// real <see cref="PermissionHandler"/>: <c>Details</c> calls
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new
/// ReadPermission())</c>, and <see cref="PermissionHandler"/> answers
/// that requirement with the public/owner/sponsor/Administrator
/// disjunction that the tests below pin. No mocking.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: <c>Details</c> never touches the file system, but the DI
/// container needs an instance to construct the service.</description></item>
/// </list>
///
/// <para>Marked <see cref="CollectionDefinitionAttribute"/> so a single
/// fixture instance (one store, one DI container) is shared across
/// every <c>[Collection("Yavsc Server")]</c> test class. Each test
/// resets the store via <see cref="ResetDatabase"/>.</para>
/// </summary>
[CollectionDefinition("Yavsc Server")]
public sealed class ServerServicesFixture : IDisposable
{
private const string DbProviderEnvVar = "YAVSC_SERVER_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscServerTestDb";
private static readonly object _npgsqlLock = new();
private static string? _sharedNpgsqlConnectionString;
private readonly SqliteConnection? _sqliteConnection;
private readonly ServiceProvider _services;
public IServiceProvider Services => _services;
public ServerServicesFixture()
{
var services = new ServiceCollection();
if (UseNpgsqlProvider())
{
var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseNpgsql(npgsqlConnectionString,
x => x.MigrationsAssembly("Yavsc.Org")));
}
else
{
// Mode=Memory + Cache=Shared: a named in-memory database
// every connection string referencing "YavscServerTests"
// resolves to the same backing store, as long as at least
// one connection stays open against it. _sqliteConnection
// is held for the fixture's lifetime (disposed in Dispose),
// so the store survives across scoped DbContexts per test.
_sqliteConnection = new SqliteConnection(
"Data Source=YavscServerTests;Mode=Memory;Cache=Shared");
_sqliteConnection.Open();
services.AddDbContext<ApplicationDbContext>(opt =>
// UseSqlite(DbConnection) keeps _sqliteConnection alive
// for the DbContext's lifetime instead of letting EF
// open and close its own — without this each DbContext
// would get a fresh connection pointing at an empty
// store.
opt.UseSqlite(_sqliteConnection));
}
// BlogSpotService.AttachFiles writes under siteSettings.Blog.
// Details itself doesn't read it, but the service constructor
// throws if SiteSettings.Blog is null, so configure a temp
// value to keep construction alive.
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
// Trivial file-system auth: Details never calls into it, but
// the DI container needs an instance to construct the service.
services.AddSingleton<IFileSystemAuthManager>(
new NoopFileSystemAuthManager());
// The real BlogSpotService — same shape the production host
// builds (ApplicationDbContext, IAuthorizationService,
// IFileSystemAuthManager, IOptions<SiteSettings>,
// ILoggerFactory).
services.AddScoped<BlogSpotService>();
// The real PermissionHandler: Details authorizes with
// ReadPermission, and PermissionHandler resolves it via the
// IsPublic / IsOwner / IsSponsor / Administrator disjunction.
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
// Default authorization services register
// IAuthorizationService (DefaultAuthorizationService), which
// collects the IAuthorizationHandler above and runs it against
// the requirement passed to AuthorizeAsync.
services.AddAuthorization();
services.AddLogging();
_services = services.BuildServiceProvider();
// Create the schema once. Npgsql uses Migrate (the Yavsc.Org
// migrations) so the test schema matches production; SQLite uses
// EnsureCreated (idempotent, model-derived). ResetDatabase
// re-creates / purges per test.
using (var scope = _services.CreateScope())
{
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
if (UseNpgsqlProvider())
db.Database.Migrate();
else
db.Database.EnsureCreated();
}
}
/// <summary>True when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c>
/// is set — the fixture then runs against a real PostgreSQL server
/// instead of the SQLite in-memory default.</summary>
public static bool UseNpgsqlProvider()
=> string.Equals(
Environment.GetEnvironmentVariable(DbProviderEnvVar),
"npgsql",
StringComparison.OrdinalIgnoreCase);
/// <summary>Reset the store to a known empty state for the next
/// test. SQLite: <c>EnsureDeleted</c> + <c>EnsureCreated</c> (the
/// named in-memory store survives because _sqliteConnection is held
/// open). Npgsql: purge the blog-graph tables in FK order —
/// children before parents — so no DELETE trips a foreign key,
/// avoiding a full re-migration per test.</summary>
public void ResetDatabase()
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
if (UseNpgsqlProvider())
{
// Children before parents. BlogAttachedFile, Comment and
// BlogSpotPublication reference BlogSpot; CircleAuthorization
// ToBlogPost references BlogSpot + Circle; BlogTag references
// BlogSpot + Tag; BlogSpot references ApplicationUser;
// CircleMember references Circle + ApplicationUser.
db.BlogAttachedFiles.RemoveRange(db.BlogAttachedFiles);
db.Comment.RemoveRange(db.Comment);
db.blogSpotPublications.RemoveRange(db.blogSpotPublications);
db.CircleAuthorizationToBlogPost
.RemoveRange(db.CircleAuthorizationToBlogPost);
db.BlogTag.RemoveRange(db.BlogTag);
db.UploadedFiles.RemoveRange(db.UploadedFiles);
db.BlogSpot.RemoveRange(db.BlogSpot);
db.CircleMembers.RemoveRange(db.CircleMembers);
db.Circle.RemoveRange(db.Circle);
db.Users.RemoveRange(db.Users);
db.SaveChanges();
return;
}
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
/// <summary>Resolve a fresh service scope and the
/// <see cref="ApplicationDbContext"/> from it, run
/// <paramref name="body"/>, and dispose the scope (and its
/// DbContext). Use this for seeding writes that should not share
/// a DbContext with the service call under test.</summary>
public T WithDb<T>(Func<ApplicationDbContext, T> body)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
return body(db);
}
public void SeedUser(string userName,
Action<ApplicationUser>? configure = null)
{
WithDb(db =>
{
if (db.Users.Any(u => u.Id == userName)) return false;
var user = new ApplicationUser
{
Id = userName,
UserName = userName,
Email = $"{userName}@example.test",
};
configure?.Invoke(user);
db.Users.Add(user);
db.SaveChanges();
return true;
});
}
public long SeedBlogPost(string authorId, string title,
Action<BlogPost>? configure = null)
{
return WithDb(db =>
{
var post = new BlogPost
{
AuthorId = authorId,
Title = title,
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
configure?.Invoke(post);
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
});
}
/// <summary>Insert a <see cref="BlogSpotPublication"/> row for
/// <paramref name="postId"/> — the existence of that row is what
/// makes a post "public" (readable by anyone) in
/// <see cref="PermissionHandler"/>. This is the same write
/// <c>SetPublishAsync</c> performs in production.</summary>
public void Publish(long postId)
{
WithDb(db =>
{
db.blogSpotPublications.Add(
new BlogSpotPublication { PostId = postId });
db.SaveChanges();
return true;
});
}
/// <summary>Seed a circle owned by <paramref name="ownerId"/> and
/// return its server-assigned id. Sponsorship in
/// <see cref="PermissionHandler"/> is "the viewer is a member of a
/// circle the post's author owns", so a circle + a
/// <see cref="SeedCircleMember"/> entry is what grants a non-owner
/// read access to a draft.</summary>
public long SeedCircle(string ownerId, string name)
{
return WithDb(db =>
{
var circle = new Circle
{
OwnerId = ownerId,
Name = name,
Public = false,
};
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
});
}
public void SeedCircleMember(long circleId, string memberId)
{
WithDb(db =>
{
db.CircleMembers.Add(
new CircleMember { CircleId = circleId, MemberId = memberId });
db.SaveChanges();
return true;
});
}
/// <summary>Attach a <see cref="CircleAuthorizationToBlogPost"/> ACL
/// entry to <paramref name="postId"/> for <paramref name="circleId"/>.
/// The owner always sees the ACL; non-owners have it scrubbed by
/// <see cref="BlogSpotService"/>'s ScrubAclForViewer.</summary>
public void SeedAcl(long postId, long circleId)
{
WithDb(db =>
{
db.CircleAuthorizationToBlogPost.Add(
new CircleAuthorizationToBlogPost
{
BlogPostId = postId,
CircleId = circleId,
});
db.SaveChanges();
return true;
});
}
/// <summary>Seed a comment on <paramref name="postId"/> authored by
/// <paramref name="authorId"/>. <see cref="BlogSpotService.Details"/>
/// loads each comment's <c>Author</c> navigation from
/// <c>_context.Users</c>; seeding the user first keeps the FK
/// valid (SQLite and PostgreSQL both enforce it).</summary>
public void SeedComment(long postId, string authorId, string article)
{
WithDb(db =>
{
db.Comment.Add(new Comment
{
ReceiverId = postId,
AuthorId = authorId,
Article = article,
Visible = true,
UserModified = authorId,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
db.SaveChanges();
return true;
});
}
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
/// fixture's DI container, run <paramref name="body"/> against it,
/// and dispose the request scope (and its scoped DbContext). The
/// service is scoped, so it must be resolved from a scope — and the
/// scope is what we dispose to avoid leaking a DbContext against
/// the shared connection across tests.</summary>
public async Task<T> WithServiceAsync<T>(
Func<BlogSpotService, Task<T>> body)
{
using var scope = _services.CreateScope();
var service = scope.ServiceProvider
.GetRequiredService<BlogSpotService>();
return await body(service);
}
private static string EnsureNpgsqlDatabaseCreated()
{
lock (_npgsqlLock)
{
if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
return _sharedNpgsqlConnectionString;
var adminConnectionString = BuildAdminConnectionString();
var databaseName = DedicatedNpgsqlDatabaseName;
using (var adminConnection = new NpgsqlConnection(adminConnectionString))
{
adminConnection.Open();
using var existsCommand = adminConnection.CreateCommand();
existsCommand.CommandText =
"SELECT 1 FROM pg_database WHERE datname = @databaseName";
existsCommand.Parameters.AddWithValue("databaseName", databaseName);
if (existsCommand.ExecuteScalar() is null)
{
using var createCommand = adminConnection.CreateCommand();
createCommand.CommandText =
$"CREATE DATABASE \"{databaseName}\"";
createCommand.ExecuteNonQuery();
}
}
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
{
Database = databaseName,
Pooling = false,
IncludeErrorDetail = true
};
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
return _sharedNpgsqlConnectionString;
}
}
private static string BuildAdminConnectionString()
{
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
if (string.IsNullOrWhiteSpace(configured))
throw new InvalidOperationException(
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(configured)
{
Pooling = false,
IncludeErrorDetail = true
};
// The admin connection targets the maintenance database
// (postgres) so we can CREATE/DROP the dedicated test database;
// whatever database the env var names is overridden here.
builder.Database = "postgres";
return builder.ToString();
}
public void Dispose()
{
_services.Dispose();
_sqliteConnection?.Dispose();
}
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(
System.Security.Claims.ClaimsPrincipal user,
string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath,
FileAccessRight access) { }
}
}

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Server.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -509,5 +509,9 @@ namespace Yavsc.Models
public DbSet<ModerationLog> ModerationLogs { get; set; } public DbSet<ModerationLog> ModerationLogs { get; set; }
public DbSet<NominativeServiceCommand> NominativeServiceCommands { get; set; } public DbSet<NominativeServiceCommand> NominativeServiceCommands { get; set; }
public DbSet<EstimateAttachedFile> EstimateAttachedFiles { get; set; }
public DbSet<QueryAttachedFile> QueryAttachedFiles { get; set; }
} }
} }

View file

@ -0,0 +1,13 @@
namespace Yavsc.Models.Billing
{
/// <summary>
/// Corps de requête pour attacher un fichier de l'espace perso à un
/// devis (fournisseur) ou à une demande (client), par référence à son
/// identifiant <see cref="Yavsc.Models.Blog.UploadedFile"/>.
/// </summary>
public class AttachFileRequest
{
/// <summary>Identifiant du fichier référencé.</summary>
public long FileId { get; set; }
}
}

View file

@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models.Blog;
namespace Yavsc.Models.Billing
{
/// <summary>
/// Pièce jointe d'un devis, attachée **par référence** à un fichier
/// de l'espace de stockage personnel du fournisseur
/// (<see cref="UploadedFile"/>). Le fichier reste dans l'espace perso
/// du fournisseur (pas de duplication) ; seul le lien est persisté.
/// Le téléchargement est servi par le host Blogs, qui autorise
/// l'appelant (fournisseur ou client du devis) via cette table.
/// </summary>
[PrimaryKey(nameof(FileId), nameof(EstimateId))]
public class EstimateAttachedFile
{
/// <summary>
/// Identifiant du fichier référencé (partie de la clé composite).
/// </summary>
public long FileId { get; set; }
[ForeignKey("FileId")]
public virtual UploadedFile File { get; set; }
/// <summary>
/// Identifiant du devis auquel le fichier est rattaché
/// (partie de la clé composite).
/// </summary>
public long EstimateId { get; set; }
[ForeignKey("EstimateId")]
public virtual Estimate Estimate { get; set; }
}
}

View file

@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models.Blog;
namespace Yavsc.Models.Billing
{
/// <summary>
/// Pièce jointe d'une demande (NominativeServiceCommand), attachée
/// **par référence** à un fichier de l'espace de stockage personnel
/// du client (<see cref="UploadedFile"/>). Le fichier reste dans
/// l'espace perso du client (pas de duplication) ; seul le lien est
/// persisté. Le téléchargement est servi par le host Blogs, qui
/// autorise l'appelant (client ou fournisseur de la demande) via
/// cette table.
/// </summary>
[PrimaryKey(nameof(FileId), nameof(CommandId))]
public class QueryAttachedFile
{
/// <summary>
/// Identifiant du fichier référencé (partie de la clé composite).
/// </summary>
public long FileId { get; set; }
[ForeignKey("FileId")]
public virtual UploadedFile File { get; set; }
/// <summary>
/// Identifiant de la demande à laquelle le fichier est rattaché
/// (partie de la clé composite).
/// </summary>
public long CommandId { get; set; }
[ForeignKey("CommandId")]
public virtual NominativeServiceCommand Command { get; set; }
}
}

View file

@ -1,3 +1,4 @@
#nullable enable annotations
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -33,6 +34,19 @@ namespace Yavsc.Models.Blog
/// </summary> /// </summary>
/// <value></value> /// <value></value>
public string ContentType { get; set; } public string ContentType { get; set; }
/// <summary>
/// Propriétaire du fichier dans l'espace de stockage personnel.
/// Renseigné pour les fichiers téléversés via <c>api/v1/fs</c>
/// (référencés par <see cref="EstimateAttachedFile"/> /
/// <see cref="QueryAttachedFile"/>). Reste <c>null</c> pour les
/// pièces jointes de blog historiques (<see cref="BlogAttachedFile"/>),
/// qui ne sont pas adressées par propriétaire.
/// </summary>
public string? OwnerId { get; set; }
[ForeignKey("OwnerId")]
public virtual ApplicationUser? Owner { get; set; }
} }
[PrimaryKey(nameof(FileId), nameof(PostId))] [PrimaryKey(nameof(FileId), nameof(PostId))]

View file

@ -48,6 +48,24 @@ namespace Yavsc.Server.Models.FileSystem
public bool Overridden { get; set; } public bool Overridden { get; set; }
public bool QuotaOffense { get; set; } public bool QuotaOffense { get; set; }
/// <summary>
/// Identifiant de la ligne <see cref="Yavsc.Models.Blog.UploadedFile"/>
/// créée à l'enregistrement du fichier dans l'espace perso
/// (<c>api/v1/fs</c>). Nul si aucune métadonnée n'a été persistée.
/// </summary>
public long? FileId { get; set; }
/// <summary>
/// Taille du fichier reçu (octets), pour le reflet côté métadonnées.
/// </summary>
public long Length { get; set; }
/// <summary>
/// Type MIME du fichier reçu.
/// </summary>
public string ContentType { get; set; }
public string FullName { get => Path.Combine(DestDir, FileName); } public string FullName { get => Path.Combine(DestDir, FileName); }
} }

View file

@ -9,7 +9,7 @@
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -14,7 +14,7 @@
--> -->
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Server.Tests", "src\Yavsc.Server.Tests\Yavsc.Server.Tests.csproj", "{899B7240-729A-48BC-A993-300A6CCFCBEA}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -243,6 +245,18 @@ Global
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.ActiveCfg = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.Build.0 = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.ActiveCfg = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.Build.0 = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.Build.0 = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.ActiveCfg = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.Build.0 = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.ActiveCfg = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -265,5 +279,6 @@ Global
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE} {021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE}
{49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F} {49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F}
{899B7240-729A-48BC-A993-300A6CCFCBEA} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal