594 lines
25 KiB
C#
594 lines
25 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,
|
|
ILogger<PostController> logger
|
|
): 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.Status422UnprocessableEntity
|
|
);
|
|
tags.Add(tag);
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Begins the creation process of a new post.
|
|
/// </summary>
|
|
/// <param name="dto">Post metadata</param>
|
|
/// <response code="201">Returns post metadata</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="401">If authentication is missing</response>
|
|
/// <response code="403">If authorization is missing</response>
|
|
/// <response code="422">If data provided does not fit constraints</response>
|
|
[HttpPost]
|
|
[Authorize(Roles = UserRoles.Regular)]
|
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
|
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();
|
|
|
|
if (dto.FileSize.Value > 10*1024*1024) return Problem("File cannot exceed 10MB", statusCode: StatusCodes.Status422UnprocessableEntity);
|
|
|
|
if (dto.FileMimeType != "image/png" && dto.FileMimeType != "image/jpeg")
|
|
return Problem("File must be image", statusCode: StatusCodes.Status422UnprocessableEntity);
|
|
|
|
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 }
|
|
);
|
|
return CreatedAtAction(nameof(Get), new { id = created.Post.Id },
|
|
PostDto.FromPost(created.Post, fileUrl));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Uploads a file chunk of the associated post.
|
|
/// </summary>
|
|
/// <param name="postId">Associated post</param>
|
|
/// <param name="fileId">ID of file being uploaded</param>
|
|
/// <response code="202">If file chunk was accepted</response>
|
|
/// <response code="204">If file was successfully uploaded</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="401">If authentication is missing</response>
|
|
/// <response code="403">If authorization is missing</response>
|
|
/// <response code="404">If post or file is not found</response>
|
|
/// <response code="409">If a chunk was already uploaded</response>
|
|
/// <response code="415">If finished upload mime does not match provided</response>
|
|
[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.Status409Conflict)]
|
|
[ProducesResponseType(StatusCodes.Status415UnsupportedMediaType)]
|
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
|
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 || post.File.Id != fileRecord.Id) return NotFound();
|
|
|
|
// If not the resource owner
|
|
var userId = HttpContext.User.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value;
|
|
if (userId != post.Author.Id) return Forbid();
|
|
|
|
if (fileRecord.FinishedDate != null)
|
|
return Problem(statusCode: StatusCodes.Status409Conflict,
|
|
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.Status422UnprocessableEntity,
|
|
detail: $"Total file size mismatch. Expected: {totalSize} bytes, Received: {totalSizeFromHeader} bytes."
|
|
);
|
|
}
|
|
|
|
// Append the chunk to the temporary file
|
|
var tempFilePath = fileRecord.FilePath;
|
|
try
|
|
{
|
|
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);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
logger.LogCritical(e.ToString());
|
|
return Problem("Chunk has already been uploaded", statusCode: StatusCodes.Status409Conflict);
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the specified file content of the associated post.
|
|
/// </summary>
|
|
/// <param name="postId">Associated post ID</param>
|
|
/// <param name="fileId">File ID</param>
|
|
/// <response code="200">The file content</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="404">If post or file is not found</response>
|
|
[HttpGet("{postId:int}/files/{fileId:int}")]
|
|
[ResponseCache(Duration = 604800, Location = ResponseCacheLocation.Any)] // Cache for 7 days
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
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 || post.File.Id != fileRecord.Id) 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the specified post.
|
|
/// </summary>
|
|
/// <param name="id">Post ID</param>
|
|
/// <response code="200">The post content</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="404">If post is not found</response>
|
|
[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 }
|
|
);
|
|
return Ok(PostDto.FromPost(entry, fileUrl));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a paginated list of posts.
|
|
/// </summary>
|
|
/// <param name="query">Query to filter posts by tags. Use +tag-name to require a tag, -tag-name to exclude a tag.</param>
|
|
/// <param name="page">The page number.</param>
|
|
/// <response code="200">The paginated list</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
[HttpGet]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<ActionResult<PagedList<PostDto>>> GetAll(string? query, [Range(1, int.MaxValue)] int page = 1)
|
|
{
|
|
var list = await postService.FindAll(query, page);
|
|
var newItems = list.Items.Select(i =>
|
|
{
|
|
var fileUrl = Url.Action(nameof(PatchFileContent), "Post",
|
|
new { postId = i.Id, fileId = i.File.Id }
|
|
);
|
|
return PostDto.FromPost(i, fileUrl);
|
|
}).ToList();
|
|
return Ok(new PagedList<PostDto>(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete specified post.
|
|
/// </summary>
|
|
/// <param name="id">Post ID</param>
|
|
/// <response code="204">If deleted successfully</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="401">If authentication is missing</response>
|
|
/// <response code="403">If authorization is missing</response>
|
|
/// <response code="404">If post is not found</response>
|
|
[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<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) return 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update specified post.
|
|
/// </summary>
|
|
/// <param name="id">Post ID</param>
|
|
/// <param name="dto">Post edit data</param>
|
|
/// <response code="200">New post data</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="401">If authentication is missing</response>
|
|
/// <response code="403">If authorization is missing</response>
|
|
/// <response code="404">If post is not found</response>
|
|
[Authorize(Roles = UserRoles.Regular)]
|
|
[HttpPatch("{id:int}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
|
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) return 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 }
|
|
);
|
|
return Ok(PostDto.FromPost(updated, fileUrl));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a comment under the specified post.
|
|
/// </summary>
|
|
/// <param name="postId">Post ID</param>
|
|
/// <param name="dto">Comment data</param>
|
|
/// <response code="201">New comment data</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="401">If authentication is missing</response>
|
|
/// <response code="403">If authorization is missing</response>
|
|
/// <response code="404">If post is not found</response>
|
|
[HttpPost("{postId:int}/comments")]
|
|
[Authorize(Roles = UserRoles.Regular)]
|
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get specific post comment.
|
|
/// </summary>
|
|
/// <param name="postId">Post ID</param>
|
|
/// <param name="commentId">Comment ID</param>
|
|
/// <response code="200">Comment data</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="404">If post or comment is not found</response>
|
|
[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 || entry.Id != comment.OriginalPost.Id) return NotFound();
|
|
|
|
return Ok(CommentDto.FromComment(comment));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get paginated list of specific post comments.
|
|
/// </summary>
|
|
/// <param name="postId">Post ID</param>
|
|
/// <param name="page">Page number</param>
|
|
/// <response code="200">Paginated list of comments</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="404">If post is not found</response>
|
|
[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 page = 1
|
|
)
|
|
{
|
|
var post = await postService.GetById(postId);
|
|
if (post == null) return NotFound();
|
|
|
|
var list = await commentService.GetAll(postId, page);
|
|
var newItems = list.Items.Select(CommentDto.FromComment).ToList();
|
|
return Ok(new PagedList<CommentDto>(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete a specific comment under a specific post.
|
|
/// </summary>
|
|
/// <param name="postId">Post ID</param>
|
|
/// <param name="commentId">Comment ID</param>
|
|
/// <response code="204">If comment was deleted successfully</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="401">If authentication is missing</response>
|
|
/// <response code="403">If authorization is missing</response>
|
|
/// <response code="404">If post or comment is not found</response>
|
|
[Authorize(Roles = UserRoles.Regular)]
|
|
[HttpDelete("{postId:int}/comments/{commentId:int}")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
[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 || post.Id != comment.OriginalPost.Id) 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) return Forbid();
|
|
|
|
var deleted = await commentService.Delete(comment);
|
|
if (!deleted) return NotFound();
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update a specific comment under a specific post.
|
|
/// </summary>
|
|
/// <param name="postId">Post ID</param>
|
|
/// <param name="commentId">Comment ID</param>
|
|
/// <param name="dto">Comment data</param>
|
|
/// <response code="200">New comment data</response>
|
|
/// <response code="400">If request is malformed</response>
|
|
/// <response code="401">If authentication is missing</response>
|
|
/// <response code="403">If authorization is missing</response>
|
|
/// <response code="404">If post or comment is not found</response>
|
|
[Authorize(Roles = UserRoles.Regular)]
|
|
[HttpPatch("{postId:int}/comments/{commentId:int}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
|
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 || post.Id != comment.OriginalPost.Id) 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) return Forbid();
|
|
|
|
comment.Text = dto.Text;
|
|
|
|
var updated = await commentService.Update(comment);
|
|
return Ok(CommentDto.FromComment(updated));
|
|
}
|
|
}
|