├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── src ├── .vscode │ ├── settings.json │ ├── launch.json │ └── tasks.json ├── .DS_Store ├── HBlog.Api │ ├── .DS_Store │ ├── Controllers │ │ ├── BaseApiController.cs │ │ ├── CategoriesController.cs │ │ ├── BuggyController.cs │ │ ├── LikesController.cs │ │ ├── UsersController.cs │ │ ├── TagsController.cs │ │ ├── AdminController.cs │ │ └── MessagesController.cs │ ├── appsettings.json │ ├── appsettings.Development.json │ ├── Extensions │ │ └── SwaggerExtensions.cs │ ├── Properties │ │ └── launchSettings.json │ └── HBlog.Api.csproj ├── HBlog.WebClient │ ├── wwwroot │ │ ├── images │ │ │ ├── KP.png │ │ │ └── icon-192.png │ │ ├── appsettings.json │ │ └── index.html │ ├── Components │ │ ├── UI │ │ │ ├── PostCard.razor.css │ │ │ ├── Entry.razor │ │ │ ├── RedirectToLogin.razor │ │ │ ├── TagButton.razor │ │ │ ├── TemplateDialog.razor │ │ │ ├── PostList.razor │ │ │ ├── ConfirmationModal.razor.css │ │ │ ├── PostSearchSideBar.razor │ │ │ ├── ConfirmationModal.razor │ │ │ ├── PostCard.razor │ │ │ ├── PostSearchSideBar.razor.css │ │ │ └── ConfigureTagDialog.razor │ │ ├── Layout │ │ │ ├── TopNavMenu.Razor.css │ │ │ ├── Footer.razor.css │ │ │ ├── MainLayout.razor │ │ │ └── Footer.razor │ │ ├── Pages │ │ │ ├── Users │ │ │ │ ├── Logout.razor │ │ │ │ └── Login.razor │ │ │ ├── Home.razor │ │ │ └── AboutMe.razor │ │ └── Util │ │ │ └── JSRuntimeProvider.razor │ ├── Commons │ │ ├── ApiResponse.cs │ │ └── Constants.cs │ ├── Services │ │ ├── BaseService.cs │ │ ├── MarkDownService.cs │ │ ├── CategoryClientService.cs │ │ ├── TagClientService.cs │ │ ├── AuthService.cs │ │ └── UserClientService.cs │ ├── Extensions │ │ └── ServiceExtensions.cs │ ├── _Imports.razor │ ├── States │ │ └── PostDashboardState.cs │ ├── App.razor │ ├── Helpers │ │ └── QueryHelper.cs │ ├── Handlers │ │ └── TokenHandler.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── HBlog.WebClient.csproj │ └── Providers │ │ └── ApiAuthStateProvider.cs ├── HBlog.Domain │ ├── Events │ │ ├── PostUpdatedEvent.cs │ │ ├── PostPublishedEvent.cs │ │ └── PostCreatedEvent.cs │ ├── Repositories │ │ ├── IFileDataRepository.cs │ │ ├── ICategoryRepository.cs │ │ ├── ILikesRepository.cs │ │ ├── ITagRepository.cs │ │ ├── IFileStorageRepository.cs │ │ ├── IUserRepository.cs │ │ ├── IMessageRepository.cs │ │ ├── IRepository.cs │ │ └── IPostRepository.cs │ ├── Common │ │ ├── IResult.cs │ │ ├── Params │ │ │ ├── QueryParams.cs │ │ │ ├── LikesParams.cs │ │ │ ├── PostParams.cs │ │ │ ├── MessageParams.cs │ │ │ ├── UserParams.cs │ │ │ └── PaginationParams.cs │ │ ├── DomainEvent.cs │ │ ├── Extensions │ │ │ └── DateTimeExtensions.cs │ │ ├── PaginationHeader.cs │ │ ├── BaseEntity.cs │ │ ├── DomainException.cs │ │ ├── PageList.cs │ │ └── Result.cs │ ├── Entities │ │ ├── AppRole.cs │ │ ├── Category.cs │ │ ├── AppUserRole.cs │ │ ├── UserLike.cs │ │ ├── PostTags.cs │ │ ├── Tag.cs │ │ ├── Photo.cs │ │ ├── FileStorage.cs │ │ ├── FileData.cs │ │ ├── Message.cs │ │ └── User.cs │ ├── HBlog.Domain.csproj │ └── ValueObjects │ │ ├── PostType.cs │ │ ├── PostStatus.cs │ │ └── Slug.cs ├── HBlog.Contract │ ├── DTOs │ │ ├── MessageCreateDto.cs │ │ ├── PhotoDto.cs │ │ ├── TagCreateDto.cs │ │ ├── TagDto.cs │ │ ├── LoginDto.cs │ │ ├── AccountDto.cs │ │ ├── UserUpdateDto.cs │ │ ├── LikeDto.cs │ │ ├── PostCreateDto.cs │ │ ├── RegisterDto.cs │ │ ├── UserDto.cs │ │ ├── PostUpdateDto.cs │ │ ├── MessageDto.cs │ │ ├── PostDisplayDto.cs │ │ └── PostDisplayDetailsDto.cs │ ├── Common │ │ ├── IResult.cs │ │ └── ServiceResult.cs │ └── HBlog.Contract.csproj ├── HBlog.Application │ ├── Services │ │ ├── BaseService.cs │ │ ├── ILikeService.cs │ │ ├── ITagService.cs │ │ ├── IUserService.cs │ │ ├── IMessageService.cs │ │ ├── LikeService.cs │ │ └── UserService.cs │ ├── HBlog.Application.csproj │ ├── Commands │ │ └── Posts │ │ │ ├── DeletePostCommand.cs │ │ │ ├── AddTagForPostCommand.cs │ │ │ ├── UpdatePostStatusCommand.cs │ │ │ └── UpdatePostCommand.cs │ ├── Queries │ │ └── Posts │ │ │ ├── GetPostsByUsernameQuery.cs │ │ │ ├── GetPostsTitleContainsQuery.cs │ │ │ ├── GetPostsQuery.cs │ │ │ ├── GetPostsByTagIdQuery.cs │ │ │ ├── GetPostsByTagSlugQuery.cs │ │ │ ├── GetPostByIdQuery.cs │ │ │ └── GetPostsByCategoryQuery.cs │ └── AutoMapper │ │ └── AutoMapperProfiles.cs ├── HBlog.Infrastructure │ ├── Authentications │ │ └── ITokenService.cs │ ├── Extensions │ │ ├── DateTimeExtensions.cs │ │ ├── ClaimsPrincipalExtensions.cs │ │ ├── HttpExtensions.cs │ │ └── ApplicationServiceExtensions.cs │ ├── Repositories │ │ ├── FileDataRepository.cs │ │ ├── CategoryRepository.cs │ │ ├── LikesRepository.cs │ │ ├── FileStorageRepository.cs │ │ ├── Repository.cs │ │ ├── TagRepository.cs │ │ ├── PostRepository.cs │ │ ├── UserRepository.cs │ │ └── MessageRepository.cs │ ├── Helpers │ │ └── LogUserActivity.cs │ ├── Migrations │ │ ├── 20250802024951_addingRefreshToken.cs │ │ └── 20250907160853_RemoveUnusedFields.cs │ ├── SignalR │ │ ├── PresenceHub.cs │ │ └── PresenceTracker.cs │ ├── Middlewares │ │ └── GlobalExceptionHandler.cs │ └── Data │ │ └── PostSeedData.json ├── docker-compose.yml └── DomainException.cs ├── test ├── HBlog.UnitTests │ ├── Usings.cs │ ├── Mocks │ │ └── Repositories │ │ │ ├── MockTagRepository.cs │ │ │ ├── MockMessageRepository.cs │ │ │ └── MockUserRepository.cs │ ├── Services │ │ ├── ServiceTest.cs │ │ ├── LikeServiceTest.cs │ │ └── UserServiceTest.cs │ ├── Endpoints │ │ ├── PostAppFactory.cs │ │ ├── UsersControllerTest.cs │ │ ├── UserAppFactory.cs │ │ └── PostEndpointTests.cs │ ├── AuthTestExtensions.cs │ ├── HBlog.UnitTests.csproj │ └── Repositories │ │ └── UserRepositoryTest.cs ├── HBlog.IntegrationTests │ ├── Base │ │ ├── TestBase.cs │ │ ├── IntegrationTestBase.cs │ │ ├── TestAuthHandler.cs │ │ └── MessageAppFactory.cs │ ├── appsettings.json │ ├── GlobalExceptionHandlerTest.cs │ ├── CustomWebAppFactory.cs │ └── HBlog.IntegrationTests.csproj └── HBlog.TestUtilities │ ├── TestHelper.cs │ ├── HBlog.TestUtilities.csproj │ ├── PostBuilder.cs │ ├── AuthTestExtensions.cs │ └── TestBase.cs ├── .gitignore ├── .dockerignore ├── .github ├── workflows │ ├── frontend-ci.yml │ ├── heroku.yml │ └── azure-static-web-apps-happy-wave-07649a810.yml └── CONTRIBUTING.md ├── docker-compose.yml ├── Dockerfile └── README.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using NUnit; -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyunbin7303/HBlog/HEAD/src/.DS_Store -------------------------------------------------------------------------------- /src/HBlog.Api/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyunbin7303/HBlog/HEAD/src/HBlog.Api/.DS_Store -------------------------------------------------------------------------------- /src/HBlog.WebClient/wwwroot/images/KP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyunbin7303/HBlog/HEAD/src/HBlog.WebClient/wwwroot/images/KP.png -------------------------------------------------------------------------------- /src/HBlog.WebClient/wwwroot/images/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyunbin7303/HBlog/HEAD/src/HBlog.WebClient/wwwroot/images/icon-192.png -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/PostCard.razor.css: -------------------------------------------------------------------------------- 1 | .tag-list small:not(:last-child) { 2 | margin-right: 0.2rem; /* Adjust the margin as needed */ 3 | } 4 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Events/PostUpdatedEvent.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | 3 | namespace HBlog.Domain.Events 4 | { 5 | public record PostUpdatedEvent(int PostId) : DomainEvent; 6 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Commons/ApiResponse.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.WebClient.Commons 2 | { 3 | public record ApiResponse(T Data, bool Success = true, string? ErrorMessage = null); 4 | } 5 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Events/PostPublishedEvent.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | 3 | namespace HBlog.Domain.Events 4 | { 5 | public record PostPublishedEvent(int PostId) : DomainEvent; 6 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Commons/Constants.cs: -------------------------------------------------------------------------------- 1 | 2 | namespace HBlog.WebClient.Commons; 3 | 4 | public static class Constants 5 | { 6 | public static readonly string AccessToken = "accessToken"; 7 | } 8 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/Layout/TopNavMenu.Razor.css: -------------------------------------------------------------------------------- 1 | .navStyle { 2 | background-color: var(--cf-theme-900); 3 | } 4 | 5 | .navbar-nav { 6 | --bs-navbar-hover-color: var(--cf-theme-400); 7 | } 8 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Events/PostCreatedEvent.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | using HBlog.Domain.Entities; 3 | 4 | namespace HBlog.Domain.Events 5 | { 6 | public record PostCreatedEvent(Post Post) : DomainEvent; 7 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/Entry.razor: -------------------------------------------------------------------------------- 1 |
@ChildContent
2 | @code { 3 | [Parameter] 4 | public RenderFragment? ChildContent { get; set; } 5 | } -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/MessageCreateDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | 3 | public class MessageCreateDto 4 | { 5 | public string RecipientUsername { get; set; } 6 | public string Content { get; set; } 7 | } -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/PhotoDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | 3 | public class PhotoDto 4 | { 5 | public int Id { get; set; } 6 | public string Url { get; set; } 7 | public bool IsMain { get; set; } 8 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Repositories/IFileDataRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | namespace HBlog.Domain.Repositories 3 | { 4 | public interface IFileDataRepository : IRepository 5 | { 6 | 7 | } 8 | } -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/TagCreateDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class TagCreateDto 3 | { 4 | public string Name { get; set; } 5 | public string? Desc { get; set; } 6 | public string? Slug { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/IResult.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Common; 2 | public interface IResult 3 | { 4 | public bool IsSuccess { get; set; } 5 | public string Message { get; set; } 6 | public List Errors { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /src/HBlog.Contract/Common/IResult.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.Common; 2 | public interface IResult 3 | { 4 | public bool IsSuccess { get; set; } 5 | public string Message { get; set; } 6 | public List Errors { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/Params/QueryParams.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Common.Params 2 | { 3 | public class QueryParams 4 | { 5 | public int Limit { get; set; } = 10; 6 | public int Offset { get; set; } = 0; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/RedirectToLogin.razor: -------------------------------------------------------------------------------- 1 | @inject NavigationManager Navigation 2 | 3 | @code { 4 | protected override void OnInitialized() 5 | { 6 | Navigation.NavigateTo("users/login"); 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bat 2 | *.log 3 | *.pdb 4 | *.suo 5 | *.tmp 6 | *.user 7 | .angular 8 | .vs 9 | [Bb]in 10 | [Dd]ebug 11 | [Ll]og 12 | [Oo]bj 13 | [Rr]elease 14 | [Rr]eleases 15 | [Tt]est[Rr]esults 16 | dist 17 | node_modules 18 | package-lock.json 19 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/AppRole.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace HBlog.Domain.Entities 4 | { 5 | public class AppRole : IdentityRole 6 | { 7 | public ICollection UserRoles { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/TagDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class TagDto 3 | { 4 | public int TagId { get; set; } 5 | public string Name { get; set; } 6 | public string Desc { get; set; } 7 | public string Slug { get; set; } 8 | } 9 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/DomainEvent.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Common 2 | { 3 | public abstract record DomainEvent 4 | { 5 | public Guid EventId { get; } = Guid.NewGuid(); 6 | public DateTime OccurredOn { get; } = DateTime.UtcNow; 7 | } 8 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/Params/LikesParams.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Params 2 | { 3 | public class LikesParams : PaginationParams 4 | { 5 | public Guid UserId { get; set; } 6 | public string Predicate { get; set; } 7 | 8 | } 9 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Repositories/ICategoryRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | namespace HBlog.Domain.Repositories; 3 | 4 | public interface ICategoryRepository : IRepository 5 | { 6 | Task> GetCategoriesAsync(); 7 | } 8 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/TagButton.razor: -------------------------------------------------------------------------------- 1 |
2 | @* *@ 3 | 4 |
5 | 6 | 7 | @code { 8 | public void Enter(KeyboardEventArgs e) 9 | { 10 | 11 | } 12 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/Params/PostParams.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Common.Params 2 | { 3 | public class PostParams : QueryParams 4 | { 5 | public List TagId { get; set; } = new(); 6 | public int CategoryId { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/LoginDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | 3 | public class LoginDto 4 | { 5 | public string UserName { get; set; } 6 | public string Password { get; set; } 7 | } 8 | public record RefreshTokenDto(string? AccessToken, string? RefreshToken); -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/Params/MessageParams.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Params 2 | { 3 | public class MessageParams : PaginationParams 4 | { 5 | public string Username { get; set; } 6 | public string Container { get; set; } = "Unread"; 7 | 8 | } 9 | } -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/AccountDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class AccountDto 3 | { 4 | public string Username { get; set; } 5 | public string Email { get; set;} 6 | public string Token { get; set; } 7 | public string RefreshToken { get; set; } 8 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Services/BaseService.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.WebClient.Services; 2 | 3 | public abstract class BaseService(HttpClient httpClient, ILogger logger) 4 | { 5 | protected readonly HttpClient _httpClient = httpClient; 6 | protected readonly ILogger _logger = logger; 7 | } 8 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/Category.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | namespace HBlog.Domain.Entities; 3 | public class Category : BaseEntity 4 | { 5 | public string Title { get; set; } 6 | public string Description { get; set; } 7 | public virtual ICollection Posts { get; set; } 8 | } 9 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/AppUserRole.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | 3 | namespace HBlog.Domain.Entities 4 | { 5 | public class AppUserRole : IdentityUserRole 6 | { 7 | public User User { get; set; } 8 | public AppRole Role { get; set; } 9 | 10 | } 11 | } -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/UserUpdateDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class UserUpdateDto 3 | { 4 | public string Introduction { get; set; } 5 | public string LookingFor { get; set; } 6 | public string Interests { get; set; } 7 | public string City { get; set; } 8 | public string Country { get; set; } 9 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/UserLike.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Entities 2 | { 3 | public class UserLike 4 | { 5 | public User SourceUser { get; set; } 6 | public Guid SourceUserId { get; set; } 7 | public User TargetUser { get; set; } 8 | public Guid TargetUserId { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Repositories/ILikesRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | namespace HBlog.Domain.Repositories 3 | { 4 | public interface ILikesRepository 5 | { 6 | Task GetUserLike(Guid srcUserId, Guid targetUserId); 7 | Task GetUserWithLikes(Guid userId); 8 | 9 | } 10 | } -------------------------------------------------------------------------------- /src/HBlog.Application/Services/BaseService.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | 3 | namespace HBlog.Application.Services 4 | { 5 | public abstract class BaseService 6 | { 7 | protected IMapper _mapper; 8 | public BaseService(IMapper mapper) 9 | { 10 | _mapper = mapper; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/PostTags.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Entities 2 | { 3 | public class PostTags 4 | { 5 | public int PostId { get; set; } 6 | public int TagId { get; set; } 7 | public virtual Post Post { get; set; } = null!; 8 | public virtual Tag Tag { get; set; } = null!; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/LikeDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class LikeDto 3 | { 4 | public Guid Id { get; set; } 5 | public string UserName { get; set; } 6 | public int Age { get; set; } 7 | public string KnownAs { get; set; } 8 | public string PhotoUrl { get; set; } 9 | public string City { get; set; } 10 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/Pages/Users/Logout.razor: -------------------------------------------------------------------------------- 1 | @page "/users/logout" 2 | @using HBlog.WebClient.Services 3 | @inject IAuthService authService 4 | @inject NavigationManager navManager 5 | @code { 6 | protected override async Task OnInitializedAsync() 7 | { 8 | await authService.Logout(); 9 | navManager.NavigateTo("/"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Repositories/ITagRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | 3 | namespace HBlog.Domain.Repositories 4 | { 5 | public interface ITagRepository : IRepository 6 | { 7 | Task Delete(Tag tag); 8 | Task Update(Tag tag); 9 | Task> GetAll(); 10 | Task FindbySlug(string slug); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/TemplateDialog.razor: -------------------------------------------------------------------------------- 1 | @if (Show) 2 | { 3 |
4 |
5 | @ChildContent 6 |
7 |
8 | } 9 | 10 | @code { 11 | [Parameter, EditorRequired] public RenderFragment? ChildContent { get; set; } 12 | [Parameter] public bool Show { get; set; } 13 | } 14 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Repositories/IFileStorageRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | namespace HBlog.Domain.Repositories 3 | { 4 | public interface IFileStorageRepository : IRepository 5 | { 6 | Task> GetAllFilesByUserIdAsync(string userId); 7 | Task InsertDataAsync(string bucketName, string fileName, Stream fileStream); 8 | } 9 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/Layout/Footer.razor.css: -------------------------------------------------------------------------------- 1 | .footerStyle { 2 | background-color: var(--cf-theme-900); 3 | color: var(--cf-light-color); 4 | } 5 | .footerStyle a { 6 | color: var(--cf-light-color); 7 | } 8 | .footerStyle a:hover { 9 | color: var(--cf-theme-core); 10 | } 11 | .footerStyle .bi { 12 | font-size: 1.5rem; 13 | padding-inline-start: 0.5rem; 14 | } -------------------------------------------------------------------------------- /src/HBlog.Contract/HBlog.Contract.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/Tag.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | namespace HBlog.Domain.Entities 4 | { 5 | public class Tag : BaseEntity 6 | { 7 | public string Name { get; set; } 8 | public string Desc { get; set; } 9 | public string Slug { get; set; } 10 | public List Posts{ get; } = []; 11 | } 12 | } -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Authentications/ITokenService.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using System.Security.Claims; 3 | namespace HBlog.Infrastructure.Authentications 4 | { 5 | public interface ITokenService 6 | { 7 | Task CreateToken(User user); 8 | public string CreateRefreshToken(); 9 | ClaimsPrincipal GetPrincipalFromExpiredToken(string token); 10 | 11 | } 12 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/wwwroot/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Information" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | //"ApiBaseUrl": "https://hblog-web-api-6eb2564b6d0e.herokuapp.com/api/", 10 | 11 | //"ApiBaseUrl": "https://localhost:6001/api/", 12 | "ApiBaseUrl": "http://localhost:8090/api/" 13 | } 14 | -------------------------------------------------------------------------------- /test/HBlog.IntegrationTests/Base/TestBase.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | namespace HBlog.IntegrationTests.Base 3 | { 4 | public abstract class TestBase 5 | { 6 | protected IConfiguration _config; 7 | public TestBase() 8 | { 9 | _config = new ConfigurationBuilder().AddJsonFile(@"appsettings.json", optional: false, true).Build(); 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/Params/UserParams.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Params 2 | { 3 | public class UserParams : PaginationParams 4 | { 5 | public string CurrentUsername { get; set; } 6 | public string Gender { get; set; } 7 | public int MinAge {get; set; } = 18; 8 | public int MaxAge { get; set; } = 100; 9 | public string OrderBy {get; set; } = "lastActive"; 10 | } 11 | } -------------------------------------------------------------------------------- /src/HBlog.Api/Controllers/BaseApiController.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Infrastructure.Helpers; 2 | using Microsoft.AspNetCore.Mvc; 3 | namespace HBlog.Api.Controllers 4 | { 5 | [ServiceFilter(typeof(LogUserActivity))] 6 | [Route("api")] 7 | [ApiController] 8 | public class BaseApiController : ControllerBase 9 | { 10 | } 11 | public record ApiResponse(T Data, bool Success = true, string ErrorMessage = null); 12 | } -------------------------------------------------------------------------------- /src/HBlog.Application/Services/ILikeService.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.Common; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Common; 4 | using HBlog.Domain.Params; 5 | 6 | namespace HBlog.Application.Services 7 | { 8 | public interface ILikeService 9 | { 10 | Task> GetUserLikePageList(LikesParams likesParam); 11 | Task AddLike(Guid sourceUserId, string username); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/PostCreateDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class PostCreateDto 3 | { 4 | public string Title { get; set; } 5 | public string? Desc { get; set; } 6 | public string? Content { get; set; } 7 | public string LinkForPost { get; set; } = string.Empty; 8 | public int CategoryId { get; set; } = 0; 9 | public string Type { get; set; } 10 | public int[] TagIds { get; set; } = []; 11 | } 12 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/Extensions/DateTimeExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Common.Extensions 2 | { 3 | public static class DateTimeExtensions 4 | { 5 | public static int CalculateAge(this DateTime dob) 6 | { 7 | var today = DateTime.UtcNow; 8 | var age = today.Year - dob.Year; 9 | if(dob > today.AddYears(-age)) age--; 10 | return age; 11 | } 12 | 13 | } 14 | } -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Extensions/DateTimeExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Infrastructure.Extensions 2 | { 3 | public static class DateTimeExtensions 4 | { 5 | public static int CalculateAge(this DateTime dob) 6 | { 7 | var today = DateTime.UtcNow; 8 | var age = today.Year - dob.Year; 9 | if(dob > today.AddYears(-age)) age--; 10 | return age; 11 | } 12 | 13 | } 14 | } -------------------------------------------------------------------------------- /src/HBlog.Application/Services/ITagService.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.Common; 2 | using HBlog.Contract.DTOs; 3 | namespace HBlog.Application.Services 4 | { 5 | public interface ITagService 6 | { 7 | Task> GetAllTags(); 8 | Task>> GetTagsByPostId(int postId); 9 | Task CreateTag(TagCreateDto tag); 10 | Task RemoveTag(int tagId); 11 | 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/Params/PaginationParams.cs: -------------------------------------------------------------------------------- 1 | 2 | namespace HBlog.Domain.Params 3 | { 4 | public class PaginationParams 5 | { 6 | private const int MaxPageSize = 50; 7 | public int PageNumber { get; set; } = 1; 8 | private int _pageSize = 10; 9 | public int PageSize 10 | { 11 | get => _pageSize; 12 | set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value; 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/Photo.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations.Schema; 2 | 3 | namespace HBlog.Domain.Entities 4 | { 5 | [Table("Photos")] 6 | public class Photo 7 | { 8 | public int Id { get; set; } 9 | public string Url { get; set; } 10 | public bool IsMain { get; set; } 11 | public string PublicId { get; set; } 12 | public Guid UserId { get; set; } 13 | public User User { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/PostList.razor: -------------------------------------------------------------------------------- 1 | @if (Posts is not null) 2 | { 3 |
4 | @foreach (var post in Posts) 5 | { 6 | 7 | } 8 |
9 | } 10 | else 11 | { 12 |

Waiting for loading...

13 | 14 | } 15 | 16 | @code { 17 | [Parameter, EditorRequired] 18 | public IEnumerable Posts { get; set; } 19 | } 20 | -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/RegisterDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | namespace HBlog.Contract.DTOs; 3 | public class RegisterDto 4 | { 5 | [Required] public string UserName { get; set; } 6 | [Required] public string FirstName { get; set; } 7 | [Required] public string LastName { get; set; } 8 | [Required] public string Email { get; set; } 9 | [Required] 10 | [StringLength(20, MinimumLength =6)] 11 | public string Password { get; set; } 12 | } -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Mocks/Repositories/MockTagRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using Moq; 4 | 5 | namespace HBlog.UnitTests.Mocks.Repositories 6 | { 7 | public class MockTagRepository : Mock 8 | { 9 | public MockTagRepository MockgetTagById(Tag tag) 10 | { 11 | Setup(x => x.GetById(It.IsAny())).ReturnsAsync(tag); 12 | return this; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/UserDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | 3 | public class UserDto 4 | { 5 | public Guid Id { get; set; } 6 | public string UserName { get; set; } 7 | public string PhotoUrl { get; set; } 8 | public int Age { get; set; } 9 | public string KnownAs { get; set; } 10 | public DateTime Created { get; set; } = DateTime.UtcNow; 11 | public DateTimeOffset LastActive { get; set; } = DateTime.UtcNow; 12 | public string Introduction { get; set; } 13 | } -------------------------------------------------------------------------------- /src/HBlog.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Information" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "ConnectionStrings": { 10 | "DefaultConnection": "Server=hblog_db;port=5432;Database=HBlog;Username=postgres;Password=postgres;Pooling=true" 11 | }, 12 | "TokenKey": "", 13 | "AwsSettings": { 14 | "AccessKey": "", 15 | "SecretKey": "", 16 | "BucketName": "" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/FileStorage.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | namespace HBlog.Domain.Entities 3 | { 4 | public class FileStorage : BaseEntity 5 | { 6 | public string BucketName { get; set; } 7 | public string StorageType { get; set; } 8 | public Guid UserId { get; set; } 9 | public bool IsPublic { get; set; } 10 | public ICollection SharedUsers { get; set; } 11 | public ICollection Files { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Repositories/IUserRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | namespace HBlog.Domain.Repositories 3 | { 4 | public interface IUserRepository 5 | { 6 | void Update(User user); 7 | Task SaveAllAsync(); 8 | Task> GetUsersAsync(); 9 | Task GetUserByIdAsync(Guid id); 10 | Task GetUserByUsernameAsync(string username); 11 | IQueryable GetUserLikesQuery(string predicate, Guid userId); 12 | } 13 | } -------------------------------------------------------------------------------- /src/HBlog.Application/Services/IUserService.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.Common; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Common; 4 | using HBlog.Domain.Params; 5 | 6 | namespace HBlog.Application.Services 7 | { 8 | public interface IUserService 9 | { 10 | Task> GetMembersAsync(UserParams userParams); 11 | Task UpdateMemberAsync(UserUpdateDto User); 12 | Task> GetMembersByUsernameAsync(string username); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md 26 | !**/.gitignore 27 | !.git/HEAD 28 | !.git/config 29 | !.git/packed-refs 30 | !.git/refs/heads/** -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/PostUpdateDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class PostUpdateDto 3 | { 4 | public int Id { get; set; } 5 | public string Title { get; set; } 6 | public string Desc { get; set; } 7 | public string Content { get; set; } 8 | public string LinkForPost { get; set; } = string.Empty; 9 | public int CategoryId { get; set; } = 0; 10 | public string Type { get; set; } 11 | public int[] TagIds { get; set; } = []; 12 | } 13 | public record PostChangeStatusDto(string Status); 14 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Repositories/IMessageRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | 3 | namespace HBlog.Domain.Repositories 4 | { 5 | public interface IMessageRepository 6 | { 7 | void AddMessage(Message msg); 8 | void DeleteMessage(Message msg); 9 | Task> GetMessages(); 10 | Task GetMessage(int id); 11 | Task> GetMessageThread(string currentUsernename, string recipientUsername); 12 | Task SaveAllAsync(); 13 | } 14 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/FileData.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | 3 | namespace HBlog.Domain.Entities 4 | { 5 | public class FileData : BaseEntity 6 | { 7 | public int FileStorageId { get; set; } 8 | public string FileName { get; set; } 9 | public string FilePath { get; set; } 10 | public DateTime Created { get; set; } = DateTime.UtcNow; 11 | public DateTime LastUpdated { get; set; } = DateTime.UtcNow; 12 | public virtual FileStorage FileStorage { get; set; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Repositories/IRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | namespace HBlog.Domain.Repositories 3 | { 4 | public interface IRepository where TEntity : class 5 | { 6 | void Add(TEntity obj); 7 | Task GetById(int id); 8 | IQueryable GetAll(); 9 | Task> GetAll(Expression> predicate); 10 | IQueryable GetAllSoftDeleted(); 11 | void Remove(int id); 12 | Task SaveChangesAsync(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Extensions/ClaimsPrincipalExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | namespace HBlog.Infrastructure.Extensions 3 | { 4 | public static class ClaimsPrincipalExtensions 5 | { 6 | public static string GetUsername(this ClaimsPrincipal user) 7 | { 8 | return user.FindFirst(ClaimTypes.Name)?.Value; 9 | } 10 | public static Guid GetUserId(this ClaimsPrincipal user) 11 | { 12 | return Guid.Parse(user.FindFirst(ClaimTypes.NameIdentifier)?.Value); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/ConfirmationModal.razor.css: -------------------------------------------------------------------------------- 1 | .modal-backdrop { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | z-index: 1040; 6 | width: 100vw; 7 | height: 100vh; 8 | background-color: rgba(0, 0, 0, 0.5); 9 | } 10 | 11 | .modal-content { 12 | position: fixed; 13 | top: 50%; 14 | left: 50%; 15 | transform: translate(-50%, -50%); 16 | z-index: 1050; 17 | background: white; 18 | padding: 20px; 19 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); 20 | width: 50%; 21 | max-width: 400px; 22 | } 23 | -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/MessageDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class MessageDto 3 | { 4 | public int Id { get; set; } 5 | public int SenderId { get; set; } 6 | public string SenderUsername { get; set; } 7 | public string SenderPhotoUrl { get; set; } 8 | public int RecipientId { get; set; } 9 | public string RecipientUsername { get; set; } 10 | public string RecipientPhotoUrl { get; set; } 11 | public string Content { get; set; } 12 | public DateTime? DateRead { get; set; } 13 | public DateTime MessageSent { get; set; } 14 | } -------------------------------------------------------------------------------- /.github/workflows/frontend-ci.yml: -------------------------------------------------------------------------------- 1 | name: Frontend CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v3 18 | with: 19 | dotnet-version: 9.0.x 20 | - name: Restore dependencies 21 | run: dotnet restore 22 | working-directory: src/HBlog.WebClient 23 | - name: Build 24 | run: dotnet build src/HBlog.sln 25 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Repositories/IPostRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | 3 | namespace HBlog.Domain.Repositories 4 | { 5 | public interface IPostRepository : IRepository 6 | { 7 | Task UpdateAsync(Post post); 8 | Task> GetPostsAsync(); 9 | Task GetPostDetails(int id); 10 | Task> GetPostsTitleContainsAsync(string searchTitle); 11 | Task> GetPostsAsync(int limit, int offset); 12 | Task> GetPostsByUserName(string userName); 13 | } 14 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/HBlog.Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | disable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Repositories/FileDataRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using HBlog.Infrastructure.Data; 4 | using System; 5 | 6 | namespace HBlog.Infrastructure.Repositories 7 | { 8 | public class FileDataRepository : Repository, IFileDataRepository 9 | { 10 | private readonly DataContext _dbContext; 11 | public FileDataRepository(DataContext dbContext) : base(dbContext) 12 | { 13 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Services/MarkDownService.cs: -------------------------------------------------------------------------------- 1 | using Markdig; 2 | 3 | namespace HBlog.WebClient.Services 4 | { 5 | public class MarkdownService 6 | { 7 | public MarkdownPipeline _pipeline { get; set; } 8 | 9 | public MarkdownService() 10 | { 11 | var builder = new MarkdownPipelineBuilder(); 12 | _pipeline = builder.UseBootstrap().UseAdvancedExtensions().Build(); 13 | } 14 | public string RenderMarkdown(string markdownContent) 15 | { 16 | return Markdown.ToHtml(markdownContent, _pipeline); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/HBlog.IntegrationTests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Information" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "ConnectionStrings": { 10 | "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=KevAppDB;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;" 11 | }, 12 | "TokenKey": "super secret unguessable key", 13 | "AwsSettings": { 14 | "AccessKey": "", 15 | "SecretKey": "", 16 | "BucketName": "kevblogbucket" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/PaginationHeader.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Common 2 | { 3 | public class PaginationHeader 4 | { 5 | public PaginationHeader(int currentPage, int itemsPerPage, int totalItems, int totalPages) 6 | { 7 | CurrentPage = currentPage; 8 | ItemsPerPage = itemsPerPage; 9 | TotalItems = totalItems; 10 | TotalPages = totalPages; 11 | } 12 | 13 | public int CurrentPage {get; set; } 14 | public int ItemsPerPage { get; set; } 15 | public int TotalItems { get; set; } 16 | public int TotalPages { get; set; } 17 | 18 | 19 | } 20 | } -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/PostDisplayDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class PostDisplayDto 3 | { 4 | public int Id {get; set;} 5 | public string Title { get; set; } = string.Empty; 6 | public string Desc { get; set; } = string.Empty; 7 | public string Content { get; set; } = string.Empty; 8 | public int CategoryId { get; set; } 9 | public int Upvotes { get; set; } 10 | public string UserName { get; set; } = string.Empty; 11 | public DateTime Created { get; set; } = DateTime.UtcNow; 12 | public DateTime LastUpdated { get; set; } = DateTime.UtcNow; 13 | public IEnumerable Tags { get; set; } = null!; 14 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/PostSearchSideBar.razor: -------------------------------------------------------------------------------- 1 | 8 | 9 | @code { 10 | private bool collapseNavMenu = true; 11 | private bool isHovered = false; 12 | 13 | private void ExpandNavMenu() 14 | { 15 | isHovered = true; 16 | if (collapseNavMenu) collapseNavMenu = false; 17 | } 18 | 19 | private void CollapseNavMenu() 20 | { 21 | isHovered = false; 22 | if (!collapseNavMenu) collapseNavMenu = true; 23 | } 24 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Extensions/ServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using HBlog.WebClient.Services; 2 | 3 | namespace HBlog.WebClient.Extensions; 4 | public static class ServiceExtensions 5 | { 6 | public static void RegisterClientServices(this IServiceCollection services) 7 | { 8 | services.AddSingleton(); 9 | services.AddScoped(); 10 | services.AddScoped(); 11 | services.AddScoped(); 12 | services.AddScoped(); 13 | services.AddScoped(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/HBlog.Api/Controllers/CategoriesController.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using Microsoft.AspNetCore.Mvc; 4 | namespace HBlog.Api.Controllers 5 | { 6 | public class CategoriesController : BaseApiController 7 | { 8 | private readonly ICategoryRepository _categoryRepository; 9 | public CategoriesController(ICategoryRepository repository) 10 | { 11 | _categoryRepository = repository; 12 | } 13 | [HttpGet("categories")] 14 | public async Task>> Get() => Ok(await _categoryRepository.GetCategoriesAsync()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Services/ServiceTest.cs: -------------------------------------------------------------------------------- 1 | using HBlog.TestUtilities; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Moq; 4 | using NUnit.Framework; 5 | 6 | namespace HBlog.UnitTests.Services 7 | { 8 | [TestFixture] 9 | class ServiceTest : TestBase 10 | { 11 | protected ServiceTest() 12 | { 13 | var webHostEnv = new Mock(); 14 | webHostEnv.Setup(x => x.ContentRootPath) 15 | .Returns(System.Reflection.Assembly.GetExecutingAssembly().Location); 16 | webHostEnv.Setup(x => x.WebRootPath).Returns(Directory.GetCurrentDirectory()); 17 | 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Services/CategoryClientService.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using System.Net.Http.Json; 3 | namespace HBlog.WebClient.Services 4 | { 5 | public interface ICategoryService 6 | { 7 | Task> GetCategories(); 8 | } 9 | public class CategoryClientService:BaseService,ICategoryService 10 | { 11 | public CategoryClientService(HttpClient httpClient,ILogger logger): base(httpClient,logger) 12 | { 13 | } 14 | 15 | public async Task> GetCategories() => await _httpClient.GetFromJsonAsync>($"Categories"); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/HBlog.TestUtilities/TestHelper.cs: -------------------------------------------------------------------------------- 1 | 2 | namespace HBlog.TestUtilities 3 | { 4 | public static class TestHelper 5 | { 6 | public static DateTime GenerateRandomDateTime(DateTime start, DateTime end) 7 | { 8 | Random random = new Random(); 9 | if (start >= end) 10 | { 11 | throw new ArgumentException("End DateTime should be greater than Start DateTime."); 12 | } 13 | 14 | TimeSpan timeSpan = end - start; 15 | TimeSpan randomSpan = new TimeSpan(0, random.Next(0, (int)timeSpan.TotalMinutes), 0); 16 | return start + randomSpan; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/HBlog.IntegrationTests/Base/IntegrationTestBase.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Infrastructure.Data; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.Configuration; 4 | 5 | namespace HBlog.IntegrationTests.Base 6 | { 7 | public abstract class IntegrationTestBase : TestBase 8 | { 9 | protected readonly DataContext _dataContext; 10 | 11 | public IntegrationTestBase() 12 | { 13 | var check = _config.GetConnectionString("DefaultConnection"); 14 | var options = new DbContextOptionsBuilder() 15 | .UseNpgsql(check).Options; 16 | _dataContext = new DataContext(options); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/HBlog.Application/Services/IMessageService.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.Common; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Common; 4 | using HBlog.Domain.Entities; 5 | using HBlog.Domain.Params; 6 | 7 | namespace HBlog.Application.Services 8 | { 9 | public interface IMessageService 10 | { 11 | Task> CreateMessage(string userName, MessageCreateDto createMsgDto); 12 | Task> GetMessagesForUserPageList(MessageParams messageParams); 13 | Task> GetMessageThreads(string currUserName, string recipientUsername); 14 | Task DeleteMessage(string currUserName, int id); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using HBlog.Domain.Common; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace HBlog.Infrastructure.Extensions 6 | { 7 | public static class HttpExtensions 8 | { 9 | public static void AddPaginationHeader(this HttpResponse response, PaginationHeader header) 10 | { 11 | var jsonOptions= new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; 12 | response.Headers.Add("Pagination", JsonSerializer.Serialize(header,jsonOptions)); 13 | response.Headers.Add("Access-Control-Expose-Headers", "Pagination"); 14 | } 15 | 16 | } 17 | } -------------------------------------------------------------------------------- /src/HBlog.Application/HBlog.Application.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | disable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Forms 4 | @using Microsoft.AspNetCore.Components.Routing 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using Microsoft.AspNetCore.Components.Web.Virtualization 7 | @using Microsoft.AspNetCore.Components.WebAssembly.Http 8 | @using Microsoft.JSInterop 9 | @using HBlog.WebClient 10 | @using HBlog.WebClient.Components.Layout 11 | @using HBlog.WebClient.Components.UI 12 | @using HBlog.WebClient.Components.Pages 13 | @using HBlog.WebClient.Components.Util 14 | @using HBlog.Contract.DTOs; 15 | @using Microsoft.AspNetCore.Components.Authorization; 16 | @using Blazored.Toast; 17 | @using Blazored.Toast.Services; -------------------------------------------------------------------------------- /src/HBlog.Contract/DTOs/PostDisplayDetailsDto.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.DTOs; 2 | public class PostDisplayDetailsDto 3 | { 4 | public int Id {get; set;} 5 | public string Title { get; set; } 6 | public string Desc { get; set; } 7 | public string Status { get; set; } 8 | public string Content { get; set; } 9 | public string LinkForPost { get; set; } 10 | public string Type { get; set; } 11 | public int Upvotes { get; set; } 12 | public string UserName { get; set; } 13 | public int CategoryId { get; set; } 14 | public DateTime Created { get; set; } = DateTime.UtcNow; 15 | public DateTime LastUpdated { get; set; } = DateTime.UtcNow; 16 | public IEnumerable Tags { get; set; } 17 | 18 | } -------------------------------------------------------------------------------- /test/HBlog.TestUtilities/HBlog.TestUtilities.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/Message.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Entities 2 | { 3 | public class Message 4 | { 5 | public int Id { get; set; } 6 | public Guid SenderId { get; set; } 7 | public string SenderUsername { get; set; } 8 | public User Sender { get; set; } 9 | public Guid RecipientId { get; set; } 10 | public string RecipientUsername { get; set; } 11 | public User Recipient { get; set; } 12 | public string Content { get; set; } 13 | public DateTime? DateRead { get; set; } 14 | public DateTime MessageSent { get; set; } = DateTime.UtcNow; 15 | public bool SenderDeleted { get; set; } 16 | public bool RecipientDeleted { get; set; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Repositories/CategoryRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using HBlog.Infrastructure.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | namespace HBlog.Infrastructure.Repositories 6 | { 7 | public class CategoryRepository : Repository, ICategoryRepository 8 | { 9 | private readonly DataContext _dbContext; 10 | public CategoryRepository(DataContext dbContext) : base(dbContext) 11 | { 12 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 13 | } 14 | public async Task> GetCategoriesAsync() => await _dbContext.Categories.AsNoTracking().ToListAsync(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Services/LikeServiceTest.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Application.Services; 2 | using HBlog.Domain.Entities; 3 | using HBlog.Domain.Params; 4 | using HBlog.Domain.Repositories; 5 | using HBlog.Infrastructure.Helpers; 6 | using HBlog.TestUtilities; 7 | using Moq; 8 | 9 | namespace HBlog.UnitTests.Services 10 | { 11 | public class LikeServiceTest : TestBase 12 | { 13 | private ILikeService _likeService; 14 | private readonly Mock _likeRepoMock = new(); 15 | private readonly Mock _userRepoMock = new(); 16 | public LikeServiceTest() 17 | { 18 | _likeService = new LikeService(_mapper, _likeRepoMock.Object, _userRepoMock.Object); 19 | } 20 | 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/Layout/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @using Blazored.Toast.Configuration 2 | @inherits LayoutComponentBase 3 | 4 | 13 |
14 | 15 |
16 | @Body 17 |
18 |
19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/BaseEntity.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel.DataAnnotations.Schema; 4 | 5 | namespace HBlog.Domain.Common 6 | { 7 | public abstract class BaseEntity 8 | { 9 | public T Id { get; set; } 10 | 11 | private readonly List _domainEvents = new(); 12 | 13 | [NotMapped] 14 | public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); 15 | 16 | protected void AddDomainEvent(DomainEvent domainEvent) 17 | { 18 | _domainEvents.Add(domainEvent); 19 | } 20 | 21 | public void ClearDomainEvents() 22 | { 23 | _domainEvents.Clear(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/HBlog.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "ConnectionStrings": { 10 | "DefaultConnection": "Server=localhost;port=8091;Database=HBlog;Username=postgres;Password=postgres;Pooling=true" 11 | //"DefaultConnection": "Server=hblog_db;port=5432;Database=HBlog;Username=postgres;Password=postgres;Pooling=true" 12 | 13 | }, 14 | // Not Actual usage token. 15 | "TokenKey": "ad2d1e94db8ccc3819647ab4982ac5a28aefea079040ce1fb74cb66347026c5c79e7ed304361a859060902f9eba86ca04e77e379798c19f9937c8576e5482614", 16 | "AwsSettings": { 17 | "AccessKey": "", 18 | "SecretKey": "", 19 | "BucketName": "" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/Pages/Home.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @using HBlog.Contract.DTOs 3 | @using HBlog.WebClient.Services 4 | 5 | @inject IPostService postService; 6 | 7 | Posts 8 | 9 |
10 | 11 |
12 |

Hello, world!

13 |

14 | 15 | 16 | 17 | Welcome to the HProject 18 |

19 |
20 | 21 |
22 | @code { 23 | private IEnumerable posts; 24 | 25 | protected override async Task OnInitializedAsync() 26 | { 27 | posts = await postService.GetPostDisplays(); 28 | } 29 | } -------------------------------------------------------------------------------- /src/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | hblog_api: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | container_name: hblog_api 7 | ports: 8 | - "8090:8080" 9 | depends_on: 10 | - hblog_database 11 | environment: 12 | ASPNETCORE_ENVIRONMENT: '${ASPNETCORE_ENVIRONMENT:-Development}' 13 | DOTNET_ENVIRONMENT: '${DOTNET_ENVIRONMENT:-Development}' 14 | 15 | hblog_database: 16 | image: postgres:latest 17 | container_name: hblog_db 18 | environment: 19 | - POSTGRES_USER=postgres 20 | - POSTGRES_PASSWORD=postgres 21 | 22 | ports: 23 | - "8091:5432" 24 | healthcheck: 25 | test: ["CMD-SHELL", "pg_isready -U postgres"] 26 | interval: 10s 27 | timeout: 5s 28 | retries: 5 -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/ConfirmationModal.razor: -------------------------------------------------------------------------------- 1 | @if (IsVisible) 2 | { 3 | 11 | } 12 | 13 | @code { 14 | [Parameter] public string Title { get; set; } 15 | 16 | [Parameter] public string Message { get; set; } 17 | 18 | [Parameter] public EventCallback ConfirmationChanged { get; set; } 19 | 20 | [Parameter] public bool IsVisible { get; set; } 21 | 22 | private void Confirm() 23 | { 24 | ConfirmationChanged.InvokeAsync(true); 25 | } 26 | 27 | private void Cancel() 28 | { 29 | ConfirmationChanged.InvokeAsync(false); 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | Thanks for contributing to the HBlog. Feel free to open an issue if you have any suggestions for how Bblog could be enhanced. 3 | Please note that this project is released with a Code of conduct. By participating in this project, you agree to abide by its terms. 4 | 5 | # Issues and PRs 6 | I would be really appreciate for any kind of PRs. Since this is not a huge project, one~two issues for single PR would be great start. If you think it needs to be changed a lot, please create a ticket with `discussion` or `question` tags. We can discuss it further. 7 | 8 | # How to Submit a PR 9 | 1. Fork and clone the repository. 10 | 2. run `dotnet build` and `dotnet run` 11 | 3. Create a new Branch: `git checkout -b issue-or-feature-branch-name` 12 | 4. Change the code and add unit tests. 13 | 5. Make sure all tests are passed. 14 | 6. Push to your fork and submit a PR. 15 | 7. Take a rest and wait for approval. 16 | 17 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Mocks/Repositories/MockMessageRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using Moq; 4 | 5 | namespace HBlog.UnitTests.Mocks.Repositories 6 | { 7 | public class MockMessageRepository : Mock 8 | { 9 | public MockMessageRepository MockGetMessages(List results) 10 | { 11 | Setup(x => x.GetMessages()).ReturnsAsync(results); 12 | return this; 13 | } 14 | public MockMessageRepository MockGetMessage(Message message) 15 | { 16 | Setup(x => x.GetMessage(It.IsAny())).ReturnsAsync(message); 17 | return this; 18 | } 19 | public MockMessageRepository MockGetMessageThread(List results) 20 | { 21 | Setup(x => x.GetMessageThread(It.IsAny(), It.IsAny())).ReturnsAsync(results); 22 | return this; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Helpers/LogUserActivity.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Repositories; 2 | using HBlog.Infrastructure.Extensions; 3 | using Microsoft.AspNetCore.Mvc.Filters; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace HBlog.Infrastructure.Helpers 7 | { 8 | public class LogUserActivity : IAsyncActionFilter 9 | { 10 | public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) 11 | { 12 | var resultContext = await next(); 13 | if(!resultContext.HttpContext.User.Identity.IsAuthenticated) return; 14 | 15 | var userId = resultContext.HttpContext.User.GetUserId(); 16 | var repo = resultContext.HttpContext.RequestServices.GetRequiredService(); 17 | var user = await repo.GetUserByIdAsync(userId); 18 | user.LastActive = DateTime.Now; 19 | await repo.SaveAllAsync(); 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Repositories/LikesRepository.cs: -------------------------------------------------------------------------------- 1 | 2 | using HBlog.Domain.Entities; 3 | using HBlog.Domain.Repositories; 4 | using HBlog.Infrastructure.Data; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace HBlog.Infrastructure.Repositories 8 | { 9 | public class LikesRepository : ILikesRepository 10 | { 11 | private readonly DataContext _dbContext; 12 | public LikesRepository(DataContext dbContext) 13 | { 14 | this._dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 15 | } 16 | public async Task GetUserLike(Guid srcUserId, Guid targetUserId) 17 | { 18 | return await _dbContext.Likes.FindAsync(srcUserId, targetUserId); 19 | } 20 | 21 | public async Task GetUserWithLikes(Guid userId) 22 | { 23 | return await _dbContext.Users.Include(x=> x.LikedUsers).FirstOrDefaultAsync(x=> x.Id == userId); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | hblog_api: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | container_name: hblog_api 7 | ports: 8 | - "8090:8080" 9 | depends_on: 10 | hblog_database: 11 | condition: service_healthy 12 | 13 | environment: 14 | ASPNETCORE_ENVRIONMENT: '${ASPNETCORE_ENVRIONMENT:-Development}' 15 | DOTNET_ENVIRONMENT: '${DOTNET_ENVIRONMENT:-Development}' 16 | 17 | 18 | 19 | hblog_database: 20 | image: postgres:latest 21 | container_name: hblog_db 22 | environment: 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_DB: HBlog 26 | 27 | ports: 28 | - "8091:5432" 29 | healthcheck: 30 | test: ["CMD-SHELL", "pg_isready -U postgres -d HBlog"] 31 | interval: 10s 32 | timeout: 5s 33 | retries: 5 34 | -------------------------------------------------------------------------------- /src/DomainException.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Common; 2 | 3 | /// 4 | /// Exception thrown when domain rules are violated 5 | /// 6 | public class DomainException : Exception 7 | { 8 | public List Errors { get; } 9 | 10 | public DomainException(string message) : base(message) 11 | { 12 | Errors = new List { message }; 13 | } 14 | 15 | public DomainException(string message, Exception innerException) 16 | : base(message, innerException) 17 | { 18 | Errors = new List { message }; 19 | } 20 | 21 | public DomainException(List errors) 22 | : base(string.Join("; ", errors)) 23 | { 24 | Errors = errors; 25 | } 26 | 27 | public static DomainException ValidationFailed(string message) 28 | => new($"Validation failed: {message}"); 29 | 30 | public static DomainException InvalidOperation(string message) 31 | => new($"Invalid operation: {message}"); 32 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/DomainException.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Common; 2 | 3 | /// 4 | /// Exception thrown when domain rules are violated 5 | /// 6 | public class DomainException : Exception 7 | { 8 | public List Errors { get; } 9 | 10 | public DomainException(string message) : base(message) 11 | { 12 | Errors = new List { message }; 13 | } 14 | 15 | public DomainException(string message, Exception innerException) 16 | : base(message, innerException) 17 | { 18 | Errors = new List { message }; 19 | } 20 | 21 | public DomainException(List errors) 22 | : base(string.Join("; ", errors)) 23 | { 24 | Errors = errors; 25 | } 26 | 27 | public static DomainException ValidationFailed(string message) 28 | => new($"Validation failed: {message}"); 29 | 30 | public static DomainException InvalidOperation(string message) 31 | => new($"Invalid operation: {message}"); 32 | } -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Repositories/FileStorageRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using HBlog.Infrastructure.Data; 4 | using System; 5 | 6 | namespace HBlog.Infrastructure.Repositories 7 | { 8 | public class FileStorageRepository : Repository, IFileStorageRepository 9 | { 10 | private readonly DataContext _dbContext; 11 | public FileStorageRepository(DataContext dbContext) : base(dbContext) 12 | { 13 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 14 | } 15 | 16 | public Task> GetAllFilesByUserIdAsync(string userId) 17 | { 18 | //_dbContext.FileStorages.Where(x => x.) 19 | throw new NotImplementedException(); 20 | } 21 | public Task InsertDataAsync(string bucketName, string fileName, Stream fileStream) 22 | { 23 | throw new NotImplementedException(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/Pages/AboutMe.razor: -------------------------------------------------------------------------------- 1 | @page "/AboutMe" 2 | @using HBlog.WebClient.Services 3 | @inject UserClientService userService 4 | @inject AuthenticationStateProvider AuthenticationStateProvider 5 | 6 |
7 |
8 | @foreach (var item in Users) 9 | { 10 |
11 | 12 | 13 | @item.KnownAs 14 | @item.Introduction 15 | 16 | 17 |
18 | } 19 |
20 |
21 | 22 | 23 | 24 | @code { 25 | 26 | private IEnumerable? Users { get; set; } = new List(); 27 | 28 | protected async override Task OnInitializedAsync() 29 | { 30 | var user = await AuthenticationStateProvider.GetAuthenticationStateAsync(); 31 | Users = await userService.GetUsers(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/HBlog.Application/Commands/Posts/DeletePostCommand.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.Common; 2 | using HBlog.Domain.Repositories; 3 | using MediatR; 4 | 5 | namespace HBlog.Application.Commands.Posts; 6 | 7 | public record DeletePostCommand(int Id) : IRequest; 8 | 9 | public class DeletePostCommandHandler : IRequestHandler 10 | { 11 | private readonly IPostRepository _postRepository; 12 | 13 | public DeletePostCommandHandler(IPostRepository postRepository) 14 | { 15 | _postRepository = postRepository; 16 | } 17 | 18 | public async Task Handle(DeletePostCommand request, CancellationToken cancellationToken) 19 | { 20 | var post = await _postRepository.GetById(request.Id); 21 | if (post is null) 22 | return ServiceResult.Fail(msg: "NotFound"); 23 | 24 | _postRepository.Remove(request.Id); 25 | await _postRepository.SaveChangesAsync(); 26 | return ServiceResult.Success(msg: $"Removed Post Id: {request.Id}"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Services/TagClientService.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.Common; 2 | using HBlog.Contract.DTOs; 3 | using System.Net.Http.Json; 4 | 5 | namespace HBlog.WebClient.Services 6 | { 7 | public interface ITagService 8 | { 9 | Task> GetTagsByPostId(int postId); 10 | Task> GetTags(); 11 | } 12 | public class TagClientService : BaseService, ITagService 13 | { 14 | public TagClientService(HttpClient httpClient,ILogger logger) : base(httpClient,logger) 15 | { 16 | } 17 | 18 | public async Task> GetTags() 19 | { 20 | IEnumerable? result = await _httpClient.GetFromJsonAsync>($"tags"); 21 | return result!; 22 | } 23 | 24 | public async Task> GetTagsByPostId(int postId) 25 | { 26 | IEnumerable? result = await _httpClient.GetFromJsonAsync>($"posts/{postId}/tags"); 27 | return result!; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/HBlog.Api/Extensions/SwaggerExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.OpenApi; 2 | 3 | namespace HBlog.Api.Extensions; 4 | 5 | internal static class SwaggerExtensions 6 | { 7 | internal static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services) 8 | { 9 | 10 | services.AddSwaggerGen(options => 11 | { 12 | options.SwaggerDoc("v1", new OpenApiInfo 13 | { 14 | Title = "HBlog API", 15 | Version = "v1", 16 | Description = "A simple blog API", 17 | }); 18 | 19 | options.AddSecurityDefinition("auth", new OpenApiSecurityScheme 20 | { 21 | Name = "auth", 22 | In = ParameterLocation.Header, 23 | Type = SecuritySchemeType.Http, 24 | Scheme = "Bearer", 25 | Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"" 26 | }); 27 | options.AddSecurityRequirement(o => new OpenApiSecurityRequirement 28 | { 29 | //{ new OpenApiReference 30 | // { 31 | // Type = ReferenceType.SecurityScheme, 32 | // Id = "auth" 33 | // }, new List() } 34 | }); 35 | 36 | }); 37 | return services; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/HBlog.Application/Queries/Posts/GetPostsByUsernameQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.Common; 3 | using HBlog.Contract.DTOs; 4 | using HBlog.Domain.Repositories; 5 | using MediatR; 6 | 7 | namespace HBlog.Application.Queries.Posts; 8 | 9 | public record GetPostsByUsernameQuery(string UserName) : IRequest>>; 10 | 11 | public class GetPostsByUsernameQueryHandler : IRequestHandler>> 12 | { 13 | private readonly IPostRepository _postRepository; 14 | private readonly IMapper _mapper; 15 | 16 | public GetPostsByUsernameQueryHandler(IPostRepository postRepository, IMapper mapper) 17 | { 18 | _postRepository = postRepository; 19 | _mapper = mapper; 20 | } 21 | 22 | public async Task>> Handle(GetPostsByUsernameQuery request, CancellationToken cancellationToken) 23 | { 24 | var posts = await _postRepository.GetPostsByUserName(request.UserName); 25 | return ServiceResult.Success(_mapper.Map>(posts)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/HBlog.TestUtilities/PostBuilder.cs: -------------------------------------------------------------------------------- 1 | 2 | using HBlog.Domain.Entities; 3 | 4 | namespace HBlog.TestUtilities 5 | { 6 | public class PostBuilder 7 | { 8 | private string title = "Post Title"; 9 | private string slug = "post-title"; 10 | private string desc = "post description"; 11 | private string status = "active"; 12 | private string content = "#hahaha"; 13 | private string type = "normal"; 14 | private DateTime created = DateTime.Now; 15 | private DateTime updated = DateTime.Now; 16 | private int userId = 1; 17 | private int categoryId = 1; 18 | private List postTags; 19 | 20 | public PostBuilder WithTitle(string title) 21 | { 22 | this.title = title; 23 | return this; 24 | } 25 | public PostBuilder WithSlug(string slug) 26 | { 27 | this.slug = slug; 28 | return this; 29 | } 30 | public PostBuilder WithCategory(int categoryId) 31 | { 32 | this.categoryId = categoryId; 33 | return this; 34 | } 35 | 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/States/PostDashboardState.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.DTOs; 2 | using HBlog.WebClient.Services; 3 | 4 | namespace HBlog.WebClient.States 5 | { 6 | public class PostDashboardState 7 | { 8 | IPostService _postService; 9 | public PostDashboardState(IPostService postService) 10 | { 11 | _postService = postService; 12 | } 13 | public bool ShowingConfigureDialog { get; private set; } 14 | public List? SearchTags { get; private set; } = new(); 15 | public IEnumerable? Posts; 16 | public int? selectCategoryId = 0; 17 | public void ShowConfigureTagSelectDialog() 18 | { 19 | ShowingConfigureDialog = true; 20 | } 21 | public void CancelConfigureTagDialog() 22 | { 23 | SearchTags = null; 24 | ShowingConfigureDialog = false; 25 | } 26 | 27 | public async Task ConfirmConfigureTagDialog() 28 | { 29 | Posts = await _postService.GetPostDisplayByFilters((int)selectCategoryId, SearchTags); 30 | ShowingConfigureDialog = false; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Entities/User.cs: -------------------------------------------------------------------------------- 1 | 2 | 3 | using Microsoft.AspNetCore.Identity; 4 | 5 | namespace HBlog.Domain.Entities 6 | { 7 | public class User : IdentityUser 8 | { 9 | public DateTime DateOfBirth { get; set; } 10 | public string KnownAs { get; set; } 11 | public string FirstName { get; set; } 12 | public string LastName { get; set; } 13 | public DateTime Created { get; set; } = DateTime.UtcNow; 14 | public DateTimeOffset LastActive { get; set; } = DateTime.UtcNow; 15 | public string Gender { get; set; } 16 | public string Email { get; set; } 17 | public string? RefreshToken { get; set; } 18 | public DateTime RefreshTokenExpiryTime { get; set; } 19 | public List Photos { get; set; } = new(); 20 | public List Posts { get; set; } = new(); 21 | public List LikedByUsers {get; set;} 22 | public List LikedUsers {get; set; } 23 | public List MessagesSent { get; set; } 24 | public List MessagesReceived { get; set; } 25 | public ICollection UserRoles { get; set; } 26 | 27 | } 28 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base 2 | WORKDIR /app 3 | EXPOSE 8080 4 | EXPOSE 8081 5 | 6 | FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build 7 | ARG BUILD_CONFIGURATION=Release 8 | WORKDIR /src 9 | COPY ["/src/HBlog.Domain/*.csproj", "HBlog.Domain/"] 10 | COPY ["/src/HBlog.Application/*.csproj", "HBlog.Application/"] 11 | COPY ["/src/HBlog.Contract/*.csproj", "HBlog.Contract/"] 12 | COPY ["/src/HBlog.Infrastructure/*.csproj", "HBlog.Infrastructure/"] 13 | COPY ["/src/HBlog.Infrastructure/Data/PostSeedData.json", "HBlog.Infrastructure/Data/"] 14 | COPY ["/src/HBlog.Infrastructure/Data/UserSeedData.json", "HBlog.Infrastructure/Data/"] 15 | COPY ["/src/HBlog.Api/HBlog.Api.csproj", "HBlog.Api/"] 16 | RUN dotnet restore "./HBlog.Api/HBlog.Api.csproj" 17 | COPY . . 18 | 19 | RUN dotnet build "./src/HBlog.Api/HBlog.Api.csproj" -c %BUILD_CONFIGURATION% -o /app/build 20 | 21 | FROM build AS publish 22 | ARG BUILD_CONFIGURATION=Release 23 | RUN dotnet publish "./src/HBlog.Api/HBlog.Api.csproj" -c %BUILD_CONFIGURATION% -o /app/publish /p:UseAppHost=false 24 | 25 | FROM base AS final 26 | WORKDIR /app 27 | COPY --from=publish /app/publish . 28 | ENTRYPOINT ["dotnet", "HBlog.Api.dll"] -------------------------------------------------------------------------------- /src/HBlog.Application/Queries/Posts/GetPostsTitleContainsQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.Common; 3 | using HBlog.Contract.DTOs; 4 | using HBlog.Domain.Repositories; 5 | using MediatR; 6 | 7 | namespace HBlog.Application.Queries.Posts; 8 | 9 | public record GetPostsTitleContainsQuery(string Title) : IRequest>>; 10 | 11 | public class GetPostsTitleContainsQueryHandler : IRequestHandler>> 12 | { 13 | private readonly IPostRepository _postRepository; 14 | private readonly IMapper _mapper; 15 | 16 | public GetPostsTitleContainsQueryHandler(IPostRepository postRepository, IMapper mapper) 17 | { 18 | _postRepository = postRepository; 19 | _mapper = mapper; 20 | } 21 | 22 | public async Task>> Handle(GetPostsTitleContainsQuery request, CancellationToken cancellationToken) 23 | { 24 | var posts = await _postRepository.GetPostsTitleContainsAsync(request.Title.ToLower()); 25 | return ServiceResult.Success(_mapper.Map>(posts)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/App.razor: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @if(context.User?.Identity?.IsAuthenticated == false) 7 | { 8 | 9 | } 10 | else 11 | { 12 |
13 |

You are not authorized to access this resource.

14 |
15 | } 16 |
17 |
18 | 19 |
20 | 21 | Not found 22 | 23 |

Sorry, there's nothing at this address.

24 |
25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /test/HBlog.IntegrationTests/Base/TestAuthHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.Extensions.Options; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Security.Claims; 8 | using System.Text; 9 | using System.Text.Encodings.Web; 10 | using System.Threading.Tasks; 11 | 12 | namespace HBlog.IntegrationTests.Base 13 | { 14 | public class TestAuthHandler : AuthenticationHandler 15 | { 16 | public TestAuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) 17 | : base(options, logger, encoder) 18 | { 19 | } 20 | 21 | protected override Task HandleAuthenticateAsync() 22 | { 23 | var identity = new ClaimsIdentity(Array.Empty(), "Test"); 24 | var principal = new ClaimsPrincipal(identity); 25 | var ticket = new AuthenticationTicket(principal, "TestScheme"); 26 | 27 | var result = AuthenticateResult.Success(ticket); 28 | return Task.FromResult(result); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/PostCard.razor: -------------------------------------------------------------------------------- 1 | @using HBlog.Contract.DTOs 2 | 3 | @if(Post is not null) 4 | { 5 |
6 |
7 |

8 | @Post.Title 9 |

10 |

11 | @Post.Desc 12 |

13 |
14 | @Post.Created.ToString("MMM dd yyyy") 15 |
16 | @if (Post.Tags is not null) 17 | { 18 | foreach (var tag in Post.Tags) 19 | { 20 | @tag.Name 21 | } 22 | } 23 |
24 | 25 |
26 |
27 |
28 | } 29 | else 30 | { 31 |

Loading...

32 | 33 | } 34 | 35 | 36 | @code { 37 | [Parameter] 38 | public PostDisplayDto? Post { get; set; } 39 | } 40 | -------------------------------------------------------------------------------- /src/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | "program": "${workspaceFolder}/HBlog.Api/bin/Debug/net9.0/HBlog.Api.dll", 13 | "args": [], 14 | "cwd": "${workspaceFolder}/HBlog.Api", 15 | "stopAtEntry": false, 16 | "serverReadyAction": { 17 | "action": "openExternally", 18 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 19 | }, 20 | "env": { 21 | "ASPNETCORE_ENVIRONMENT": "Development" 22 | }, 23 | "sourceFileMap": { 24 | "/Views": "${workspaceFolder}/Views" 25 | } 26 | }, 27 | { 28 | "name": ".NET Core Attach", 29 | "type": "coreclr", 30 | "request": "attach" 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/ValueObjects/PostType.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | 3 | namespace HBlog.Domain.ValueObjects; 4 | 5 | public sealed class PostType : IEquatable 6 | { 7 | public string Value { get; } 8 | 9 | public static readonly PostType Normal = new("Normal"); 10 | public static readonly PostType Featured = new("Featured"); 11 | public static readonly PostType Pinned = new("Pinned"); 12 | 13 | private PostType(string value) 14 | { 15 | Value = value; 16 | } 17 | 18 | public static PostType FromString(string type) 19 | { 20 | return type switch 21 | { 22 | "Normal" => Normal, 23 | "Featured" => Featured, 24 | "Pinned" => Pinned, 25 | _ => throw new DomainException($"Invalid post type: '{type}'. Valid values: Normal, Featured, Pinned") 26 | }; 27 | } 28 | 29 | public bool Equals(PostType? other) => other is not null && Value == other.Value; 30 | public override bool Equals(object? obj) => obj is PostType other && Equals(other); 31 | public override int GetHashCode() => Value.GetHashCode(); 32 | public static implicit operator string(PostType type) => type.Value; 33 | public override string ToString() => Value; 34 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Helpers/QueryHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace HBlog.WebClient.Helpers 4 | { 5 | public class QueryHelper 6 | { 7 | public static string ArrayToQueryString(string key, string[] values) 8 | { 9 | StringBuilder queryString = new StringBuilder(); 10 | 11 | // Append each value to the query string 12 | foreach (string value in values) 13 | { 14 | if (queryString.Length > 0) 15 | { 16 | queryString.Append("&"); 17 | } 18 | queryString.Append(Uri.EscapeDataString(key)); 19 | queryString.Append("="); 20 | queryString.Append(Uri.EscapeDataString(value)); 21 | } 22 | 23 | return queryString.ToString(); 24 | } 25 | 26 | public static string BuildUrlWithQueryStringUsingUriBuilder(string basePath, Dictionary queryParams) 27 | { 28 | var uriBuilder = new UriBuilder(basePath) 29 | { 30 | Query = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={kvp.Value}")) 31 | }; 32 | return uriBuilder.Uri.AbsoluteUri; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/heroku.yml: -------------------------------------------------------------------------------- 1 | name: Deploy HBlog API 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} 11 | APP_NAME: 'hblog-web-api' 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Setup .NET 19 | uses: actions/setup-dotnet@v4 20 | with: 21 | dotnet-version: '9.0.x' 22 | 23 | - name: Restore dependencies 24 | run: dotnet restore src/ 25 | 26 | 27 | - name: Build 28 | run: dotnet build --no-restore src/ 29 | 30 | - name: Test 31 | run: dotnet test --no-build --verbosity normal 32 | working-directory: src/HBlog.Api 33 | 34 | deploy: 35 | needs: build 36 | if: ${{ needs.build.result == 'success' }} 37 | runs-on: ubuntu-22.04 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: akhileshns/heroku-deploy@v3.13.15 42 | with: 43 | heroku_api_key: ${{ secrets.HEROKU_API_KEY }} 44 | heroku_app_name: hblog-web-api 45 | heroku_email: ${{ secrets.HEROKU_EMAIL }} 46 | branch: main 47 | delay: 2 48 | # appdir: src/HBlog.Api 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/HBlog.sln", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary;ForceNoAlign" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/HBlog.sln", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary;ForceNoAlign" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/HBlog.sln" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /src/HBlog.Api/Controllers/BuggyController.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Infrastructure.Data; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace HBlog.Api.Controllers 7 | { 8 | public class BuggyController : BaseApiController 9 | { 10 | private readonly DataContext _dbContext; 11 | public BuggyController(DataContext dbContext) 12 | { 13 | _dbContext = dbContext; 14 | } 15 | [Authorize] 16 | [HttpGet("buggy/auth")] 17 | public ActionResult GetSecret() 18 | { 19 | return "Secret text"; 20 | } 21 | 22 | [HttpGet("buggy/not-found")] 23 | public ActionResult GetNotFound() 24 | { 25 | var thing = _dbContext.Users.Find(-1); 26 | if (thing == null) return NotFound(); 27 | return thing; 28 | } 29 | 30 | [HttpGet("buggy/server-error")] 31 | public ActionResult GetServerError() 32 | { 33 | return _dbContext.Users.Find(-1)?.ToString(); 34 | } 35 | [HttpGet("buggy/bad-request")] 36 | public ActionResult GetBadRequest() 37 | { 38 | return BadRequest("This was not a good request."); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/HBlog.Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "https": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "launchUrl": "swagger", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "dotnetRunMessages": true, 11 | "applicationUrl": "https://localhost:6001;http://localhost:6000" 12 | }, 13 | "IIS Express": { 14 | "commandName": "IISExpress", 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "Docker": { 22 | "commandName": "Docker", 23 | "launchBrowser": true, 24 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", 25 | "environmentVariables": { 26 | "ASPNETCORE_HTTPS_PORTS": "8081", 27 | "ASPNETCORE_HTTP_PORTS": "8080" 28 | }, 29 | "publishAllPorts": true, 30 | "useSSL": true 31 | } 32 | }, 33 | "$schema": "https://json.schemastore.org/launchsettings.json", 34 | "iisSettings": { 35 | "windowsAuthentication": false, 36 | "anonymousAuthentication": true, 37 | "iisExpress": { 38 | "applicationUrl": "http://localhost:59133/", 39 | "sslPort": 44367 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/src/KevBlog.Api/KevBlog.Api.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/src/KevBlog.Api/KevBlog.Api.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/src/KevBlog.Api/KevBlog.Api.csproj" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Handlers/TokenHandler.cs: -------------------------------------------------------------------------------- 1 | using Blazored.LocalStorage; 2 | using System.Net.Http.Headers; 3 | using HBlog.WebClient.Commons; 4 | 5 | namespace HBlog.WebClient.Handlers; 6 | 7 | /// 8 | /// Handler to ensure token is automatically sent over with each request. 9 | /// 10 | public class TokenHandler : DelegatingHandler 11 | { 12 | private readonly ILocalStorageService _localStorageService; 13 | 14 | public TokenHandler(ILocalStorageService localStorageService) 15 | { 16 | _localStorageService = localStorageService; 17 | } 18 | /// 19 | /// Main method to override for the handler. 20 | /// 21 | /// The original request. 22 | /// The token to handle cancellations. 23 | /// The . 24 | protected async override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 25 | { 26 | var token = await _localStorageService.GetItemAsync(Constants.AccessToken); 27 | if (token != null) 28 | { 29 | request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); 30 | } 31 | 32 | return await base.SendAsync(request, cancellationToken); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/HBlog.Application/Queries/Posts/GetPostsQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Common.Params; 4 | using HBlog.Domain.Entities; 5 | using HBlog.Domain.Repositories; 6 | using MediatR; 7 | 8 | namespace HBlog.Application.Queries.Posts; 9 | 10 | public record GetPostsQuery(PostParams Query) : IRequest>; 11 | 12 | public class GetPostsQueryHandler : IRequestHandler> 13 | { 14 | private readonly IPostRepository _postRepository; 15 | private readonly IMapper _mapper; 16 | 17 | public GetPostsQueryHandler(IPostRepository postRepository, IMapper mapper) 18 | { 19 | _postRepository = postRepository; 20 | _mapper = mapper; 21 | } 22 | 23 | public async Task> Handle(GetPostsQuery request, CancellationToken cancellationToken) 24 | { 25 | IEnumerable posts = await _postRepository.GetPostsAsync(request.Query.Limit, request.Query.Offset); 26 | 27 | if (request.Query.CategoryId != 0) 28 | posts = posts.Where(p => p.CategoryId == request.Query.CategoryId); 29 | 30 | if (request.Query.TagId.Any()) 31 | posts = posts.Where(p => p.Tags.Any(tag => request.Query.TagId.Contains(tag.Id))); 32 | 33 | return _mapper.Map>(posts); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Migrations/20250802024951_addingRefreshToken.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace HBlog.Infrastructure.Migrations 7 | { 8 | /// 9 | public partial class addingRefreshToken : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.AddColumn( 15 | name: "RefreshToken", 16 | table: "User", 17 | type: "text", 18 | nullable: true); 19 | 20 | migrationBuilder.AddColumn( 21 | name: "RefreshTokenExpiryTime", 22 | table: "User", 23 | type: "timestamp without time zone", 24 | nullable: false, 25 | defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); 26 | } 27 | 28 | /// 29 | protected override void Down(MigrationBuilder migrationBuilder) 30 | { 31 | migrationBuilder.DropColumn( 32 | name: "RefreshToken", 33 | table: "User"); 34 | 35 | migrationBuilder.DropColumn( 36 | name: "RefreshTokenExpiryTime", 37 | table: "User"); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Program.cs: -------------------------------------------------------------------------------- 1 | using Blazored.LocalStorage; 2 | using Blazored.Toast; 3 | using HBlog.WebClient; 4 | using HBlog.WebClient.Extensions; 5 | using HBlog.WebClient.Handlers; 6 | using HBlog.WebClient.Providers; 7 | using HBlog.WebClient.States; 8 | using Microsoft.AspNetCore.Components.Authorization; 9 | using Microsoft.AspNetCore.Components.Web; 10 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 11 | 12 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 13 | builder.RootComponents.Add("#app"); 14 | builder.RootComponents.Add("head::after"); 15 | 16 | builder.Services.AddBlazoredLocalStorage(); 17 | builder.Services.AddBlazoredToast(); 18 | builder.Services.RegisterClientServices(); 19 | builder.Services.AddTransient(); 20 | builder.Configuration.AddJsonFile($"appsettings.json", optional: false, reloadOnChange: true); 21 | 22 | string? apiBaseUrl = builder.Configuration["ApiBaseUrl"]; 23 | Console.WriteLine($"ApiBaseUrl value: '{apiBaseUrl}'"); 24 | builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(apiBaseUrl) }); 25 | 26 | builder.Services.AddScoped(); 27 | builder.Services.AddScoped(p => p.GetRequiredService()); 28 | builder.Services.AddScoped(); 29 | 30 | builder.Services.AddAuthorizationCore(); 31 | 32 | await builder.Build().RunAsync(); 33 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/PageList.cs: -------------------------------------------------------------------------------- 1 | 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace HBlog.Domain.Common 5 | { 6 | public class PageList : List 7 | { 8 | public PageList(IEnumerable items, int count, int pageNum, int pageSize) 9 | { 10 | CurrentPage = pageNum; 11 | TotalPages = (int) Math.Ceiling(count / (double) pageSize); 12 | PageSize = pageSize; 13 | TotalCount = count; 14 | AddRange(items); 15 | } 16 | 17 | public int CurrentPage { get; set; } 18 | public int TotalPages { get; set; } 19 | public int PageSize { get; set; } 20 | public int TotalCount { get; set; } 21 | public static async Task> CreateAsync(IQueryable source, int pageNumber, int pageSize){ 22 | var count = await source.CountAsync(); 23 | var items = await source.Skip((pageNumber-1) * pageSize).Take(pageSize).ToListAsync(); 24 | return new PageList(items, count, pageNumber, pageSize); 25 | } 26 | public static PageList CreateAsync(IEnumerable source, int pageNumber, int pageSize) 27 | { 28 | var count = source.Count(); 29 | var items = source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList(); 30 | return new PageList(items, count, pageNumber, pageSize); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /test/HBlog.IntegrationTests/Base/MessageAppFactory.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Application.Services; 2 | using HBlog.Domain.Repositories; 3 | using Microsoft.AspNetCore.Authentication; 4 | using Microsoft.AspNetCore.Hosting; 5 | using Microsoft.AspNetCore.Mvc.Testing; 6 | using Microsoft.AspNetCore.TestHost; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Moq; 9 | 10 | namespace HBlog.IntegrationTests.Base 11 | { 12 | public class MessageAppFactory : WebApplicationFactory 13 | { 14 | public Mock _mockMessageService { get; } 15 | public Mock _mockMessageRepository { get; } 16 | public Mock _mockUserService { get; } 17 | 18 | public MessageAppFactory() 19 | { 20 | _mockMessageService = new(); 21 | _mockMessageRepository = new(); 22 | _mockUserService = new(); 23 | } 24 | 25 | protected override void ConfigureWebHost(IWebHostBuilder builder) 26 | { 27 | builder.ConfigureTestServices(services => 28 | { 29 | 30 | services.AddAuthentication("TestScheme").AddScheme("Test", options => { }); 31 | 32 | services.AddSingleton(_mockMessageService.Object); 33 | services.AddSingleton(_mockMessageRepository.Object); 34 | }); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Endpoints/PostAppFactory.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Application; 2 | using HBlog.Application.Services; 3 | using HBlog.Domain.Repositories; 4 | using Microsoft.AspNetCore.Hosting; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.VisualStudio.TestPlatform.TestHost; 7 | using Moq; 8 | using System; 9 | using HBlog.Infrastructure.Data; 10 | using Microsoft.AspNetCore.Mvc.Testing; 11 | using Microsoft.AspNetCore.TestHost; 12 | using Microsoft.Extensions.Hosting; 13 | 14 | namespace HBlog.UnitTests.Endpoints 15 | { 16 | public class PostAppFactory : WebApplicationFactory 17 | { 18 | public Mock _mockPostRepository { get; } 19 | public Mock _mockUserService { get; } 20 | 21 | public PostAppFactory() 22 | { 23 | _mockPostRepository = new Mock(); 24 | _mockUserService = new Mock(); 25 | } 26 | protected override void ConfigureWebHost(IWebHostBuilder builder) 27 | { 28 | Environment.SetEnvironmentVariable("Environment", "test"); 29 | builder.UseContentRoot("."); 30 | builder.ConfigureTestServices(services => 31 | { 32 | services.AddSingleton(_mockPostRepository.Object); 33 | services.AddSingleton(_mockUserService.Object); 34 | }); 35 | } 36 | 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/Layout/Footer.razor: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 | Built By 8 | 9 |
10 | 11 |
12 |
13 |
14 |
15 | Powered by HProject 16 |
17 |
18 |
19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | 31 | @code { 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Repositories/Repository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | using HBlog.Domain.Repositories; 3 | using HBlog.Infrastructure.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | using System.Linq.Expressions; 6 | 7 | namespace HBlog.Infrastructure.Repositories 8 | { 9 | public class Repository : IRepository where TEntity : class 10 | { 11 | protected readonly DataContext _dataContext; 12 | protected readonly DbSet _dbSet; 13 | public Repository(DataContext context) 14 | { 15 | _dataContext = context; 16 | _dbSet = _dataContext.Set(); 17 | } 18 | public virtual void Add(TEntity obj) => _dbSet.Add(obj); 19 | public virtual IQueryable GetAll() => _dbSet.AsNoTracking(); 20 | public virtual IQueryable GetAllSoftDeleted() 21 | => _dbSet.IgnoreQueryFilters().Where(x => EF.Property(x, "IsDeleted")); 22 | public virtual async Task GetById(int id) => await _dbSet.FindAsync(id); 23 | 24 | public virtual void Remove(int id) 25 | => _dbSet.Remove(_dbSet.Find(id)); 26 | public virtual async Task SaveChangesAsync() 27 | => await _dataContext.SaveChangesAsync(); 28 | public async Task> GetAll(Expression> predicate) 29 | => await Task.Run(() => _dbSet.Where(predicate)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/SignalR/PresenceHub.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Infrastructure.Extensions; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.SignalR; 4 | 5 | namespace HBlog.Infrastructure.SignalR 6 | { 7 | [Authorize] 8 | public class PresenceHub : Hub 9 | { 10 | private readonly PresenceTracker _presenceTracker; 11 | 12 | public PresenceHub(PresenceTracker presenceTracker) 13 | { 14 | this._presenceTracker = presenceTracker; 15 | } 16 | public override async Task OnConnectedAsync() 17 | { 18 | await _presenceTracker.UserConncted(Context.User.GetUsername(), Context.ConnectionId); 19 | await Clients.Others.SendAsync("UserIsOnline", Context.User.GetUsername()); 20 | 21 | var currentUsers = await _presenceTracker.GetOnlineUsers(); 22 | await Clients.All.SendAsync("GetOnlineUsers", currentUsers); 23 | } 24 | public override async Task OnDisconnectedAsync(Exception exception) 25 | { 26 | await _presenceTracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId); 27 | await Clients.Others.SendAsync("UserIsOffline", Context.User.GetUsername()); 28 | 29 | var currUsers = await _presenceTracker.GetOnlineUsers(); 30 | await Clients.All.SendAsync("GetOnlineUsers", currUsers); 31 | await base.OnDisconnectedAsync(exception); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Mocks/Repositories/MockUserRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using Moq; 4 | 5 | namespace HBlog.UnitTests.Mocks.Repositories 6 | { 7 | public class MockUserRepository : Mock 8 | { 9 | public MockUserRepository MockGetUsersAsync(IEnumerable result) 10 | { 11 | Setup(x => x.GetUsersAsync()).ReturnsAsync(result); 12 | return this; 13 | } 14 | 15 | public static Mock MockGetUsers() 16 | { 17 | var mock = new Mock(); 18 | 19 | IEnumerable userList = SampleValidUserData(3); 20 | mock.Setup(m => m.GetUsersAsync().Result).Returns(() => userList); 21 | return mock; 22 | } 23 | public static IEnumerable SampleValidUserData(int howMany) 24 | { 25 | List users = new(); 26 | for (int i = 1; i <= howMany; i++) 27 | { 28 | User user = new() 29 | { 30 | Id = Guid.CreateVersion7(), 31 | UserName = "kevin" + i, 32 | KnownAs = "knownas" + i, 33 | Gender = "Male", 34 | DateOfBirth = new DateTime(1993, 7, 3), 35 | Email = "hyunbin7303@gmail.com", 36 | }; 37 | users.Add(user); 38 | } 39 | return users; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/PostSearchSideBar.razor.css: -------------------------------------------------------------------------------- 1 | .auto-hiding-nav-menu { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: flex-start; 5 | position: relative; 6 | transition: max-width 0.3s ease; 7 | background-color: #253529; 8 | color: #fff; 9 | padding: 1rem; 10 | border-radius: 15px; 11 | margin-top: 5px; 12 | } 13 | 14 | .nav-menu { 15 | overflow: hidden; 16 | height: 50%; 17 | } 18 | 19 | .collapsed { 20 | width: 25px; 21 | } 22 | 23 | .expand { 24 | width: 110px; 25 | } 26 | 27 | 28 | .nav-item-text { 29 | visibility: hidden; 30 | position: absolute; 31 | margin-left: 0.5rem; 32 | font-size: 16px; 33 | margin-top: 4px; 34 | transition: opacity 0.3s ease, transform 0.3s ease; 35 | transform-origin: left center; 36 | } 37 | 38 | .expand .nav-item-text { 39 | visibility: visible; 40 | opacity: 0.5; 41 | } 42 | 43 | .nav-link:hover .nav-item-text { 44 | opacity: 1; 45 | transform: scale(1.2); 46 | color: #DAF7A6; 47 | } 48 | 49 | .nav-item { 50 | display: flex; 51 | align-items: center; 52 | justify-content: flex-start; 53 | } 54 | 55 | .nav-item-icon { 56 | color: white; 57 | font-size: 20px; 58 | transition: opacity 0.3s ease, transform 0.3s ease; 59 | transform-origin: left center; 60 | margin-bottom: 5px; 61 | } 62 | 63 | .nav-link:hover .nav-item-icon { 64 | color: #DAF7A6; 65 | transform: scale(1.2); 66 | } 67 | 68 | .nav-link > * { 69 | align-self: flex-start; 70 | } 71 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:45470", 8 | "sslPort": 44348 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 17 | "applicationUrl": "http://localhost:5050", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 27 | "applicationUrl": "https://localhost:7183;http://localhost:5050", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/src/HBlog.Api/bin/Debug/net8.0/HBlog.Api.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/src/HBlog.Api", 16 | "stopAtEntry": false, 17 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 18 | "serverReadyAction": { 19 | "action": "openExternally", 20 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 21 | }, 22 | "env": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "sourceFileMap": { 26 | "/Views": "${workspaceFolder}/Views" 27 | } 28 | }, 29 | { 30 | "name": ".NET Core Attach", 31 | "type": "coreclr", 32 | "request": "attach" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /src/HBlog.Api/Controllers/LikesController.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Application.Services; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Common; 4 | using HBlog.Domain.Params; 5 | using HBlog.Infrastructure.Extensions; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace HBlog.Api.Controllers 9 | { 10 | public class LikesController : BaseApiController 11 | { 12 | private readonly ILikeService _likeService; 13 | 14 | public LikesController(ILikeService likeService) 15 | { 16 | _likeService = likeService; 17 | } 18 | 19 | [HttpPost("likes/{username}")] 20 | public async Task AddLike(string username){ 21 | var sourceUserId = User.GetUserId(); 22 | var result = await _likeService.AddLike(sourceUserId, username); 23 | if(!result.IsSuccess && result.Message == "NotFound") 24 | return NotFound(result.Message); 25 | if (!result.IsSuccess) 26 | return BadRequest("Failed to like user"); 27 | 28 | return Ok(result); 29 | } 30 | [HttpGet("likes")] 31 | public async Task>> GetUserLikes([FromQuery]LikesParams likesParam) { 32 | likesParam.UserId = User.GetUserId(); 33 | var users = await _likeService.GetUserLikePageList(likesParam); 34 | 35 | Response.AddPaginationHeader(new PaginationHeader(users.CurrentPage, users.PageSize, users.TotalCount, users.TotalPages)); 36 | return Ok(users); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/HBlog.Application/Commands/Posts/AddTagForPostCommand.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.Common; 2 | using HBlog.Domain.Entities; 3 | using HBlog.Domain.Repositories; 4 | using MediatR; 5 | 6 | namespace HBlog.Application.Commands.Posts; 7 | 8 | public record AddTagForPostCommand(int PostId, int[] TagIds) : IRequest; 9 | 10 | public class AddTagForPostCommandHandler : IRequestHandler 11 | { 12 | private readonly IPostRepository _postRepository; 13 | private readonly ITagRepository _tagRepository; 14 | 15 | public AddTagForPostCommandHandler( 16 | IPostRepository postRepository, 17 | ITagRepository tagRepository) 18 | { 19 | _postRepository = postRepository; 20 | _tagRepository = tagRepository; 21 | } 22 | 23 | public async Task Handle(AddTagForPostCommand request, CancellationToken cancellationToken) 24 | { 25 | var post = await _postRepository.GetById(request.PostId); 26 | if (post is null) 27 | return ServiceResult.NotFound(msg: "Cannot find post."); 28 | 29 | foreach (var tagId in request.TagIds) 30 | { 31 | var tag = await _tagRepository.GetById(tagId); 32 | if (tag is null) 33 | return ServiceResult.NotFound(msg: $"Cannot find tag with ID {tagId}."); 34 | 35 | post.AddTag(tag); 36 | } 37 | 38 | await _postRepository.UpdateAsync(post); 39 | return ServiceResult.Success($"Successfully added tags to post."); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/HBlog.Domain/ValueObjects/PostStatus.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Common; 2 | 3 | namespace HBlog.Domain.ValueObjects; 4 | 5 | public sealed class PostStatus : IEquatable 6 | { 7 | public string Value { get; } 8 | 9 | public static readonly PostStatus Draft = new("draft"); 10 | public static readonly PostStatus Active = new("active"); 11 | public static readonly PostStatus Published = new("published"); 12 | public static readonly PostStatus Removed = new("removed"); 13 | 14 | private PostStatus(string value) 15 | { 16 | Value = value; 17 | } 18 | 19 | public static PostStatus FromString(string status) 20 | { 21 | return status switch 22 | { 23 | "draft" => Draft, 24 | "active" => Active, 25 | "published" => Published, 26 | "removed" => Removed, 27 | _ => throw new DomainException($"Invalid post status: '{status}'. Valid values: draft, active, published, removed ") 28 | }; 29 | } 30 | 31 | public bool IsDraft => Equals(Draft); 32 | public bool IsPublished => Equals(Published); 33 | public bool IsRemoved => Equals(Removed); 34 | 35 | public bool Equals(PostStatus? other) => other is not null && Value == other.Value; 36 | public override bool Equals(object? obj) => obj is PostStatus other && Equals(other); 37 | public override int GetHashCode() => Value.GetHashCode(); 38 | public static implicit operator string(PostStatus status) => status.Value; 39 | public override string ToString() => Value; 40 | } -------------------------------------------------------------------------------- /test/HBlog.IntegrationTests/GlobalExceptionHandlerTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using HBlog.Infrastructure.Middlewares; 7 | using Microsoft.AspNetCore.Builder; 8 | using Microsoft.AspNetCore.Hosting; 9 | using Microsoft.AspNetCore.TestHost; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Hosting; 12 | using NUnit.Framework; 13 | 14 | namespace HBlog.IntegrationTests 15 | { 16 | 17 | public class GlobalExceptionHandlerTest 18 | { 19 | 20 | [SetUp] 21 | public void Init() 22 | { 23 | 24 | } 25 | 26 | [Test] 27 | public async Task MiddlewareTest_ReturnsError() 28 | { 29 | //builder.Services.AddExceptionHandler(); 30 | 31 | using var host = await new HostBuilder() 32 | .ConfigureWebHost(webBuilder => 33 | { 34 | webBuilder 35 | .UseTestServer() 36 | .ConfigureServices(services => 37 | { 38 | services.AddExceptionHandler(); 39 | }) 40 | .Configure(app => 41 | { 42 | }); 43 | }) 44 | .StartAsync(); 45 | 46 | var response = await host.GetTestClient().GetAsync("/api/posts"); 47 | 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/HBlog.Api/HBlog.Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | disable 6 | enable 7 | cfa4d88a-b484-4a3e-a85f-01988d32f45f 8 | Linux 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | all 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Always 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/HBlog.Application/Commands/Posts/UpdatePostStatusCommand.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.Common; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Entities; 4 | using HBlog.Domain.Repositories; 5 | using HBlog.Domain.ValueObjects; 6 | using MediatR; 7 | 8 | namespace HBlog.Application.Commands.Posts; 9 | 10 | public record UpdatePostStatusCommand(int Id, PostChangeStatusDto UpdateStatusDto) : IRequest; 11 | 12 | public class UpdatePostStatusCommandHandler : IRequestHandler 13 | { 14 | private readonly IPostRepository _postRepository; 15 | 16 | public UpdatePostStatusCommandHandler(IPostRepository postRepository) 17 | { 18 | _postRepository = postRepository; 19 | } 20 | 21 | public async Task Handle(UpdatePostStatusCommand request, CancellationToken cancellationToken) 22 | { 23 | Post post = await _postRepository.GetById(request.Id); 24 | if (post == null || post.Status.IsRemoved) 25 | return ServiceResult.Fail(msg: "Post does not exist."); 26 | 27 | // Use domain methods to change status 28 | var newStatus = PostStatus.FromString(request.UpdateStatusDto.Status); 29 | 30 | if (newStatus.Equals(PostStatus.Published)) 31 | post.Publish(); 32 | else if (newStatus.Equals(PostStatus.Active)) 33 | post.Activate(); 34 | else if (newStatus.Equals(PostStatus.Removed)) 35 | post.Archive(); 36 | 37 | await _postRepository.UpdateAsync(post); 38 | return ServiceResult.Success(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HBlog 2 | This project is for building my own blog using Asp.net Web API and Blazor WebAssembly. 3 | 4 | ## Technology Stacks 5 | * ASP.NET Web API .NET8 6 | * EFCore 7 | * Blazor WebAssembly 8 | * Bootstrap 9 | * Postgres 10 | 11 | 12 | # Technology implementations 13 | ## Frontend(Blaor WASM) Side 14 | * HTTP Client service layer to interact with Web API. 15 | * Authentication using JWT Token. 16 | * Authorization 17 | 18 | ## Backend(ASP.NET Web API) Side 19 | * SOLID Principle 20 | * A RESTful API design 21 | * Service layer for business logic 22 | * Repository Pattern for persisting data 23 | * Authentication via JWT 24 | * Pagination for handling large data 25 | * Infrastructure layer(Extensions, Helpers, Data migrations/seed) 26 | * AutoMapper for Domain-DTO mapping. 27 | * Unit Testing / Integration testing (InProgress) 28 | 29 | ## Getting started 30 | 1. Clone the git repository 31 | 2. Turn on Docker desktop 32 | 3. Within the Hblog folder, create containers for Postgres and Web API. `docker compose up -d` 33 | 4. Access to `http://localhost:8090/swagger/index.html` to check swagger page(Web API). 34 | 5. Get authentication token using `/account/login` endpoint. Json Body : `{ "username" : "testuser", "password" : "Testing#1234!"}` 35 | 6. In Visual studio, change the startup project to `HBlog.WebClient` 36 | 7. Run the project. 37 | 38 | 39 | ## Contribution 40 | 41 | If you have any suggestions for how HP could be improved, feel free to create a issue and do some works for me! 42 | For more, checkout the [Contributing guidelines](https://github.com/hyunbin7303/HBlog/blob/main/.github/CONTRIBUTING.md). 43 | 44 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Repositories/TagRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using HBlog.Domain.Entities; 6 | using HBlog.Domain.Repositories; 7 | using HBlog.Infrastructure.Data; 8 | using Microsoft.EntityFrameworkCore; 9 | using Microsoft.Extensions.Hosting; 10 | 11 | namespace HBlog.Infrastructure.Repositories 12 | { 13 | public class TagRepository : Repository, ITagRepository 14 | { 15 | private readonly DataContext _dbContext; 16 | public TagRepository(DataContext dbContext) : base(dbContext) 17 | { 18 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 19 | } 20 | public async Task Delete(Tag tag) 21 | { 22 | _dbContext.Entry(tag).State = EntityState.Deleted; 23 | await _dbContext.SaveChangesAsync(); 24 | } 25 | 26 | public async Task Update(Tag tag) 27 | { 28 | _dbContext.Entry(tag).State = EntityState.Modified; 29 | await _dbContext.SaveChangesAsync(); 30 | } 31 | 32 | public async Task> GetAll() 33 | { 34 | return await _dbContext.Tags.AsNoTracking().ToListAsync(); 35 | } 36 | 37 | public async Task FindbySlug(string slug) 38 | { 39 | return _dbContext.Tags.Where(x => x.Slug == slug).FirstOrDefault(); 40 | } 41 | 42 | public async Task GetById(int id) 43 | { 44 | return await _dbContext.Tags.FindAsync(id); 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /src/HBlog.Application/Queries/Posts/GetPostsByTagIdQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.Common; 3 | using HBlog.Contract.DTOs; 4 | using HBlog.Domain.Repositories; 5 | using MediatR; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace HBlog.Application.Queries.Posts; 9 | 10 | public record GetPostsByTagIdQuery(int TagId) : IRequest>>; 11 | 12 | public class GetPostsByTagIdQueryHandler : IRequestHandler>> 13 | { 14 | private readonly ITagRepository _tagRepository; 15 | private readonly IRepository _postTagRepository; 16 | private readonly IMapper _mapper; 17 | 18 | public GetPostsByTagIdQueryHandler( 19 | ITagRepository tagRepository, 20 | IRepository postTagRepository, 21 | IMapper mapper) 22 | { 23 | _tagRepository = tagRepository; 24 | _postTagRepository = postTagRepository; 25 | _mapper = mapper; 26 | } 27 | 28 | public async Task>> Handle(GetPostsByTagIdQuery request, CancellationToken cancellationToken) 29 | { 30 | var tag = await _tagRepository.GetById(request.TagId); 31 | if (tag is null) 32 | return ServiceResult.Fail>(msg: "NotFound Tag."); 33 | 34 | var postTags = _postTagRepository.GetAll(); 35 | var posts = postTags.Include(o => o.Post).Where(t => t.TagId == request.TagId).Select(x => x.Post); 36 | return ServiceResult.Success(_mapper.Map>(posts)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/HBlog.Application/Queries/Posts/GetPostsByTagSlugQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.Common; 3 | using HBlog.Contract.DTOs; 4 | using HBlog.Domain.Repositories; 5 | using MediatR; 6 | 7 | namespace HBlog.Application.Queries.Posts; 8 | 9 | public record GetPostsByTagSlugQuery(string TagSlug) : IRequest>>; 10 | 11 | public class GetPostsByTagSlugQueryHandler : IRequestHandler>> 12 | { 13 | private readonly ITagRepository _tagRepository; 14 | private readonly IRepository _postTagRepository; 15 | private readonly IMapper _mapper; 16 | 17 | public GetPostsByTagSlugQueryHandler( 18 | ITagRepository tagRepository, 19 | IRepository postTagRepository, 20 | IMapper mapper) 21 | { 22 | _tagRepository = tagRepository; 23 | _postTagRepository = postTagRepository; 24 | _mapper = mapper; 25 | } 26 | 27 | public async Task>> Handle(GetPostsByTagSlugQuery request, CancellationToken cancellationToken) 28 | { 29 | var tags = await _tagRepository.FindbySlug(request.TagSlug); 30 | if (tags is null) 31 | return ServiceResult.Fail>(msg: "Tag does not exist."); 32 | 33 | var tagPosts = await _postTagRepository.GetAll(o => o.TagId == tags.Id); 34 | var posts = tagPosts.Select(o => o.Post); 35 | var result = _mapper.Map>(posts); 36 | return ServiceResult.Success(result); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/HBlog.Application/Queries/Posts/GetPostByIdQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.Common; 3 | using HBlog.Contract.DTOs; 4 | using HBlog.Domain.Entities; 5 | using HBlog.Domain.Repositories; 6 | using HBlog.Domain.ValueObjects; 7 | using MediatR; 8 | 9 | namespace HBlog.Application.Queries.Posts; 10 | 11 | public record GetPostByIdQuery(int Id) : IRequest>; 12 | 13 | public class GetPostByIdQueryHandler : IRequestHandler> 14 | { 15 | private readonly IPostRepository _postRepository; 16 | private readonly IUserRepository _userRepository; 17 | private readonly IMapper _mapper; 18 | 19 | public GetPostByIdQueryHandler( 20 | IPostRepository postRepository, 21 | IUserRepository userRepository, 22 | IMapper mapper) 23 | { 24 | _postRepository = postRepository; 25 | _userRepository = userRepository; 26 | _mapper = mapper; 27 | } 28 | 29 | public async Task> Handle(GetPostByIdQuery request, CancellationToken cancellationToken) 30 | { 31 | Post post = await _postRepository.GetPostDetails(request.Id); 32 | if (post is null || post.Status.IsRemoved) 33 | return ServiceResult.Fail(msg: "Post is not exist or status is removed."); 34 | 35 | var postDisplay = _mapper.Map(post); 36 | User user = await _userRepository.GetUserByIdAsync(post.UserId); 37 | postDisplay.UserName = user?.UserName ?? "Unknown"; 38 | 39 | return ServiceResult.Success(postDisplay); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Endpoints/UsersControllerTest.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Api.Controllers; 2 | using HBlog.Application.Services; 3 | using HBlog.Contract.Common; 4 | using HBlog.Contract.DTOs; 5 | using HBlog.Domain.Entities; 6 | using HBlog.Domain.Params; 7 | using HBlog.TestUtilities; 8 | using HBlog.UnitTests.Mocks.Repositories; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Moq; 11 | using NUnit.Framework; 12 | 13 | namespace HBlog.UnitTests.Controllers 14 | { 15 | public class UsersControllerTest : TestBase 16 | { 17 | private Mock _userService; 18 | private UsersController _controller; 19 | 20 | [SetUp] 21 | public void Init() 22 | { 23 | _userService = new Mock(); 24 | _controller = new UsersController(_userService.Object); 25 | _controller.ControllerContext = new ControllerContext { HttpContext = UserSetup() }; 26 | } 27 | 28 | [Test] 29 | public async Task UpdateUser_UpdateUserInfo_SuccessReturnOk() 30 | { 31 | IEnumerable userList = MockUserRepository.SampleValidUserData(3); 32 | //_userRepository.Setup(repo => repo.GetUsersAsync()).Returns(Task.FromResult(userList)); 33 | //var test = await _userRepository.Object.GetUsersAsync(); 34 | 35 | UserUpdateDto userUpdateDto = new UserUpdateDto(); 36 | userUpdateDto.Introduction = "Member Update Dto"; 37 | userUpdateDto.LookingFor = ""; 38 | userUpdateDto.Interests = "Programming"; 39 | 40 | var result = await _controller.Update(userUpdateDto); 41 | var obj = result as ObjectResult; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/HBlog.Application/Queries/Posts/GetPostsByCategoryQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.Common; 3 | using HBlog.Contract.DTOs; 4 | using HBlog.Domain.Repositories; 5 | using MediatR; 6 | 7 | namespace HBlog.Application.Queries.Posts; 8 | 9 | public record GetPostsByCategoryQuery(int CategoryId) : IRequest>>; 10 | 11 | public class GetPostsByCategoryQueryHandler : IRequestHandler>> 12 | { 13 | private readonly IPostRepository _postRepository; 14 | private readonly ICategoryRepository _categoryRepository; 15 | private readonly IMapper _mapper; 16 | 17 | public GetPostsByCategoryQueryHandler( 18 | IPostRepository postRepository, 19 | ICategoryRepository categoryRepository, 20 | IMapper mapper) 21 | { 22 | _postRepository = postRepository; 23 | _categoryRepository = categoryRepository; 24 | _mapper = mapper; 25 | } 26 | 27 | public async Task>> Handle(GetPostsByCategoryQuery request, CancellationToken cancellationToken) 28 | { 29 | var category = await _categoryRepository.GetById(request.CategoryId); 30 | if (category is null) 31 | return ServiceResult.Fail>(msg: "NotFound Category."); 32 | 33 | var posts = await _postRepository.GetAll(o => o.CategoryId == request.CategoryId); 34 | if (posts.Count() == 0) 35 | return ServiceResult.Fail>(msg: "NotFound Posts."); 36 | 37 | return ServiceResult.Success(_mapper.Map>(posts)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/HBlog.Application/AutoMapper/AutoMapperProfiles.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Common.Extensions; 4 | using HBlog.Domain.Entities; 5 | 6 | namespace HBlog.Application.Automapper 7 | { 8 | public class AutoMapperProfiles : Profile 9 | { 10 | public AutoMapperProfiles() 11 | { 12 | CreateMap() 13 | .ForMember(dest => dest.TagId, opt => opt.MapFrom(s => s.Id)); 14 | 15 | CreateMap() 16 | .ForMember(dest => dest.PhotoUrl, opt => opt.MapFrom(src => src.Photos.FirstOrDefault(x => x.IsMain).Url)) 17 | .ForMember(dest => dest.Age, opt => opt.MapFrom(src => src.DateOfBirth.CalculateAge())); 18 | 19 | CreateMap(); 20 | CreateMap() 21 | .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.User.UserName)) 22 | .ForMember(dest => dest.Tags, opt => opt.MapFrom(Post => Post.Tags.Select(x => x))); 23 | CreateMap() 24 | .ForMember(dest => dest.Tags, opt => opt.MapFrom(Post => Post.Tags.Select(x => x))); 25 | 26 | CreateMap(); 27 | CreateMap(); 28 | CreateMap(); 29 | CreateMap(); 30 | CreateMap() 31 | .ForMember(dest => dest.SenderPhotoUrl, opt => opt.MapFrom(s => s.Sender.Photos.FirstOrDefault(x => x.IsMain).Url)) 32 | .ForMember(dest => dest.RecipientPhotoUrl, opt => opt.MapFrom(s => s.Recipient.Photos.FirstOrDefault(x => x.IsMain).Url)); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/SignalR/PresenceTracker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace HBlog.Infrastructure.SignalR 7 | { 8 | public class PresenceTracker 9 | { 10 | private static Dictionary> OnlineUsers = new(); 11 | public Task UserConncted(string username, string connectionId) 12 | { 13 | lock (OnlineUsers) 14 | { 15 | if (OnlineUsers.ContainsKey(username)) 16 | { 17 | OnlineUsers[username].Add(connectionId); 18 | } 19 | else 20 | { 21 | OnlineUsers.Add(username, new List { connectionId }); 22 | } 23 | } 24 | return Task.CompletedTask; 25 | } 26 | public Task UserDisconnected(string username, string connectionId) 27 | { 28 | lock(OnlineUsers) 29 | { 30 | if (!OnlineUsers.ContainsKey(username)) return Task.CompletedTask; 31 | 32 | OnlineUsers[username].Remove(connectionId); 33 | if (OnlineUsers[username].Count == 0) 34 | { 35 | OnlineUsers.Remove(username); 36 | } 37 | } 38 | return Task.CompletedTask; 39 | } 40 | 41 | public Task GetOnlineUsers() 42 | { 43 | string[] onlineUsers; 44 | lock (OnlineUsers) 45 | { 46 | onlineUsers = OnlineUsers.OrderBy(k => k.Key).Select(k => k.Key).ToArray(); 47 | } 48 | return Task.FromResult(onlineUsers); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/Pages/Users/Login.razor: -------------------------------------------------------------------------------- 1 | @page "/users/Login" 2 | @inject IAuthService authService 3 | @inject NavigationManager navManager 4 | @using HBlog.WebClient.Services 5 |
6 |

Sign In

7 |
8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 |
16 |
17 | 18 | 19 | 20 |
21 | 22 |
23 |
24 |
25 | 26 | 27 | 28 | @code { 29 | public string? _errorMsg; 30 | 31 | LoginDto LoginDtoModel = new LoginDto(); 32 | 33 | protected override Task OnInitializedAsync() 34 | { 35 | return base.OnInitializedAsync(); 36 | } 37 | 38 | private async Task HandleSignIn() 39 | { 40 | AccountDto account = await authService.AuthenAsync(LoginDtoModel); 41 | if (string.IsNullOrEmpty(account.Token)) 42 | { 43 | navManager.NavigateTo("/users/login"); 44 | } 45 | else 46 | { 47 | navManager.NavigateTo("/"); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Extensions/ApplicationServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Application.Services; 2 | using HBlog.Domain.Repositories; 3 | using HBlog.Infrastructure.Helpers; 4 | using HBlog.Infrastructure.Repositories; 5 | using HBlog.Infrastructure.SignalR; 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | namespace HBlog.Infrastructure.Extensions 9 | { 10 | public static class ApplicationServiceExtensions 11 | { 12 | public static IServiceCollection AddApplicationServices(this IServiceCollection services){ 13 | // Repository Layer DI 14 | services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); 15 | services.AddScoped(); 16 | services.AddScoped(); 17 | services.AddScoped(); 18 | services.AddScoped(); 19 | services.AddScoped(); 20 | services.AddScoped(); 21 | services.AddScoped(); 22 | services.AddScoped(); 23 | 24 | // Application Service Layer DI 25 | services.AddScoped(); 26 | services.AddScoped(); 27 | services.AddScoped(); 28 | services.AddScoped(); 29 | 30 | 31 | services.AddScoped(); 32 | services.AddSignalR(); 33 | 34 | services.AddSingleton(); 35 | 36 | return services; 37 | } 38 | 39 | } 40 | } -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Middlewares/GlobalExceptionHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Diagnostics; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.Extensions.Logging; 4 | using System.Net; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace HBlog.Infrastructure.Middlewares 8 | { 9 | public class GlobalExceptionHandler : IExceptionHandler 10 | { 11 | public ILogger _logger { get; } 12 | public GlobalExceptionHandler(ILogger logger) 13 | { 14 | _logger = logger; 15 | } 16 | 17 | 18 | public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) 19 | { 20 | httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; 21 | httpContext.Response.ContentType = "application/json"; 22 | 23 | (int statusCode, string errorMsg) = exception switch 24 | { 25 | //=> (403, null), 26 | ArgumentException argumentException => (400, argumentException.Message), 27 | BadHttpRequestException badrequestException => (400, badrequestException.Message), 28 | _ => (500, "Internal server error.") 29 | }; 30 | 31 | var problemDetails = new ProblemDetails 32 | { 33 | Status = statusCode, 34 | Title = errorMsg, 35 | Type = exception.GetType().Name, 36 | Detail = exception.Message 37 | }; 38 | 39 | _logger.LogError(exception, exception.Message); 40 | await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken: cancellationToken); 41 | return true; 42 | 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/HBlog.Domain/Common/Result.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Domain.Common; 2 | public struct Result : IResult 3 | { 4 | public bool IsSuccess { get; set; } 5 | public string Message { get; set; } 6 | public List Errors { get; set; } 7 | 8 | public Result(bool isSuccess, string message, List? errors) 9 | { 10 | if (message == "") 11 | message = isSuccess ? "Success to return from service layer." : "Failed to return from service layer."; 12 | 13 | IsSuccess = isSuccess; 14 | Message = message; 15 | Errors = errors ?? new List(); 16 | } 17 | 18 | public static Result Success(string msg = "") => new(true, msg, default); 19 | public static Result Success(T? value = default, string msg = "") => new(true, msg, value, default); 20 | public static Result Fail(List errors = default, string msg = "") => new(false, msg, errors); 21 | public static Result Fail(List errors = default, string msg = "") => new(false, msg, default, errors); 22 | public static Result NotFound(string msg = "") => new(false, "NotFound", default); 23 | public static Result NotFound(string msg = "") => new(false, "NotFound", default); 24 | } 25 | public struct Result : IResult 26 | { 27 | public bool IsSuccess { get; set; } 28 | public string Message { get; set; } 29 | public List Errors { get; set; } 30 | public T Value { get; set; } 31 | public Result(bool isSuccess, string message, T value, List? errors) 32 | { 33 | if (message == "") 34 | message = isSuccess ? "Success for the operation" : "Failed this operation"; 35 | 36 | IsSuccess = isSuccess; 37 | Message = message; 38 | Value = value; 39 | Errors = errors ?? new List(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/HBlog.IntegrationTests/CustomWebAppFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using HBlog.Infrastructure.Data; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.Mvc.Testing; 9 | using Microsoft.EntityFrameworkCore; 10 | using Microsoft.Extensions.DependencyInjection; 11 | 12 | namespace HBlog.IntegrationTests 13 | { 14 | public class CustomWebAppFactory : WebApplicationFactory where TProgram : class 15 | { 16 | protected override void ConfigureWebHost(IWebHostBuilder builder) 17 | { 18 | builder.UseEnvironment("test"); 19 | builder.ConfigureServices(services => 20 | { 21 | var context = services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DataContext)); 22 | if (context != null) 23 | { 24 | services.Remove(context); 25 | var options = services.Where(r => r.ServiceType == typeof(DbContextOptions) 26 | || r.ServiceType.IsGenericType && r.ServiceType.GetGenericTypeDefinition() == typeof(DbContextOptions<>)).ToArray(); 27 | foreach (var option in options) 28 | { 29 | services.Remove(option); 30 | } 31 | } 32 | 33 | // Add a new registration for ApplicationDbContext with an in-memory database 34 | services.AddDbContext(options => 35 | { 36 | // Provide a unique name for your in-memory database 37 | options.UseInMemoryDatabase("HBlogInMemory"); 38 | }); 39 | }); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Endpoints/UserAppFactory.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Application.Services; 2 | using HBlog.Domain.Repositories; 3 | using Microsoft.AspNetCore.Authentication; 4 | using Microsoft.AspNetCore.Authentication.JwtBearer; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.Mvc.Testing; 7 | using Microsoft.AspNetCore.TestHost; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Logging; 10 | using Moq; 11 | namespace HBlog.UnitTests.Endpoints 12 | { 13 | public class UserAppFactory : WebApplicationFactory 14 | { 15 | public Mock _mockUserService { get; } 16 | public Mock _mockUserRepository { get; } 17 | public int DefaultUserId { get; set; } = 1; 18 | 19 | public UserAppFactory() 20 | { 21 | _mockUserService = new Mock(); 22 | _mockUserRepository = new Mock(); 23 | } 24 | protected override void ConfigureWebHost(IWebHostBuilder builder) 25 | { 26 | Environment.SetEnvironmentVariable("Environment", "test"); 27 | builder.UseContentRoot("."); 28 | builder.ConfigureTestServices(services => 29 | { 30 | services.Configure(options => options.DefaultUserId = DefaultUserId); 31 | services.AddAuthentication(TestAuthHandler.AuthenticationScheme) 32 | .AddScheme(TestAuthHandler.AuthenticationScheme, _ => { }); 33 | services.AddLogging(builder => builder.ClearProviders().AddConsole().AddDebug()); 34 | 35 | services.AddSingleton(_mockUserService.Object); 36 | services.AddSingleton(_mockUserRepository.Object); 37 | 38 | }); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Services/AuthService.cs: -------------------------------------------------------------------------------- 1 | using Blazored.LocalStorage; 2 | using HBlog.Contract.Common; 3 | using HBlog.Contract.DTOs; 4 | using HBlog.WebClient.Providers; 5 | using Microsoft.AspNetCore.Components.Authorization; 6 | using Microsoft.Extensions.Logging; 7 | using System.Net.Http.Headers; 8 | using System.Net.Http.Json; 9 | using HBlog.WebClient.Commons; 10 | 11 | namespace HBlog.WebClient.Services 12 | { 13 | public interface IAuthService 14 | { 15 | Task AuthenAsync(LoginDto loginDto); 16 | Task InjectToken(); 17 | Task Logout(); 18 | } 19 | public class AuthService( 20 | HttpClient httpClient, 21 | ILogger logger, 22 | ILocalStorageService localStorageService, 23 | AuthenticationStateProvider authenticationStateProvider) 24 | : BaseService(httpClient, logger), IAuthService 25 | { 26 | public async Task AuthenAsync(LoginDto loginDto) 27 | { 28 | var result = await _httpClient.PostAsJsonAsync($"Account/login", loginDto); 29 | var obj = await result.Content.ReadFromJsonAsync(); 30 | 31 | await localStorageService.SetItemAsync(Constants.AccessToken, obj!.Token); 32 | 33 | await ((ApiAuthStateProvider)authenticationStateProvider).LoggedIn(); 34 | 35 | return obj; 36 | } 37 | public async Task InjectToken() 38 | { 39 | var token = await localStorageService.GetItemAsync(Constants.AccessToken); 40 | if (token != null) 41 | { 42 | _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); 43 | } 44 | } 45 | public async Task Logout() 46 | { 47 | await ((ApiAuthStateProvider)authenticationStateProvider).LoggedOut(); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/HBlog.Contract/Common/ServiceResult.cs: -------------------------------------------------------------------------------- 1 | namespace HBlog.Contract.Common; 2 | public struct ServiceResult : IResult 3 | { 4 | public bool IsSuccess { get; set; } 5 | public string Message { get; set; } 6 | public List Errors { get; set; } 7 | 8 | public ServiceResult(bool isSuccess, string message, List? errors) 9 | { 10 | if (message == "") 11 | message = isSuccess ? "Success to return from service layer." : "Failed to return from service layer."; 12 | 13 | IsSuccess = isSuccess; 14 | Message = message; 15 | Errors = errors ?? new List(); 16 | } 17 | 18 | public static ServiceResult Success(string msg = "") => new(true, msg, default); 19 | public static ServiceResult Success(T? value = default, string msg = "") => new(true, msg, value, default); 20 | public static ServiceResult Fail(List errors = default, string msg = "") => new(false, msg, errors); 21 | public static ServiceResult Fail(List errors = default, string msg = "") => new(false, msg, default, errors); 22 | public static ServiceResult NotFound(string msg = "") => new(false, "NotFound", default); 23 | public static ServiceResult NotFound(string msg = "") => new(false, "NotFound", default); 24 | } 25 | public struct ServiceResult : IResult 26 | { 27 | public bool IsSuccess { get; set; } 28 | public string Message { get; set; } 29 | public List Errors { get; set; } 30 | public T Value { get; set; } 31 | public ServiceResult(bool isSuccess, string message, T value, List? errors) 32 | { 33 | if (message == "") 34 | message = isSuccess ? "Success for the operation" : "Failed this operation"; 35 | 36 | IsSuccess = isSuccess; 37 | Message = message; 38 | Value = value; 39 | Errors = errors ?? new List(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/AuthTestExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using System.Text.Encodings.Web; 3 | using Microsoft.AspNetCore.Authentication; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace HBlog.UnitTests 8 | { 9 | public class TestAuthHandlerOptions : AuthenticationSchemeOptions 10 | { 11 | public int DefaultUserId { get; set; } 12 | } 13 | public class TestAuthHandler : AuthenticationHandler 14 | { 15 | public const int UserId = 1; 16 | 17 | public const string AuthenticationScheme = "TestScheme"; 18 | private readonly int _defaultUserId; 19 | 20 | public TestAuthHandler( 21 | IOptionsMonitor options, 22 | ILoggerFactory logger, 23 | UrlEncoder encoder, 24 | ISystemClock clock) : base(options, logger, encoder, clock) 25 | { 26 | _defaultUserId = options.CurrentValue.DefaultUserId; 27 | } 28 | 29 | protected override Task HandleAuthenticateAsync() 30 | { 31 | var claims = new List { new (ClaimTypes.Name, "TestUser") }; 32 | 33 | if (Context.Request.Headers.TryGetValue(UserId.ToString(), out var userId)) 34 | { 35 | claims.Add(new Claim(ClaimTypes.NameIdentifier, userId[0])); 36 | } 37 | else 38 | { 39 | claims.Add(new Claim(ClaimTypes.NameIdentifier, _defaultUserId.ToString())); 40 | } 41 | var identity = new ClaimsIdentity(claims, AuthenticationScheme); 42 | var principal = new ClaimsPrincipal(identity); 43 | var ticket = new AuthenticationTicket(principal, AuthenticationScheme); 44 | 45 | var result = AuthenticateResult.Success(ticket); 46 | 47 | return Task.FromResult(result); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/HBlog.TestUtilities/AuthTestExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using System.Text.Encodings.Web; 3 | using Microsoft.AspNetCore.Authentication; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace HBlog.TestUtilities 8 | { 9 | public class TestAuthHandlerOptions : AuthenticationSchemeOptions 10 | { 11 | public int DefaultUserId { get; set; } 12 | } 13 | public class TestAuthHandler : AuthenticationHandler 14 | { 15 | public const int UserId = 1; 16 | 17 | public const string AuthenticationScheme = "TestScheme"; 18 | private readonly int _defaultUserId; 19 | 20 | public TestAuthHandler( 21 | IOptionsMonitor options, 22 | ILoggerFactory logger, 23 | UrlEncoder encoder, 24 | ISystemClock clock) : base(options, logger, encoder, clock) 25 | { 26 | _defaultUserId = options.CurrentValue.DefaultUserId; 27 | } 28 | 29 | protected override Task HandleAuthenticateAsync() 30 | { 31 | var claims = new List { new (ClaimTypes.Name, "TestUser") }; 32 | 33 | if (Context.Request.Headers.TryGetValue(UserId.ToString(), out var userId)) 34 | { 35 | claims.Add(new Claim(ClaimTypes.NameIdentifier, userId[0])); 36 | } 37 | else 38 | { 39 | claims.Add(new Claim(ClaimTypes.NameIdentifier, _defaultUserId.ToString())); 40 | } 41 | var identity = new ClaimsIdentity(claims, AuthenticationScheme); 42 | var principal = new ClaimsPrincipal(identity); 43 | var ticket = new AuthenticationTicket(principal, AuthenticationScheme); 44 | 45 | var result = AuthenticateResult.Success(ticket); 46 | 47 | return Task.FromResult(result); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/HBlog.WebClient.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/HBlog.Api/Controllers/UsersController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Authorization; 3 | using HBlog.Infrastructure.Extensions; 4 | using HBlog.Domain.Params; 5 | using HBlog.Domain.Common; 6 | using HBlog.Application.Services; 7 | using HBlog.Contract.DTOs; 8 | 9 | namespace HBlog.Api.Controllers 10 | { 11 | [Authorize] 12 | public class UsersController : BaseApiController 13 | { 14 | private readonly IUserService _userService; 15 | public UsersController(IUserService userService) 16 | { 17 | _userService = userService; 18 | } 19 | 20 | [HttpGet("users")] 21 | public async Task>> GetUsers([FromQuery]UserParams userParams) 22 | { 23 | userParams.CurrentUsername = User.GetUsername(); 24 | var users = await _userService.GetMembersAsync(userParams); 25 | Response.AddPaginationHeader(new PaginationHeader(users.CurrentPage, users.PageSize, users.TotalCount, users.TotalPages)); 26 | return Ok(users); 27 | } 28 | 29 | [HttpGet("users/{username}")] 30 | public async Task> GetUser(string username) 31 | { 32 | var user = await _userService.GetMembersByUsernameAsync(username); 33 | if (user.Value is null) 34 | return NotFound($"Input user: {username} cannot find."); 35 | 36 | return Ok(user); 37 | } 38 | 39 | [HttpPut("users")] 40 | public async Task Update(UserUpdateDto userUpdateDto) 41 | { 42 | if (userUpdateDto is null) 43 | return BadRequest("Member Update Properties are Empty."); 44 | 45 | var user = await _userService.GetMembersByUsernameAsync(User.GetUsername()); 46 | if (user.Value is null) return NotFound(); 47 | 48 | var result = await _userService.UpdateMemberAsync(userUpdateDto); 49 | 50 | return BadRequest("Failed to update user"); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Data/PostSeedData.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Title": "Dotnet Programming is fun!", 4 | "Desc": "Description for this Post", 5 | "Content": "## How to handle Concurrency \n * Task.", 6 | "Type": "Programming", 7 | "Status": "active", 8 | "UserId": "01959b38-b3f9-7ec5-8ac8-e353bfe08a2d", 9 | "CategoryId": 1, 10 | "LinkForPost": "", 11 | "Created": "2020-01-05" 12 | }, 13 | { 14 | "Title": "How to use Azure in dotnet", 15 | "Desc": "Basic implementation using Azure", 16 | "Content": "# Azure investigation", 17 | "Type": "Self-Reflection", 18 | "Status": "active", 19 | "UserId": "01959b38-b3f9-7ec5-8ac8-e353bfe08a2d", 20 | "CategoryId": 2, 21 | "LinkForPost": "", 22 | "Created": "2022-07-05" 23 | }, 24 | { 25 | "Title": "My life", 26 | "Desc": "TestTest", 27 | "Content": "# My life in Korea", 28 | "Type": "Self-Reflection", 29 | "Status": "active", 30 | "UserId": "01959b38-b3f9-7ec5-8ac8-e353bfe08a2d", 31 | "CategoryId": 3, 32 | "LinkForPost": "", 33 | "Created": "2021-01-20" 34 | }, 35 | { 36 | "Title": "My travel", 37 | "Desc": "TestTest", 38 | "Content": "# korea trip", 39 | "Type": "Self-Reflection", 40 | "Status": "active", 41 | "UserId": "01959b38-b3f9-7ec5-8ac8-e353bfe08a2d", 42 | "CategoryId": 3, 43 | "LinkForPost": "", 44 | "Created": "2019-05-06" 45 | }, 46 | { 47 | "Title": "Macy Life!!!", 48 | "Desc": "TestTest", 49 | "Content": "# korea trip", 50 | "Type": "Self-Reflection", 51 | "Status": "active", 52 | "UserId": "01959b39-febd-770d-9e1b-e5ee392fce54", 53 | "CategoryId": 3, 54 | "LinkForPost": "", 55 | "Created": "2020-01-05" 56 | }, 57 | { 58 | "Title": "Recent Book - Wonder", 59 | "Desc": "Book read", 60 | "Content": "# My impression about Wonder", 61 | "Type": "Self-Reflection", 62 | "Status": "active", 63 | "UserId": "01959b39-febd-770d-9e1b-e5ee392fce54", 64 | "CategoryId": 3, 65 | "LinkForPost": "", 66 | "Created": "2024-03-10" 67 | } 68 | ] -------------------------------------------------------------------------------- /src/HBlog.Api/Controllers/TagsController.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Application.Services; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Entities; 4 | using HBlog.Infrastructure.Extensions; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace HBlog.Api.Controllers 9 | { 10 | [Authorize] 11 | public class TagsController : BaseApiController 12 | { 13 | private readonly ITagService _tagService; 14 | private readonly IUserService _userService; 15 | public TagsController(ITagService tagService, IUserService userService) 16 | { 17 | _tagService = tagService; 18 | _userService = userService; 19 | } 20 | [HttpPost("tags")] 21 | public async Task Create(TagCreateDto tagCreateDto) 22 | { 23 | if (tagCreateDto is null) 24 | return BadRequest($"Json body is not valid format. {nameof(tagCreateDto)}"); 25 | 26 | if (string.IsNullOrEmpty(tagCreateDto.Name)) 27 | return BadRequest("Tag Name cannot be empty."); 28 | 29 | var user = await _userService.GetMembersByUsernameAsync(User.GetUsername()); 30 | if (user.Value is null) return NotFound(); 31 | 32 | var result = _tagService.CreateTag(tagCreateDto); 33 | return Ok(result); 34 | } 35 | 36 | [HttpDelete("tags/{id}")] 37 | public async Task Delete(int id) 38 | { 39 | var result = await _tagService.RemoveTag(id); 40 | return Ok(result); 41 | } 42 | 43 | [AllowAnonymous] 44 | [HttpGet("tags")] 45 | public async Task>> Get() => Ok(await _tagService.GetAllTags()); 46 | 47 | [AllowAnonymous] 48 | [HttpGet("posts/{postId}/tags")] 49 | public async Task>> GetTagByPostId(int postId) 50 | { 51 | var result = await _tagService.GetTagsByPostId(postId); 52 | return Ok(result.Value); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Repositories/PostRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using HBlog.Infrastructure.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace HBlog.Infrastructure.Repositories 7 | { 8 | public class PostRepository : Repository, IPostRepository 9 | { 10 | private readonly DataContext _dbContext; 11 | public PostRepository(DataContext dbContext) : base(dbContext) 12 | { 13 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 14 | } 15 | 16 | public async Task> GetPostsByUserName(string userName) 17 | { 18 | return await _dbContext.Posts.Where(x => x.User.UserName == userName).ToListAsync(); 19 | } 20 | public async Task> GetPostsAsync() 21 | { 22 | return await _dbContext.Posts.AsNoTracking().Include(u => u.User).ToListAsync(); 23 | } 24 | 25 | public async Task> GetPostsTitleContainsAsync(string searchTitle) 26 | { 27 | return await _dbContext.Posts.Where(p => p.Title.ToString().ToLower().Contains(searchTitle)).ToListAsync(); 28 | } 29 | 30 | public async Task> GetPostsAsync(int limit, int offset) 31 | { 32 | return await _dbContext.Posts.Include(p => p.Tags) 33 | .AsNoTracking() 34 | .OrderByDescending(p => p.Created) 35 | .Skip(offset).Take(limit).ToListAsync(); 36 | } 37 | public async Task UpdateAsync(Post user) 38 | { 39 | _dbContext.Entry(user).State = EntityState.Modified; 40 | await _dbContext.SaveChangesAsync(); 41 | } 42 | 43 | public async Task GetPostDetails(int id) 44 | { 45 | return await _dbContext.Posts 46 | .Where(p => p.Id == id) 47 | .Include(p => p.User) 48 | .Include(t => t.Tags) 49 | .FirstOrDefaultAsync(); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/HBlog.WebClient/Services/UserClientService.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.DTOs; 2 | using Microsoft.AspNetCore.Identity; 3 | using System.Net.Http.Json; 4 | using System.Text.Json; 5 | namespace HBlog.WebClient.Services 6 | { 7 | public class UserClientService(HttpClient httpClient, ILogger logger, IAuthService authService) 8 | : BaseService(httpClient, logger) 9 | { 10 | public async Task<(bool, IEnumerable?)> RegisterNewUser(RegisterDto registerDto) 11 | { 12 | var result = await _httpClient.PostAsJsonAsync($"Account/register", registerDto); 13 | if (result.IsSuccessStatusCode) 14 | return (true, null); 15 | 16 | var responseJson = await result.Content.ReadAsStringAsync(); 17 | var options = new JsonSerializerOptions 18 | { 19 | PropertyNameCaseInsensitive = true 20 | }; 21 | var responseData = JsonSerializer.Deserialize>(responseJson, options); 22 | return (false, responseData); 23 | } 24 | 25 | public async ValueTask GetUserDtoByUsername(string username) 26 | { 27 | await authService.InjectToken(); 28 | var result = await _httpClient.GetFromJsonAsync($"users/{username}"); 29 | if (result!.IsSuccessStatusCode) 30 | { 31 | return await result.Content.ReadFromJsonAsync(); 32 | } 33 | return new UserDto(); 34 | 35 | } 36 | 37 | public async Task?> GetUsers() 38 | { 39 | try 40 | { 41 | await authService.InjectToken(); 42 | var result = await _httpClient.GetFromJsonAsync>("users"); 43 | return result; 44 | 45 | } 46 | catch (Exception ex) 47 | { 48 | _logger.LogError(ex.Message); 49 | } 50 | return default; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/HBlog.IntegrationTests/HBlog.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | true 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | all 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | Always 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Repositories/UserRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using HBlog.Infrastructure.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | namespace HBlog.Infrastructure.Repositories 6 | { 7 | public class UserRepository : IUserRepository 8 | { 9 | private readonly DataContext _dbContext; 10 | public UserRepository(DataContext dbContext) 11 | { 12 | _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); 13 | } 14 | public async Task GetUserByIdAsync(Guid id) 15 | { 16 | return await _dbContext.Users.FindAsync(id); 17 | } 18 | 19 | public async Task GetUserByUsernameAsync(string username) 20 | { 21 | return await _dbContext.Users.Include(p => p.Photos).SingleOrDefaultAsync(x => x.UserName == username); 22 | } 23 | 24 | public async Task> GetUsersAsync() 25 | { 26 | return await _dbContext.Users.Include(p=> p.Photos).AsNoTracking().ToListAsync(); 27 | } 28 | public async Task SaveAllAsync() 29 | { 30 | return await _dbContext.SaveChangesAsync() > 0; 31 | } 32 | public void Update(User user) 33 | { 34 | _dbContext.Entry(user).State = EntityState.Modified; 35 | } 36 | 37 | public IQueryable GetUserLikesQuery(string predicate, Guid userId) 38 | { 39 | var users = _dbContext.Users.OrderBy(x => x.UserName).AsQueryable(); 40 | var likes = _dbContext.Likes.AsQueryable(); 41 | 42 | if (predicate == "liked") 43 | { 44 | likes = likes.Where(l => l.SourceUserId == userId); 45 | users = likes.Select(l => l.TargetUser); 46 | } 47 | if (predicate == "likedBy") 48 | { 49 | likes = likes.Where(l => l.TargetUserId == userId); 50 | users = likes.Select(l => l.SourceUser); 51 | } 52 | return users; 53 | } 54 | 55 | 56 | } 57 | } -------------------------------------------------------------------------------- /.github/workflows/azure-static-web-apps-happy-wave-07649a810.yml: -------------------------------------------------------------------------------- 1 | # name: Azure Static Web Apps CI/CD 2 | 3 | # on: 4 | # push: 5 | # branches: 6 | # - main 7 | # pull_request: 8 | # types: [opened, synchronize, reopened, closed] 9 | # branches: 10 | # - main 11 | 12 | # jobs: 13 | # build_and_deploy_job: 14 | # if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') 15 | # runs-on: ubuntu-latest 16 | # name: Build and Deploy Job 17 | # steps: 18 | # - uses: actions/checkout@v3 19 | # with: 20 | # submodules: true 21 | # lfs: false 22 | # - name: Build And Deploy 23 | # id: builddeploy 24 | # uses: Azure/static-web-apps-deploy@v1 25 | # with: 26 | # azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_HAPPY_WAVE_07649A810 }} 27 | # repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) 28 | # action: "upload" 29 | # ###### Repository/Build Configurations - These values can be configured to match your app requirements. ###### 30 | # # For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig 31 | # app_location: "./src/HBlog.WebClient" # App source code path 32 | # api_location: "" # Api source code path - optional 33 | # output_location: "wwwroot" # Built app content directory - optional 34 | # ###### End of Repository/Build Configurations ###### 35 | 36 | # close_pull_request_job: 37 | # if: github.event_name == 'pull_request' && github.event.action == 'closed' 38 | # runs-on: ubuntu-latest 39 | # name: Close Pull Request Job 40 | # steps: 41 | # - name: Close Pull Request 42 | # id: closepullrequest 43 | # uses: Azure/static-web-apps-deploy@v1 44 | # with: 45 | # azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_HAPPY_WAVE_07649A810 }} 46 | # action: "close" 47 | -------------------------------------------------------------------------------- /test/HBlog.TestUtilities/TestBase.cs: -------------------------------------------------------------------------------- 1 | using System.Resources; 2 | using AutoMapper; 3 | using HBlog.Application.Automapper; 4 | using HBlog.Domain.Entities; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using System.Security.Claims; 8 | using Microsoft.AspNetCore.Http; 9 | 10 | namespace HBlog.TestUtilities 11 | { 12 | public abstract class TestBase 13 | { 14 | protected readonly IMapper _mapper; 15 | private IConfiguration _configuration; 16 | private static readonly ServiceProvider _serviceProvider; 17 | private static readonly ResourceManager _resourceManager; 18 | 19 | public TestBase() 20 | { 21 | if (_mapper == null) 22 | { 23 | var mappingConfig = new MapperConfiguration(mc => 24 | { 25 | mc.AddProfile(new AutoMapperProfiles()); 26 | }, null); 27 | IMapper mapper = mappingConfig.CreateMapper(); 28 | _mapper = mapper; 29 | } 30 | 31 | var inMemorySettings = new Dictionary { 32 | //{"TopLevelKey", "TopLevelValue"}, 33 | //{"SectionName:SomeKey", "SectionValue"}, 34 | // Need to set up the 35 | }; 36 | 37 | _configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemorySettings).Build(); 38 | 39 | } 40 | 41 | protected static T GetService() 42 | { 43 | return _serviceProvider.GetRequiredService(); 44 | } 45 | 46 | public DefaultHttpContext UserSetup() 47 | { 48 | var context = new DefaultHttpContext(); 49 | var claims = new List 50 | { 51 | new Claim(ClaimTypes.NameIdentifier, "kevin0"), 52 | new Claim(ClaimTypes.Name, "kevin0"), 53 | 54 | }; 55 | var identity = new ClaimsIdentity(claims, "TestAuthType"); 56 | var claimsPrincipal = new ClaimsPrincipal(identity); 57 | context.User = claimsPrincipal; 58 | return context; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/UI/ConfigureTagDialog.razor: -------------------------------------------------------------------------------- 1 | @using HBlog.WebClient.Services 2 | @inject ITagService tagService; 3 | 4 |
5 |
6 |

Tag filter

7 |
8 |
9 |
10 | 11 | 19 | 20 |
21 |
22 | @if (SearchTags is not null) 23 | { 24 | @foreach (var tag in SearchTags) 25 | { 26 | 27 | } 28 | } 29 |
30 |
31 | 32 |
33 | 34 | 35 |
36 |
37 | 38 | @code { 39 | List? tags; 40 | [Parameter, EditorRequired] public List SearchTags { get; set; } 41 | [Parameter, EditorRequired] public EventCallback OnCancel { get; set; } 42 | [Parameter, EditorRequired] public EventCallback OnConfirm { get; set; } 43 | 44 | 45 | protected override async Task OnInitializedAsync() 46 | { 47 | tags = (await tagService.GetTags()).ToList(); 48 | } 49 | 50 | private void SearchTagSelected(ChangeEventArgs e) 51 | { 52 | if (tags is null) return; 53 | if (int.TryParse((string?)e.Value, out var index) && index >= 0) 54 | AddTag(tags[index]); 55 | } 56 | private void AddTag(TagDto tag) 57 | { 58 | if (SearchTags?.Find(t => t.TagId == tag.TagId) is null) 59 | SearchTags?.Add(tag); 60 | } 61 | private void RemoveTag(TagDto tag) 62 | { 63 | SearchTags.Remove(tag); 64 | } 65 | 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Migrations/20250907160853_RemoveUnusedFields.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace HBlog.Infrastructure.Migrations 6 | { 7 | /// 8 | public partial class RemoveUnusedFields : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.DropTable( 14 | name: "Connections"); 15 | 16 | migrationBuilder.DropTable( 17 | name: "Groups"); 18 | } 19 | 20 | /// 21 | protected override void Down(MigrationBuilder migrationBuilder) 22 | { 23 | migrationBuilder.CreateTable( 24 | name: "Groups", 25 | columns: table => new 26 | { 27 | Name = table.Column(type: "text", nullable: false) 28 | }, 29 | constraints: table => 30 | { 31 | table.PrimaryKey("PK_Groups", x => x.Name); 32 | }); 33 | 34 | migrationBuilder.CreateTable( 35 | name: "Connections", 36 | columns: table => new 37 | { 38 | ConnectionId = table.Column(type: "text", nullable: false), 39 | GroupName = table.Column(type: "text", nullable: true), 40 | Username = table.Column(type: "text", nullable: true) 41 | }, 42 | constraints: table => 43 | { 44 | table.PrimaryKey("PK_Connections", x => x.ConnectionId); 45 | table.ForeignKey( 46 | name: "FK_Connections_Groups_GroupName", 47 | column: x => x.GroupName, 48 | principalTable: "Groups", 49 | principalColumn: "Name"); 50 | }); 51 | 52 | migrationBuilder.CreateIndex( 53 | name: "IX_Connections_GroupName", 54 | table: "Connections", 55 | column: "GroupName"); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/HBlog.Application/Commands/Posts/UpdatePostCommand.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Contract.Common; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Entities; 4 | using HBlog.Domain.Repositories; 5 | using HBlog.Domain.ValueObjects; 6 | using MediatR; 7 | 8 | namespace HBlog.Application.Commands.Posts; 9 | 10 | public record UpdatePostCommand(PostUpdateDto UpdateDto) : IRequest; 11 | 12 | public class UpdatePostCommandHandler : IRequestHandler 13 | { 14 | private readonly IPostRepository _postRepository; 15 | private readonly ITagRepository _tagRepository; 16 | 17 | public UpdatePostCommandHandler(IPostRepository postRepository, ITagRepository tagRepository) 18 | { 19 | _postRepository = postRepository; 20 | _tagRepository = tagRepository; 21 | } 22 | 23 | public async Task Handle(UpdatePostCommand request, CancellationToken cancellationToken) 24 | { 25 | Post post = await _postRepository.GetById(request.UpdateDto.Id); 26 | if (post == null || post.Status.IsRemoved) 27 | return ServiceResult.Fail(msg: "Post does not exist."); 28 | 29 | // Use the domain method to update the post 30 | post.Update( 31 | title: request.UpdateDto.Title, 32 | description: request.UpdateDto.Desc ?? string.Empty, 33 | content: request.UpdateDto.Content ?? string.Empty, 34 | categoryId: request.UpdateDto.CategoryId 35 | ); 36 | 37 | // Update type if changed 38 | if (!string.IsNullOrEmpty(request.UpdateDto.Type)) 39 | { 40 | post.ChangeType(PostType.FromString(request.UpdateDto.Type)); 41 | } 42 | 43 | // Add tags if provided 44 | if (request.UpdateDto.TagIds?.Length > 0) 45 | { 46 | foreach (var tagId in request.UpdateDto.TagIds) 47 | { 48 | var tag = await _tagRepository.GetById(tagId); 49 | if (tag is not null) 50 | { 51 | post.AddTag(tag); 52 | } 53 | } 54 | } 55 | 56 | await _postRepository.UpdateAsync(post); 57 | return ServiceResult.Success(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/HBlog.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | disable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | all 24 | runtime; build; native; contentfiles; analyzers; buildtransitive 25 | 26 | 27 | 28 | 29 | runtime; build; native; contentfiles; analyzers; buildtransitive 30 | all 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/HBlog.Api/Controllers/AdminController.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Infrastructure.Extensions; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Identity; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace HBlog.Api.Controllers 9 | { 10 | public class AdminController : BaseApiController 11 | { 12 | private readonly UserManager _userManager; 13 | 14 | public AdminController(UserManager userManager) 15 | { 16 | this._userManager = userManager; 17 | } 18 | 19 | [Authorize(Policy = HBlogPolicy.RequireAdminRole)] 20 | [HttpGet("admin/users-with-roles")] 21 | public async Task GetUsersWithRoles() 22 | { 23 | var users = await _userManager.Users 24 | .OrderBy(u => u.UserName) 25 | .Select(u => new 26 | { 27 | u.Id, 28 | Username = u.UserName, 29 | Roles = u.UserRoles.Select(r => r.Role.Name).ToList() 30 | }) 31 | .ToListAsync(); 32 | 33 | return Ok(users); 34 | } 35 | [Authorize(Policy = HBlogPolicy.RequireAdminRole)] 36 | [HttpPost("admin/edit-roles/{username}")] 37 | public async Task EditRoles(string username, [FromQuery]string roles) 38 | { 39 | if (string.IsNullOrEmpty(roles)) return BadRequest("You must select at least one role"); 40 | 41 | var selectedRoles = roles.Split(',').ToArray(); 42 | var user = await _userManager.FindByNameAsync(username); 43 | if (user == null) return NotFound(); 44 | 45 | var userRoles = await _userManager.GetRolesAsync(user); 46 | var result = await _userManager.AddToRolesAsync(user, selectedRoles.Except(userRoles)); 47 | if (!result.Succeeded) return BadRequest("Failed to add to roles"); 48 | 49 | return Ok(await _userManager.GetRolesAsync(user)); 50 | } 51 | 52 | [Authorize(Policy = HBlogPolicy.AdminModeratorRole)] 53 | [HttpGet("admin/photos-to-moderate")] 54 | public ActionResult GetPhotosForModeration() 55 | { 56 | return Ok("Admins or moderators can see this."); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/HBlog.Api/Controllers/MessagesController.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Application.Services; 2 | using HBlog.Contract.DTOs; 3 | using HBlog.Domain.Common; 4 | using HBlog.Domain.Params; 5 | using HBlog.Infrastructure.Extensions; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace HBlog.Api.Controllers 9 | { 10 | public class MessagesController : BaseApiController 11 | { 12 | private readonly IMessageService _messageService; 13 | public MessagesController(IMessageService messageService) 14 | { 15 | _messageService= messageService; 16 | } 17 | 18 | [HttpGet("Messages/thread/{username}")] 19 | public async Task>> GetMessageThread(string username) => Ok(await _messageService.GetMessageThreads(User.GetUsername(), username)); 20 | 21 | [HttpGet("messages")] 22 | public async Task>> GetMessagesForUser([FromQuery] MessageParams messageParams) 23 | { 24 | messageParams.Username = User.GetUsername(); 25 | var messages = await _messageService.GetMessagesForUserPageList(messageParams); 26 | Response.AddPaginationHeader(new PaginationHeader(messages.CurrentPage, messages.PageSize, messages.TotalCount, messages.TotalPages)); 27 | 28 | return messages; 29 | } 30 | 31 | [HttpPost("messages")] 32 | public async Task> CreateMessage(MessageCreateDto createMsgDto) 33 | { 34 | if (createMsgDto is null) 35 | throw new ArgumentNullException(nameof(createMsgDto)); 36 | 37 | var msgResult = await _messageService.CreateMessage(User.GetUsername(), createMsgDto); 38 | if (!msgResult.IsSuccess) 39 | return BadRequest(msgResult.Message); 40 | 41 | return Ok(msgResult); 42 | } 43 | 44 | [HttpDelete("messages/{id}")] 45 | public async Task DeleteMessage(int id) { 46 | var result = await _messageService.DeleteMessage(User.GetUsername(), id); 47 | if(!result.IsSuccess) { 48 | if(result.Message == "Unauthorized") 49 | return Unauthorized(); 50 | 51 | return BadRequest(result.Message); 52 | } 53 | return Ok(); 54 | } 55 | 56 | } 57 | } -------------------------------------------------------------------------------- /src/HBlog.Domain/ValueObjects/Slug.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using HBlog.Domain.Common; 3 | 4 | namespace HBlog.Domain.ValueObjects; 5 | 6 | public sealed partial class Slug : IEquatable 7 | { 8 | public string Value { get; } 9 | private const int MaxLength = 200; 10 | 11 | private Slug(string value) 12 | { 13 | Value = value; 14 | } 15 | 16 | public static Slug FromString(string input) 17 | { 18 | if (string.IsNullOrWhiteSpace(input)) 19 | throw new DomainException("Cannot create slug from empty string"); 20 | 21 | var slug = GenerateSlug(input); 22 | 23 | if (slug.Length > MaxLength) 24 | slug = slug[..MaxLength]; 25 | 26 | return new Slug(slug); 27 | } 28 | 29 | public static Slug FromValue(string slugValue) 30 | { 31 | if (string.IsNullOrWhiteSpace(slugValue)) 32 | throw new DomainException("Slug cannot be empty"); 33 | 34 | if (!IsValidSlug(slugValue)) 35 | throw new DomainException("Invalid slug format. Use lowercase letters, numbers, and hyphens only"); 36 | 37 | return new Slug(slugValue); 38 | } 39 | 40 | private static string GenerateSlug(string input) 41 | { 42 | var slug = input.ToLowerInvariant(); 43 | slug = SlugRegex().Replace(slug, ""); 44 | slug = WhitespaceRegex().Replace(slug, "-"); 45 | slug = slug.Trim('-'); 46 | return slug; 47 | } 48 | 49 | private static bool IsValidSlug(string value) 50 | { 51 | return ValidSlugRegex().IsMatch(value); 52 | } 53 | 54 | [GeneratedRegex(@"[^a-z0-9\s-]")] 55 | private static partial Regex SlugRegex(); 56 | 57 | [GeneratedRegex(@"[\s-]+")] 58 | private static partial Regex WhitespaceRegex(); 59 | 60 | [GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")] 61 | private static partial Regex ValidSlugRegex(); 62 | 63 | public bool Equals(Slug? other) 64 | { 65 | if (other is null) return false; 66 | return string.Equals(Value, other.Value, StringComparison.Ordinal); 67 | } 68 | 69 | public override bool Equals(object? obj) => obj is Slug other && Equals(other); 70 | public override int GetHashCode() => Value.GetHashCode(); 71 | public static implicit operator string(Slug slug) => slug.Value; 72 | public override string ToString() => Value; 73 | } -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Endpoints/PostEndpointTests.cs: -------------------------------------------------------------------------------- 1 | using Moq; 2 | using System.Net; 3 | using System.Text.Json; 4 | using HBlog.Api.Controllers; 5 | using HBlog.Contract.DTOs; 6 | using HBlog.Domain.Common.Params; 7 | using NUnit.Framework; 8 | using static NUnit.Framework.Legacy.CollectionAssert; 9 | using Assert = NUnit.Framework.Assert; 10 | namespace HBlog.UnitTests.Endpoints 11 | { 12 | public class PostEndpointTests : IDisposable 13 | { 14 | private PostAppFactory _factory; 15 | private HttpClient _client; 16 | 17 | public PostEndpointTests() 18 | { 19 | _factory = new PostAppFactory(); 20 | _client = _factory.CreateClient(); 21 | } 22 | 23 | //[Test] 24 | //public async Task GivenValidPosts_WhenGetPostsCalled_ThenResponsePosts() 25 | //{ 26 | // IEnumerable posts = new List 27 | // { 28 | // new() { Id = 1, Title = "PostDisplay#1", Desc = "TestingDesc1", Content = "TestingContent1", UserName="hyunbin7303" }, 29 | // new() { Id = 2, Title = "PostDisplay#2", Desc = "TestingDesc2", Content = "TestingContent2", UserName="hyunbin7303" }, 30 | // }; 31 | // _factory._mockPostService.Setup(x => x.GetPosts(It.IsAny())).ReturnsAsync(posts); 32 | // var response = await _client.GetAsync("/api/posts"); 33 | // Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); 34 | 35 | // var data = JsonSerializer.Deserialize>>(await response.Content.ReadAsStringAsync(), new JsonSerializerOptions 36 | // { 37 | // WriteIndented = true, 38 | // PropertyNameCaseInsensitive = true 39 | // }); 40 | // IEnumerable resultPosts = data.Data; 41 | 42 | // if (resultPosts != null) AllItemsAreNotNull(resultPosts); 43 | //} 44 | 45 | 46 | [Test] 47 | public async Task GivenNotExistPostId_GetPostById_ReturnNotFound() 48 | { 49 | var response = await _client.GetAsync("/api/posts/1"); 50 | 51 | Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); 52 | } 53 | 54 | 55 | public void Dispose() 56 | { 57 | _client.Dispose(); 58 | _factory.Dispose(); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HBlog 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | 38 | 39 | 40 | 41 |
42 |
43 | 44 |
45 | An unhandled error has occurred. 46 | Reload 47 | 🗙 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/HBlog.Infrastructure/Repositories/MessageRepository.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using HBlog.Infrastructure.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace HBlog.Infrastructure.Repositories 7 | { 8 | public class MessageRepository : IMessageRepository 9 | { 10 | private readonly DataContext _dbContext; 11 | public MessageRepository(DataContext dbContext) 12 | { 13 | this._dbContext = dbContext; 14 | } 15 | public void AddMessage(Message msg) 16 | { 17 | _dbContext.Messages.Add(msg); 18 | } 19 | public void DeleteMessage(Message msg) 20 | { 21 | _dbContext.Messages.Remove(msg); 22 | } 23 | public async Task> GetMessages() 24 | { 25 | return await _dbContext.Messages.OrderByDescending(x=> x.MessageSent).ToListAsync(); 26 | } 27 | public async Task GetMessage(int id) 28 | { 29 | return await _dbContext.Messages.FindAsync(id); 30 | } 31 | 32 | public async Task> GetMessageThread(string currentUsernename, string recipientUsername) 33 | { 34 | var messages = await _dbContext.Messages 35 | .Include(x=> x.Sender).ThenInclude(p => p.Photos) 36 | .Include(x=> x.Recipient).ThenInclude(p => p.Photos) 37 | .Where(m => m.RecipientUsername ==currentUsernename && !m.RecipientDeleted 38 | && m.SenderUsername == recipientUsername 39 | || m.RecipientUsername == recipientUsername && !m.SenderDeleted 40 | && m.SenderUsername == currentUsernename) 41 | .OrderBy(m => m.MessageSent) 42 | .ToListAsync(); 43 | 44 | var unreadMsgs = messages.Where(m => m.DateRead == null && m.RecipientUsername == currentUsernename).ToList(); 45 | 46 | if(unreadMsgs.Any()){ 47 | foreach(var msg in unreadMsgs){ 48 | msg.DateRead = DateTime.UtcNow; 49 | } 50 | await _dbContext.SaveChangesAsync(); 51 | } 52 | return messages; 53 | } 54 | 55 | public async Task SaveAllAsync() 56 | { 57 | return await _dbContext.SaveChangesAsync() > 0; 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Services/UserServiceTest.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Application.Services; 2 | using HBlog.Domain.Entities; 3 | using HBlog.Domain.Params; 4 | using HBlog.Domain.Repositories; 5 | using HBlog.TestUtilities; 6 | using HBlog.UnitTests.Mocks.Repositories; 7 | using Moq; 8 | using NUnit.Framework; 9 | using Assert = NUnit.Framework.Assert; 10 | 11 | namespace HBlog.UnitTests.Services 12 | { 13 | public class UserServiceTest : TestBase 14 | { 15 | private IUserService _userService; 16 | private readonly MockUserRepository _userRepositoryMock = new(); // Just use this? 17 | public UserServiceTest() 18 | { 19 | _userService = new UserService(_mapper, _userRepositoryMock.Object); 20 | } 21 | 22 | [Test] 23 | public async Task GetMembersAsync_ExistingUser_ReturnPageList() 24 | { 25 | string username = "kevin0"; 26 | UserParams userParams = new UserParams { Gender = "male", PageNumber=0, PageSize = 5 }; 27 | userParams.CurrentUsername = username; 28 | 29 | var result = await _userService.GetMembersAsync(userParams); 30 | 31 | Assert.That(result, Is.Not.Null); 32 | _userRepositoryMock.Verify(x => x.GetUserByUsernameAsync(username), Times.Once); 33 | } 34 | 35 | [Test] 36 | public async Task GetMembersByUsernameAsync_ExistingUser_ReturnMemberDto() 37 | { 38 | string username = "kevin0"; 39 | var userId = Guid.CreateVersion7(); 40 | _userRepositoryMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(new User { Id = userId, UserName = username }); 41 | 42 | var result = await _userService.GetMembersByUsernameAsync(username); 43 | 44 | Assert.That(result.IsSuccess, Is.True); 45 | Assert.That(result.Value.UserName, Is.EqualTo(username)); 46 | Assert.That(result.Value.Id, Is.EqualTo(userId)); 47 | } 48 | 49 | [Test] 50 | public async Task GetMembersByUsernameAsync_NotExistingUser_ResultFailure() 51 | { 52 | string username = "NonExisting"; 53 | _userRepositoryMock.Setup(x => x.GetUserByUsernameAsync("kevin0")).ReturnsAsync(new User { Id = Guid.CreateVersion7(), UserName = "kevin0" }); 54 | 55 | var result = await _userService.GetMembersByUsernameAsync(username); 56 | 57 | Assert.That(result.IsSuccess, Is.False); 58 | Assert.That(result.Message, Is.EqualTo("Failed to get user")); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Providers/ApiAuthStateProvider.cs: -------------------------------------------------------------------------------- 1 | using Blazored.LocalStorage; 2 | using Microsoft.AspNetCore.Components.Authorization; 3 | using System.IdentityModel.Tokens.Jwt; 4 | using System.Security.Claims; 5 | using HBlog.WebClient.Commons; 6 | 7 | namespace HBlog.WebClient.Providers 8 | { 9 | public class ApiAuthStateProvider : AuthenticationStateProvider 10 | { 11 | private readonly ILocalStorageService _localStorageService; 12 | private readonly JwtSecurityTokenHandler _jwtSecurityTokenHandler; 13 | public ApiAuthStateProvider(ILocalStorageService localStorageService) 14 | { 15 | _localStorageService = localStorageService; 16 | _jwtSecurityTokenHandler = new JwtSecurityTokenHandler(); 17 | } 18 | 19 | public override async Task GetAuthenticationStateAsync() 20 | { 21 | var user = new ClaimsPrincipal(new ClaimsIdentity()); 22 | var saveToken = await _localStorageService.GetItemAsync(Constants.AccessToken); 23 | if (saveToken == null) 24 | { 25 | return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); 26 | } 27 | 28 | var tokenContent = _jwtSecurityTokenHandler.ReadJwtToken(saveToken); 29 | if(tokenContent.ValidTo < DateTime.Now) 30 | { 31 | return new AuthenticationState(user); 32 | } 33 | 34 | var claims = tokenContent.Claims; 35 | user = new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt")); 36 | return new AuthenticationState(user); 37 | } 38 | 39 | public async Task LoggedIn() 40 | { 41 | var savedToken = await _localStorageService.GetItemAsync(Constants.AccessToken); 42 | var tokenContent = _jwtSecurityTokenHandler.ReadJwtToken(savedToken); 43 | var claims = tokenContent.Claims.ToList(); 44 | //claims.Add(new Claim(ClaimTypes.Name, tokenContent.Subject)); 45 | var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt")); 46 | var authState = Task.FromResult(new AuthenticationState(user)); 47 | NotifyAuthenticationStateChanged(authState); 48 | } 49 | 50 | public async Task LoggedOut() 51 | { 52 | await _localStorageService.RemoveItemAsync(Constants.AccessToken); 53 | var nobody = new ClaimsPrincipal(new ClaimsIdentity()); 54 | var authState = Task.FromResult(new AuthenticationState(nobody)); 55 | NotifyAuthenticationStateChanged(authState); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/HBlog.Application/Services/LikeService.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.Common; 3 | using HBlog.Contract.DTOs; 4 | using HBlog.Domain.Common; 5 | using HBlog.Domain.Common.Extensions; 6 | using HBlog.Domain.Entities; 7 | using HBlog.Domain.Params; 8 | using HBlog.Domain.Repositories; 9 | 10 | namespace HBlog.Application.Services 11 | { 12 | public class LikeService : BaseService, ILikeService 13 | { 14 | private readonly ILikesRepository _likesRepository; 15 | private readonly IUserRepository _userRepository; 16 | public LikeService(IMapper mapper, ILikesRepository likesRepository, IUserRepository userRepository) : base(mapper) 17 | { 18 | _likesRepository = likesRepository; 19 | _userRepository = userRepository; 20 | } 21 | 22 | public async Task AddLike(Guid sourceUserId, string username) 23 | { 24 | var likedUser = await _userRepository.GetUserByUsernameAsync(username); 25 | var sourceUser = await _likesRepository.GetUserWithLikes(sourceUserId); 26 | if (likedUser is null) return ServiceResult.NotFound(msg: "Cannot find liked User."); 27 | if (sourceUser.UserName == username) return ServiceResult.Fail(msg: "You cannot like yourself."); 28 | 29 | var userLike = await _likesRepository.GetUserLike(sourceUserId, likedUser.Id); 30 | if (userLike != null) return ServiceResult.Fail(new List { "BadRequest" } ,"You already like this user."); 31 | 32 | userLike = new UserLike 33 | { 34 | SourceUserId = sourceUserId, 35 | TargetUserId = likedUser.Id 36 | }; 37 | sourceUser.LikedUsers.Add(userLike); 38 | if (await _userRepository.SaveAllAsync()) 39 | return ServiceResult.Success(); 40 | 41 | return ServiceResult.Fail(new List { "BadRequest" }, msg: "Bad Request."); 42 | } 43 | 44 | public async Task> GetUserLikePageList(LikesParams likesParam) 45 | { 46 | var userQuery = _userRepository.GetUserLikesQuery(likesParam.Predicate, likesParam.UserId); 47 | var likeDto = userQuery.Select(u => new LikeDto 48 | { 49 | UserName = u.UserName, 50 | KnownAs = u.KnownAs, 51 | Age = u.DateOfBirth.CalculateAge(), 52 | PhotoUrl = u.Photos.FirstOrDefault(x => x.IsMain).Url, 53 | Id = u.Id 54 | }); 55 | return await PageList.CreateAsync(likeDto, likesParam.PageNumber, likesParam.PageSize); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/HBlog.Application/Services/UserService.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using HBlog.Contract.Common; 3 | using HBlog.Contract.DTOs; 4 | using HBlog.Domain.Common; 5 | using HBlog.Domain.Entities; 6 | using HBlog.Domain.Params; 7 | using HBlog.Domain.Repositories; 8 | 9 | namespace HBlog.Application.Services 10 | { 11 | public class UserService : BaseService ,IUserService 12 | { 13 | private readonly IUserRepository _userRepository; 14 | public UserService(IMapper mapper, IUserRepository userRepository) : base(mapper) 15 | { 16 | this._userRepository = userRepository; 17 | } 18 | 19 | public async Task> GetMembersAsync(UserParams userParams) 20 | { 21 | User user = await _userRepository.GetUserByUsernameAsync(userParams.CurrentUsername); 22 | if (string.IsNullOrEmpty(userParams.Gender)) 23 | userParams.Gender = user.Gender == "male" ? "female" : "male"; 24 | 25 | var usersList = await _userRepository.GetUsersAsync(); 26 | usersList = usersList.Where(x => x.UserName != userParams.CurrentUsername); 27 | usersList = usersList.Where(x => x.Gender == userParams.Gender); 28 | 29 | var minDob = DateTime.Today.AddYears(-userParams.MaxAge - 1); 30 | var maxDob = DateTime.Today.AddYears(-userParams.MinAge); 31 | usersList = usersList.Where(x => x.DateOfBirth >= minDob && x.DateOfBirth <= maxDob); 32 | 33 | usersList = userParams.OrderBy switch 34 | { 35 | "created" => usersList.OrderByDescending(x => x.Created), 36 | _ => usersList.OrderByDescending(x => x.LastActive) 37 | }; 38 | 39 | var members = _mapper.Map>(usersList); 40 | return PageList.CreateAsync(members, userParams.PageNumber, userParams.PageSize); 41 | } 42 | 43 | public async Task> GetMembersByUsernameAsync(string username) 44 | { 45 | User user = await _userRepository.GetUserByUsernameAsync(username); 46 | if (user is null) 47 | return ServiceResult.Fail(msg: "Failed to get user"); 48 | 49 | UserDto userDto = _mapper.Map(user); 50 | return ServiceResult.Success(userDto); 51 | } 52 | public async Task UpdateMemberAsync(UserUpdateDto user) 53 | { 54 | UserUpdateDto userUpdateDto = new UserUpdateDto(); 55 | _mapper.Map(userUpdateDto, user); 56 | if (await _userRepository.SaveAllAsync()) return true; 57 | 58 | return false; 59 | 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/HBlog.WebClient/Components/Util/JSRuntimeProvider.razor: -------------------------------------------------------------------------------- 1 | @implements IAsyncDisposable 2 | @inject IJSRuntime JS 3 | 4 | @code { 5 | [Parameter] public string Fullname { get; set; } = string.Empty; 6 | [Parameter] public EventCallback OnJSLoaded { get; set; } 7 | 8 | private IJSObjectReference? module; 9 | 10 | protected async override Task OnAfterRenderAsync(bool firstRender) 11 | { 12 | if (firstRender) 13 | { 14 | try 15 | { 16 | // Dynamically load the JS module based on Path and Filename 17 | module = await JS.InvokeAsync("import", $"../{Fullname}.razor.js"); 18 | 19 | // Notify the parent component that the JS module is loaded 20 | if (module != null) 21 | { 22 | await OnJSLoaded.InvokeAsync(); 23 | } 24 | } 25 | catch (Exception ex) 26 | { 27 | Console.Error.WriteLine($"Error loading JS module: {ex.Message}"); 28 | } 29 | } 30 | } 31 | 32 | public async ValueTask InvokeJsFunction(string functionName, params object[] args) where T : class 33 | { 34 | if (module != null) 35 | { 36 | try 37 | { 38 | // Dynamically invoke the specified function in the JS module 39 | return await module.InvokeAsync(functionName, args); 40 | } 41 | catch (Exception ex) 42 | { 43 | Console.Error.WriteLine($"Error invoking JS function '{functionName}': {ex.Message}"); 44 | } 45 | } 46 | else 47 | { 48 | Console.Error.WriteLine("JS module is not declarded yet."); 49 | } 50 | return await Task.FromResult(default); 51 | } 52 | 53 | public async Task InvokeJsFunction(string functionName, params object[] args) 54 | { 55 | if (module != null) 56 | { 57 | try 58 | { 59 | // Dynamically invoke the specified function in the JS module 60 | await module.InvokeVoidAsync(functionName, args); 61 | } 62 | catch (Exception ex) 63 | { 64 | Console.Error.WriteLine($"Error invoking JS function '{functionName}': {ex.Message}"); 65 | } 66 | } 67 | else 68 | { 69 | Console.Error.WriteLine("JS module is not declarded yet."); 70 | } 71 | } 72 | 73 | async ValueTask IAsyncDisposable.DisposeAsync() 74 | { 75 | if (module is not null) 76 | { 77 | await module.DisposeAsync(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/HBlog.UnitTests/Repositories/UserRepositoryTest.cs: -------------------------------------------------------------------------------- 1 | using HBlog.Domain.Entities; 2 | using HBlog.Domain.Repositories; 3 | using HBlog.Infrastructure.Data; 4 | using HBlog.Infrastructure.Repositories; 5 | using HBlog.UnitTests.Mocks.Repositories; 6 | using Microsoft.EntityFrameworkCore; 7 | using NUnit.Framework; 8 | using Assert = NUnit.Framework.Assert; 9 | 10 | namespace HBlog.UnitTests.Repositories 11 | { 12 | public class UserRepositoryTest : IDisposable 13 | { 14 | private readonly DataContext _context; 15 | private readonly IUserRepository _userRepository; 16 | public UserRepositoryTest() 17 | { 18 | var dbContextOptions = new DbContextOptionsBuilder() 19 | .UseInMemoryDatabase(databaseName: "TestingUserRepo").Options; 20 | 21 | _context = new DataContext(dbContextOptions); 22 | 23 | IEnumerable userList = MockUserRepository.SampleValidUserData(3); 24 | _context.Users.AddRange(userList); 25 | _context.SaveChanges(); 26 | _userRepository = new UserRepository(_context); 27 | } 28 | 29 | [Test] 30 | public async Task WhenGetUser_ThenReturnUsers() 31 | { 32 | var users = await _userRepository.GetUsersAsync(); 33 | 34 | Assert.That(users, Is.Not.Null); 35 | } 36 | 37 | [Test] 38 | public async Task GivenExistingUserName_WhenGetUserByUsername_ThenReturnUser() 39 | { 40 | var user = await _userRepository.GetUserByUsernameAsync("kevin1"); 41 | 42 | Assert.That(user, Is.Not.Null); 43 | Assert.That(user.UserName, Is.EqualTo("kevin1")); 44 | } 45 | 46 | 47 | [Test] 48 | public async Task GivenNotExistingUserName_WhenGetUserByUsername_TheReturnNull() 49 | { 50 | var user = await _userRepository.GetUserByUsernameAsync("nouser"); 51 | 52 | Assert.That(user, Is.Null); 53 | } 54 | 55 | [Test] 56 | public async Task GivenValidUserId_WhenGetUserById_ThenReturnUser() 57 | { 58 | var userId = Guid.CreateVersion7(); 59 | 60 | var user = await _userRepository.GetUserByIdAsync(userId); 61 | 62 | Assert.That(user, Is.Not.Null); 63 | Assert.That(user.Id, Is.EqualTo(userId)); 64 | } 65 | 66 | [Test] 67 | public async Task GivenInvalidUserId_WhenGetUserById_ThenReturnNull() 68 | { 69 | var user = await _userRepository.GetUserByIdAsync(Guid.CreateVersion7()); 70 | 71 | Assert.That(user, Is.Null); 72 | } 73 | 74 | public void Dispose() 75 | { 76 | _context.Dispose(); 77 | } 78 | } 79 | } 80 | --------------------------------------------------------------------------------