Compare commits

..
Author SHA1 Message Date
8faabbb5ff
refacto site settings
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m10s
2026-09-17 22:22:18 +01:00
8 changed files with 96 additions and 148 deletions

View file

@ -11,7 +11,6 @@ using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures; using Yavsc.Blogs.Tests.Fixtures;
using BlogPostDto = Yavsc.Blogspot.BlogPostDto; using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
using NuGet.Protocol;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
@ -339,9 +338,9 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
ResetAndSeedDefaultUser(); ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
// AttachFiles writes under the fixture-configured var previousRoot = "blog";
// SiteSettings.Blog (BlogFilesRoot), not a local temp dir. var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N"));
var filesRoot = _fixture.BlogFilesRoot; Directory.CreateDirectory(tempRoot);
try try
{ {
@ -374,10 +373,8 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
DateModified = DateTime.UtcNow DateModified = DateTime.UtcNow
}; };
var form = new MultipartFormDataContent var form = new MultipartFormDataContent();
{ form.Add(new StringContent(JsonSerializer.Serialize(update)), "blog");
{ new StringContent(JsonSerializer.Serialize(update)), "blog" }
};
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test"); var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
var fileContent = new ByteArrayContent(fileBytes); var fileContent = new ByteArrayContent(fileBytes);
@ -407,9 +404,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
} }
finally finally
{ {
// BlogFilesRoot is the fixture-shared temp root (one per try { Directory.Delete(tempRoot, recursive: true); } catch { }
// test run); leave it in place for the other multipart
// tests. The OS reclaims it.
} }
} }
@ -483,9 +478,9 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
ResetAndSeedDefaultUser(); ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
// AttachFiles writes under the fixture-configured var previousRoot = "blog";
// SiteSettings.Blog (BlogFilesRoot), not a local temp dir. var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N"));
var filesRoot = _fixture.BlogFilesRoot; Directory.CreateDirectory(tempRoot);
try try
{ {
@ -571,18 +566,16 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// markdown link points: // markdown link points:
// {UserFilesDirName}/{user}/blogs/{postId}/{fileName}. // {UserFilesDirName}/{user}/blogs/{postId}/{fileName}.
var expectedFile = Path.Combine( var expectedFile = Path.Combine(
filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt"); tempRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
var written = Directory.Exists(filesRoot) var written = Directory.Exists(tempRoot)
? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories)) ? string.Join(", ", Directory.EnumerateFiles(tempRoot, "*", SearchOption.AllDirectories))
: "<none>"; : "<none>";
Assert.True(File.Exists(expectedFile), Assert.True(File.Exists(expectedFile),
$"Expected uploaded file at {expectedFile}. Actually written: {written}"); $"Expected uploaded file at {expectedFile}. Actually written: {written}");
} }
finally finally
{ {
// BlogFilesRoot is the fixture-shared temp root (one per try { Directory.Delete(tempRoot, recursive: true); } catch { }
// test run); leave it in place for the other multipart
// tests. The OS reclaims it.
} }
} }

View file

@ -63,16 +63,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture
public long PostId { get; private set; } public long PostId { get; private set; }
public string DefaultUserLogin { get => "alice"; } public string DefaultUserLogin { get => "alice"; }
/// <summary>Root directory under which
/// <c>BlogSpotService.AttachFiles</c> writes uploaded files
/// during tests (the configured <c>SiteSettings.Blog</c>).
/// Attachments land at
/// <c>{BlogFilesRoot}/{userName}/blogs/{postId}/{fileName}</c>.
/// A fresh per-run temp directory, so tests can assert on the
/// physical file without touching the developer's real
/// user-files tree.</summary>
public string BlogFilesRoot { get; private set; } = string.Empty;
// A single SqliteConnection held open at the static level, // A single SqliteConnection held open at the static level,
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its // mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
// shared configuration into static slots. Closing the // shared configuration into static slots. Closing the
@ -122,18 +112,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture
builder.Services.AddSingleton<IFileSystemAuthManager>( builder.Services.AddSingleton<IFileSystemAuthManager>(
new NoopFileSystemAuthManager()); new NoopFileSystemAuthManager());
// BlogSpotService.AttachFiles writes uploaded files under
// siteSettings.Blog. Without a configured SiteSettings the
// IOptions<SiteSettings> 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<SiteSettings>(s =>
{
s.Blog = BlogFilesRoot;
});
// Real BlogSpotService — same instance the production host // Real BlogSpotService — same instance the production host
// builds (ApplicationDbContext, IAuthorizationService, // builds (ApplicationDbContext, IAuthorizationService,
// IFileSystemAuthManager). With PermissionHandler registered // IFileSystemAuthManager). With PermissionHandler registered

View file

@ -57,31 +57,12 @@ namespace Yavsc.Blogs.Controllers
[HttpPut("{id}")] [HttpPut("{id}")]
public async Task<IActionResult> PutBlog(long id) public async Task<IActionResult> PutBlog(long id)
{ {
var blog = await ReadBlogRequestAsync(); var blog = await ReadPutBlogRequestAsync();
if (blog is null) if (blog is null)
{ {
return BadRequest(ModelState); 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<System.ComponentModel.DataAnnotations.ValidationResult>();
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 // These properties are server-managed or optional graph members and
// should not block JSON payloads coming from API clients. // should not block JSON payloads coming from API clients.
ModelState.Remove(nameof(Models.Blog.BlogPost.Author)); ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
@ -112,8 +93,7 @@ namespace Yavsc.Blogs.Controllers
try try
{ {
await blogSpotService.Modify(User, blog); await blogSpotService.Modify(User, blog, files);
blogSpotService.AttachFiles(files, User.GetUserId(), id);
} }
catch (AuthorizationFailureException) catch (AuthorizationFailureException)
{ {
@ -125,14 +105,8 @@ namespace Yavsc.Blogs.Controllers
// POST: api/v1/blogspot // POST: api/v1/blogspot
[HttpPost] [HttpPost]
public async Task<IActionResult> PostBlog() public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog)
{ {
var blog = await ReadBlogRequestAsync();
if (blog is null)
{
return BadRequest(ModelState);
}
// These properties are server-managed or optional graph members and // These properties are server-managed or optional graph members and
// should not block JSON payloads coming from API clients. // should not block JSON payloads coming from API clients.
ModelState.Remove(nameof(Models.Blog.BlogPost.Author)); ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
@ -141,46 +115,35 @@ namespace Yavsc.Blogs.Controllers
ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated)); ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated));
ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified)); 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<System.ComponentModel.DataAnnotations.ValidationResult>();
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) if (!ModelState.IsValid)
{ {
return BadRequest(ModelState); 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: // Two valid use cases for this endpoint:
// 1. JSON body only (no files) — PostIt path. // 1. JSON body only (no files) — PostIt path.
// 2. multipart/form-data with a 'blog' field + 0..N // 2. multipart/form-data with a 'blog' field + 0..N
// files — PostIt "create with attachments" path. // files — future browser / server-rendered path.
// //
// Branch on HasFormContentType: pass the form files when // Branch on HasFormContentType: pass the form files when
// present, pass an empty collection otherwise. Reading // present, pass an empty collection otherwise. The
// Request.Form.Files on a plain JSON request throws // FileSystem branch in BlogSpotService.Create then
// "This request does not have a Content-Type header…", // short-circuits to "no files to handle".
// and AttachFiles short-circuits on an empty collection.
var files = Request.HasFormContentType var files = Request.HasFormContentType
? Request.Form.Files ? Request.Form.Files
: (IFormFileCollection)new FormFileCollection(); : (IFormFileCollection)new FormFileCollection();
var uid = User.GetUserId(); var uid = User.GetUserId();
var post = blogSpotService.Create(uid, blog); var post = blogSpotService.Create(uid, blog, files);
blogSpotService.AttachFiles(files, uid, post.Id);
return CreatedAtRoute("GetBlog", new { id = post.Id }, return CreatedAtRoute("GetBlog", new { id = post.Id },
post.GetPayload()); post.GetPayload());
} }
@ -247,17 +210,7 @@ namespace Yavsc.Blogs.Controllers
base.Dispose(disposing); base.Dispose(disposing);
} }
/// <summary> private async Task<Models.Blog.BlogPost?> ReadPutBlogRequestAsync()
/// Read a <see cref="Models.Blog.BlogPost"/> from either a
/// JSON body or a multipart/form-data request carrying the
/// payload in a <c>blog</c> form field. Shared by
/// <see cref="PostBlog"/> and <see cref="PutBlog"/>: both
/// accept the two shapes so PostIt can attach files on
/// create as well as on update. Returns <c>null</c> (with
/// the reason recorded in <c>ModelState</c>) when the
/// multipart form has no usable <c>blog</c> field.
/// </summary>
private async Task<Models.Blog.BlogPost?> ReadBlogRequestAsync()
{ {
if (!Request.HasFormContentType) if (!Request.HasFormContentType)
{ {

View file

@ -9,10 +9,6 @@
"FavIcon": "/favicon.ico", "FavIcon": "/favicon.ico",
"Icon": "/images/yavsc.png", "Icon": "/images/yavsc.png",
"GitRepository": "testingrepo", "GitRepository": "testingrepo",
"Blog": "blog",
"Bills": "bills",
"TempDir": "temp",
"Avatars": "avatars",
"Owner": { "Owner": {
"Name": "Site Owner Name", "Name": "Site Owner Name",
"EMail": "your@email", "EMail": "your@email",

View file

@ -109,9 +109,8 @@ namespace Yavsc.Org.Controllers
{ {
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
var uid = User.GetUserId(); Models.Blog.BlogPost post = blogSpotService.Create(User.GetUserId(),
Models.Blog.BlogPost post = blogSpotService.Create(uid, blogInput); blogInput, Request.Form.Files);
blogSpotService.AttachFiles(Request.Form.Files, uid, post.Id);
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
return View("Edit", blogInput); return View("Edit", blogInput);

View file

@ -1241,26 +1241,6 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
SiteSettings settings, SiteSettings settings,
bool enableDirectoryBrowsing = false) 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); var userFilesDirInfo = new DirectoryInfo(settings.Blog);

View file

@ -33,8 +33,7 @@
"Bills": "bills", "Bills": "bills",
"Blog": "blog", "Blog": "blog",
"TempDir": "temp", "TempDir": "temp",
"DataDir": "data", "DataDir": "data"
"GitRepository": "git"
}, },
"Smtp": { "Smtp": {
"Host": "[YOURSMTPHOST]", "Host": "[YOURSMTPHOST]",

View file

@ -35,8 +35,14 @@ public class BlogSpotService
this.siteSettings = siteSettings.Value; this.siteSettings = siteSettings.Value;
} }
public void AttachFiles(IFormFileCollection files, string userId, long postId) public BlogPost Create(string userId, BlogPost post, IFormFileCollection files)
{ {
// 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 // Traiter les fichiers attaches s'il y en a
if (files != null && files.Count > 0) if (files != null && files.Count > 0)
{ {
@ -46,7 +52,7 @@ public class BlogSpotService
try try
{ {
string blogFilesSubdir = $"blogs/{postId}"; string blogFilesSubdir = $"blogs/{post.Id}";
string destDir = Path.Combine( string destDir = Path.Combine(
siteSettings.Blog, siteSettings.Blog,
user.UserName, user.UserName,
@ -70,7 +76,7 @@ public class BlogSpotService
var attachment = new BlogAttachedFile var attachment = new BlogAttachedFile
{ {
PostId = postId, PostId = post.Id,
FileId = uploadedFile.Id FileId = uploadedFile.Id
}; };
_context.BlogAttachedFiles.Add(attachment); _context.BlogAttachedFiles.Add(attachment);
@ -83,15 +89,7 @@ public class BlogSpotService
Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}"); 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; return post;
} }
@ -193,6 +191,58 @@ public class BlogSpotService
_context.SaveChanges(user.GetUserId()); _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<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25) public async Task<IEnumerable<IBlogPost>> Index(ClaimsPrincipal user, string id, int skip = 0, int take = 25)
{ {