diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index de10cac30..b2989ef50 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -11,6 +11,7 @@ using Yavsc.Server.Helpers; using Yavsc.Tests.Shared; using Yavsc.Blogs.Tests.Fixtures; using BlogPostDto = Yavsc.Blogspot.BlogPostDto; +using NuGet.Protocol; namespace Yavsc.Blogs.Tests; @@ -338,9 +339,9 @@ public sealed class BlogApiTests : IClassFixture ResetAndSeedDefaultUser(); using var http = NewClient(subject: "tester"); - var previousRoot = "blog"; - var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempRoot); + // AttachFiles writes under the fixture-configured + // SiteSettings.Blog (BlogFilesRoot), not a local temp dir. + var filesRoot = _fixture.BlogFilesRoot; try { @@ -373,8 +374,10 @@ public sealed class BlogApiTests : IClassFixture DateModified = DateTime.UtcNow }; - var form = new MultipartFormDataContent(); - form.Add(new StringContent(JsonSerializer.Serialize(update)), "blog"); + var form = new MultipartFormDataContent + { + { new StringContent(JsonSerializer.Serialize(update)), "blog" } + }; var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test"); var fileContent = new ByteArrayContent(fileBytes); @@ -404,7 +407,9 @@ public sealed class BlogApiTests : IClassFixture } finally { - try { Directory.Delete(tempRoot, recursive: true); } catch { } + // BlogFilesRoot is the fixture-shared temp root (one per + // test run); leave it in place for the other multipart + // tests. The OS reclaims it. } } @@ -478,9 +483,9 @@ public sealed class BlogApiTests : IClassFixture ResetAndSeedDefaultUser(); using var http = NewClient(subject: "tester"); - var previousRoot = "blog"; - var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempRoot); + // AttachFiles writes under the fixture-configured + // SiteSettings.Blog (BlogFilesRoot), not a local temp dir. + var filesRoot = _fixture.BlogFilesRoot; try { @@ -566,16 +571,18 @@ public sealed class BlogApiTests : IClassFixture // markdown link points: // {UserFilesDirName}/{user}/blogs/{postId}/{fileName}. var expectedFile = Path.Combine( - tempRoot, "tester", "blogs", created.Id.ToString(), "note.txt"); - var written = Directory.Exists(tempRoot) - ? string.Join(", ", Directory.EnumerateFiles(tempRoot, "*", SearchOption.AllDirectories)) + filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt"); + var written = Directory.Exists(filesRoot) + ? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories)) : ""; Assert.True(File.Exists(expectedFile), $"Expected uploaded file at {expectedFile}. Actually written: {written}"); } finally { - try { Directory.Delete(tempRoot, recursive: true); } catch { } + // BlogFilesRoot is the fixture-shared temp root (one per + // test run); leave it in place for the other multipart + // tests. The OS reclaims it. } } diff --git a/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs index e0606f5d7..f464a1caa 100644 --- a/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs @@ -63,6 +63,16 @@ public sealed class BlogsWebServerFixture : WebHostFixture public long PostId { get; private set; } public string DefaultUserLogin { get => "alice"; } + /// Root directory under which + /// BlogSpotService.AttachFiles writes uploaded files + /// during tests (the configured SiteSettings.Blog). + /// Attachments land at + /// {BlogFilesRoot}/{userName}/blogs/{postId}/{fileName}. + /// A fresh per-run temp directory, so tests can assert on the + /// physical file without touching the developer's real + /// user-files tree. + public string BlogFilesRoot { get; private set; } = string.Empty; + // A single SqliteConnection held open at the static level, // mirroring how Yavsc.Org.Tests.WebServerFixture hoists its // shared configuration into static slots. Closing the @@ -112,6 +122,18 @@ public sealed class BlogsWebServerFixture : WebHostFixture builder.Services.AddSingleton( new NoopFileSystemAuthManager()); + // BlogSpotService.AttachFiles writes uploaded files under + // siteSettings.Blog. Without a configured SiteSettings the + // IOptions value has Blog == null and + // Path.Combine(null, …) throws inside AttachFiles' catch-all, + // silently persisting no attachment. Point Blog at a + // per-run temp directory so the multipart tests can assert + // on real files. + builder.Services.Configure(s => + { + s.Blog = BlogFilesRoot; + }); + // Real BlogSpotService — same instance the production host // builds (ApplicationDbContext, IAuthorizationService, // IFileSystemAuthManager). With PermissionHandler registered diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs index 97c070a3c..7aaf221e4 100644 --- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs @@ -57,12 +57,31 @@ namespace Yavsc.Blogs.Controllers [HttpPut("{id}")] public async Task PutBlog(long id) { - var blog = await ReadPutBlogRequestAsync(); + var blog = await ReadBlogRequestAsync(); if (blog is null) { return BadRequest(ModelState); } + // Without [FromBody], the data-annotations attributes + // ([Required] on Title, etc.) are no longer evaluated by + // the model binder — the body is read manually. Run + // validation explicitly so an empty Title still yields + // 400 (and not a 500 from the DB NOT NULL constraint). + var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(blog); + var validationResults = new List(); + if (!System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + blog, validationContext, validationResults, validateAllProperties: true)) + { + foreach (var result in validationResults) + { + ModelState.AddModelError( + result.MemberNames.FirstOrDefault() ?? string.Empty, + result.ErrorMessage ?? "Invalid value."); + } + return BadRequest(ModelState); + } + // These properties are server-managed or optional graph members and // should not block JSON payloads coming from API clients. ModelState.Remove(nameof(Models.Blog.BlogPost.Author)); @@ -93,7 +112,8 @@ namespace Yavsc.Blogs.Controllers try { - await blogSpotService.Modify(User, blog, files); + await blogSpotService.Modify(User, blog); + blogSpotService.AttachFiles(files, User.GetUserId(), id); } catch (AuthorizationFailureException) { @@ -105,8 +125,14 @@ namespace Yavsc.Blogs.Controllers // POST: api/v1/blogspot [HttpPost] - public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog) + public async Task PostBlog() { + var blog = await ReadBlogRequestAsync(); + if (blog is null) + { + return BadRequest(ModelState); + } + // These properties are server-managed or optional graph members and // should not block JSON payloads coming from API clients. ModelState.Remove(nameof(Models.Blog.BlogPost.Author)); @@ -115,35 +141,46 @@ namespace Yavsc.Blogs.Controllers ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated)); ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified)); + // Without [FromBody], the data-annotations attributes + // ([Required] on Title, etc.) are no longer evaluated by + // the model binder — the body is read manually. Run + // validation explicitly so an empty Title still yields + // 400 (and not a 500 from the DB NOT NULL constraint). + var validationContext = new System.ComponentModel.DataAnnotations.ValidationContext(blog); + var validationResults = new List(); + if (!System.ComponentModel.DataAnnotations.Validator.TryValidateObject( + blog, validationContext, validationResults, validateAllProperties: true)) + { + foreach (var result in validationResults) + { + ModelState.AddModelError( + result.MemberNames.FirstOrDefault() ?? string.Empty, + result.ErrorMessage ?? "Invalid value."); + } + return BadRequest(ModelState); + } + if (!ModelState.IsValid) { return BadRequest(ModelState); } - // The BlogSpotService.Create() signature requires an - // IFormFileCollection for file uploads. Reading - // Request.Form.Files when the request is a plain JSON - // body (e.g. from PostIt) throws - // "This request does not have a Content-Type header. - // Forms are available from requests with bodies like - // POSTs and a form Content-Type of either - // application/x-www-form-urlencoded or - // multipart/form-data." - // // Two valid use cases for this endpoint: // 1. JSON body only (no files) — PostIt path. // 2. multipart/form-data with a 'blog' field + 0..N - // files — future browser / server-rendered path. + // files — PostIt "create with attachments" path. // // Branch on HasFormContentType: pass the form files when - // present, pass an empty collection otherwise. The - // FileSystem branch in BlogSpotService.Create then - // short-circuits to "no files to handle". + // present, pass an empty collection otherwise. Reading + // Request.Form.Files on a plain JSON request throws + // "This request does not have a Content-Type header…", + // and AttachFiles short-circuits on an empty collection. var files = Request.HasFormContentType ? Request.Form.Files : (IFormFileCollection)new FormFileCollection(); var uid = User.GetUserId(); - var post = blogSpotService.Create(uid, blog, files); + var post = blogSpotService.Create(uid, blog); + blogSpotService.AttachFiles(files, uid, post.Id); return CreatedAtRoute("GetBlog", new { id = post.Id }, post.GetPayload()); } @@ -210,7 +247,17 @@ namespace Yavsc.Blogs.Controllers base.Dispose(disposing); } - private async Task ReadPutBlogRequestAsync() + /// + /// Read a from either a + /// JSON body or a multipart/form-data request carrying the + /// payload in a blog form field. Shared by + /// and : both + /// accept the two shapes so PostIt can attach files on + /// create as well as on update. Returns null (with + /// the reason recorded in ModelState) when the + /// multipart form has no usable blog field. + /// + private async Task ReadBlogRequestAsync() { if (!Request.HasFormContentType) { diff --git a/src/Yavsc.Org.Tests/appsettings.json b/src/Yavsc.Org.Tests/appsettings.json index ef95fef61..f21f348ae 100644 --- a/src/Yavsc.Org.Tests/appsettings.json +++ b/src/Yavsc.Org.Tests/appsettings.json @@ -9,6 +9,10 @@ "FavIcon": "/favicon.ico", "Icon": "/images/yavsc.png", "GitRepository": "testingrepo", + "Blog": "blog", + "Bills": "bills", + "TempDir": "temp", + "Avatars": "avatars", "Owner": { "Name": "Site Owner Name", "EMail": "your@email", diff --git a/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs b/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs index 98611e5fe..b55171879 100644 --- a/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs +++ b/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs @@ -109,8 +109,9 @@ namespace Yavsc.Org.Controllers { if (ModelState.IsValid) { - Models.Blog.BlogPost post = blogSpotService.Create(User.GetUserId(), - blogInput, Request.Form.Files); + var uid = User.GetUserId(); + Models.Blog.BlogPost post = blogSpotService.Create(uid, blogInput); + blogSpotService.AttachFiles(Request.Form.Files, uid, post.Id); return RedirectToAction("Index"); } return View("Edit", blogInput); diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 567d05233..8c1db2a73 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -1241,6 +1241,26 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;"); SiteSettings settings, bool enableDirectoryBrowsing = false) { + if (settings.GitRepository == null) + { + throw new InvalidOperationException("GitRepository path is not configured in SiteSettings."); + } + if (settings.Blog == null) + { + throw new InvalidOperationException("Blog path is not configured in SiteSettings."); + } + if (settings.Avatars == null) + { + throw new InvalidOperationException("Avatars path is not configured in SiteSettings."); + } + if (settings.Bills == null) + { + throw new InvalidOperationException("Bills path is not configured in SiteSettings."); + } + if (settings.TempDir == null) + { + throw new InvalidOperationException("TempDir path is not configured in SiteSettings."); + } var userFilesDirInfo = new DirectoryInfo(settings.Blog); diff --git a/src/Yavsc.Org/appsettings-org.json b/src/Yavsc.Org/appsettings-org.json index add67eca4..58aa287ae 100644 --- a/src/Yavsc.Org/appsettings-org.json +++ b/src/Yavsc.Org/appsettings-org.json @@ -33,7 +33,8 @@ "Bills": "bills", "Blog": "blog", "TempDir": "temp", - "DataDir": "data" + "DataDir": "data", + "GitRepository": "git" }, "Smtp": { "Host": "[YOURSMTPHOST]", diff --git a/src/Yavsc.Server/Services/BlogSpotService.cs b/src/Yavsc.Server/Services/BlogSpotService.cs index 3d845658c..a0507ffe0 100644 --- a/src/Yavsc.Server/Services/BlogSpotService.cs +++ b/src/Yavsc.Server/Services/BlogSpotService.cs @@ -35,14 +35,8 @@ public class BlogSpotService this.siteSettings = siteSettings.Value; } - public BlogPost Create(string userId, BlogPost post, IFormFileCollection files) + public void AttachFiles(IFormFileCollection files, string userId, long postId) { - // Sauvegarder le post d'abord pour obtenir son ID - // Le createur vient de l'authentification, donc on ne le prend pas du post - post.AuthorId = userId; - _context.BlogSpot.Add(post); - _context.SaveChanges(userId); - // Traiter les fichiers attaches s'il y en a if (files != null && files.Count > 0) { @@ -52,7 +46,7 @@ public class BlogSpotService try { - string blogFilesSubdir = $"blogs/{post.Id}"; + string blogFilesSubdir = $"blogs/{postId}"; string destDir = Path.Combine( siteSettings.Blog, user.UserName, @@ -76,7 +70,7 @@ public class BlogSpotService var attachment = new BlogAttachedFile { - PostId = post.Id, + PostId = postId, FileId = uploadedFile.Id }; _context.BlogAttachedFiles.Add(attachment); @@ -89,7 +83,15 @@ public class BlogSpotService Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}"); } } + } + public BlogPost Create(string userId, BlogPost post ) + { + // Sauvegarder le post d'abord pour obtenir son ID + // Le createur vient de l'authentification, donc on ne le prend pas du post + post.AuthorId = userId; + _context.BlogSpot.Add(post); + _context.SaveChanges(userId); return post; } @@ -191,58 +193,6 @@ public class BlogSpotService _context.SaveChanges(user.GetUserId()); } - public async Task Modify(ClaimsPrincipal user, BlogPost blog, IFormFileCollection files) - { - await Modify(user, blog); - - if (files == null || files.Count == 0) - return; - - var userId = user.GetUserId(); - var userEntity = _context.Users.FirstOrDefault(u => u.Id == userId); - if (userEntity == null) - return; - - try - { - string blogFilesSubdir = $"blogs/{blog.Id}"; - string destDir = Path.Combine( - siteSettings.Blog, - userEntity.UserName, - blogFilesSubdir); - var di = new DirectoryInfo(destDir); - if (!di.Exists) di.Create(); - - foreach (var formFile in files) - { - var fileInfo = userEntity.ReceiveUserFile(destDir, formFile); - if (fileInfo != null && !fileInfo.QuotaOffense) - { - var uploadedFile = new UploadedFile - { - Path = fileInfo.FileName, - ContentType = formFile.ContentType, - Length = formFile.Length - }; - _context.UploadedFiles.Add(uploadedFile); - _context.SaveChanges(userId); - - var attachment = new BlogAttachedFile - { - PostId = blog.Id, - FileId = uploadedFile.Id - }; - _context.BlogAttachedFiles.Add(attachment); - } - } - - _context.SaveChanges(userId); - } - catch (Exception ex) - { - Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}"); - } - } public async Task> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25) {