summaryrefslogtreecommitdiff
path: root/Controllers
diff options
context:
space:
mode:
authorJake Mannens <jake@asger.xyz>2026-03-17 03:04:36 +1100
committerJake Mannens <jake@asger.xyz>2026-06-07 12:32:37 +1000
commitc51ff4e755f009ca0bc8e935a92c04e583c4ee8a (patch)
tree0a9a311c5404a96495df1047e613dc3aea3d0f15 /Controllers
Initial commit
Diffstat (limited to 'Controllers')
-rw-r--r--Controllers/ApiFeedController.cs25
-rw-r--r--Controllers/ApiMediaController.cs227
-rw-r--r--Controllers/ApiStatisticsController.cs26
-rw-r--r--Controllers/ApiTagController.cs256
-rw-r--r--Controllers/ApiUserController.cs113
-rw-r--r--Controllers/LoginController.cs49
-rw-r--r--Controllers/MediaController.cs145
7 files changed, 841 insertions, 0 deletions
diff --git a/Controllers/ApiFeedController.cs b/Controllers/ApiFeedController.cs
new file mode 100644
index 0000000..068cc17
--- /dev/null
+++ b/Controllers/ApiFeedController.cs
@@ -0,0 +1,25 @@
+using HyperBooru.ApiModels;
+using HyperBooru.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace HyperBooru.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("/api/feed")]
+public class ApiFeedController : Controller {
+ private IFeedService feedService;
+
+ public ApiFeedController(IDbContextFactory<HBContext> dbFactory, IFeedService feedService) =>
+ this.feedService = feedService;
+
+ [HttpPost]
+ public IActionResult FetchChunkAsync([FromBody] FeedRequest feedRequest) {
+ if(feedRequest.Count > 1000)
+ throw new ApiModels.ArgumentException("Total number of requested items exceeds maximum");
+
+ return Ok(feedService.LoadChunk(feedRequest).Select(m => m.Guid).ToArray());
+ }
+}
diff --git a/Controllers/ApiMediaController.cs b/Controllers/ApiMediaController.cs
new file mode 100644
index 0000000..bb6c81e
--- /dev/null
+++ b/Controllers/ApiMediaController.cs
@@ -0,0 +1,227 @@
+using HyperBooru.ApiModels;
+using HyperBooru.Services;
+using HyperBooru.Util;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using System.Text.Json;
+
+namespace HyperBooru.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("/api/media")]
+public class ApiMediaController : Controller {
+ private IDbContextFactory<HBContext> dbFactory;
+ private IMediaService mediaService;
+
+ public ApiMediaController(IDbContextFactory<HBContext> dbFactory, IMediaService mediaService) {
+ this.dbFactory = dbFactory;
+ this.mediaService = mediaService;
+ }
+
+ [HttpGet("{mediaId}")]
+ public async Task<IActionResult> Get([FromRoute] Guid mediaId) {
+ using var db = dbFactory.CreateDbContext();
+
+ var media = await db.Media.FirstOrDefaultAsync(m => m.Guid == mediaId);
+
+ if(media is null)
+ throw new ObjectNotFoundException([ mediaId ]);
+
+ return Ok((ApiModels.Media) media);
+ }
+
+ [HttpGet("{mediaId}/files")]
+ public async Task<IActionResult> GetUploadedFiles([FromRoute] Guid mediaId) {
+ using var db = dbFactory.CreateDbContext();
+
+ var media = await db.Media
+ .Include(m => m.UploadedFiles)
+ .FirstOrDefaultAsync(m => m.Guid == mediaId);
+
+ if(media is null)
+ throw new ObjectNotFoundException([ mediaId ]);
+
+ return Ok(media.UploadedFiles.Select(uf => (ApiModels.UploadedFile) uf).ToArray());
+ }
+
+ [HttpPatch]
+ public async Task<IActionResult> UpdateMedia([FromBody] ApiModels.Media updatedMedia) {
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var media = await db.Media.FirstOrDefaultAsync(m => m.Guid == updatedMedia.MediaId);
+ if(media is null)
+ throw new ObjectNotFoundException([ updatedMedia.MediaId ]);
+
+ media.ShortDescription = updatedMedia.ShortDescription?.NullIfEmpty();
+ media.LongDescription = updatedMedia.LongDescription?.NullIfEmpty();
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok();
+ }
+
+ [HttpPost]
+ public IActionResult Upload() {
+ if(Request.Form.Files.Count == 0)
+ throw new ApiModels.ArgumentException("No files");
+ if(Request.Form.Files.Count > 1)
+ throw new ApiModels.ArgumentException("More than one file supplied");
+
+ var metadataString = Request.Form.Files
+ .First()
+ .Headers["X-HyperBooru-Metadata"]
+ .ElementAtOrDefault(0);
+
+ MediaUploadRequest? metadata = metadataString is null ? null :
+ JsonSerializer.Deserialize<MediaUploadRequest>(metadataString);
+
+ var formFile = Request.Form.Files.First();
+
+ var media = mediaService.Create(
+ formFile.OpenReadStream(),
+ formFile.FileName,
+ metadata?.Checksum,
+ metadata?.LastAccessTime,
+ metadata?.LastWriteTime,
+ metadata?.CreateTime,
+ metadata?.Path.NullIfEmpty(),
+ metadata?.PathType,
+ metadata?.Tags);
+
+ return Ok((ApiModels.Media) media);
+ }
+
+ [HttpDelete("{mediaId}")]
+ public void Delete([FromRoute] Guid mediaId) =>
+ mediaService.Delete(mediaId);
+
+ [HttpGet("{mediaId}/tags")]
+ public async Task<IActionResult> GetMediaTagsAsync([FromRoute] Guid mediaId) {
+ using var db = dbFactory.CreateDbContext();
+
+ var media = await db.Media
+ .Include(m => m.Tags)
+ .ThenInclude(t => t.TagDefinition)
+ .ThenInclude(td => td.ImplicitTags)
+ .FirstOrDefaultAsync(m => m.Guid == mediaId);
+ if(media is null)
+ throw new ObjectNotFoundException([ mediaId ]);
+
+ return Ok(media.Tags.Select(t => (ApiModels.TagDefinition) t.TagDefinition).ToArray());
+ }
+
+ [HttpPatch("{mediaId}/tags")]
+ public async Task<IActionResult> AddTagsToMediaAsync(
+ [FromRoute] Guid mediaId,
+ [FromBody] Guid[] tagIds) {
+
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var missing = new List<Guid>();
+
+ var media = await db.Media
+ .Include(m => m.Tags)
+ .ThenInclude(t => t.TagDefinition)
+ .ThenInclude(td => td.ImplicitTags)
+ .FirstOrDefaultAsync(m => m.Guid == mediaId);
+ if(media is null)
+ missing.Add(mediaId);
+
+ tagIds = tagIds.Distinct().ToArray();
+
+ var tags = await db.TagDefinitions
+ .Where(td => tagIds.Contains(td.Guid))
+ .ToArrayAsync();
+
+ missing.AddRange(tagIds.Except(tags.Select(td => td.Guid)));
+ if(missing.Any())
+ throw new ObjectNotFoundException(missing);
+
+ media.Tags.AddRange(tags
+ .Where(td => !media.Tags.Select(t => t.TagDefinition.Guid).Contains(td.Guid))
+ .Select(td => new Tag() { TagDefinition = td }));
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok(media.Tags.Select(t => (ApiModels.TagDefinition) t.TagDefinition).ToArray());
+ }
+
+ [HttpPut("{mediaId}/tags")]
+ public async Task<IActionResult> ReplaceMediaTagsAsync(
+ [FromRoute] Guid mediaId,
+ [FromBody] Guid[] tagIds) {
+
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var missing = new List<Guid>();
+
+ var media = await db.Media
+ .Include(m => m.Tags)
+ .ThenInclude(t => t.TagDefinition)
+ .ThenInclude(td => td.ImplicitTags)
+ .FirstOrDefaultAsync(m => m.Guid == mediaId);
+ if(media is null)
+ missing.Add(mediaId);
+
+ tagIds = tagIds.Distinct().Order().ToArray();
+ var tags = await db.TagDefinitions
+ .Where(td => tagIds.Contains(td.Guid))
+ .ToArrayAsync();
+
+ missing.AddRange(tagIds.Except(tags.Select(td => td.Guid)));
+ if(missing.Any())
+ throw new ObjectNotFoundException(missing);
+
+ media.Tags.AddRange(tags
+ .Where(td => !media.Tags.Select(t => t.TagDefinition.Guid).Contains(td.Guid))
+ .Select(td => new Tag() { TagDefinition = td }));
+
+ db.Tags.RemoveRange(
+ media.Tags.Where(t => !tagIds.Contains(t.TagDefinition.Guid)));
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok(media.Tags.Select(t => (ApiModels.TagDefinition) t.TagDefinition).ToArray());
+ }
+
+ [HttpPatch("{mediaId}/tags/delete")]
+ public async Task<IActionResult> DeleteTagsFromMediaAsync(
+ [FromRoute] Guid mediaId,
+ [FromBody] Guid[] tagIds) {
+
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var missing = new List<Guid>();
+
+ var media = await db.Media
+ .Include(m => m.Tags)
+ .ThenInclude(t => t.TagDefinition)
+ .ThenInclude(td => td.ImplicitTags)
+ .FirstOrDefaultAsync(m => m.Guid == mediaId);
+ if(media is null)
+ missing.Add(mediaId);
+
+ tagIds = tagIds.Distinct().Order().ToArray();
+
+ missing.AddRange(tagIds.Except(media.Tags.Select(t => t.TagDefinition.Guid)));
+ if(missing.Any())
+ throw new ObjectNotFoundException(missing);
+
+ db.Tags.RemoveRange(
+ media.Tags.Where(t => tagIds.Contains(t.TagDefinition.Guid)));
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok(media.Tags.Select(t => (ApiModels.TagDefinition) t.TagDefinition).ToArray());
+ }
+}
diff --git a/Controllers/ApiStatisticsController.cs b/Controllers/ApiStatisticsController.cs
new file mode 100644
index 0000000..3acd1d5
--- /dev/null
+++ b/Controllers/ApiStatisticsController.cs
@@ -0,0 +1,26 @@
+using HyperBooru.ApiModels;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace HyperBooru.Controllers;
+
+[ApiController]
+[Route("/api/stats")]
+public class ApiStatisticsController : Controller {
+ private IDbContextFactory<HBContext> dbFactory;
+
+ public ApiStatisticsController(IDbContextFactory<HBContext> dbFactory) =>
+ this.dbFactory = dbFactory;
+
+ [HttpGet("ingest")]
+ public async Task<IActionResult> GetIngestStatistics() {
+ using var db = dbFactory.CreateDbContext();
+
+ return Ok(new IngestStatistics() {
+ TotalMediaCount = db.Media.Count(),
+ UntaggedMediaCount = db.Media
+ .Where(m => m.Tags.Any(t => t.TagDefinition.ObjectId == (int) HBObjectId.IngestTag))
+ .Count(),
+ });
+ }
+}
diff --git a/Controllers/ApiTagController.cs b/Controllers/ApiTagController.cs
new file mode 100644
index 0000000..d1e49ee
--- /dev/null
+++ b/Controllers/ApiTagController.cs
@@ -0,0 +1,256 @@
+using HyperBooru.ApiModels;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace HyperBooru.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("/api/tag")]
+public class ApiTagController : Controller {
+ private IDbContextFactory<HBContext> dbFactory;
+
+ public ApiTagController(IDbContextFactory<HBContext> dbFactory) =>
+ this.dbFactory = dbFactory;
+
+ [HttpGet("definition")]
+ public async Task<IActionResult> GetAllTagDefinitionsAsync() {
+ using var db = dbFactory.CreateDbContext();
+
+ var definitions = await db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .Select(td => (ApiModels.TagDefinition)td)
+ .ToArrayAsync();
+
+ return Ok(definitions);
+ }
+
+ [HttpGet("definition/{tagDefinitionId}")]
+ public async Task<IActionResult> GetTagDefinitionAsync([FromRoute] Guid tagDefinitionId) {
+ using var db = dbFactory.CreateDbContext();
+
+ var tagDefinition = await db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .FirstOrDefaultAsync(td => td.Guid == tagDefinitionId);
+
+ if(tagDefinition is null)
+ throw new ObjectNotFoundException([ tagDefinitionId ]);
+
+ return Ok(tagDefinition);
+ }
+
+ [HttpPost("definition")]
+ public async Task<IActionResult> CreateTagDefinitionAsync([FromBody] TagCreateRequest request) {
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var nameExists = db.TagDefinitions.Any(td => td.Name == request.Name);
+ var aliasExists =
+ request.Alias is not null && db.TagDefinitions.Any(td => td.Alias == request.Alias);
+
+ if(nameExists || aliasExists)
+ throw new TagDuplicateException(nameExists, aliasExists);
+
+ List<TagDefinition> implicitTags = new();
+ if(request.ImplicitTags is not null) {
+ implicitTags = await db.TagDefinitions
+ .Where(td => request.ImplicitTags.Distinct().Contains(td.Guid))
+ .ToListAsync();
+ }
+
+ var tagDefinition = new TagDefinition {
+ Source = TagSource.UserTag,
+ Namespace = request.Namespace,
+ Name = request.Name,
+ Alias = request.Alias,
+ ImplicitTags = implicitTags
+ };
+
+ db.TagDefinitions.Add(tagDefinition);
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok((ApiModels.TagDefinition) tagDefinition);
+ }
+
+ [HttpDelete("definition/{tagDefinitionId}")]
+ public async Task<IActionResult> DeleteTagDefinitionAsync([FromRoute] Guid tagDefinitionId) {
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var tagDefinition = await db.TagDefinitions
+ .FirstOrDefaultAsync(td => td.Guid == tagDefinitionId);
+
+ if(tagDefinition is null)
+ throw new ObjectNotFoundException([ tagDefinitionId ]);
+
+ if(tagDefinition.ObjectId < 0)
+ throw new ApiModels.ArgumentException("Cannot delete built-in tag definition");
+
+ db.TagDefinitions.Remove(tagDefinition);
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok();
+ }
+
+ [HttpPatch("definition/{tagDefinitionId}")]
+ public async Task<IActionResult> UpdateTagDefinitionAsync(
+ [FromRoute] Guid tagDefinitionId,
+ [FromBody] TagUpdateRequest request) {
+
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var tagDefinition = await db.TagDefinitions
+ .FirstOrDefaultAsync(td => td.Guid == tagDefinitionId);
+
+ if(tagDefinition is null)
+ throw new ObjectNotFoundException([ tagDefinitionId ]);
+
+ if(tagDefinition.ObjectId < 0)
+ throw new ApiModels.ArgumentException("Cannot update built-in tag definition");
+
+ var nameExists =
+ request.Name is not null && db.TagDefinitions.Any(td => td.Name == request.Name);
+ var aliasExists =
+ request.Alias is not null && db.TagDefinitions.Any(td => td.Alias == request.Alias);
+
+ if(nameExists || aliasExists)
+ throw new TagDuplicateException(nameExists, aliasExists);
+
+ tagDefinition.Namespace = request.Namespace ?? tagDefinition.Namespace;
+ tagDefinition.Name = request.Name ?? tagDefinition.Name;
+ tagDefinition.Alias = request.Alias ?? tagDefinition.Alias;
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok((ApiModels.TagDefinition) tagDefinition);
+ }
+
+ [HttpPatch("definition/{tagDefinitionId}/implicit")]
+ public async Task<IActionResult> AddImplicitTagsAsync(
+ [FromRoute] Guid tagDefinitionId,
+ [FromBody] Guid[] implicitTagIds) {
+
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var missing = new List<Guid>();
+
+ var tagDefinition = await db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .FirstOrDefaultAsync(td => td.Guid == tagDefinitionId);
+
+ if(tagDefinition is null)
+ missing.Add(tagDefinitionId);
+
+ implicitTagIds = implicitTagIds.Distinct().ToArray();
+
+ var implicitTags = db.TagDefinitions
+ .Where(td => implicitTagIds.Contains(td.Guid))
+ .ToArray();
+
+ missing.AddRange(implicitTagIds.Except(implicitTags.Select(td => td.Guid)));
+ if(missing.Any())
+ throw new ObjectNotFoundException(missing);
+
+ if(tagDefinition!.ObjectId < 0)
+ throw new ApiModels.ArgumentException("Cannot update built-in tag definition");
+
+ tagDefinition.ImplicitTags.AddRange(
+ implicitTags.ExceptBy(tagDefinition.ImplicitTags.Select(td => td.Guid), td => td.Guid));
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok();
+ }
+
+ [HttpPut("definition/{tagDefinitionId}/implicit")]
+ public async Task<IActionResult> ReplaceImplicitTagsAsync(
+ [FromRoute] Guid tagDefinitionId,
+ [FromBody] Guid[] implicitTagIds) {
+
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var missing = new List<Guid>();
+
+ var tagDefinition = await db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .FirstOrDefaultAsync(td => td.Guid == tagDefinitionId);
+
+ if(tagDefinition is null)
+ missing.Add(tagDefinitionId);
+
+ implicitTagIds = implicitTagIds.Distinct().ToArray();
+
+ var implicitTags = db.TagDefinitions
+ .Where(td => implicitTagIds.Contains(td.Guid))
+ .ToArray();
+
+ missing.AddRange(implicitTagIds.Except(implicitTags.Select(td => td.Guid)));
+ if(missing.Any())
+ throw new ObjectNotFoundException(missing);
+
+ if(tagDefinition!.ObjectId < 0)
+ throw new ApiModels.ArgumentException("Cannot update built-in tag definition");
+
+ tagDefinition.ImplicitTags.AddRange(
+ implicitTags.ExceptBy(tagDefinition.ImplicitTags.Select(td => td.Guid), td => td.Guid));
+
+ var toRemove = tagDefinition.ImplicitTags
+ .Where(td => !implicitTags.Select(td => td.Guid).Contains(td.Guid))
+ .ToArray();
+
+ foreach(var td in toRemove)
+ tagDefinition.ImplicitTags.Remove(td);
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok();
+ }
+
+ [HttpPost("definition/{tagDefinitionId}/implicit/delete")]
+ public async Task<IActionResult> DeleteImplicitTagAsync(
+ [FromRoute] Guid tagDefinitionId,
+ [FromBody] Guid[] implicitTagIds) {
+
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var tagDefinition = await db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .FirstOrDefaultAsync(td => td.Guid == tagDefinitionId);
+
+ if(tagDefinition is null)
+ throw new ObjectNotFoundException([ tagDefinitionId ]);
+
+ if(tagDefinition.ObjectId < 0)
+ throw new ApiModels.ArgumentException("Cannot update built-in tag definition");
+
+ implicitTagIds = implicitTagIds.Distinct().ToArray();
+
+ var missingTags = implicitTagIds.Except(tagDefinition.ImplicitTags.Select(td => td.Guid));
+ if(missingTags.Any())
+ throw new ObjectNotFoundException(missingTags);
+
+ var toRemove = tagDefinition.ImplicitTags
+ .Where(td => !implicitTagIds.Contains(td.Guid))
+ .ToArray();
+
+ foreach(var td in toRemove)
+ tagDefinition.ImplicitTags.Remove(td);
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok();
+ }
+}
diff --git a/Controllers/ApiUserController.cs b/Controllers/ApiUserController.cs
new file mode 100644
index 0000000..3230218
--- /dev/null
+++ b/Controllers/ApiUserController.cs
@@ -0,0 +1,113 @@
+using HyperBooru.ApiModels;
+using HyperBooru.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace HyperBooru.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("/api/user")]
+public class ApiUserController : Controller {
+ private IDbContextFactory<HBContext> dbFactory;
+
+ public ApiUserController(IDbContextFactory<HBContext> dbFactory) =>
+ this.dbFactory = dbFactory;
+
+ [HttpGet]
+ public async Task<IActionResult> GetAllUsersAsync() {
+ using var db = dbFactory.CreateDbContext();
+
+ return Ok(await db.Users
+ .Select(u => (ApiModels.User) u)
+ .ToArrayAsync());
+ }
+
+ [HttpGet("{userId}")]
+ public async Task<IActionResult> GetUserAsync([FromRoute] Guid userId) {
+ using var db = dbFactory.CreateDbContext();
+
+ var user = await db.Users
+ .FirstOrDefaultAsync(u => u.Guid == userId);
+
+ if(user is null)
+ throw new ObjectNotFoundException([ userId ]);
+
+ return Ok((ApiModels.User) user);
+ }
+
+ [HttpPost]
+ public async Task<IActionResult> CreateUserAsync([FromBody] ApiModels.UserCreateRequest request) {
+ using var db = dbFactory.CreateDbContext();
+
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ if(await db.Users.AnyAsync(u => u.Username == request.Username))
+ throw new ApiModels.ArgumentException("Username already exists");
+
+ var user = new User() {
+ Username = request.Username,
+ PasswordHash = UserService.HashPassword(request.Password)
+ };
+
+ db.Users.Add(user);
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok((ApiModels.User) user);
+ }
+
+ [HttpPatch("{userId}")]
+ public async Task<IActionResult> UpdateUserAsync(
+ [FromRoute] Guid userId,
+ [FromBody] ApiModels.UserUpdateRequest request) {
+
+ using var db = dbFactory.CreateDbContext();
+
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var user = await db.Users.FirstOrDefaultAsync(u => u.Guid == userId);
+ if(user is null)
+ throw new ObjectNotFoundException([ userId ]);
+
+ if(request.Username is not null) {
+ if(string.IsNullOrWhiteSpace(request.Username))
+ throw new ApiModels.ArgumentException("Username cannot be empty");
+ user.Username = request.Username;
+ }
+
+ if(request.Password is not null) {
+ if(string.IsNullOrWhiteSpace(request.Password))
+ throw new ApiModels.ArgumentException("Password cannot be empty");
+ user.PasswordHash = UserService.HashPassword(request.Password);
+ }
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok((ApiModels.User) user);
+ }
+
+ [HttpDelete("{userId}")]
+ public async Task<IActionResult> DeleteUserAsync([FromRoute] Guid userId) {
+ if(userId == HBContext.AdminUser)
+ throw new ApiModels.ArgumentException("Cannot delete the admin user");
+
+ using var db = dbFactory.CreateDbContext();
+
+ using var transaction = await db.Database.BeginTransactionAsync();
+
+ var user = await db.Users.FirstOrDefaultAsync(u => u.Guid == userId);
+ if(user is null)
+ throw new ObjectNotFoundException([ userId ]);
+
+ db.Users.Remove(user);
+
+ await db.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ return Ok((ApiModels.User) user);
+ }
+}
diff --git a/Controllers/LoginController.cs b/Controllers/LoginController.cs
new file mode 100644
index 0000000..c93f0d5
--- /dev/null
+++ b/Controllers/LoginController.cs
@@ -0,0 +1,49 @@
+using HyperBooru.Services;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Mvc;
+using System.Security.Claims;
+
+namespace HyperBooru.Controllers;
+
+[ApiController]
+[Route("/")]
+public class LoginController : Controller {
+ private IHttpContextAccessor httpContextAccessor;
+
+ public LoginController(IHttpContextAccessor httpContextAccessor) =>
+ this.httpContextAccessor = httpContextAccessor;
+
+ [HttpPost("Login")]
+ public async Task<IActionResult> Login(
+ [FromForm] string username,
+ [FromForm] string password,
+ HBContext db) {
+
+ var user = db.Users.FirstOrDefault(u => u.Username == username);
+ if(user is null)
+ return StatusCode(403);
+
+ var hash = UserService.HashPassword(password);
+ if(hash != user.PasswordHash)
+ return StatusCode(403);
+
+ var claims = new Claim[] {
+ new Claim(ClaimTypes.Name, user.Username),
+ new Claim("ObjectId", user.ObjectId.ToString())
+ };
+
+ var claimsIdentity = new ClaimsIdentity(
+ claims,
+ CookieAuthenticationDefaults.AuthenticationScheme);
+
+ var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
+
+ await httpContextAccessor.HttpContext!.SignInAsync(claimsPrincipal);
+ return Ok();
+ }
+
+ [HttpPost("Logout")]
+ public async Task Logout() =>
+ await httpContextAccessor.HttpContext!.SignOutAsync();
+}
diff --git a/Controllers/MediaController.cs b/Controllers/MediaController.cs
new file mode 100644
index 0000000..248765a
--- /dev/null
+++ b/Controllers/MediaController.cs
@@ -0,0 +1,145 @@
+using HyperBooru.ApiModels;
+using HyperBooru.Services;
+using HyperBooru.Util;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace HyperBooru.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("/media")]
+public class MediaController : Controller {
+ private IHttpContextAccessor httpContextAccessor;
+ private IMediaService mediaService;
+ private IConfigService config;
+ private HBContext db;
+
+ private readonly string[] FormatPriority = [
+ "image/webp",
+ "image/png"
+ ];
+
+ public MediaController(
+ IHttpContextAccessor httpContextAccessor,
+ IMediaService mediaService,
+ IConfigService config,
+ HBContext db) {
+
+ this.httpContextAccessor = httpContextAccessor;
+ this.mediaService = mediaService;
+ this.config = config;
+ this.db = db;
+ }
+
+ [HttpGet("{mediaId}")]
+ public IActionResult Fetch([FromRoute] Guid mediaId) {
+ var media = db.Media
+ .Include(m => m.CurrentUploadedFile)
+ .First(m => m.Guid == mediaId);
+ if(media is null)
+ throw new ObjectNotFoundException([ mediaId ]);
+
+ // Check if the requested media item is a HEIC image and if it is, convert it
+ // otherwise, return the original file content, unaltered
+ if(media.CurrentUploadedFile!.MimeType == "image/heic") {
+ // If the media needs to be converted, check the HTTP request for allowed
+ // media formats, and convert to the best available format or WebP otherwise
+ var allowedTypes = httpContextAccessor
+ .HttpContext?
+ .Request
+ .GetTypedHeaders().Accept.Select(h => h.MediaType.ToString()) ?? Array.Empty<string>();
+
+ var format = FormatPriority.FirstOrDefault(f => allowedTypes.Contains(f)) ?? "image/webp";
+
+ var fs = mediaService.GetConverted(media, format);
+
+ return new FileStreamResult(fs, format);
+ } else {
+ var fs = System.IO.File.OpenRead(mediaService.GetPath(media));
+ return new FileStreamResult(fs, media.CurrentUploadedFile!.MimeType);
+ }
+ }
+
+ [HttpGet("thumb/{mediaId}")]
+ public IActionResult Thumbnail(
+ [FromRoute] Guid mediaId,
+ [FromQuery(Name = "w")] int? width,
+ [FromQuery(Name = "h")] int? height) {
+
+ var thumb = mediaService.GetThumbnail(mediaId, width, height);
+ return new FileStreamResult(thumb, "image/jpeg");
+ }
+
+ [HttpDelete("{mediaId}")]
+ public void Delete([FromRoute] Guid mediaId) {
+ mediaService.Delete(mediaId);
+ }
+
+ [HttpPost]
+ public IActionResult Upload() {
+ if(Request.Form.Files.Count == 0)
+ throw new ApiModels.ArgumentException("No files");
+
+ Media media = new();
+
+ foreach(var formFile in Request.Form.Files) {
+ // Parse timestamps from headers
+ DateTime? lastAccessTime =
+ formFile.Headers["X-HyperBooru-LastAccessTime"]
+ .ElementAtOrDefault(0)?
+ .TryParseDateTimeUtc();
+ DateTime? lastWriteTime =
+ formFile.Headers["X-HyperBooru-LastWriteTime"]
+ .ElementAtOrDefault(0)?
+ .TryParseDateTimeUtc();
+ DateTime? createTime =
+ formFile.Headers["X-HyperBooru-CreateTime"]
+ .ElementAtOrDefault(0)?
+ .TryParseDateTimeUtc();
+
+ // Parse original path from headers
+ string? path =
+ formFile.Headers["X-HyperBooru-Path"]
+ .ElementAtOrDefault(0);
+
+ object? pathType = null;
+ string? pathTypeString =
+ formFile.Headers["X-HyperBooru-PathType"]
+ .ElementAtOrDefault(0);
+ Enum.TryParse(typeof(PathType), pathTypeString, true, out pathType);
+
+ // Parse tag IDs from headers
+ Guid[]? tagIds = formFile.Headers["X-HyperBooru-Tags"]
+ .ElementAtOrDefault(0)?
+ .Split(',')
+ .Select(t => Guid.Parse(t))
+ .ToArray();
+
+ media = mediaService.Create(
+ formFile.OpenReadStream(),
+ formFile.FileName,
+ formFile.Headers["X-HyperBooru-Checksum"]
+ .ElementAtOrDefault(0),
+ lastAccessTime,
+ lastWriteTime,
+ createTime,
+ path,
+ (PathType?) pathType,
+ tagIds);
+
+ // Return the GUID of the new media object if requested
+ bool returnMetadataParsed = bool.TryParse(
+ formFile.Headers["X-HyperBooru-ReturnMediaId"], out var returnMetadata);
+
+ if(returnMetadataParsed && returnMetadata)
+ return Content(media.Guid.ToString());
+ }
+
+ if(Request.Form.Files.Count == 1)
+ return Redirect($"/ViewMedia?m={media.Guid}");
+ else
+ return Redirect($"/Gallery");
+ }
+} \ No newline at end of file