diff options
| -rw-r--r-- | Controllers/ApiMediaController.cs | 98 | ||||
| -rw-r--r-- | Controllers/ApiTagController.cs | 36 | ||||
| -rw-r--r-- | Controllers/LoginController.cs | 1 | ||||
| -rw-r--r-- | Controllers/MediaController.cs | 3 | ||||
| -rw-r--r-- | Dockerfile | 7 | ||||
| -rw-r--r-- | Enum.cs | 12 | ||||
| -rw-r--r-- | Exception.cs | 69 | ||||
| -rw-r--r-- | ExceptionMiddleware.cs | 64 | ||||
| -rw-r--r-- | HBContext.cs | 4 | ||||
| -rw-r--r-- | Media.cs | 37 | ||||
| -rw-r--r-- | Pages/Component/TagEditDialog.razor | 2 | ||||
| -rw-r--r-- | Pages/Gallery.razor | 4 | ||||
| -rw-r--r-- | Program.cs | 9 | ||||
| -rw-r--r-- | Server.csproj | 8 | ||||
| -rw-r--r-- | Services/ConfigService.cs | 4 | ||||
| -rw-r--r-- | Services/FeedService.cs | 3 | ||||
| -rw-r--r-- | Services/MediaService.cs | 4 | ||||
| -rw-r--r-- | Services/TagService.cs | 6 | ||||
| -rw-r--r-- | Services/UserService.cs | 1 | ||||
| -rw-r--r-- | Tag.cs | 12 |
20 files changed, 274 insertions, 110 deletions
diff --git a/Controllers/ApiMediaController.cs b/Controllers/ApiMediaController.cs new file mode 100644 index 0000000..0539b77 --- /dev/null +++ b/Controllers/ApiMediaController.cs @@ -0,0 +1,98 @@ +using HyperBooru.ApiModels; +using HyperBooru.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; + +namespace HyperBooru.Controllers; + +[ApiController] +[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) + return NotFound(); + + media.ShortDescription = updatedMedia.ShortDescription; + media.LongDescription = updatedMedia.LongDescription; + + await db.SaveChangesAsync(); + await transaction.CommitAsync(); + + return Ok(); + } + + [HttpPost] + public IActionResult Upload() { + if(Request.Form.Files.Count == 0) + return BadRequest("No files"); + if(Request.Form.Files.Count > 1) + return BadRequest("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, + metadata?.PathType, + metadata?.Tags); + + return Ok((ApiModels.Media) media); + } + + [HttpDelete("{mediaId}")] + public void Delete([FromRoute] Guid mediaId) => + mediaService.Delete(mediaId); +} diff --git a/Controllers/ApiTagController.cs b/Controllers/ApiTagController.cs new file mode 100644 index 0000000..afd5b05 --- /dev/null +++ b/Controllers/ApiTagController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace HyperBooru.Controllers; + +[ApiController] +[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); + + return tagDefinition is not null ? Ok(tagDefinition) : NotFound(); + } +} diff --git a/Controllers/LoginController.cs b/Controllers/LoginController.cs index b01553c..c93f0d5 100644 --- a/Controllers/LoginController.cs +++ b/Controllers/LoginController.cs @@ -1,7 +1,6 @@ using HyperBooru.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Cryptography.KeyDerivation; using Microsoft.AspNetCore.Mvc; using System.Security.Claims; diff --git a/Controllers/MediaController.cs b/Controllers/MediaController.cs index 7eb46c6..96ecb44 100644 --- a/Controllers/MediaController.cs +++ b/Controllers/MediaController.cs @@ -1,4 +1,5 @@ -using HyperBooru.Services; +using HyperBooru.ApiModels; +using HyperBooru.Services; using HyperBooru.Util; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -1,7 +1,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:f061e5a7532b36fa1d1b684857fe1f504ba92115b9934f154643266613c44c62 AS build -WORKDIR /App +WORKDIR /App/Server -COPY . ./ +COPY Server /App/Server +COPY ApiModels /App/ApiModels RUN dotnet restore RUN dotnet publish -o out @@ -11,5 +12,5 @@ RUN apt install -y imagemagick tesseract-ocr tesseract-ocr-eng RUN apt clean RUN rm -rf /var/lib/apt/lists/* WORKDIR /App -COPY --from=build /App/out . +COPY --from=build /App/Server/out . ENTRYPOINT [ "dotnet", "HyperBooru.dll" ] diff --git a/Enum.cs b/Enum.cs deleted file mode 100644 index bc49691..0000000 --- a/Enum.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace HyperBooru; - -public enum SortOrder { - ObjectId, - LastWriteTime, - Random, -} - -public enum PathType { - Windows, - Unix -} diff --git a/Exception.cs b/Exception.cs deleted file mode 100644 index fc3feda..0000000 --- a/Exception.cs +++ /dev/null @@ -1,69 +0,0 @@ -namespace HyperBooru; - -public class HBException : Exception { - public HBException() - : base() {} - public HBException(string message) - : base(message) {} - public HBException(string message, Exception inner) - : base(message, inner) {} -} - -public class ObjectNotFoundException : HBException { - public Guid Guid { get; private init; } - - public ObjectNotFoundException(Guid guid) - : base($"Object not found: {guid}") {} -} - -public class TagException : HBException { - public TagDefinition? TagDefinition { get; private init; } - - public TagException(string message) : base(message) {} - public TagException(string message, TagDefinition tagDefinition) - : base(message) => - TagDefinition = tagDefinition; -} - -public class TagDuplicateException : TagException { - public bool NameExists { get; private init; } - public bool AliasExists { get; private init; } - - public TagDuplicateException(bool nameExists, bool aliasExists) - : base(GenerateMessage(nameExists, aliasExists)) { - - NameExists = nameExists; - AliasExists = aliasExists; - } - - private static string GenerateMessage(bool nameExists, bool aliasExists) { - if(nameExists && aliasExists) - return $"Both tag name and alias already exist!"; - else if(nameExists) - return $"Tag name already exists!"; - else - return $"Tag alias already exists"; - } -} - -public class MediaException : HBException { - public Guid? MediaId { get; private init; } = null; - - public MediaException(string message) : base(message) {} - public MediaException(string message, Guid mediaId) : base(message) => - MediaId = mediaId; - public MediaException(string message, Media media) : base(message) => - MediaId = media.Guid; -} - -public class MediaCreateException : MediaException { - public MediaCreateException(string message) - : base(message) {} -} - -public class ThumbnailException : MediaException { - public ThumbnailException(string message, Guid mediaId) - : base(message, mediaId) {} - public ThumbnailException(string message, Media media) - : base(message, media) {} -} diff --git a/ExceptionMiddleware.cs b/ExceptionMiddleware.cs new file mode 100644 index 0000000..29d0e10 --- /dev/null +++ b/ExceptionMiddleware.cs @@ -0,0 +1,64 @@ +using HyperBooru.ApiModels; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace HyperBooru; + +// Middleware class to intercept API controller exceptions and +// return said exceptions to API clients as serialized JSON objects +public sealed class ExceptionMiddleware { + private RequestDelegate next; + + public ExceptionMiddleware(RequestDelegate next) => + this.next = next; + + public async Task Invoke(HttpContext context) { + try { + await next(context); + } catch(HBException e) { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = + e.GetType().GetCustomAttribute<ExceptionStatusCodeAttribute>()?.StatusCode ?? + StatusCodes.Status500InternalServerError; + + await context.Response.WriteAsJsonAsync(e); + + var x = 1; + } catch(Exception) { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + + context.Response.Clear(); + + await context.Response.WriteAsync(string.Empty); + } + } +} + +// This class is needed as the JSON serializer often fails to serialize +// members of the native 'Exception' class +public sealed class ExceptionJsonResolver : DefaultJsonTypeInfoResolver { + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) { + var info = base.GetTypeInfo(type, options); + + if(!typeof(Exception).IsAssignableFrom(type)) + return info; + + string[] excludedProps = [ + "data", + "hResult", + "helpLink", + "innerException", + "source", + "stackTrace", + "targetSite" + ]; + + foreach(var p in info.Properties.Where(p => excludedProps.Contains(p.Name))) + p.ShouldSerialize = (_, _) => false; + + return info; + } +} diff --git a/HBContext.cs b/HBContext.cs index bb0572f..b684a51 100644 --- a/HBContext.cs +++ b/HBContext.cs @@ -1,5 +1,5 @@ -using Microsoft.EntityFrameworkCore; -using HyperBooru.Services; +using HyperBooru.Services; +using Microsoft.EntityFrameworkCore; namespace HyperBooru; @@ -1,8 +1,7 @@ -using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using HyperBooru.ApiModels; using Microsoft.EntityFrameworkCore; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using System.Net.NetworkInformation; namespace HyperBooru; @@ -27,6 +26,13 @@ public class Media : HBObject { .First()?.Filename ?? Guid.ToString().ToUpper(); } } + + public static explicit operator ApiModels.Media(Media media) => + new() { + MediaId = media.Guid, + ShortDescription = media.ShortDescription, + LongDescription = media.LongDescription + }; } public class UploadedFile : HBObject { @@ -44,6 +50,25 @@ public class UploadedFile : HBObject { public string? Path { get; set; } public PathType? PathType { get; set; } public virtual Media Media { get; set; } + + public static explicit operator ApiModels.UploadedFile(UploadedFile uploadedFile) => + new() { + MediaId = uploadedFile.Media.Guid, + UploadedFileId = uploadedFile.Guid, + Checksum = uploadedFile.Checksum, + ChecksumVerified = uploadedFile.ChecksumVerified, + Filename = uploadedFile.Filename, + Length = uploadedFile.Length, + MimeType = uploadedFile.MimeType, + Width = uploadedFile.Width, + Height = uploadedFile.Height, + UploadTime = uploadedFile.UploadTime, + LastAccessTime = uploadedFile.LastAccessTime, + LastWriteTime = uploadedFile.LastWriteTime, + CreateTime = uploadedFile.CreateTime, + Path = uploadedFile.Path, + PathType = (ApiModels.PathType?) uploadedFile.PathType + }; } public class OcrData { @@ -56,4 +81,12 @@ public class OcrData { public string SearchableText { get; set; } public DateTime Timestamp { get; set; } public virtual Media Media { get; set; } + + public static explicit operator ApiModels.OcrData(OcrData ocrData) => + new() { + MediaId = ocrData.Media.Guid, + Text = ocrData.Text, + SearchableText = ocrData.SearchableText, + Timestamp = ocrData.Timestamp + }; }
\ No newline at end of file diff --git a/Pages/Component/TagEditDialog.razor b/Pages/Component/TagEditDialog.razor index 2e443d4..afa312e 100644 --- a/Pages/Component/TagEditDialog.razor +++ b/Pages/Component/TagEditDialog.razor @@ -84,7 +84,7 @@ } else { tagService.UpdateTagDefinition(TagDefinition, tagName, tagNamespace, tagAlias); } - } catch(TagDuplicateException e) { + } catch(ApiModels.TagDuplicateException e) { nameExists = e.NameExists; aliasExists = e.AliasExists; return; diff --git a/Pages/Gallery.razor b/Pages/Gallery.razor index 1464c13..743485e 100644 --- a/Pages/Gallery.razor +++ b/Pages/Gallery.razor @@ -98,8 +98,8 @@ if(initial) displayMedia = new(); - SortOrder? sortOrder = null; - if(Enum.TryParse<SortOrder>(SortOrder, true, out var so)) + ApiModels.SortOrder? sortOrder = null; + if(Enum.TryParse<ApiModels.SortOrder>(SortOrder, true, out var so)) sortOrder = so; if(TagId is not null && Query is null) { @@ -1,8 +1,9 @@ -using Microsoft.EntityFrameworkCore; -using System.Text.Json.Serialization; using HyperBooru.Services; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.EntityFrameworkCore; +using System.Text.Json.Serialization; namespace HyperBooru; @@ -17,6 +18,9 @@ public class Program { var converter = new JsonStringEnumConverter(); o.JsonSerializerOptions.Converters.Add(converter); }); + builder.Services.Configure<JsonOptions>(o => { + o.SerializerOptions.TypeInfoResolverChain.Insert(0, new ExceptionJsonResolver()); + }); builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); @@ -49,6 +53,7 @@ public class Program { app.UseHsts(); app.UseHttpsRedirection(); app.UseStaticFiles(); + app.UseMiddleware<ExceptionMiddleware>(); app.UseRouting(); app.MapBlazorHub(); app.MapControllers(); diff --git a/Server.csproj b/Server.csproj index b743529..c9afced 100644 --- a/Server.csproj +++ b/Server.csproj @@ -6,9 +6,9 @@ <ImplicitUsings>enable</ImplicitUsings> <AssemblyName>HyperBooru</AssemblyName> <RootNamespace>HyperBooru</RootNamespace> - <AssemblyVersion>0.12.0.0</AssemblyVersion> + <AssemblyVersion>0.13.0.0</AssemblyVersion> <FileVersion>$(AssemblyVersion)</FileVersion> - <Version>0.12-alpha</Version> + <Version>0.13-alpha</Version> <UserSecretsId>2907567f-4640-4581-8f4d-0977952d26bd</UserSecretsId> </PropertyGroup> @@ -45,4 +45,8 @@ <PackageReference Include="Tesseract" Version="5.2.0" /> </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\ApiModels\ApiModels.csproj" /> + </ItemGroup> + </Project> diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index 752f9f5..ac1f155 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -1,4 +1,6 @@ -namespace HyperBooru.Services; +using HyperBooru.ApiModels; + +namespace HyperBooru.Services; public interface IConfigService { public string DataPath { get; } diff --git a/Services/FeedService.cs b/Services/FeedService.cs index 77f92b5..067bff7 100644 --- a/Services/FeedService.cs +++ b/Services/FeedService.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +using HyperBooru.ApiModels; +using Microsoft.EntityFrameworkCore; namespace HyperBooru.Services; diff --git a/Services/MediaService.cs b/Services/MediaService.cs index 763b953..e497570 100644 --- a/Services/MediaService.cs +++ b/Services/MediaService.cs @@ -1,5 +1,5 @@ -using ImageMagick; -using ImageMagick.Formats; +using HyperBooru.ApiModels; +using ImageMagick; using Microsoft.EntityFrameworkCore; using MimeDetective; using MimeDetective.Definitions; diff --git a/Services/TagService.cs b/Services/TagService.cs index 56aba04..f7b91dc 100644 --- a/Services/TagService.cs +++ b/Services/TagService.cs @@ -1,9 +1,5 @@ -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Razor.TagHelpers; +using HyperBooru.ApiModels; using Microsoft.EntityFrameworkCore; -using System.Reflection; -using System.Reflection.Metadata; namespace HyperBooru.Services; diff --git a/Services/UserService.cs b/Services/UserService.cs index 39b1963..9e79dc6 100644 --- a/Services/UserService.cs +++ b/Services/UserService.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Cryptography.KeyDerivation; -using Microsoft.EntityFrameworkCore; namespace HyperBooru.Services; @@ -1,6 +1,4 @@ -using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations.Schema; namespace HyperBooru; @@ -15,6 +13,14 @@ public class TagDefinition : HBObject { public string Name { get; set; } public string? Alias { get; set;} public virtual List<TagDefinition> ImplicitTags { get; set; } = new(); + + public static explicit operator ApiModels.TagDefinition(TagDefinition tagDefinition) => new() { + TagDefinitionId = tagDefinition.Guid, + Namespace = tagDefinition.Namespace, + Name = tagDefinition.Name, + Alias = tagDefinition.Alias, + ImplicitTags = tagDefinition.ImplicitTags.Select(td => td.Guid).ToArray() + }; } public class Tag : HBObject { |
