diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..98b454c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,26 @@
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/.idea
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+**/db_data
+LICENSE
+README.md
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..052bb64
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/db_data/
\ No newline at end of file
diff --git a/.idea/.idea.T120B165-ImgBoard/.idea/encodings.xml b/.idea/.idea.T120B165-ImgBoard/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/.idea.T120B165-ImgBoard/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.T120B165-ImgBoard/.idea/indexLayout.xml b/.idea/.idea.T120B165-ImgBoard/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/.idea/.idea.T120B165-ImgBoard/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.T120B165-ImgBoard/.idea/projectSettingsUpdater.xml b/.idea/.idea.T120B165-ImgBoard/.idea/projectSettingsUpdater.xml
new file mode 100644
index 0000000..ef20cb0
--- /dev/null
+++ b/.idea/.idea.T120B165-ImgBoard/.idea/projectSettingsUpdater.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.T120B165-ImgBoard/.idea/vcs.xml b/.idea/.idea.T120B165-ImgBoard/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/.idea.T120B165-ImgBoard/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.T120B165-ImgBoard/.idea/workspace.xml b/.idea/.idea.T120B165-ImgBoard/.idea/workspace.xml
new file mode 100644
index 0000000..6d17940
--- /dev/null
+++ b/.idea/.idea.T120B165-ImgBoard/.idea/workspace.xml
@@ -0,0 +1,240 @@
+
+
+
+ T120B165-ImgBoard/T120B165-ImgBoard.csproj
+ T120B165-ImgBoard/T120B165-ImgBoard.csproj
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {}
+ {
+ "isMigrated": true
+}
+ {
+ "customColor": "",
+ "associatedIndex": 3
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1757916641972
+
+
+ 1757916641972
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 4c06715..b201d70 100644
--- a/README.md
+++ b/README.md
@@ -7,32 +7,73 @@ Veikimo principas - pati platforma bus prieinama per interneto naršyklę ir bus
Sistema bus kuriama naudojant modernias technologijas, o diegimas bus supaprastintas, siekiant greitesnio diegimo. Naudotojams bus suteikiami skirtingi vaidmenys su atitinkamomis teisėmis, užtikrinant sklandų ir kontroliuojamą turinio valdymą. Naudotojai galės filtruoti nuotraukas pagal joms paskirtas žymas.
-## Funkciniai reikalavimai
+### Funkciniai reikalavimai
-### Bendrieji reikalavimai
+#### Bendrieji reikalavimai
- Sistema privalo turėti vartotojo sąsają, kuri leistų peržiūrėti, įkelti, ir tvarkyti nuotraukas bei komentarus.
- Duomenų bazė turi būti lengvai keičiama dėl pasirinkto _ORM_ (angl. Object-Relational Mapping) sluoksnio.
- Serverio ir klientinės dalys turi būti supakuotos į vieną diegimo vienetą (binary), siekiant supaprastinti diegiamosios sistemos procesą.
-### Rolių reikalavimai
+#### Rolių reikalavimai
+
+**! Nubraukti reikalavimai yra kol kas nėra įgyvendinti. Taip pat kol kas nėra rolių.**
**Svečiai**
1. Gali peržiūrėti visus įkeltus paveikslus.
2. Gali matyti visus komentarus po paveikslais.
-3. Gali filtruoti paveikslus pagal nustatytas žymas.
+3. ~~Gali filtruoti paveikslus pagal nustatytas žymas.~~
4. Gali užsiregistruoti ir tapti registruotais naudotojais.
**Registruoti naudotojai**
1. Gali prisijungti prie sistemos.
-2. Gali atsijungti nuo sistemos.
+2. ~~Gali atsijungti nuo sistemos.~~
3. Gali įkelti naujus paveikslus.
4. Gali redaguoti savo įkeltų paveikslų metaduomenis (žymas, aprašą).
5. Gali ištrinti savo įkeltus paveikslus.
6. Gali rašyti komentarus po paveikslais.
7. Gali redaguoti savo komentarus.
8. Gali ištrinti savo komentarus.
-9. Gali keisti savo paskyros informaciją (slaptažodį, el. paštą).
+9. ~~Gali keisti savo paskyros informaciją (slaptažodį, el. paštą).~~
**Moderatoriai**
1. Turi teisę šalinti ir redaguoti kitų naudotojų paveikslus bei komentarus.
-2. Gali kurti, redaguoti ir ištrinti žymas, kurios naudojamos turinio kategorizavimui.
\ No newline at end of file
+2. Gali kurti, redaguoti ir ištrinti žymas, kurios naudojamos turinio kategorizavimui.
+
+## Konfiguracija
+
+**apsettings.json**
+```json
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "ConnectionStrings": {
+ "DbContext": "server=localhost;port=3306;database=database_name;user id=user_name;password=password;"
+ },
+ "AllowedHosts": "*",
+ "Jwt": {
+ "Key": "openssl rand -base64 32",
+ "Issuer": "ImgBoard",
+ "Audience": "ImgBoard-Users",
+ "DurationInMinutes": 10
+ }
+}
+```
+
+## Paleidimas
+
+Paleidimui naudojama Docker komanda:
+
+```sh
+docker compose up --build
+```
+
+## API dokumentacija
+
+API dokumentacija yra pasiekiama naudojant `Development` versiją šia nuoroda:
+http://localhost:5259/swagger/v1/swagger.json
+
+A copy of the file is provided in the repository called `swagger.json`. May not be up-to-date.
\ No newline at end of file
diff --git a/T120B165-ImgBoard.sln b/T120B165-ImgBoard.sln
new file mode 100644
index 0000000..888745b
--- /dev/null
+++ b/T120B165-ImgBoard.sln
@@ -0,0 +1,21 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "T120B165-ImgBoard", "T120B165-ImgBoard\T120B165-ImgBoard.csproj", "{FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0D67DB4A-BAE6-47D3-8E4E-4D3814C796E5}"
+ ProjectSection(SolutionItems) = preProject
+ compose.yaml = compose.yaml
+ EndProjectSection
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FDD7EF71-09BD-4FB3-B0A4-18AFEE0794B1}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/T120B165-ImgBoard.sln.DotSettings.user b/T120B165-ImgBoard.sln.DotSettings.user
new file mode 100644
index 0000000..055641a
--- /dev/null
+++ b/T120B165-ImgBoard.sln.DotSettings.user
@@ -0,0 +1,11 @@
+
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
+ ForceIncluded
\ No newline at end of file
diff --git a/T120B165-ImgBoard/.gitignore b/T120B165-ImgBoard/.gitignore
new file mode 100644
index 0000000..5281164
--- /dev/null
+++ b/T120B165-ImgBoard/.gitignore
@@ -0,0 +1,4 @@
+/bin/
+/obj/
+/Storage/
+/appsettings.json
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Controllers/AuthController.cs b/T120B165-ImgBoard/Controllers/AuthController.cs
new file mode 100644
index 0000000..09d3b64
--- /dev/null
+++ b/T120B165-ImgBoard/Controllers/AuthController.cs
@@ -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 userManager, ITokenService tokenService): ControllerBase
+{
+
+ [HttpPost("register")]
+ public async Task> 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> 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> 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));
+ }
+}
diff --git a/T120B165-ImgBoard/Controllers/PostController.cs b/T120B165-ImgBoard/Controllers/PostController.cs
new file mode 100644
index 0000000..1987f0d
--- /dev/null
+++ b/T120B165-ImgBoard/Controllers/PostController.cs
@@ -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 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;
+ }
+
+ [HttpPost]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ 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));
+ }
+
+ [HttpPatch("{postId:int}/files/{fileId:int}")]
+ 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 (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 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> 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>> 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(newItems, list.CurrentPage, list.PageSize, list.TotalCount));
+ }
+
+
+ [Authorize]
+ [HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [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();
+ }
+
+ [Authorize]
+ [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));
+ }
+}
diff --git a/T120B165-ImgBoard/Controllers/TagController.cs b/T120B165-ImgBoard/Controllers/TagController.cs
new file mode 100644
index 0000000..ca0457a
--- /dev/null
+++ b/T120B165-ImgBoard/Controllers/TagController.cs
@@ -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> 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>> GetAll(int pageNumber = 1)
+ {
+ return Ok(await tagService.GetAll(pageNumber));
+ }
+
+ [HttpGet("{name}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> 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 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> 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);
+ }
+}
diff --git a/T120B165-ImgBoard/Data/DbInitializer.cs b/T120B165-ImgBoard/Data/DbInitializer.cs
new file mode 100644
index 0000000..c414b26
--- /dev/null
+++ b/T120B165-ImgBoard/Data/DbInitializer.cs
@@ -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>();
+ string[] roleNames = [UserRoles.Admin, UserRoles.Regular];
+ foreach (var roleName in roleNames)
+ {
+ if (!await roleManager.RoleExistsAsync(roleName))
+ {
+ await roleManager.CreateAsync(new IdentityRole(roleName));
+ }
+ }
+ }
+}
diff --git a/T120B165-ImgBoard/Data/ImgBoardContext.cs b/T120B165-ImgBoard/Data/ImgBoardContext.cs
new file mode 100644
index 0000000..d474d4d
--- /dev/null
+++ b/T120B165-ImgBoard/Data/ImgBoardContext.cs
@@ -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 options) : IdentityDbContext(options)
+{
+ public DbSet RefreshTokens { get; set; }
+
+ public DbSet Tags { get; set; }
+ public DbSet Posts { get; set; }
+ public DbSet Comments { get; set; }
+ public DbSet Files { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity().ToTable("RefreshTokens");
+
+ modelBuilder.Entity().ToTable("Tags");
+ modelBuilder.Entity().ToTable("Posts");
+ modelBuilder.Entity().ToTable("Files");
+ modelBuilder.Entity().ToTable("Comments");
+ }
+}
diff --git a/T120B165-ImgBoard/Dockerfile b/T120B165-ImgBoard/Dockerfile
new file mode 100644
index 0000000..12ebb57
--- /dev/null
+++ b/T120B165-ImgBoard/Dockerfile
@@ -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"]
diff --git a/T120B165-ImgBoard/Dtos/Comment/CommentDto.cs b/T120B165-ImgBoard/Dtos/Comment/CommentDto.cs
new file mode 100644
index 0000000..24be179
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/Comment/CommentDto.cs
@@ -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)
+ );
+ }
+}
diff --git a/T120B165-ImgBoard/Dtos/Comment/CreateCommentDto.cs b/T120B165-ImgBoard/Dtos/Comment/CreateCommentDto.cs
new file mode 100644
index 0000000..40476e7
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/Comment/CreateCommentDto.cs
@@ -0,0 +1,4 @@
+namespace T120B165_ImgBoard.Dtos.Comment;
+
+public record CreateCommentDto(string Text);
+public record EditCommentDto(string Text);
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Dtos/LoginDto.cs b/T120B165-ImgBoard/Dtos/LoginDto.cs
new file mode 100644
index 0000000..d9333af
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/LoginDto.cs
@@ -0,0 +1,3 @@
+namespace T120B165_ImgBoard.Dtos;
+
+public record LoginDto(string Email, string Password);
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Dtos/Post/CreatePostDto.cs b/T120B165-ImgBoard/Dtos/Post/CreatePostDto.cs
new file mode 100644
index 0000000..28bf84c
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/Post/CreatePostDto.cs
@@ -0,0 +1,34 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace T120B165_ImgBoard.Dtos.Post;
+
+public record CreatePostDto(string Title,
+ [Required]
+ string Description,
+ [Required]
+ List 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? Tags): IValidatableObject
+{
+ public IEnumerable 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)]
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Dtos/Post/PostDto.cs b/T120B165-ImgBoard/Dtos/Post/PostDto.cs
new file mode 100644
index 0000000..79d0d6b
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/Post/PostDto.cs
@@ -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 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
+ );
+ }
+}
diff --git a/T120B165-ImgBoard/Dtos/RefreshDto.cs b/T120B165-ImgBoard/Dtos/RefreshDto.cs
new file mode 100644
index 0000000..37f178b
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/RefreshDto.cs
@@ -0,0 +1,3 @@
+namespace T120B165_ImgBoard.Dtos;
+
+public record RefreshDto(string RefreshToken);
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Dtos/RegisterDto.cs b/T120B165-ImgBoard/Dtos/RegisterDto.cs
new file mode 100644
index 0000000..ac14014
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/RegisterDto.cs
@@ -0,0 +1,10 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace T120B165_ImgBoard.Dtos;
+
+public record RegisterDto(
+ string UserName,
+ [EmailAddress]
+ string Email,
+ string Password
+ );
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Dtos/SlimUserDto.cs b/T120B165-ImgBoard/Dtos/SlimUserDto.cs
new file mode 100644
index 0000000..56053c8
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/SlimUserDto.cs
@@ -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);
+ }
+};
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Dtos/Tag/CreateTagDto.cs b/T120B165-ImgBoard/Dtos/Tag/CreateTagDto.cs
new file mode 100644
index 0000000..5300499
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/Tag/CreateTagDto.cs
@@ -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);
diff --git a/T120B165-ImgBoard/Dtos/TokenDto.cs b/T120B165-ImgBoard/Dtos/TokenDto.cs
new file mode 100644
index 0000000..cbb4d01
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/TokenDto.cs
@@ -0,0 +1,3 @@
+namespace T120B165_ImgBoard.Dtos;
+
+public record TokenDto(string AccessToken, string RefreshToken);
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Dtos/UserDto.cs b/T120B165-ImgBoard/Dtos/UserDto.cs
new file mode 100644
index 0000000..f3adb5c
--- /dev/null
+++ b/T120B165-ImgBoard/Dtos/UserDto.cs
@@ -0,0 +1,5 @@
+using T120B165_ImgBoard.Models;
+
+namespace T120B165_ImgBoard.Dtos;
+
+public record UserDto(int Id, string Name, string Email);
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Models/Comment.cs b/T120B165-ImgBoard/Models/Comment.cs
new file mode 100644
index 0000000..a1191c5
--- /dev/null
+++ b/T120B165-ImgBoard/Models/Comment.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Models/File.cs b/T120B165-ImgBoard/Models/File.cs
new file mode 100644
index 0000000..df23fd9
--- /dev/null
+++ b/T120B165-ImgBoard/Models/File.cs
@@ -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; }
+}
diff --git a/T120B165-ImgBoard/Models/Post.cs b/T120B165-ImgBoard/Models/Post.cs
new file mode 100644
index 0000000..7840de0
--- /dev/null
+++ b/T120B165-ImgBoard/Models/Post.cs
@@ -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 Tags { get; set; }
+ public required File File { get; set; }
+}
diff --git a/T120B165-ImgBoard/Models/Tag.cs b/T120B165-ImgBoard/Models/Tag.cs
new file mode 100644
index 0000000..7eecf3c
--- /dev/null
+++ b/T120B165-ImgBoard/Models/Tag.cs
@@ -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; }
+}
diff --git a/T120B165-ImgBoard/Models/User.cs b/T120B165-ImgBoard/Models/User.cs
new file mode 100644
index 0000000..542f19c
--- /dev/null
+++ b/T120B165-ImgBoard/Models/User.cs
@@ -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 Posts { get; set; }
+};
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Program.cs b/T120B165-ImgBoard/Program.cs
new file mode 100644
index 0000000..0276aac
--- /dev/null
+++ b/T120B165-ImgBoard/Program.cs
@@ -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()
+ .AddEntityFrameworkStores()
+ .AddDefaultTokenProviders();
+
+
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+
+ // If we're in dev environment, use in-memory database
+ if (builder.Environment.IsDevelopment())
+ {
+ builder.Services.AddDbContext(options =>
+ options.UseInMemoryDatabase("T120B165_ImgBoard"));
+ }
+ else
+ {
+ Console.WriteLine("Running in production mode");
+ builder.Services.AddDbContext(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();
+ context.Database.EnsureCreated();
+ DbInitializer.SeedRolesAsync(services).GetAwaiter().GetResult();
+ }
+
+ app.UseHttpsRedirection();
+
+ app.UseAuthorization();
+
+ app.MapControllers();
+ app.Run();
+ }
+}
\ No newline at end of file
diff --git a/T120B165-ImgBoard/Properties/launchSettings.json b/T120B165-ImgBoard/Properties/launchSettings.json
new file mode 100644
index 0000000..474fb27
--- /dev/null
+++ b/T120B165-ImgBoard/Properties/launchSettings.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/T120B165-ImgBoard/Services/CommentService.cs b/T120B165-ImgBoard/Services/CommentService.cs
new file mode 100644
index 0000000..ef75c1b
--- /dev/null
+++ b/T120B165-ImgBoard/Services/CommentService.cs
@@ -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 Create(string text, User author, Post post);
+ Task GetById(int commentId);
+ Task> GetAll(int pageNumber = 1);
+ Task Delete(Comment comment);
+ Task Update(Comment comment);
+}
+
+public class CommentService(ImgBoardContext context): ICommentService
+{
+ private const int PageSize = 20;
+
+ public async Task 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 GetById(int commentId)
+ {
+ return await context.Comments.Where(t => t.Id == commentId)
+ .Include(b => b.Author)
+ .Include(b => b.OriginalPost)
+ .FirstOrDefaultAsync();
+ }
+
+ public async Task> 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(items, pageNumber, PageSize, totalCount);
+ }
+
+ public async Task Delete(Comment comment)
+ {
+ context.Comments.Remove(comment);
+ await context.SaveChangesAsync();
+ return true;
+ }
+
+ public async Task Update(Comment comment)
+ {
+ context.Comments.Update(comment);
+ await context.SaveChangesAsync();
+ return comment;
+ }
+}
diff --git a/T120B165-ImgBoard/Services/FileService.cs b/T120B165-ImgBoard/Services/FileService.cs
new file mode 100644
index 0000000..6d581ba
--- /dev/null
+++ b/T120B165-ImgBoard/Services/FileService.cs
@@ -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 GetFileById(long id);
+ Task Update(File file);
+ Task Delete(File file);
+}
+
+public class FileService(ImgBoardContext context): IFileService
+{
+ public async Task GetFileById(long id)
+ {
+ return await context.Files.Where(f => f.Id == id).FirstOrDefaultAsync();
+ }
+
+ public async Task 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();
+ }
+}
diff --git a/T120B165-ImgBoard/Services/PostService.cs b/T120B165-ImgBoard/Services/PostService.cs
new file mode 100644
index 0000000..5d428b9
--- /dev/null
+++ b/T120B165-ImgBoard/Services/PostService.cs
@@ -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 Create(
+ string title, string description, List tags, User author,
+ string fileName, string fileContentType, long fileSize);
+ Task GetById(int postId, bool includeUnfinished = false);
+ Task> GetAll(int pageNumber = 1);
+ Task Delete(Post post);
+ Task Update(Post post);
+}
+
+public record CreatedPost(Post Post, File File);
+
+public class PostService(ImgBoardContext context): IPostService
+{
+ private const int PageSize = 20;
+ public async Task Create(
+ string title,
+ string description,
+ List 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 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> 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(items, pageNumber, PageSize, totalCount);
+ }
+
+ public async Task Delete(Post post)
+ {
+ context.Posts.Remove(post);
+ await context.SaveChangesAsync();
+ return true;
+ }
+
+ public async Task Update(Post post)
+ {
+ context.Posts.Update(post);
+ await context.SaveChangesAsync();
+ return post;
+ }
+}
diff --git a/T120B165-ImgBoard/Services/TagService.cs b/T120B165-ImgBoard/Services/TagService.cs
new file mode 100644
index 0000000..2cfd507
--- /dev/null
+++ b/T120B165-ImgBoard/Services/TagService.cs
@@ -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 GetByName(string name);
+
+ Task DeleteByName(string name);
+
+ Task> GetAll(int pageNumber = 1);
+
+ Task Update(Tag tag, TagType type);
+
+ Task Create(TagType type, string name);
+}
+
+public class TagService(ImgBoardContext context): ITagService
+{
+ public async Task GetByName(string name)
+ {
+ return await context.Tags.Where(t => t.Name == name).FirstOrDefaultAsync();
+ }
+
+ public async Task> 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(products, pageNumber, pageSize, totalCount);;
+ }
+
+ public async Task Update(Tag tag, TagType type)
+ {
+ tag.Type = type;
+ context.Tags.Update(tag);
+ await context.SaveChangesAsync();
+ return tag;
+ }
+
+ public async Task 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 Create(TagType type, string name)
+ {
+ var tag = new Tag
+ {
+ Name = name,
+ Type = type,
+ };
+ await context.Tags.AddAsync(tag);
+ await context.SaveChangesAsync();
+ return tag;
+ }
+}
diff --git a/T120B165-ImgBoard/Services/TokenService.cs b/T120B165-ImgBoard/Services/TokenService.cs
new file mode 100644
index 0000000..712818e
--- /dev/null
+++ b/T120B165-ImgBoard/Services/TokenService.cs
@@ -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 GenerateJwtToken(User user);
+ Task GenerateRefreshToken(User user);
+
+ Task GetRefreshTokenByValue(string refreshToken);
+ Task InvalidateRefreshToken(RefreshToken refreshToken);
+}
+
+public class TokenService(ImgBoardContext context, IConfiguration config, UserManager userManager): ITokenService
+{
+
+ public async Task GetRefreshTokenByValue(string refreshToken)
+ {
+ return await context.RefreshTokens.Where(t => t.Token == refreshToken && t.Expires > DateTime.Now)
+ .Include(t => t.User)
+ .FirstOrDefaultAsync();
+ }
+
+ public async Task InvalidateRefreshToken(RefreshToken refreshToken)
+ {
+ context.RefreshTokens.Remove(refreshToken);
+ await context.SaveChangesAsync();
+ return true;
+ }
+
+ public async Task GenerateJwtToken(User user)
+ {
+ var roles = await userManager.GetRolesAsync(user);
+ var claims = new List
+ {
+ 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 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;
+ }
+}
diff --git a/T120B165-ImgBoard/Services/UserService.cs b/T120B165-ImgBoard/Services/UserService.cs
new file mode 100644
index 0000000..03e5aa4
--- /dev/null
+++ b/T120B165-ImgBoard/Services/UserService.cs
@@ -0,0 +1,18 @@
+using Microsoft.EntityFrameworkCore;
+using T120B165_ImgBoard.Data;
+using T120B165_ImgBoard.Models;
+
+namespace T120B165_ImgBoard.Services;
+
+public interface IUserService
+{
+ public Task GetUserById(string id);
+}
+
+public class UserService(ImgBoardContext context): IUserService
+{
+ public async Task GetUserById(string id)
+ {
+ return await context.Users.Where(u => u.Id == id).FirstOrDefaultAsync();
+ }
+}
diff --git a/T120B165-ImgBoard/T120B165-ImgBoard.csproj b/T120B165-ImgBoard/T120B165-ImgBoard.csproj
new file mode 100644
index 0000000..4ffa873
--- /dev/null
+++ b/T120B165-ImgBoard/T120B165-ImgBoard.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net9.0
+ enable
+ enable
+ T120B165_ImgBoard
+ Linux
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ .dockerignore
+
+
+
+
diff --git a/T120B165-ImgBoard/Utils/PagedList.cs b/T120B165-ImgBoard/Utils/PagedList.cs
new file mode 100644
index 0000000..f9d7694
--- /dev/null
+++ b/T120B165-ImgBoard/Utils/PagedList.cs
@@ -0,0 +1,11 @@
+namespace T120B165_ImgBoard.Utils;
+
+public class PagedList(List 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 Items { get; } = items;
+}
diff --git a/T120B165-ImgBoard/appsettings.Development.json b/T120B165-ImgBoard/appsettings.Development.json
new file mode 100644
index 0000000..2bd5a0c
--- /dev/null
+++ b/T120B165-ImgBoard/appsettings.Development.json
@@ -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
+ }
+}
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..b344d5b
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,29 @@
+services:
+ t120b165-imgboard:
+ image: t120b165-imgboard
+ container_name: t120b165_app
+ restart: unless-stopped
+ build:
+ context: .
+ dockerfile: T120B165-ImgBoard/Dockerfile
+ depends_on:
+ - db
+ ports:
+ - "8080:8080"
+ volumes:
+ - app_data:/app/Storage
+ db:
+ image: mariadb:lts
+ container_name: t120b165_db
+ restart: unless-stopped
+ environment:
+ MARIADB_ROOT_PASSWORD: supersecretpassword
+ MARIADB_DATABASE: imgboard_db
+ MARIADB_USER: imgboard
+ MARIADB_PASSWORD: supersecretpassword
+ volumes:
+ - ./db_data:/var/lib/mysql
+
+volumes:
+ app_data:
+ driver: local
diff --git a/swagger.json b/swagger.json
new file mode 100644
index 0000000..26e6f05
--- /dev/null
+++ b/swagger.json
@@ -0,0 +1,1482 @@
+{
+ "x-generator": "NSwag v14.5.0.0 (NJsonSchema v11.4.0.0 (Newtonsoft.Json v13.0.0.0))",
+ "openapi": "3.0.0",
+ "info": {
+ "title": "My Title",
+ "version": "1.0.0"
+ },
+ "servers": [
+ {
+ "url": "http://localhost:5259"
+ }
+ ],
+ "paths": {
+ "/api/auth/register": {
+ "post": {
+ "tags": [
+ "Auth"
+ ],
+ "operationId": "Auth_Register",
+ "requestBody": {
+ "x-name": "dto",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RegisterDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 1
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/auth/login": {
+ "post": {
+ "tags": [
+ "Auth"
+ ],
+ "operationId": "Auth_Login",
+ "requestBody": {
+ "x-name": "dto",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/LoginDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 1
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TokenDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/auth/refresh": {
+ "post": {
+ "tags": [
+ "Auth"
+ ],
+ "operationId": "Auth_Refresh",
+ "requestBody": {
+ "x-name": "dto",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RefreshDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 1
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TokenDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/posts": {
+ "post": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_Create",
+ "requestBody": {
+ "x-name": "dto",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreatePostDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 1
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PostDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_GetAll",
+ "parameters": [
+ {
+ "name": "pageNumber",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "default": 1,
+ "maximum": 2147483647.0,
+ "minimum": 1.0
+ },
+ "x-position": 1
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PagedListOfPostDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/posts/{postId}/files/{fileId}": {
+ "patch": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_PatchFileContent",
+ "parameters": [
+ {
+ "name": "postId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ },
+ {
+ "name": "fileId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 2
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_GetFileContent",
+ "parameters": [
+ {
+ "name": "postId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ },
+ {
+ "name": "fileId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 2
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/posts/{id}": {
+ "get": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_Get",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PostDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_Delete",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": ""
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_Update",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ }
+ ],
+ "requestBody": {
+ "x-name": "dto",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EditPostDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 2
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PostDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/posts/{postId}/comments": {
+ "post": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_CreateComment",
+ "parameters": [
+ {
+ "name": "postId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ }
+ ],
+ "requestBody": {
+ "x-name": "dto",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateCommentDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 2
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PostDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_GetAllComments",
+ "parameters": [
+ {
+ "name": "postId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ },
+ {
+ "name": "pageNumber",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "default": 1,
+ "maximum": 2147483647.0,
+ "minimum": 1.0
+ },
+ "x-position": 2
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PagedListOfCommentDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/posts/{postId}/comments/{commentId}": {
+ "get": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_GetComment",
+ "parameters": [
+ {
+ "name": "postId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ },
+ {
+ "name": "commentId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 2
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CommentDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_DeleteComment",
+ "parameters": [
+ {
+ "name": "postId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ },
+ {
+ "name": "commentId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 2
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": ""
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Post"
+ ],
+ "operationId": "Post_Update2",
+ "parameters": [
+ {
+ "name": "postId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 1
+ },
+ {
+ "name": "commentId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "x-position": 2
+ }
+ ],
+ "requestBody": {
+ "x-name": "dto",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EditCommentDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 3
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CommentDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/tags": {
+ "post": {
+ "tags": [
+ "Tag"
+ ],
+ "operationId": "Tag_Create",
+ "requestBody": {
+ "x-name": "dto",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateTagDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 1
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Tag"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "Tag"
+ ],
+ "operationId": "Tag_GetAll",
+ "parameters": [
+ {
+ "name": "pageNumber",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "format": "int32",
+ "default": 1
+ },
+ "x-position": 1
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PagedListOfTag"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/tags/{name}": {
+ "get": {
+ "tags": [
+ "Tag"
+ ],
+ "operationId": "Tag_Get",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "x-position": 1
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Tag"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Tag"
+ ],
+ "operationId": "Tag_Delete",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "x-position": 1
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": ""
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Tag"
+ ],
+ "operationId": "Tag_Update",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "x-position": 1
+ }
+ ],
+ "requestBody": {
+ "x-name": "dto",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EditTagDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 2
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Tag"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "User": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/IdentityUser"
+ },
+ {
+ "type": "object",
+ "additionalProperties": false
+ }
+ ]
+ },
+ "IdentityUser": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/IdentityUserOfString"
+ },
+ {
+ "type": "object",
+ "description": "The default implementation of IdentityUser`1 which uses a string as a primary key.",
+ "additionalProperties": false
+ }
+ ]
+ },
+ "IdentityUserOfString": {
+ "type": "object",
+ "description": "Represents a user in the identity system",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Gets or sets the primary key for this user.",
+ "nullable": true
+ },
+ "userName": {
+ "type": "string",
+ "description": "Gets or sets the user name for this user.",
+ "nullable": true
+ },
+ "normalizedUserName": {
+ "type": "string",
+ "description": "Gets or sets the normalized user name for this user.",
+ "nullable": true
+ },
+ "email": {
+ "type": "string",
+ "description": "Gets or sets the email address for this user.",
+ "nullable": true
+ },
+ "normalizedEmail": {
+ "type": "string",
+ "description": "Gets or sets the normalized email address for this user.",
+ "nullable": true
+ },
+ "emailConfirmed": {
+ "type": "boolean",
+ "description": "Gets or sets a flag indicating if a user has confirmed their email address."
+ },
+ "passwordHash": {
+ "type": "string",
+ "description": "Gets or sets a salted and hashed representation of the password for this user.",
+ "nullable": true
+ },
+ "securityStamp": {
+ "type": "string",
+ "description": "A random value that must change whenever a users credentials change (password changed, login removed)",
+ "nullable": true
+ },
+ "concurrencyStamp": {
+ "type": "string",
+ "description": "A random value that must change whenever a user is persisted to the store",
+ "nullable": true
+ },
+ "phoneNumber": {
+ "type": "string",
+ "description": "Gets or sets a telephone number for the user.",
+ "nullable": true
+ },
+ "phoneNumberConfirmed": {
+ "type": "boolean",
+ "description": "Gets or sets a flag indicating if a user has confirmed their telephone address."
+ },
+ "twoFactorEnabled": {
+ "type": "boolean",
+ "description": "Gets or sets a flag indicating if two factor authentication is enabled for this user."
+ },
+ "lockoutEnd": {
+ "type": "string",
+ "description": "Gets or sets the date and time, in UTC, when any user lockout ends.",
+ "format": "date-time",
+ "nullable": true
+ },
+ "lockoutEnabled": {
+ "type": "boolean",
+ "description": "Gets or sets a flag indicating if the user could be locked out."
+ },
+ "accessFailedCount": {
+ "type": "integer",
+ "description": "Gets or sets the number of failed login attempts for the current user.",
+ "format": "int32"
+ }
+ }
+ },
+ "RegisterDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "userName": {
+ "type": "string"
+ },
+ "email": {
+ "type": "string"
+ },
+ "password": {
+ "type": "string"
+ }
+ }
+ },
+ "TokenDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "accessToken": {
+ "type": "string"
+ },
+ "refreshToken": {
+ "type": "string"
+ }
+ }
+ },
+ "ProblemDetails": {
+ "type": "object",
+ "additionalProperties": {
+ "nullable": true
+ },
+ "properties": {
+ "type": {
+ "type": "string",
+ "nullable": true
+ },
+ "title": {
+ "type": "string",
+ "nullable": true
+ },
+ "status": {
+ "type": "integer",
+ "format": "int32",
+ "nullable": true
+ },
+ "detail": {
+ "type": "string",
+ "nullable": true
+ },
+ "instance": {
+ "type": "string",
+ "nullable": true
+ },
+ "extensions": {
+ "type": "object",
+ "additionalProperties": {}
+ }
+ }
+ },
+ "LoginDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "email": {
+ "type": "string"
+ },
+ "password": {
+ "type": "string"
+ }
+ }
+ },
+ "RefreshDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "refreshToken": {
+ "type": "string"
+ }
+ }
+ },
+ "PostDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "author": {
+ "$ref": "#/components/schemas/SlimUserDto"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Tag"
+ }
+ },
+ "fileUrl": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "SlimUserDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "userId": {
+ "type": "string"
+ },
+ "userName": {
+ "type": "string"
+ }
+ }
+ },
+ "Tag": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "type": {
+ "$ref": "#/components/schemas/TagType"
+ }
+ }
+ },
+ "TagType": {
+ "type": "string",
+ "description": "",
+ "x-enum-names": [
+ "General",
+ "Copyright"
+ ],
+ "x-enum-varnames": [
+ "General",
+ "Copyright"
+ ],
+ "x-enumNames": [
+ "General",
+ "Copyright"
+ ],
+ "x-enum-descriptions": [
+ null,
+ null
+ ],
+ "x-enumDescriptions": [
+ null,
+ null
+ ],
+ "enum": [
+ "General",
+ "Copyright"
+ ]
+ },
+ "CreatePostDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "fileName": {
+ "type": "string"
+ },
+ "fileMimeType": {
+ "type": "string"
+ },
+ "fileSize": {
+ "type": "integer",
+ "format": "int64",
+ "nullable": true
+ }
+ }
+ },
+ "PagedListOfPostDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "currentPage": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "pageSize": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "totalCount": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "totalPages": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PostDto"
+ }
+ }
+ }
+ },
+ "EditPostDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "title": {
+ "type": "string",
+ "nullable": true
+ },
+ "description": {
+ "type": "string",
+ "nullable": true
+ },
+ "tags": {
+ "type": "array",
+ "nullable": true,
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "CreateCommentDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "text": {
+ "type": "string"
+ }
+ }
+ },
+ "CommentDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "text": {
+ "type": "string"
+ },
+ "author": {
+ "$ref": "#/components/schemas/SlimUserDto"
+ }
+ }
+ },
+ "PagedListOfCommentDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "currentPage": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "pageSize": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "totalCount": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "totalPages": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CommentDto"
+ }
+ }
+ }
+ },
+ "EditCommentDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "text": {
+ "type": "string"
+ }
+ }
+ },
+ "CreateTagDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "$ref": "#/components/schemas/TagType"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "PagedListOfTag": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "currentPage": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "pageSize": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "totalCount": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "totalPages": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Tag"
+ }
+ }
+ }
+ },
+ "EditTagDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "$ref": "#/components/schemas/TagType"
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file