Add project files

This commit is contained in:
2025-10-04 13:27:29 +03:00
parent 75eba696c9
commit cc53824229
46 changed files with 3328 additions and 7 deletions

4
T120B165-ImgBoard/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/bin/
/obj/
/Storage/
/appsettings.json

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using T120B165_ImgBoard.Dtos;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Services;
namespace T120B165_ImgBoard.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController(UserManager<User> userManager, ITokenService tokenService): ControllerBase
{
[HttpPost("register")]
public async Task<ActionResult<User>> Register(RegisterDto dto)
{
var user = new User
{
UserName = dto.UserName,
Email = dto.Email,
};
var result = await userManager.CreateAsync(user, dto.Password);
await userManager.AddToRoleAsync(user, UserRoles.Regular);
if (!result.Succeeded)
{
return BadRequest(result.Errors);
}
return Ok(user);
}
[HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenDto>> Login(LoginDto dto)
{
var user = await userManager.FindByEmailAsync(dto.Email);
if (user == null || !await userManager.CheckPasswordAsync(user, dto.Password))
return Unauthorized();
var accessToken = await tokenService.GenerateJwtToken(user);
var refreshToken = await tokenService.GenerateRefreshToken(user);
return Ok(new TokenDto(AccessToken: accessToken, RefreshToken: refreshToken));
}
[HttpPost("refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<TokenDto>> Refresh(RefreshDto dto)
{
var token = await tokenService.GetRefreshTokenByValue(dto.RefreshToken);
if (token == null) return Unauthorized();
var user = token.User;
await tokenService.InvalidateRefreshToken(token);
var accessToken = await tokenService.GenerateJwtToken(user);
var newRefreshToken = await tokenService.GenerateRefreshToken(user);
return Ok(new TokenDto(AccessToken: accessToken, RefreshToken: newRefreshToken));
}
}

View File

@@ -0,0 +1,456 @@
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Security.Claims;
using System.Text.RegularExpressions;
using FileSignatures;
using FileSignatures.Formats;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using T120B165_ImgBoard.Dtos.Comment;
using T120B165_ImgBoard.Dtos.Post;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Services;
using T120B165_ImgBoard.Utils;
namespace T120B165_ImgBoard.Controllers;
[ApiController]
[Route("api/posts")]
public class PostController(
IPostService postService,
IUserService userService,
ITagService tagService,
ICommentService commentService,
IFileService fileService,
IWebHostEnvironment env
): ControllerBase
{
private static async Task<string> InspectFileContent(string filePath)
{
// Define the types we support
var inspector = new FileFormatInspector(new List<FileFormat>
{
new Jpeg(),
new Png(),
});
// Open the file and inspect
await using var stream = System.IO.File.OpenRead(filePath);
var format = inspector.DetermineFileFormat(stream);
// If the file matches a known format, return its MIME type
if (format != null) return format.MediaType;
// If no signature matches, assume generic binary type
return "application/octet-stream";
}
private string GetFinalFilePath(Models.File fileRecord)
{
var baseDir = Path.Combine(env.ContentRootPath, "Storage", "Attachments");
// Create a clean, organized subdirectory (e.g., /Storage/Attachments/2025/10/)
var dateDir = Path.Combine(baseDir, DateTime.UtcNow.Year.ToString(), DateTime.UtcNow.Month.ToString("D2"));
// Ensure the directory exists before attempting to save the file
if (!Directory.Exists(dateDir))
{
Directory.CreateDirectory(dateDir);
}
// Create a unique file name to avoid collisions
// Use the file ID or a GUID + a sanitized version of the original file name
var uniqueFileName = $"{Guid.NewGuid()}-{Path.GetFileName(fileRecord.OriginalFileName)}";
return Path.Combine(dateDir, uniqueFileName);
}
private static bool TryParseContentRange(string header, out long start, out long end, out long totalSize)
{
start = end = totalSize = 0;
if (string.IsNullOrWhiteSpace(header))
return false;
// We expect a format like "bytes 0-1048575/5242880"
// Regex pattern to capture the parts
var match = Regex.Match(header, @"bytes\s+(?<start>\d+)-(?<end>\d+)/(?<total>\d+)");
if (!match.Success)
return false;
// Safely parse the captured groups
if (!long.TryParse(match.Groups["start"].Value, out start) ||
!long.TryParse(match.Groups["end"].Value, out end) ||
!long.TryParse(match.Groups["total"].Value, out totalSize))
{
return false;
}
// The length of the chunk must match the difference between end and start
// Note: 'end' is inclusive, so the number of bytes is (end - start + 1)
return start >= 0 && end >= start && totalSize > 0 && end < totalSize;
}
private async Task<ActionResult<List<Tag>>> TagNamesToTags(List<string> tagNames)
{
var tags = new List<Tag>();
foreach (var tagName in tagNames)
{
var tag = await tagService.GetByName(tagName);
if (tag == null)
return Problem(
detail: $"'{tagName}' is not a valid tag",
statusCode: StatusCodes.Status400BadRequest
);
tags.Add(tag);
}
return tags;
}
[HttpPost]
[Authorize]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PostDto>> Create(CreatePostDto dto)
{
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var user = await userService.GetUserById(userId);
if (user == null) return Unauthorized();
var maybeTags = await TagNamesToTags(dto.Tags);
List<Tag> tags;
if (maybeTags.Value != null)
{
tags = maybeTags.Value;
}
else
{
Debug.Assert(maybeTags.Result != null);
return maybeTags.Result;
}
var created = await postService.Create(
dto.Title, dto.Description, tags, user,
dto.FileName, dto.FileMimeType, dto.FileSize.Value
);
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
new { postId = created.Post.Id, fileId = created.File.Id }, Request.Scheme
);
return CreatedAtAction(nameof(Get), new { id = created.Post.Id },
PostDto.FromPost(created.Post, fileUrl));
}
[HttpPatch("{postId:int}/files/{fileId:int}")]
public async Task<IActionResult> PatchFileContent(int postId, int fileId)
{
var post = await postService.GetById(postId, includeUnfinished: true);
if (post == null) return NotFound();
var fileRecord = await fileService.GetFileById(fileId);
if (fileRecord == null) return NotFound();
if (fileRecord.FinishedDate != null)
return Problem(statusCode: StatusCodes.Status400BadRequest,
detail: "File was already uploaded.");
// Parse the Content-Range Header
// Example: Content-Range: bytes 0-1048575/5242880
var rangeHeader = Request.Headers.ContentRange.FirstOrDefault();
if (string.IsNullOrEmpty(rangeHeader) || !TryParseContentRange(rangeHeader, out long start, out long end, out long totalSizeFromHeader))
{
return Problem(
statusCode: StatusCodes.Status400BadRequest,
detail: "Missing or invalid Content-Range header."
);
}
var totalSize = fileRecord.Size;
if (totalSizeFromHeader != totalSize)
{
return Problem(
statusCode: StatusCodes.Status400BadRequest,
detail: $"Total file size mismatch. Expected: {totalSize} bytes, Received: {totalSizeFromHeader} bytes."
);
}
// Append the chunk to the temporary file
var tempFilePath = fileRecord.FilePath;
await using (var stream = new FileStream(tempFilePath, FileMode.Append, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true))
{
stream.Seek(start, SeekOrigin.Begin);
await Request.Body.CopyToAsync(stream);
}
// Check if the upload is complete
// Return 202 Accepted for a successful intermediate chunk
if (end + 1 != totalSize) return Accepted();
// Otherwise, all chunks received. Finalize the file.
var finalFilePath = GetFinalFilePath(fileRecord);
System.IO.File.Move(tempFilePath, finalFilePath, overwrite: true);
// Compare the actual type to the expected type
var actualContentType = await InspectFileContent(finalFilePath);
if (!string.Equals(actualContentType, fileRecord.ContentType, StringComparison.OrdinalIgnoreCase))
{
// If the file's content doesn't match its declared MIME type, clean up
System.IO.File.Delete(finalFilePath);
await fileService.Delete(fileRecord);
//await postService.Delete(post);
return Problem(statusCode: StatusCodes.Status415UnsupportedMediaType,
detail: $"Uploaded file type '{actualContentType}' does not match declared type '{fileRecord.ContentType}'.");
}
// Update the database record with the final path
fileRecord.FilePath = finalFilePath;
fileRecord.FinishedDate = DateTime.Now;
await fileService.Update(fileRecord);
return NoContent();
}
[HttpGet("{postId:int}/files/{fileId:int}")]
public async Task<IActionResult> GetFileContent(int postId, int fileId)
{
var post = await postService.GetById(postId, includeUnfinished: true);
if (post == null) return NotFound();
var fileRecord = await fileService.GetFileById(fileId);
if (fileRecord == null) return NotFound();
// Ensure the file has been successfully uploaded and finalized
if (string.IsNullOrEmpty(fileRecord.FilePath) || fileRecord.FinishedDate == null)
{
return Problem(statusCode: StatusCodes.Status404NotFound,
detail: "File content is not yet available or was corrupted.");
}
var fullPath = Path.Combine(env.ContentRootPath, fileRecord.FilePath);
// Check if the file actually exists
if (!System.IO.File.Exists(fullPath))
{
return Problem(statusCode: StatusCodes.Status404NotFound,
detail: "The physical file could not be found.");
}
var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return File(stream, fileRecord.ContentType, fileRecord.OriginalFileName);
}
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PostDto>> Get(int id)
{
var entry = await postService.GetById(id);
if (entry == null) return NotFound();
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
new { postId = entry.Id, fileId = entry.File.Id }, Request.Scheme
);
return Ok(PostDto.FromPost(entry, fileUrl));
}
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PagedList<PostDto>>> GetAll([Range(1, int.MaxValue)] int pageNumber = 1)
{
var list = await postService.GetAll(pageNumber);
var newItems = list.Items.Select(i =>
{
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
new { postId = i.Id, fileId = i.File.Id }, Request.Scheme
);
return PostDto.FromPost(i, fileUrl);
}).ToList();
return Ok(new PagedList<PostDto>(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
}
[Authorize]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Delete(int id)
{
var post = await postService.GetById(id);
if (post == null) return NotFound();
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var isAdmin = HttpContext.User.IsInRole(UserRoles.Admin);
// If neither the admin nor the resource owner
if (!isAdmin && userId != post.Author.Id)
{
Forbid();
}
// Clean up the file record first
var fullPath = Path.Combine(env.ContentRootPath, post.File.FilePath);
if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath);
await fileService.Delete(post.File);
var deleted = await postService.Delete(post);
if (!deleted) return NotFound();
return NoContent();
}
[Authorize]
[HttpPatch("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PostDto>> Update(int id, EditPostDto dto)
{
var post = await postService.GetById(id);
if (post == null) return NotFound();
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var isAdmin = HttpContext.User.IsInRole(UserRoles.Admin);
// If neither the admin nor the resource owner
if (!isAdmin && userId != post.Author.Id)
{
Forbid();
}
if (!string.IsNullOrEmpty(dto.Title))
post.Title = dto.Title;
if (!string.IsNullOrEmpty(dto.Description))
post.Description = dto.Description;
if (dto.Tags != null)
{
var maybeTags = await TagNamesToTags(dto.Tags);
if (maybeTags.Value != null)
{
post.Tags = maybeTags.Value;
}
else
{
Debug.Assert(maybeTags.Result != null);
return maybeTags.Result;
}
}
var updated = await postService.Update(post);
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
new { postId = updated.Id, fileId = updated.File.Id }, Request.Scheme
);
return Ok(PostDto.FromPost(updated, fileUrl));
}
[HttpPost("{postId:int}/comments")]
[Authorize]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PostDto>> CreateComment(int postId, CreateCommentDto dto)
{
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var user = await userService.GetUserById(userId);
if (user == null) return Unauthorized();
var post = await postService.GetById(postId);
if (post == null) return NotFound();
var created = await commentService.Create(dto.Text, user, post);
return CreatedAtAction(nameof(GetComment), new {postId = postId, commentId = created.Id}, CommentDto.FromComment(created));
}
[HttpGet("{postId:int}/comments/{commentId:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CommentDto>> GetComment(int postId, int commentId)
{
var entry = await postService.GetById(postId);
if (entry == null) return NotFound();
var comment = await commentService.GetById(commentId);
if (comment == null) return NotFound();
return Ok(CommentDto.FromComment(comment));
}
[HttpGet("{postId:int}/comments")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PagedList<CommentDto>>> GetAllComments(
int postId,
[Range(1, int.MaxValue)] int pageNumber = 1
)
{
var post = await postService.GetById(postId);
if (post == null) return NotFound();
var list = await commentService.GetAll(pageNumber);
var newItems = list.Items.Select(CommentDto.FromComment).ToList();
return Ok(new PagedList<CommentDto>(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
}
[Authorize]
[HttpDelete("{postId:int}/comments/{commentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteComment(int postId, int commentId)
{
var post = await postService.GetById(postId);
if (post == null) return NotFound();
var comment = await commentService.GetById(commentId);
if (comment == null) return NotFound();
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var isAdmin = HttpContext.User.IsInRole(UserRoles.Admin);
// If neither the admin nor the resource owner
if (!isAdmin && userId != comment.Author.Id)
{
Forbid();
}
var deleted = await commentService.Delete(comment);
if (!deleted) return NotFound();
return NoContent();
}
[Authorize]
[HttpPatch("{postId:int}/comments/{commentId:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CommentDto>> Update(int postId, int commentId, EditCommentDto dto)
{
var post = await postService.GetById(postId);
if (post == null) return NotFound();
var comment = await commentService.GetById(commentId);
if (comment == null) return NotFound();
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
var isAdmin = HttpContext.User.IsInRole(UserRoles.Admin);
// If neither the admin nor the resource owner
if (!isAdmin && userId != comment.Author.Id)
{
Forbid();
}
comment.Text = dto.Text;
var updated = await commentService.Update(comment);
return Ok(CommentDto.FromComment(updated));
}
}

View File

@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Mvc;
using T120B165_ImgBoard.Dtos;
using T120B165_ImgBoard.Dtos.Tag;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Services;
using T120B165_ImgBoard.Utils;
namespace T120B165_ImgBoard.Controllers;
[ApiController]
[Route("api/tags")]
public class TagController(ITagService tagService) : ControllerBase
{
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<Tag>> Create(CreateTagDto dto)
{
// Check if tag exists, if it does, throw a conflict
var existingTag = await tagService.GetByName(dto.Name);
if (existingTag != null)
{
return Problem(
detail: "Tag with such name already exists.",
statusCode: StatusCodes.Status409Conflict
);
}
var createdTag = await tagService.Create(dto.Type, dto.Name);
return CreatedAtAction(nameof(Get), new { name = createdTag.Name }, createdTag);
}
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<PagedList<Tag>>> GetAll(int pageNumber = 1)
{
return Ok(await tagService.GetAll(pageNumber));
}
[HttpGet("{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Tag>> Get(string name)
{
var tag = await tagService.GetByName(name);
if (tag == null)
{
return NotFound();
}
return Ok(tag);
}
[HttpDelete("{name}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Delete(string name)
{
var deleted = await tagService.DeleteByName(name);
if (!deleted) return NotFound();
return NoContent();
}
[HttpPatch("{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Tag>> Update(string name, EditTagDto dto)
{
var tag = await tagService.GetByName(name);
if (tag == null) return NotFound();
var updatedTag = await tagService.Update(tag, dto.Type);
return Ok(updatedTag);
}
}

View File

@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Identity;
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Data;
public static class DbInitializer
{
public static async Task SeedRolesAsync(IServiceProvider serviceProvider)
{
var roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();
string[] roleNames = [UserRoles.Admin, UserRoles.Regular];
foreach (var roleName in roleNames)
{
if (!await roleManager.RoleExistsAsync(roleName))
{
await roleManager.CreateAsync(new IdentityRole(roleName));
}
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Models;
using File = T120B165_ImgBoard.Models.File;
namespace T120B165_ImgBoard.Data;
public class ImgBoardContext(DbContextOptions<ImgBoardContext> options) : IdentityDbContext<User>(options)
{
public DbSet<RefreshToken> RefreshTokens { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<Post> Posts { get; set; }
public DbSet<Comment> Comments { get; set; }
public DbSet<File> Files { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<RefreshToken>().ToTable("RefreshTokens");
modelBuilder.Entity<Tag>().ToTable("Tags");
modelBuilder.Entity<Post>().ToTable("Posts");
modelBuilder.Entity<File>().ToTable("Files");
modelBuilder.Entity<Comment>().ToTable("Comments");
}
}

View File

@@ -0,0 +1,25 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["T120B165-ImgBoard/T120B165-ImgBoard.csproj", "T120B165-ImgBoard/"]
RUN dotnet restore "T120B165-ImgBoard/T120B165-ImgBoard.csproj"
COPY . .
WORKDIR "/src/T120B165-ImgBoard"
RUN dotnet build "./T120B165-ImgBoard.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./T120B165-ImgBoard.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir -p /app/Storage && chown -R app:app /app/Storage
VOLUME /app/Storage
ENTRYPOINT ["dotnet", "T120B165-ImgBoard.dll"]

View File

@@ -0,0 +1,13 @@
namespace T120B165_ImgBoard.Dtos.Comment;
public record CommentDto(int Id, string Text, SlimUserDto Author)
{
public static CommentDto FromComment(Models.Comment comment)
{
return new CommentDto(
Id: comment.Id,
Text: comment.Text,
Author: SlimUserDto.FromUser(comment.Author)
);
}
}

View File

@@ -0,0 +1,4 @@
namespace T120B165_ImgBoard.Dtos.Comment;
public record CreateCommentDto(string Text);
public record EditCommentDto(string Text);

View File

@@ -0,0 +1,3 @@
namespace T120B165_ImgBoard.Dtos;
public record LoginDto(string Email, string Password);

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
namespace T120B165_ImgBoard.Dtos.Post;
public record CreatePostDto(string Title,
[Required]
string Description,
[Required]
List<string> Tags,
[Required]
string FileName,
[Required]
string FileMimeType,
[Required]
[Range(0, long.MaxValue, ErrorMessage = "The {0} must be a valid number and at least {1}.")]
long? FileSize
);
public record EditPostDto(string? Title, string? Description, List<string>? Tags): IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var allEmpty = string.IsNullOrWhiteSpace(Title) &&
string.IsNullOrWhiteSpace(Description) &&
Tags == null;
if (allEmpty)
{
yield return new ValidationResult(
"You must provide at least one value to edit: Title, Description, or Tags.",
[nameof(Title), nameof(Description), nameof(Tags)]
);
}
}
}

View File

@@ -0,0 +1,25 @@
using File = T120B165_ImgBoard.Models.File;
namespace T120B165_ImgBoard.Dtos.Post;
public record PostDto(
int Id,
string Title,
string Description,
SlimUserDto Author,
List<Models.Tag> Tags,
string? FileUrl
)
{
public static PostDto FromPost(Models.Post post, string? fileUrl)
{
return new PostDto(
Id: post.Id,
Title: post.Title,
Description: post.Description,
Author: SlimUserDto.FromUser(post.Author),
Tags: post.Tags,
FileUrl: fileUrl
);
}
}

View File

@@ -0,0 +1,3 @@
namespace T120B165_ImgBoard.Dtos;
public record RefreshDto(string RefreshToken);

View File

@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace T120B165_ImgBoard.Dtos;
public record RegisterDto(
string UserName,
[EmailAddress]
string Email,
string Password
);

View File

@@ -0,0 +1,11 @@
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Dtos;
public record SlimUserDto(string UserId, string UserName)
{
public static SlimUserDto FromUser(User user)
{
return new SlimUserDto(user.Id, user.UserName);
}
};

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Dtos.Tag;
public record CreateTagDto(
[Required]
TagType Type,
[Required]
[StringLength(64)]
string Name
);
public record EditTagDto([Required] TagType Type);

View File

@@ -0,0 +1,3 @@
namespace T120B165_ImgBoard.Dtos;
public record TokenDto(string AccessToken, string RefreshToken);

View File

@@ -0,0 +1,5 @@
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Dtos;
public record UserDto(int Id, string Name, string Email);

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace T120B165_ImgBoard.Models;
public class Comment
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public string Text { get; set; }
public User Author { get; set; }
public Post OriginalPost { get; set; }
public DateTime Created { get; set; }
public DateTime? Updated { get; set; }
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace T120B165_ImgBoard.Models;
public class File
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
// The physical path where the file is stored
public required string FilePath { get; set; }
// Original file name uploaded by the user
public required string OriginalFileName { get; set; }
// The size of the file in bytes
public required long Size { get; set; }
// The MIME type, will be validated at the end of the upload
public required string ContentType { get; set; }
// Tracks when the file metadata was created/upload process started
public required DateTime CreatedDate { get; set; }
// Tracks when the file was successfully finalized
// Null until the final PATCH request completes
public DateTime? FinishedDate { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace T120B165_ImgBoard.Models;
public class Post
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public required string Title { get; set; }
public required string Description { get; set; }
public User Author { get; set; }
public required DateTime Created { get; set; }
public required List<Tag> Tags { get; set; }
public required File File { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace T120B165_ImgBoard.Models;
public enum TagType
{
General,
Copyright,
}
public class Tag
{
[Key]
public required string Name { get; init; }
public required TagType Type { get; set; }
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
namespace T120B165_ImgBoard.Models;
public static class UserRoles
{
public const string Admin = "Admin";
public const string Regular = "Regular";
}
public class RefreshToken
{
[Key]
public string Token { get; set; }
public User User { get; set; }
public DateTime Expires { get; set; }
}
public class User : IdentityUser
{
//public List<Post> Posts { get; set; }
};

View File

@@ -0,0 +1,108 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Converters;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Services;
namespace T120B165_ImgBoard;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddControllers().AddNewtonsoftJson(
options => options.SerializerSettings.Converters.Add(new StringEnumConverter()));
builder.Services.AddOpenApiDocument();
builder.Services.AddOpenApi();
builder.Services.AddIdentity<User, IdentityRole>()
.AddEntityFrameworkStores<ImgBoardContext>()
.AddDefaultTokenProviders();
builder.Services.AddScoped<ITagService, TagService>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IPostService, PostService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ICommentService, CommentService>();
builder.Services.AddScoped<IFileService, FileService>();
// If we're in dev environment, use in-memory database
if (builder.Environment.IsDevelopment())
{
builder.Services.AddDbContext<ImgBoardContext>(options =>
options.UseInMemoryDatabase("T120B165_ImgBoard"));
}
else
{
Console.WriteLine("Running in production mode");
builder.Services.AddDbContext<ImgBoardContext>(options =>
options.UseMySql(
builder.Configuration.GetConnectionString("DbContext"),
new MySqlServerVersion(new Version(11, 8, 3))
)
);
}
var jwtSettings = builder.Configuration.GetSection("Jwt");
var key = Encoding.UTF8.GetBytes(jwtSettings["Key"]);
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(key)
};
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.UseOpenApi();
app.UseSwaggerUi();
}
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
var context = services.GetRequiredService<ImgBoardContext>();
context.Database.EnsureCreated();
DbInitializer.SeedRolesAsync(services).GetAwaiter().GetResult();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5259",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7261;http://localhost:5259",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Utils;
namespace T120B165_ImgBoard.Services;
public interface ICommentService
{
Task<Comment> Create(string text, User author, Post post);
Task<Comment?> GetById(int commentId);
Task<PagedList<Comment>> GetAll(int pageNumber = 1);
Task<bool> Delete(Comment comment);
Task<Comment> Update(Comment comment);
}
public class CommentService(ImgBoardContext context): ICommentService
{
private const int PageSize = 20;
public async Task<Comment> Create(string text, User author, Post post)
{
var entry = new Comment
{
Text = text,
Author = author,
OriginalPost = post,
Created = DateTime.Now,
};
await context.Comments.AddAsync(entry);
await context.SaveChangesAsync();
return entry;
}
public async Task<Comment?> GetById(int commentId)
{
return await context.Comments.Where(t => t.Id == commentId)
.Include(b => b.Author)
.Include(b => b.OriginalPost)
.FirstOrDefaultAsync();
}
public async Task<PagedList<Comment>> GetAll(int pageNumber = 1)
{
var totalCount = await context.Comments.CountAsync();
var items = await context.Comments
.Skip((pageNumber - 1) * PageSize)
.Take(PageSize)
.Include(b => b.Author)
.Include(b => b.OriginalPost)
.ToListAsync();
return new PagedList<Comment>(items, pageNumber, PageSize, totalCount);
}
public async Task<bool> Delete(Comment comment)
{
context.Comments.Remove(comment);
await context.SaveChangesAsync();
return true;
}
public async Task<Comment> Update(Comment comment)
{
context.Comments.Update(comment);
await context.SaveChangesAsync();
return comment;
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using File = T120B165_ImgBoard.Models.File;
namespace T120B165_ImgBoard.Services;
public interface IFileService
{
public Task<File?> GetFileById(long id);
Task<File> Update(File file);
Task Delete(File file);
}
public class FileService(ImgBoardContext context): IFileService
{
public async Task<File?> GetFileById(long id)
{
return await context.Files.Where(f => f.Id == id).FirstOrDefaultAsync();
}
public async Task<File> Update(File file)
{
context.Files.Update(file);
await context.SaveChangesAsync();
return file;
}
public async Task Delete(File file)
{
var actualFile = await GetFileById(file.Id);
context.Files.Remove(actualFile);
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Utils;
using File = T120B165_ImgBoard.Models.File;
namespace T120B165_ImgBoard.Services;
public interface IPostService
{
Task<CreatedPost> Create(
string title, string description, List<Tag> tags, User author,
string fileName, string fileContentType, long fileSize);
Task<Post?> GetById(int postId, bool includeUnfinished = false);
Task<PagedList<Post>> GetAll(int pageNumber = 1);
Task<bool> Delete(Post post);
Task<Post> Update(Post post);
}
public record CreatedPost(Post Post, File File);
public class PostService(ImgBoardContext context): IPostService
{
private const int PageSize = 20;
public async Task<CreatedPost> Create(
string title,
string description,
List<Tag> tags,
User author,
string fileName,
string fileContentType,
long fileSize
)
{
var file = new File
{
OriginalFileName = fileName,
Size = fileSize,
ContentType = fileContentType,
FilePath = Path.GetTempFileName(),
CreatedDate = DateTime.Now,
};
await context.Files.AddAsync(file);
var entry = new Post
{
Title = title,
Description = description,
Author = author,
Created = DateTime.Now,
Tags = tags,
File = file
};
await context.Posts.AddAsync(entry);
await context.SaveChangesAsync();
return new CreatedPost(entry, file);
}
public async Task<Post?> GetById(int postId, bool includeUnfinished = false)
{
var query = context.Posts.Where(t => t.Id == postId);
if (!includeUnfinished)
{
query = query.Where(t => t.File.FinishedDate != null);
}
return await query.Include(b => b.Author)
.Include(b => b.Tags)
.Include(b => b.File)
.FirstOrDefaultAsync();
}
public async Task<PagedList<Post>> GetAll(int pageNumber = 1)
{
var totalCount = await context.Posts.Where(p => p.File.FinishedDate != null).CountAsync();
var items = await context.Posts
.Skip((pageNumber - 1) * PageSize)
.Take(PageSize)
.Where(p => p.File.FinishedDate != null)
.Include(b => b.Author)
.Include(b => b.Tags)
.Include(b => b.File)
.ToListAsync();
return new PagedList<Post>(items, pageNumber, PageSize, totalCount);
}
public async Task<bool> Delete(Post post)
{
context.Posts.Remove(post);
await context.SaveChangesAsync();
return true;
}
public async Task<Post> Update(Post post)
{
context.Posts.Update(post);
await context.SaveChangesAsync();
return post;
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Dtos;
using T120B165_ImgBoard.Models;
using T120B165_ImgBoard.Utils;
namespace T120B165_ImgBoard.Services;
public interface ITagService
{
Task<Tag?> GetByName(string name);
Task<bool> DeleteByName(string name);
Task<PagedList<Tag>> GetAll(int pageNumber = 1);
Task<Tag> Update(Tag tag, TagType type);
Task<Tag> Create(TagType type, string name);
}
public class TagService(ImgBoardContext context): ITagService
{
public async Task<Tag?> GetByName(string name)
{
return await context.Tags.Where(t => t.Name == name).FirstOrDefaultAsync();
}
public async Task<PagedList<Tag>> GetAll(int pageNumber)
{
int pageSize = 20;
var totalCount = await context.Tags.CountAsync();
var products = await context.Tags
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedList<Tag>(products, pageNumber, pageSize, totalCount);;
}
public async Task<Tag> Update(Tag tag, TagType type)
{
tag.Type = type;
context.Tags.Update(tag);
await context.SaveChangesAsync();
return tag;
}
public async Task<bool> DeleteByName(string name)
{
var tag = await context.Tags.FindAsync(name);
if (tag == null) return false;
context.Tags.Remove(tag);
await context.SaveChangesAsync();
return true;
}
public async Task<Tag> Create(TagType type, string name)
{
var tag = new Tag
{
Name = name,
Type = type,
};
await context.Tags.AddAsync(tag);
await context.SaveChangesAsync();
return tag;
}
}

View File

@@ -0,0 +1,74 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Services;
public interface ITokenService
{
Task<string> GenerateJwtToken(User user);
Task<string> GenerateRefreshToken(User user);
Task<RefreshToken?> GetRefreshTokenByValue(string refreshToken);
Task<bool> InvalidateRefreshToken(RefreshToken refreshToken);
}
public class TokenService(ImgBoardContext context, IConfiguration config, UserManager<User> userManager): ITokenService
{
public async Task<RefreshToken?> GetRefreshTokenByValue(string refreshToken)
{
return await context.RefreshTokens.Where(t => t.Token == refreshToken && t.Expires > DateTime.Now)
.Include(t => t.User)
.FirstOrDefaultAsync();
}
public async Task<bool> InvalidateRefreshToken(RefreshToken refreshToken)
{
context.RefreshTokens.Remove(refreshToken);
await context.SaveChangesAsync();
return true;
}
public async Task<string> GenerateJwtToken(User user)
{
var roles = await userManager.GetRolesAsync(user);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(JwtRegisteredClaimNames.Email, user.Email)
};
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: config["Jwt:Issuer"],
audience: config["Jwt:Audience"],
claims: claims,
expires: DateTime.Now.AddMinutes(Convert.ToDouble(config["Jwt:DurationInMinutes"])),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public async Task<string> GenerateRefreshToken(User user)
{
var token = new RefreshToken
{
Expires = DateTime.Now.AddDays(30),
Token = Guid.NewGuid().ToString(),
User = user
};
await context.RefreshTokens.AddAsync(token);
await context.SaveChangesAsync();
return token.Token;
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using T120B165_ImgBoard.Data;
using T120B165_ImgBoard.Models;
namespace T120B165_ImgBoard.Services;
public interface IUserService
{
public Task<User?> GetUserById(string id);
}
public class UserService(ImgBoardContext context): IUserService
{
public async Task<User?> GetUserById(string id)
{
return await context.Users.Where(u => u.Id == id).FirstOrDefaultAsync();
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>T120B165_ImgBoard</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FileSignatures" Version="6.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.9" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NSwag.AspNetCore" Version="14.5.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
namespace T120B165_ImgBoard.Utils;
public class PagedList<T>(List<T> items, int pageNumber, int pageSize, int totalCount)
{
public int CurrentPage { get; } = pageNumber;
public int PageSize { get; } = pageSize;
public int TotalCount { get; } = totalCount;
public int TotalPages => (int) Math.Ceiling((double)TotalCount / PageSize);
public List<T> Items { get; } = items;
}

View File

@@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Jwt": {
"Key": "your_secret_key_here_asta_lavista_baby",
"Issuer": "your_app",
"Audience": "your_app_users",
"DurationInMinutes": 60
}
}