postit and them also

This commit is contained in:
Paul Schneider 2026-06-10 00:23:17 +01:00
commit fa7d6242f1
18 changed files with 5322 additions and 121 deletions

View file

@ -1,18 +1,6 @@
APP_NAME=Yavsc
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=yavsc
POSTGRES_USER=yavsc
POSTGRES_PASSWORD=lame-YAVSC_CONNECTION_PASSWORD
ASPNETCORE_ConnectionStrings__YavscConnection=Server=$POSTGRES_HOST;Port=$POSTGRES_PORT;Database=$POSTGRES_DB;Username=$POSTGRES_USER;Password=$POSTGRES_PASSWORD;
ANTHROPIC_API_KEY=<votre-clé-api-anthropic> ANTHROPIC_API_KEY=<votre-clé-api-anthropic>
MODERATION_AUTO_REJECT_THRESHOLD=0.9 MODERATION_AUTO_REJECT_THRESHOLD=0.9
MODERATION_AUTO_APPROVE_THRESHOLD=0.7 MODERATION_AUTO_APPROVE_THRESHOLD=0.7
# Limite par appel # Limite par appel
ANTHROPIC_MAX_TOKENS=256 # la modération n'a pas besoin de plus ANTHROPIC_MAX_TOKENS=256 # la modération n'a pas besoin de plus
YAVSC_API_HOST=127.0.0.1
YAVSC_API_PORT=6001
ASPNETCORE_Kestrel__Endpoints__Https__Url="https://$YAVSC_API_HOST:$YAVSC_API_PORT"

2
.vscode/launch.json vendored
View file

@ -64,7 +64,7 @@
"env": { "env": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },
"envFile": "${workspaceFolder}/src/Yavsc.Org/.env", "envFile": "${workspaceFolder}/.env",
"sourceFileMap": { "sourceFileMap": {
"/Views": "${workspaceFolder}/src/Yavsc.Org/Views" "/Views": "${workspaceFolder}/src/Yavsc.Org/Views"
} }

View file

@ -0,0 +1,16 @@
using System;
namespace PostIt.Models;
public class BlogPost
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Article { get; set; }
public string? Photo { get; set; }
public string? AuthorId { get; set; }
public DateTime DateCreated { get; set; }
public string? UserCreated { get; set; }
public DateTime DateModified { get; set; }
public string? UserModified { get; set; }
}

View file

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using PostIt.Models;
namespace PostIt.Services;
public sealed class BlogApiClient : IDisposable
{
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _serializerOptions;
public BlogApiClient(string baseUrl, string? bearerToken = null)
: this(CreateHttpClient(baseUrl, bearerToken))
{
}
public BlogApiClient(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
}
private static HttpClient CreateHttpClient(string baseUrl, string? bearerToken)
{
var client = new HttpClient
{
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/")
};
if (!string.IsNullOrWhiteSpace(bearerToken))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken.Trim());
}
return client;
}
public async Task<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25)
{
var result = await _httpClient.GetFromJsonAsync<List<BlogPost>>($"api/blog?start={start}&take={take}", _serializerOptions).ConfigureAwait(false);
return result ?? new List<BlogPost>();
}
public Task<BlogPost?> GetPostAsync(long id)
=> _httpClient.GetFromJsonAsync<BlogPost>($"api/blog/{id}", _serializerOptions);
public async Task<BlogPost?> CreatePostAsync(BlogPost post)
{
var response = await _httpClient.PostAsJsonAsync("api/blog", post, _serializerOptions).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<BlogPost>(_serializerOptions).ConfigureAwait(false);
}
public async Task UpdatePostAsync(long id, BlogPost post)
{
var response = await _httpClient.PutAsJsonAsync($"api/blog/{id}", post, _serializerOptions).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task DeletePostAsync(long id)
{
var response = await _httpClient.DeleteAsync($"api/blog/{id}").ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public void Dispose()
{
_httpClient.Dispose();
}
}

View file

@ -1,9 +1,216 @@
using CommunityToolkit.Mvvm.ComponentModel; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Models;
using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public partial class MainViewModel : ViewModelBase public partial class MainViewModel : ViewModelBase
{ {
[ObservableProperty] [ObservableProperty]
private string _greeting = "Welcome to Avalonia!"; private string _apiUrl = "http://localhost:5000";
[ObservableProperty]
private string _searchText = string.Empty;
[ObservableProperty]
private string? _bearerToken;
[ObservableProperty]
private ObservableCollection<BlogPost> _posts = new();
[ObservableProperty]
private ObservableCollection<BlogPost> _filteredPosts = new();
[ObservableProperty]
private BlogPost? _selectedPost;
[ObservableProperty]
private string _statusMessage = "Ready";
[ObservableProperty]
private bool _isBusy;
public MainViewModel()
{
}
partial void OnSearchTextChanged(string value)
{
ApplyFilter();
}
partial void OnSelectedPostChanged(BlogPost? value)
{
UpdateCommandStates();
}
partial void OnIsBusyChanged(bool value)
{
UpdateCommandStates();
}
[RelayCommand]
public async Task LoadPostsAsync()
{
await ExecuteAsync(async () =>
{
using var client = CreateClient();
var posts = await client.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
StatusMessage = $"Loaded {Posts.Count} posts.";
});
}
[RelayCommand]
public void Search()
{
ApplyFilter();
}
[RelayCommand]
public async Task SaveAsync()
{
if (SelectedPost is null)
{
StatusMessage = "A post must be selected before saving.";
return;
}
await ExecuteAsync(async () =>
{
using var client = CreateClient();
if (SelectedPost.Id == 0)
{
SelectedPost.DateCreated = DateTime.UtcNow;
SelectedPost.DateModified = DateTime.UtcNow;
var created = await client.CreatePostAsync(SelectedPost);
if (created is not null)
{
SelectedPost = created;
StatusMessage = $"Created post {created.Id}.";
}
}
else
{
SelectedPost.DateModified = DateTime.UtcNow;
await client.UpdatePostAsync(SelectedPost.Id, SelectedPost);
StatusMessage = $"Saved post {SelectedPost.Id}.";
}
await RefreshPostsAsync();
});
}
[RelayCommand]
public async Task DeleteAsync()
{
if (SelectedPost is null || SelectedPost.Id == 0)
{
StatusMessage = "Select an existing post before deleting.";
return;
}
await ExecuteAsync(async () =>
{
using var client = CreateClient();
await client.DeletePostAsync(SelectedPost.Id);
StatusMessage = $"Deleted post {SelectedPost.Id}.";
SelectedPost = null;
await RefreshPostsAsync();
});
}
[RelayCommand]
public void New()
{
SelectedPost = new BlogPost
{
Title = string.Empty,
Article = string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
StatusMessage = "New blog post ready.";
}
private async Task RefreshPostsAsync()
{
using var client = CreateClient();
var posts = await client.GetPostsAsync();
Posts.Clear();
foreach (var post in posts.OrderByDescending(p => p.DateModified))
{
Posts.Add(post);
}
ApplyFilter();
if (SelectedPost is not null)
{
SelectedPost = Posts.FirstOrDefault(post => post.Id == SelectedPost.Id) ?? SelectedPost;
}
}
private void ApplyFilter()
{
var query = SearchText?.Trim();
var filtered = string.IsNullOrWhiteSpace(query)
? Posts.OrderByDescending(p => p.DateModified)
: Posts.Where(p => p.Title?.Contains(query, StringComparison.OrdinalIgnoreCase) == true
|| p.Article?.Contains(query, StringComparison.OrdinalIgnoreCase) == true
|| p.AuthorId?.Contains(query, StringComparison.OrdinalIgnoreCase) == true)
.OrderByDescending(p => p.DateModified);
FilteredPosts.Clear();
foreach (var post in filtered)
{
FilteredPosts.Add(post);
}
}
private async Task ExecuteAsync(Func<Task> action)
{
try
{
IsBusy = true;
StatusMessage = "Working...";
await action();
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private BlogApiClient CreateClient()
=> new BlogApiClient(ApiUrl, BearerToken);
private void UpdateCommandStates()
{
LoadPostsCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
NewCommand.NotifyCanExecuteChanged();
}
private bool CanSave() => SelectedPost is not null && !IsBusy;
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
} }

View file

@ -3,26 +3,68 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:models="using:PostIt.Models"
xmlns:views="using:PostIt.Views" xmlns:views="using:PostIt.Views"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
x:Class="PostIt.Views.MainView" x:Class="PostIt.Views.MainView"
x:DataType="vm:MainViewModel"> x:DataType="vm:MainViewModel">
<Design.DataContext> <Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainViewModel /> <vm:MainViewModel />
</Design.DataContext> </Design.DataContext>
<StackPanel>
<Label>Hello</Label> <StackPanel Margin="12" Spacing="12">
<AvaloniaEdit:TextEditor <TextBlock Text="PostIt Blog API Interface" FontSize="20" FontWeight="Bold" />
ShowLineNumbers="True"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace" <Grid ColumnDefinitions="Auto,1*" RowDefinitions="Auto,Auto,Auto,Auto" ColumnSpacing="8" RowSpacing="8">
Background="AliceBlue" <TextBlock Text="API URL" VerticalAlignment="Center" />
Foreground="Black" <TextBox Grid.Column="1" Text="{Binding ApiUrl, Mode=TwoWay}" />
Watermark="Hit me strong!"
/> <TextBlock Grid.Row="1" Text="Bearer token" VerticalAlignment="Center" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding BearerToken, Mode=TwoWay}" PlaceholderText="Optional token for blog scope" />
<TextBlock Grid.Row="2" Text="Search" VerticalAlignment="Center" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Search title, article, author" />
<StackPanel Grid.Row="3" Grid.ColumnSpan="2" Orientation="Horizontal" Spacing="8">
<Button Command="{Binding LoadPostsCommand}" Content="Load posts" />
<Button Command="{Binding SearchCommand}" Content="Filter" />
<Button Command="{Binding NewCommand}" Content="New post" />
<Button Command="{Binding SaveCommand}" Content="Save" />
<Button Command="{Binding DeleteCommand}" Content="Delete" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="2*,3*" RowDefinitions="*" ColumnSpacing="12">
<Border BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPost">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />
<TextBlock Text="{Binding AuthorId}" FontSize="10" Foreground="DarkSlateGray" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<Border BorderBrush="Gray" BorderThickness="1" Padding="8">
<StackPanel Spacing="10">
<TextBlock Text="Post detail" FontWeight="SemiBold" />
<TextBox Text="{Binding SelectedPost.Title, Mode=TwoWay}" PlaceholderText="Title" />
<TextBox Text="{Binding SelectedPost.AuthorId, Mode=TwoWay}" PlaceholderText="Author id" />
<AvaloniaEdit:TextEditor
views:TextEditorBinding.Text="{Binding SelectedPost.Article, Mode=TwoWay}"
ShowLineNumbers="True"
FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
Height="320"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" />
<TextBlock Text="{Binding StatusMessage}" Foreground="Gray" />
</StackPanel>
</Border>
</Grid>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View file

@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
@ -13,43 +15,48 @@ namespace Yavsc.Controllers
public class BlogApiController : Controller public class BlogApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly BlogSpotService blogSpotService;
public BlogApiController(ApplicationDbContext context) public BlogApiController(BlogSpotService blogSpotService)
{ {
_context = context; this.blogSpotService = blogSpotService;
} }
// GET: api/BlogApi // GET: api/BlogApi
[HttpGet] [HttpGet]
public IEnumerable<BlogPost> GetBlogspot(int start=0, int take=25) public async Task<IEnumerable<IBlogPost>> GetBlogspot(int start = 0, int take = 25)
{ {
return _context.BlogSpot.OrderByDescending(b => b.UserModified) return await blogSpotService.Index(User, null, start, take);
.Skip(start).Take(take);
} }
// GET: api/BlogApi/5 // GET: api/BlogApi/5
[HttpGet("{id}", Name = "GetBlog")] [HttpGet("{id}", Name = "GetBlog")]
public IActionResult GetBlog([FromRoute] long id) public async Task<IActionResult> GetBlog([FromRoute] long id)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
return BadRequest(ModelState); return BadRequest(ModelState);
} }
BlogPost blog = _context.BlogSpot.Single(m => m.Id == id); try
if (blog == null)
{ {
return NotFound(); var blog = await blogSpotService.Details(User, id);
} if (blog == null)
{
return NotFound();
}
return Ok(blog); return Ok(blog);
}
catch (AuthorizationFailureException)
{
return Challenge();
}
} }
// PUT: api/BlogApi/5 // PUT: api/BlogApi/5
[HttpPut("{id}")] [HttpPut("{id}")]
public IActionResult PutBlog(long id, [FromBody] BlogPost blog) public async Task<IActionResult> PutBlog(long id, [FromBody] BlogPost blog)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
@ -61,22 +68,19 @@ namespace Yavsc.Controllers
return BadRequest(); return BadRequest();
} }
_context.Entry(blog).State = EntityState.Modified; var existing = await blogSpotService.GetBlogPostAsync(id);
if (existing == null)
{
return NotFound();
}
try try
{ {
_context.SaveChanges(User.GetUserId()); await blogSpotService.Modify(User, blog);
} }
catch (DbUpdateConcurrencyException) catch (AuthorizationFailureException)
{ {
if (!BlogExists(id)) return Challenge();
{
return NotFound();
}
else
{
throw;
}
} }
return new StatusCodeResult(StatusCodes.Status204NoContent); return new StatusCodeResult(StatusCodes.Status204NoContent);
@ -91,59 +95,32 @@ namespace Yavsc.Controllers
return BadRequest(ModelState); return BadRequest(ModelState);
} }
_context.BlogSpot.Add(blog); var post = blogSpotService.Create(User.GetUserId(), blog, Request.Form.Files);
try return CreatedAtRoute("GetBlog", new { id = post.Id }, post);
{
_context.SaveChanges(User.GetUserId());
}
catch (DbUpdateException)
{
if (BlogExists(blog.Id))
{
return new StatusCodeResult(StatusCodes.Status409Conflict);
}
else
{
throw;
}
}
return CreatedAtRoute("GetBlog", new { id = blog.Id }, blog);
} }
// DELETE: api/BlogApi/5 // DELETE: api/BlogApi/5
[HttpDelete("{id}")] [HttpDelete("{id}")]
public IActionResult DeleteBlog(long id) public async Task<IActionResult> DeleteBlog(long id)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
return BadRequest(ModelState); return BadRequest(ModelState);
} }
BlogPost blog = _context.BlogSpot.Single(m => m.Id == id); var blog = await blogSpotService.GetBlogPostAsync(id);
if (blog == null) if (blog == null)
{ {
return NotFound(); return NotFound();
} }
_context.BlogSpot.Remove(blog); await blogSpotService.Delete(User, id);
_context.SaveChanges(User.GetUserId());
return Ok(blog); return Ok(blog);
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing)
{
_context.Dispose();
}
base.Dispose(disposing); base.Dispose(disposing);
} }
private bool BlogExists(long id)
{
return _context.BlogSpot.Count(e => e.Id == id) > 0;
}
} }
} }

View file

@ -11,6 +11,7 @@
*/ */
using IdentityModel; using IdentityModel;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc; using Yavsc;
@ -19,6 +20,7 @@ using Yavsc.Interface;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Extensions;
internal class Program internal class Program
{ {
@ -78,6 +80,8 @@ internal class Program
.AddTransient<IBillingService, BillingService>() .AddTransient<IBillingService, BillingService>()
.AddTransient<ICalendarManager, CalendarManager>(); .AddTransient<ICalendarManager, CalendarManager>();
services.AddTransient<IFileSystemAuthManager, FileSystemAuthManager>(); services.AddTransient<IFileSystemAuthManager, FileSystemAuthManager>();
services.AddTransient<BlogSpotService>();
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
services.AddLocalization(options => services.AddLocalization(options =>
{ {

View file

@ -10,5 +10,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" /> <ProjectReference Include="../Yavsc.Server/Yavsc.Server.csproj" />
<ProjectReference Include="../Yavsc.Org/Yavsc.Org.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class AddBlogFileAttachments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UploadedFiles",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Path = table.Column<string>(type: "text", nullable: true),
Length = table.Column<long>(type: "bigint", nullable: false),
ContentType = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UploadedFiles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "BlogAttachedFiles",
columns: table => new
{
FileId = table.Column<long>(type: "bigint", nullable: false),
PostId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BlogAttachedFiles", x => new { x.FileId, x.PostId });
table.ForeignKey(
name: "FK_BlogAttachedFiles_BlogSpot_PostId",
column: x => x.PostId,
principalTable: "BlogSpot",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BlogAttachedFiles_UploadedFiles_FileId",
column: x => x.FileId,
principalTable: "UploadedFiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BlogAttachedFiles_PostId",
table: "BlogAttachedFiles",
column: "PostId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BlogAttachedFiles");
migrationBuilder.DropTable(
name: "UploadedFiles");
}
}
}

View file

@ -1413,6 +1413,21 @@ namespace Yavsc.Migrations
b.ToTable("ExceptionsSIREN"); b.ToTable("ExceptionsSIREN");
}); });
modelBuilder.Entity("Yavsc.Models.Blog.BlogAttachedFile", b =>
{
b.Property<long>("FileId")
.HasColumnType("bigint");
b.Property<long>("PostId")
.HasColumnType("bigint");
b.HasKey("FileId", "PostId");
b.HasIndex("PostId");
b.ToTable("BlogAttachedFiles");
});
modelBuilder.Entity("Yavsc.Models.Blog.BlogPost", b => modelBuilder.Entity("Yavsc.Models.Blog.BlogPost", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -1518,6 +1533,28 @@ namespace Yavsc.Migrations
b.ToTable("Comment"); b.ToTable("Comment");
}); });
modelBuilder.Entity("Yavsc.Models.Blog.UploadedFile", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.HasColumnType("text");
b.Property<long>("Length")
.HasColumnType("bigint");
b.Property<string>("Path")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("UploadedFiles");
});
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b => modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
{ {
b.Property<long>("BlogpostId") b.Property<long>("BlogpostId")
@ -3700,6 +3737,25 @@ namespace Yavsc.Migrations
b.Navigation("Query"); b.Navigation("Query");
}); });
modelBuilder.Entity("Yavsc.Models.Blog.BlogAttachedFile", b =>
{
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
.WithMany()
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")
.WithMany()
.HasForeignKey("PostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("File");
b.Navigation("Post");
});
modelBuilder.Entity("Yavsc.Models.Blog.BlogPost", b => modelBuilder.Entity("Yavsc.Models.Blog.BlogPost", b =>
{ {
b.HasOne("Yavsc.Models.ApplicationUser", "Author") b.HasOne("Yavsc.Models.ApplicationUser", "Author")

View file

@ -1,29 +0,0 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "https://localhost:5001",
"sslPort": 5001
}
},
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:5001;",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -11,6 +11,8 @@ using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.ViewModels.Auth; using Yavsc.ViewModels.Auth;
using Yavsc.Abstract.Helpers;
using Microsoft.AspNetCore.Http;
public class BlogSpotService public class BlogSpotService
{ {
@ -29,11 +31,63 @@ public class BlogSpotService
public BlogPost Create(string userId, BlogPost post, IFormFileCollection files) public BlogPost Create(string userId, BlogPost post, IFormFileCollection files)
{ {
foreach (var file in files) // Sauvegarder le post d'abord pour obtenir son ID
{
}
_context.BlogSpot.Add(post); _context.BlogSpot.Add(post);
_context.SaveChanges(userId); _context.SaveChanges(userId);
// Traiter les fichiers attachés s'il y en a
if (files != null && files.Count > 0)
{
var user = _context.Users.FirstOrDefault(u => u.Id == userId);
if (user != null)
{
try
{
// Créer un répertoire pour les fichiers du blog
string blogFilesSubdir = $"blogs/{post.Id}";
string destDir = Path.Combine(
AbstractFileSystemHelpers.UserFilesDirName,
user.UserName,
blogFilesSubdir
);
var di = new DirectoryInfo(destDir);
if (!di.Exists) di.Create();
// Traiter chaque fichier
foreach (var formFile in files)
{
var fileInfo = user.ReceiveUserFile(destDir, formFile);
if (fileInfo != null && !fileInfo.QuotaOffense)
{
// Créer une entrée UploadedFile si nécessaire
var uploadedFile = new UploadedFile
{
Path = fileInfo.FileName,
ContentType = formFile.ContentType,
Length = formFile.Length
};
_context.UploadedFiles.Add(uploadedFile);
_context.SaveChanges(userId);
// Lier le fichier au post
var attachment = new BlogAttachedFile
{
PostId = post.Id,
FileId = uploadedFile.Id
};
_context.BlogAttachedFiles.Add(attachment);
}
}
_context.SaveChanges(userId);
}
catch (Exception ex)
{
// Logger l'erreur mais ne pas échouer la création du post
System.Diagnostics.Debug.WriteLine($"Erreur lors du traitement des fichiers : {ex.Message}");
}
}
}
return post; return post;
} }
public async Task<BlogPostEditViewModel> GetPostForEdition(ClaimsPrincipal user, long blogPostId) public async Task<BlogPostEditViewModel> GetPostForEdition(ClaimsPrincipal user, long blogPostId)
@ -112,6 +166,29 @@ public class BlogSpotService
_context.SaveChanges(user.GetUserId()); _context.SaveChanges(user.GetUserId());
} }
public async Task Modify(ClaimsPrincipal user, BlogPost blog)
{
var existing = await _context.BlogSpot.Include(b => b.ACL).SingleOrDefaultAsync(b => b.Id == blog.Id);
if (existing == null)
{
throw new InvalidOperationException($"Blog post {blog.Id} not found.");
}
var auth = await _authorizationService.AuthorizeAsync(user, existing, new EditPermission());
if (!auth.Succeeded)
{
throw new AuthorizationFailureException(auth);
}
existing.Title = blog.Title;
existing.Article = blog.Article;
existing.Photo = blog.Photo;
existing.ACL = blog.ACL;
_context.Update(existing);
_context.SaveChanges(user.GetUserId());
}
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)
{ {
IEnumerable<IBlogPost> posts; IEnumerable<IBlogPost> posts;
@ -148,7 +225,9 @@ public class BlogSpotService
.Select(p => p.BlogPost).ToArray(); .Select(p => p.BlogPost).ToArray();
} }
var data = posts.OrderByDescending(p => p.DateModified); var data = posts.OrderByDescending(p => p.DateModified)
.Skip(skip)
.Take(take);
return data; return data;
} }

View file

@ -385,6 +385,10 @@ namespace Yavsc.Models
public DbSet<BlogSpotPublication> blogSpotPublications { get; set; } public DbSet<BlogSpotPublication> blogSpotPublications { get; set; }
public DbSet<UploadedFile> UploadedFiles { get; set; }
public DbSet<BlogAttachedFile> BlogAttachedFiles { get; set; }
public DbSet<Client> Clients { get; set; } public DbSet<Client> Clients { get; set; }
public DbSet<ClientIdPRestriction> ClientIdPRestrictions { get; set; } public DbSet<ClientIdPRestriction> ClientIdPRestrictions { get; set; }
public DbSet<ClientProperty> ClientProperties { get; set; } public DbSet<ClientProperty> ClientProperties { get; set; }

View file

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Modes;
using Microsoft.EntityFrameworkCore;
namespace Yavsc.Models.Blog namespace Yavsc.Models.Blog
{ {
@ -34,11 +35,12 @@ namespace Yavsc.Models.Blog
/// <value></value> /// <value></value>
public string ContentType { get; set; } public string ContentType { get; set; }
} }
[PrimaryKey(nameof(FileId), nameof(PostId))]
public class BlogAttachedFile public class BlogAttachedFile
{ {
/// <summary> /// <summary>
/// Post Id /// File Id (part of composite key)
/// </summary> /// </summary>
/// <value></value> /// <value></value>
public long FileId { get; set; } public long FileId { get; set; }
@ -47,7 +49,7 @@ namespace Yavsc.Models.Blog
public virtual UploadedFile File { get; set; } public virtual UploadedFile File { get; set; }
/// <summary> /// <summary>
/// Post Id /// Post Id (part of composite key)
/// </summary> /// </summary>
/// <value></value> /// <value></value>
public long PostId { get; set; } public long PostId { get; set; }

View file

@ -0,0 +1,79 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels;
using Xunit;
namespace Yavsc.Tests.NonRegression;
public class PostItViewModelTests
{
[Fact]
public void SearchCommand_filters_posts_by_title_article_or_author()
{
var viewModel = new MainViewModel();
viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
viewModel.Posts.Add(new BlogPost { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
viewModel.Posts.Add(new BlogPost { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
viewModel.SearchText = "search";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(3, viewModel.FilteredPosts[0].Id);
viewModel.SearchText = "bob";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts);
Assert.Equal(2, viewModel.FilteredPosts[0].Id);
}
[Fact]
public async Task BlogApiClient_GetPostsAsync_returns_posts_from_api()
{
var expected = new List<BlogPost>
{
new() { Id = 1, Title = "Hello" },
new() { Id = 2, Title = "World" }
};
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, JsonSerializer.Serialize(expected));
using var client = new HttpClient(handler)
{
BaseAddress = new System.Uri("http://localhost/")
};
using var apiClient = new BlogApiClient(client);
var posts = await apiClient.GetPostsAsync();
Assert.Equal(2, posts.Count);
Assert.Equal("Hello", posts[0].Title);
}
private sealed class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly HttpResponseMessage _response;
public FakeHttpMessageHandler(HttpStatusCode statusCode, string content)
{
_response = new HttpResponseMessage(statusCode)
{
Content = new StringContent(content, Encoding.UTF8, "application/json")
};
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
{
return Task.FromResult(_response);
}
}
}

View file

@ -39,6 +39,7 @@
<ProjectReference Include="..\..\src\Yavsc.Org\Yavsc.Org.csproj" /> <ProjectReference Include="..\..\src\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\..\src\Yavsc.Abstract\Yavsc.Abstract.csproj" /> <ProjectReference Include="..\..\src\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\..\src\Yavsc.Server\Yavsc.Server.csproj" /> <ProjectReference Include="..\..\src\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\..\src\PostIt\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />