diff --git a/src/Yavsc.Api/appsettings-api.json b/src/Yavsc.Api/appsettings-api.json index 98723bdf7..6783f565e 100644 --- a/src/Yavsc.Api/appsettings-api.json +++ b/src/Yavsc.Api/appsettings-api.json @@ -4,8 +4,7 @@ "Audience": ["api"], "CorsAllowedOrigins": [ "https://localhost:5003", - "https://yavsc.pschneider.fr", - "https://blogs.pschneider.fr" + "https://yavsc.pschneider.fr" ] }, "Logging": { diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index c57c31c3f..9d4f6ad81 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -4,13 +4,11 @@ using System.Net.Http.Json; using System.Security.Claims; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; -using Yavsc.Api.Client; using Yavsc.Models; using Yavsc.Models.Blog; using Yavsc.Server.Helpers; using Yavsc.Tests.Shared; using Yavsc.Blogs.Tests.Fixtures; -using BlogPostDto = Yavsc.Blogspot.BlogPostDto; namespace Yavsc.Blogs.Tests; @@ -452,137 +450,6 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode); } - [Fact] - public async Task PutBlog_from_PostIt_client_with_attachment_returns_204_and_persists_attachment() - { - // Full PostIt "Save" flow for an existing post with a new - // attachment, driven through the real BlogApiClient — the - // same code PostIt runs — so any drift between the - // client's wire shape and the controller's contract fails - // here before it reaches a user: - // - // 1. BlogsViewModel.SaveAsync (update branch) calls - // BlogApiClient.UpdatePostAsync(id, update, attachments) - // with a non-empty attachments list → - // multipart/form-data PUT built by - // CreateMultipartContent: a "blog" field holding the - // BlogPostDto serialised camelCase + WhenWritingNull, - // plus one "file" part per attachment. - // 2. TryAppendAttachmentLinks then appends a markdown - // link per attachment to the article, and the VM - // issues a second UpdatePostAsync without files → - // plain JSON PUT. - // - // The test asserts the final state: the article carries - // the markdown link, the attachment row is persisted, and - // the file physically landed under the user-files root - // the link points to. - ResetAndSeedDefaultUser(); - using var http = NewClient(subject: "tester"); - - var previousRoot = AbstractFileSystemHelpers.UserFilesDirName; - var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempRoot); - AbstractFileSystemHelpers.UserFilesDirName = tempRoot; - - try - { - // BlogApiClient takes the versioned base address - // (…/api/v1/) and prepends its "blogspot" prefix — - // exactly how PostIt wires it against production. - var client = new BlogApiClient( - new TestApiTransport(http), - _fixture.ApiUrl("")); - - // Seed the post through the same path PostIt uses for - // a first Save on a new draft: CreatePostAsync without - // files → plain JSON POST. - var draft = new BlogPostDto - { - Title = "Avant PostIt", - Article = "Contenu initial.", - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow - }; - var created = await client.CreatePostAsync(draft, - ct: TestContext.Current.CancellationToken); - Assert.NotNull(created); - Assert.NotEqual(0, created!.Id); - Assert.Equal("tester", created.AuthorId); - - // Step 1 — the update DTO the VM builds from the - // editor buffer (SelectedPost is the read-only hint - // for Id, AuthorId, Photo and DateCreated), sent with - // one attachment → multipart PUT. - var update = new BlogPostDto - { - Id = created.Id, - AuthorId = created.AuthorId, - Photo = created.Photo, - Title = "Modifié depuis PostIt", - Article = "Contenu modifié.", - DateCreated = created.DateCreated, - DateModified = DateTime.UtcNow - }; - var attachments = new[] - { - new BlogUploadFile( - "note.txt", - System.Text.Encoding.UTF8.GetBytes("payload test"), - "text/plain") - }; - - await client.UpdatePostAsync(created.Id, update, attachments, - TestContext.Current.CancellationToken); - - // Step 2 — TryAppendAttachmentLinks appended a - // markdown link to the article (the payload does not - // embed Author, so the owner segment falls back to - // AuthorId), and the VM PUTs the article again, - // without files → JSON path of UpdatePostAsync. - var fileUrl = $"{Yavsc.Constants.UserFilesPath}/tester/blogs/{created.Id}/note.txt"; - var linkUpdate = new BlogPostDto - { - Id = created.Id, - AuthorId = created.AuthorId, - Photo = created.Photo, - Title = update.Title, - Article = update.Article + $"\n\n- [note.txt]({fileUrl})", - DateCreated = created.DateCreated, - DateModified = DateTime.UtcNow - }; - - await client.UpdatePostAsync(created.Id, linkUpdate, - ct: TestContext.Current.CancellationToken); - - // Final state, read back through the client. - var details = await client.GetPostAsync(created.Id, - TestContext.Current.CancellationToken); - Assert.NotNull(details); - Assert.Equal("Modifié depuis PostIt", details!.Title); - Assert.Contains($"- [note.txt]({fileUrl})", details.Article); - - // The attachment row is persisted… - Assert.True(CountAttachmentsForPost(created.Id) >= 1); - - // …and the file physically landed where the appended - // 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)) - : ""; - Assert.True(File.Exists(expectedFile), - $"Expected uploaded file at {expectedFile}. Actually written: {written}"); - } - finally - { - AbstractFileSystemHelpers.UserFilesDirName = previousRoot; - try { Directory.Delete(tempRoot, recursive: true); } catch { } - } - } - [Fact] public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list() { diff --git a/src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs b/src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs deleted file mode 100644 index 9cbc8f1ba..000000000 --- a/src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; -using Yavsc.Api.Client; - -namespace Yavsc.Blogs.Tests.Fixtures; - -/// -/// Minimal transport for integration -/// tests: it forwards calls over a -/// test-owned (already authenticated by -/// the caller) and applies the contract documented on the -/// interface — JSON bodies serialised with web defaults -/// (camelCase, case-insensitive reads), and an -/// carrying the response body -/// on non-2xx statuses so assertion failures stay actionable. -/// -/// Unlike PostIt's YavscApiClient, there is no -/// silent-refresh-on-401: the test token never expires mid-test, -/// and a 401 is a failure we want surfaced, not retried. -/// -/// The is owned by the test, not by this -/// transport, so is a no-op. -/// -public sealed class TestApiTransport : IYavscApiClient -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - public TestApiTransport(HttpClient http) - { - Http = http ?? throw new ArgumentNullException(nameof(http)); - } - - public HttpClient Http { get; } - - public Task CallAsync( - HttpMethod method, - string path, - object? body = null, - CancellationToken ct = default) - => SendAsync(method, path, - body is null ? null : () => JsonContent.Create(body, options: JsonOptions), - ct); - - public Task CallAsync( - HttpMethod method, - string path, - Func contentFactory, - CancellationToken ct = default) - => SendAsync(method, path, contentFactory, ct); - - public async Task CallAsync( - HttpMethod method, - string path, - object? body = null, - CancellationToken ct = default) - => await SendAsync(method, path, - body is null ? null : () => JsonContent.Create(body, options: JsonOptions), - ct); - - public async Task CallAsync( - HttpMethod method, - string path, - Func contentFactory, - CancellationToken ct = default) - => await SendAsync(method, path, contentFactory, ct); - - private async Task SendAsync( - HttpMethod method, - string path, - Func? contentFactory, - CancellationToken ct) - { - using var request = new HttpRequestMessage(method, path); - if (contentFactory is not null) - request.Content = contentFactory(); - - using var response = await Http.SendAsync(request, ct); - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync(ct); - throw new HttpRequestException( - $"HTTP {(int)response.StatusCode} {response.StatusCode} on {method} {path}. Body: {error}"); - } - - var payload = await response.Content.ReadAsStringAsync(ct); - if (string.IsNullOrWhiteSpace(payload)) - return default!; // 201/204 with an empty body (PUT returns 204). - - return JsonSerializer.Deserialize(payload, JsonOptions)!; - } - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; -} diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj index 1972b283a..887648060 100644 --- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -25,7 +25,6 @@ - @@ -33,4 +32,4 @@ - + \ No newline at end of file diff --git a/src/Yavsc.Blogs/appsettings-blogs.json b/src/Yavsc.Blogs/appsettings-blogs.json index 3888af71f..a54531869 100644 --- a/src/Yavsc.Blogs/appsettings-blogs.json +++ b/src/Yavsc.Blogs/appsettings-blogs.json @@ -6,8 +6,7 @@ "Authority": "https://localhost:5001", "Audience": ["blogs"], "CorsAllowedOrigins": [ - "https://localhost:5005", - "https://api.pschneider.fr" + "https://localhost:5005" ] }, "ConnectionStrings": { diff --git a/src/Yavsc.Org/appsettings-org-template.json b/src/Yavsc.Org/appsettings-org-template.json index 85593635f..6c0da5b8f 100644 --- a/src/Yavsc.Org/appsettings-org-template.json +++ b/src/Yavsc.Org/appsettings-org-template.json @@ -18,11 +18,8 @@ "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]", diff --git a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs index 4c83d99ca..29ada6b77 100644 --- a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs +++ b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs @@ -153,23 +153,22 @@ namespace Yavsc.Server.Helpers return new FsOperationInfo { Done = true }; } + static string ParseFileNameFromDisposition(string disposition) + { + // form-data_ name=_file__ filename=_Constants.Private.cs_ + var parts = disposition.Split(' '); + var filename = parts[2].Split('=')[1]; + filename = filename.Substring(1, filename.Length - 2); + return filename; + } + public static void AddQuota(this ApplicationUser user, int quota) { user.DiskQuota += quota; } public static FileReceivedInfo ReceiveUserFile(this ApplicationUser user, string root, IFormFile f, string destFileName = null) { - // f.FileName is parsed by ASP.NET Core from the - // Content-Disposition header: it handles unquoted - // values and the RFC 5987 filename* parameter that - // modern HttpClient multipart bodies emit. The - // previous hand-rolled parser assumed a quoted - // filename in a fixed position and silently dropped - // the first character ("note.txt" → "ote.txt"), - // which broke the attachment links PostIt appends - // to the article. FilterFileName, downstream, still - // sanitises any path traversal attempt. - return ReceiveUserFile(user, root, f.OpenReadStream(), destFileName ?? f.FileName, f.ContentType, CancellationToken.None); + return ReceiveUserFile(user, root, f.OpenReadStream(), destFileName ?? ParseFileNameFromDisposition(f.ContentDisposition), f.ContentType, CancellationToken.None); } /// diff --git a/src/Yavsc.Server/Helpers/ServiceExtensions.cs b/src/Yavsc.Server/Helpers/ServiceExtensions.cs index f20d50476..1a69ca42f 100644 --- a/src/Yavsc.Server/Helpers/ServiceExtensions.cs +++ b/src/Yavsc.Server/Helpers/ServiceExtensions.cs @@ -137,6 +137,22 @@ public static class ServiceExtensions }; } + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + // Fallback for clients that cannot reliably attach Authorization header + // on multipart uploads. Restrict query-token support to this endpoint only. + if (string.IsNullOrEmpty(context.Token) + && context.Request.Path.Value?.Contains("/api/v1/account/set-avatar", StringComparison.OrdinalIgnoreCase) == true + && context.Request.Query.TryGetValue("access_token", out var tokenValues)) + { + context.Token = tokenValues.ToString(); + } + + return Task.CompletedTask; + } + }; }); return result;