Files
T120B165/T120B165-ImgBoard/Controllers/PostController.cs
2025-10-06 10:42:32 +03:00

457 lines
17 KiB
C#

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(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<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));
}
}