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 InspectFileContent(string filePath) { // Define the types we support var inspector = new FileFormatInspector(new List { 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+(?\d+)-(?\d+)/(?\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>> TagNamesToTags(List tagNames) { var tags = new List(); 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; } /// /// Begins the creation process of a new post. /// /// Post metadata /// Returns post metadata /// If request is malformed /// If authentication is missing /// If authorization is missing [HttpPost] [Authorize(Roles = UserRoles.Regular)] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task> 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 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)); } /// /// Uploads a file chunk of the associated post. /// /// Associated post /// ID of file being uploaded /// If file chunk was accepted /// If file was successfully uploaded /// If request is malformed /// If authentication is missing /// If authorization is missing /// If post or file is not found /// If finished upload mime does not match provided [HttpPatch("{postId:int}/files/{fileId:int}")] [Authorize(Roles = UserRoles.Regular)] [ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status415UnsupportedMediaType)] public async Task 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 not the resource owner var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value; if (userId != post.Author.Id) Forbid(); 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(); } /// /// Get the specified file content of the associated post. /// /// Associated post ID /// File ID /// The file content /// If request is malformed /// If post or file is not found [HttpGet("{postId:int}/files/{fileId:int}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task 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); } /// /// Get the specified post. /// /// Post ID /// The post content /// If request is malformed /// If post is not found [HttpGet("{id:int}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> 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)); } /// /// Get a paginated list of posts. /// /// Query to filter posts by tags. Use +tag-name to require a tag, -tag-name to exclude a tag. /// The page number. /// The paginated list /// If request is malformed [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task>> GetAll(string? query, [Range(1, int.MaxValue)] int pageNumber = 1) { var list = await postService.FindAll(query, 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(newItems, list.CurrentPage, list.PageSize, list.TotalCount)); } /// /// Delete specified post. /// /// Post ID /// If deleted successfully /// If request is malformed /// If authentication is missing /// If authorization is missing /// If post is not found [Authorize(Roles = UserRoles.Regular)] [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task 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(); } /// /// Update specified post. /// /// Post ID /// Post edit data /// New post data /// If request is malformed /// If authentication is missing /// If authorization is missing /// If post is not found [Authorize(Roles = UserRoles.Regular)] [HttpPatch("{id:int}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> 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> 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> 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>> 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(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 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> 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)); } }