summaryrefslogtreecommitdiff
path: root/Server/Services
diff options
context:
space:
mode:
authorJake Mannens <jake@asger.xyz>2026-05-22 00:52:16 +1000
committerJake Mannens <jake@asger.xyz>2026-05-23 22:22:55 +1000
commit12eaa5814ef20b0910e8d64a753378b6f6797989 (patch)
tree062cf477c29054e0f089cb80f0cd79a9f3b7ccd9 /Server/Services
parent6de5d7f5364fe1d54703da6d6b7cb08ea26e939f (diff)
Initial commitwasm-initial
Diffstat (limited to 'Server/Services')
-rw-r--r--Server/Services/ConfigService.cs72
-rw-r--r--Server/Services/FeedService.cs212
-rw-r--r--Server/Services/MediaService.cs400
-rw-r--r--Server/Services/OcrService.cs128
-rw-r--r--Server/Services/SourceService.cs20
-rw-r--r--Server/Services/TagService.cs305
-rw-r--r--Server/Services/UserService.cs76
7 files changed, 1213 insertions, 0 deletions
diff --git a/Server/Services/ConfigService.cs b/Server/Services/ConfigService.cs
new file mode 100644
index 0000000..ac1f155
--- /dev/null
+++ b/Server/Services/ConfigService.cs
@@ -0,0 +1,72 @@
+using HyperBooru.ApiModels;
+
+namespace HyperBooru.Services;
+
+public interface IConfigService {
+ public string DataPath { get; }
+ public string KeyPath { get; }
+ public string DbConnectionString { get; }
+ public string MediaBasePath { get; }
+ public string ThumbnailBasePath { get; }
+ public string ConvertedMediaBasePath { get; }
+ public bool EnableOcr { get; }
+}
+
+public class ConfigService : IConfigService {
+ private IConfiguration config;
+
+ private const string AppName = "HyperBooru";
+
+ public string DataPath {
+ get {
+ #if DEBUG
+ return "Data";
+ #else
+ string? path = config["DataPath"];
+ if(path is not null)
+ return path;
+
+ switch(Environment.OSVersion.Platform) {
+ case PlatformID.Win32NT:
+ return Path.Join(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ AppName);
+ case PlatformID.Unix:
+ return $"/var/lib/{AppName.ToLower()}";
+ default:
+ throw new NotImplementedException(
+ $"Unknown Operating System: {Environment.OSVersion.Platform}");
+ }
+ #endif
+ }
+ }
+
+ public string KeyPath =>
+ Path.Join(DataPath, "keys");
+
+ public string DbConnectionString =>
+ config.GetConnectionString("DefaultConnection") ??
+ throw new HBException("Unable to get default connection string");
+
+ public string MediaBasePath =>
+ Path.Join(DataPath, "media");
+
+ public string ThumbnailBasePath =>
+ Path.Join(DataPath, "thumb");
+
+ public string ConvertedMediaBasePath =>
+ Path.Join(DataPath, "converted");
+
+ public bool EnableOcr =>
+ bool.TryParse(config["DisableOcr"], out bool x) ? !x : true;
+
+ public ConfigService(IConfiguration config) {
+ this.config = config;
+ InitDirectoryStructure();
+ }
+
+ private void InitDirectoryStructure() {
+ Directory.CreateDirectory(DataPath);
+ Directory.CreateDirectory(MediaBasePath);
+ }
+} \ No newline at end of file
diff --git a/Server/Services/FeedService.cs b/Server/Services/FeedService.cs
new file mode 100644
index 0000000..3744e73
--- /dev/null
+++ b/Server/Services/FeedService.cs
@@ -0,0 +1,212 @@
+using HyperBooru.ApiModels;
+using Microsoft.EntityFrameworkCore;
+
+namespace HyperBooru.Services;
+
+public interface IFeedService {
+ public Media[] LoadChunk(
+ bool selectIngest,
+ bool includeNsfw,
+ Media? key = null,
+ int count = 50,
+ SortOrder sortOrder = SortOrder.ObjectId);
+
+ public Media[] LoadChunk(
+ bool selectIngest,
+ bool includeNsfw,
+ string query,
+ Media? key = null,
+ int count = 50,
+ SortOrder sortOrder = SortOrder.ObjectId);
+
+ public Media[] LoadChunk(
+ bool selectIngest,
+ bool includeNsfw,
+ Guid tagId,
+ Media? key = null,
+ int count = 50,
+ SortOrder sortOrder = SortOrder.ObjectId);
+
+ public Media[] LoadChunk(FeedRequest feedRequest);
+}
+
+public class FeedService : IFeedService {
+ private IDbContextFactory<HBContext> dbFactory;
+
+ public FeedService(IDbContextFactory<HBContext> dbFactory) =>
+ this.dbFactory = dbFactory;
+
+ public Media[] LoadChunk(
+ bool selectIngest,
+ bool includeNsfw,
+ Media? continuationToken,
+ int count,
+ SortOrder sortOrder) => LoadChunkInternal(
+ selectIngest, includeNsfw, null, null, continuationToken?.Guid, count, sortOrder);
+
+ public Media[] LoadChunk(
+ bool selectIngest,
+ bool includeNsfw,
+ string query,
+ Media? continuationToken,
+ int count,
+ SortOrder sortOrder) => LoadChunkInternal(
+ selectIngest, includeNsfw, query, null, continuationToken?.Guid, count, sortOrder);
+
+ public Media[] LoadChunk(
+ bool selectIngest,
+ bool includeNsfw,
+ Guid tagId,
+ Media? continuationToken,
+ int count,
+ SortOrder sortOrder) => LoadChunkInternal(
+ selectIngest, includeNsfw, null, tagId, continuationToken?.Guid, count, sortOrder);
+
+ public Media[] LoadChunk(FeedRequest feedRequest) {
+ switch(feedRequest) {
+ case FeedSearchRequest searchRequest:
+ return LoadChunkInternal(
+ selectIngest: searchRequest.SelectIngest,
+ includeNsfw: searchRequest.IncludeNsfw,
+ query: searchRequest.Query,
+ tagId: null,
+ continuationToken: searchRequest.ContinuationToken,
+ count: searchRequest.Count,
+ sortOrder: searchRequest.SortOrder);
+ case FeedTagRequest tagRequest:
+ return LoadChunkInternal(
+ selectIngest: tagRequest.SelectIngest,
+ includeNsfw: tagRequest.IncludeNsfw,
+ query: null,
+ tagId: tagRequest.TagId,
+ continuationToken: tagRequest.ContinuationToken,
+ count: tagRequest.Count,
+ sortOrder: tagRequest.SortOrder);
+ default:
+ return LoadChunkInternal(
+ selectIngest: feedRequest.SelectIngest,
+ includeNsfw: feedRequest.IncludeNsfw,
+ query: null,
+ tagId: null,
+ continuationToken: feedRequest.ContinuationToken,
+ count: feedRequest.Count,
+ sortOrder: feedRequest.SortOrder);
+ }
+ }
+
+ private Media[] LoadChunkInternal(
+ bool selectIngest,
+ bool includeNsfw,
+ string? query,
+ Guid? tagId,
+ Guid? continuationToken,
+ int count,
+ SortOrder sortOrder) {
+
+ if(selectIngest && !includeNsfw)
+ return Array.Empty<Media>();
+
+ using var db = dbFactory.CreateDbContext();
+
+ IQueryable<Media> media = db.Media
+ .AsSingleQuery()
+ .AsNoTracking()
+ .Include(m => m.Tags)
+ .Include(m => m.CurrentUploadedFile);
+
+ if(!includeNsfw)
+ media = media
+ .Where(m => !TagsThatImply(db, HBContext.NsfwTag)
+ .Intersect(m.Tags.Select(t => t.TagDefinitionId))
+ .Any());
+
+ if(selectIngest) {
+ media = media
+ .Where(m => m.Tags
+ .Select(t => t.TagDefinitionId)
+ .Contains((int) HBObjectId.IngestTag));
+ } else {
+ media = media
+ .Where(m => !m.Tags
+ .Select(t => t.TagDefinitionId)
+ .Contains((int) HBObjectId.IngestTag));
+ }
+
+ if(query is not null) {
+ media = Search(media, query);
+ } else if(tagId is not null) {
+ media = media
+ .Where(m => TagsThatImply(db, (Guid) tagId)
+ .Intersect(m.Tags.Select(t => t.TagDefinitionId))
+ .Any());
+ }
+
+ if(continuationToken is not null)
+ media = media
+ .Where(m => m.ObjectId > db.Media.First(m => m.Guid == continuationToken).ObjectId);
+
+ switch(sortOrder) {
+ case SortOrder.ObjectId:
+ media = media.OrderBy(m => m.ObjectId);
+ break;
+ case SortOrder.LastWriteTime:
+ media = media.OrderBy(m => m.CurrentUploadedFile!.LastWriteTime);
+ break;
+ case SortOrder.Random:
+ media = media.OrderBy(m => EF.Functions.Random());
+ break;
+ }
+
+ return media
+ .Take(count)
+ .ToArray();
+ }
+
+ private static IQueryable<Media> Search(IQueryable<Media> media, string query) {
+ // TODO: search implicit tags as well
+
+ query = query.ToLower().Trim();
+
+ return media
+ .Where(m =>
+ (m.ShortDescription != null && m.ShortDescription.ToLower().Contains(query)) ||
+ (m.LongDescription != null && m.LongDescription.ToLower().Contains(query)) ||
+ (m.UploadedFiles.Any(uf => uf.Filename != null && uf.Filename.ToLower().Contains(query))) ||
+ (m.OcrData != null && m.OcrData.SearchableText.ToLower().Contains(query)) ||
+ (m.Tags.Any(t => t.TagDefinition.Name.ToLower().Contains(query))));
+ }
+
+ private static IQueryable<int> TagsThatImply(HBContext db, Guid tagId) =>
+ db.Database.SqlQueryRaw<int>("""
+ WITH RECURSIVE basetag AS (
+ SELECT "ObjectId" FROM "Objects" WHERE "Guid" = {0}
+ ),
+ impliedtags AS (
+ SELECT
+ "TagDefinitionObjectId"
+ FROM
+ "TagDefinitionTagDefinition"
+ INNER JOIN
+ basetag
+ ON
+ "ImplicitTagsObjectId" = basetag."ObjectId"
+ UNION
+ SELECT
+ "TagDefinitionTagDefinition"."TagDefinitionObjectId"
+ FROM
+ "TagDefinitionTagDefinition"
+ INNER JOIN
+ impliedtags
+ ON
+ impliedtags."TagDefinitionObjectId" = "TagDefinitionTagDefinition"."ImplicitTagsObjectId"
+ )
+ SELECT DISTINCT
+ "TagDefinitionObjectId" AS "Value"
+ FROM impliedtags
+ UNION
+ SELECT
+ "ObjectId" AS "Value"
+ FROM
+ basetag
+ """, tagId);
+}
diff --git a/Server/Services/MediaService.cs b/Server/Services/MediaService.cs
new file mode 100644
index 0000000..e497570
--- /dev/null
+++ b/Server/Services/MediaService.cs
@@ -0,0 +1,400 @@
+using HyperBooru.ApiModels;
+using ImageMagick;
+using Microsoft.EntityFrameworkCore;
+using MimeDetective;
+using MimeDetective.Definitions;
+using System.Security.Cryptography;
+using System.Text.RegularExpressions;
+
+namespace HyperBooru.Services;
+
+public interface IMediaService {
+ public void SetDescription(
+ Media media,
+ string? shortDescription,
+ string? longDescription);
+
+ public void SetIngest(Media media, bool ingest);
+
+ public Media Create(
+ Stream fileData,
+ string fileName,
+ string? checksum = null,
+ DateTime? lastAccessTime = null,
+ DateTime? lastWriteTime = null,
+ DateTime? createTime = null,
+ string? path = null,
+ PathType? pathType = null,
+ Guid[]? tagIds = null);
+
+ public void Delete(Guid media);
+ public void Delete(Media media);
+ public void DeleteThumbnails(Guid media);
+ public void DeleteThumbnails(Media media);
+ public Stream GetThumbnail(Guid media, int? width, int? height);
+ public Stream GetThumbnail(Media media, int? width, int? height);
+ public Stream GetConverted(Guid mediaId, string mimeType = "image/png");
+ public Stream GetConverted(Media media, string mimeType = "image/png");
+ public string GetPath(Guid media);
+ public string GetPath(Media media);
+
+}
+
+public class MediaService : IMediaService {
+ private readonly Dictionary<string,MagickFormat> FormatMap = new() {
+ ["image/jpeg"] = MagickFormat.Jpeg,
+ ["image/jpg"] = MagickFormat.Jpg,
+ ["image/png"] = MagickFormat.Png,
+ ["image/webp"] = MagickFormat.WebP
+ };
+
+ private IDbContextFactory<HBContext> dbFactory;
+ private IConfigService config;
+
+ private IContentInspector inspector;
+
+ public MediaService(IDbContextFactory<HBContext> dbFactory,
+ IConfigService config) {
+
+ this.dbFactory = dbFactory;
+ this.config = config;
+
+ ContentInspectorBuilder inspectorBuilder = new() {
+ Definitions =
+ DefaultDefinitions.FileTypes.Images.All()
+ .Union(DefaultDefinitions.FileTypes.Video.All())
+ .ToList()
+ };
+
+ inspector = inspectorBuilder.Build();
+ }
+
+ public void SetIngest(Media media, bool ingest) {
+ using var db = dbFactory.CreateDbContext();
+ media = db.Media
+ .Include(m => m.Tags)
+ .ThenInclude(t => t.TagDefinition)
+ .First(m => m.Guid == media.Guid);
+ var ingestTag = db.TagDefinitions
+ .First(td => td.Guid == HBContext.IngestTag);
+
+ if(ingest) {
+ if(!media.Tags.Select(t => t.TagDefinition.Guid).Contains(HBContext.IngestTag))
+ media.Tags.Add(new(ingestTag));
+ } else {
+ media.Tags.RemoveAll(t => t.TagDefinition.Guid == HBContext.IngestTag);
+ }
+
+ db.SaveChanges();
+ }
+
+ public void SetDescription(
+ Media media,
+ string? shortDescription,
+ string? longDescription) {
+
+ using var db = dbFactory.CreateDbContext();
+ var m = db.Media.First(m => m.Guid == media.Guid);
+
+ shortDescription = shortDescription?.Trim();
+ longDescription = longDescription?.Trim();
+
+ if(string.IsNullOrEmpty(shortDescription))
+ shortDescription = null;
+ if(string.IsNullOrEmpty(longDescription))
+ longDescription = null;
+
+ m.ShortDescription = shortDescription;
+ m.LongDescription = longDescription;
+
+ db.SaveChanges();
+ }
+
+ public Media Create(
+ Stream fileData,
+ string fileName,
+ string? checksum = null,
+ DateTime? lastAccessTime = null,
+ DateTime? lastWriteTime = null,
+ DateTime? createTime = null,
+ string? path = null,
+ PathType? pathType = null,
+ Guid[]? tagIds = null) {
+
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = db.Database.BeginTransaction();
+
+ if(fileData.Length == 0)
+ throw new MediaCreateException("File is empty");
+
+ // Calculate the checksum using the in-memory file contents
+ var hash = BitConverter
+ .ToString(MD5.Create().ComputeHash(fileData))
+ .Replace("-", "")
+ .ToLower();
+
+ if(checksum is not null && hash != checksum.ToLower())
+ throw new MediaCreateException("Checksum does not match");
+
+ // Determine the MIME type
+ fileData.Seek(0, SeekOrigin.Begin);
+ var defs = inspector.Inspect(fileData);
+ var mime = defs.ByMimeType().FirstOrDefault()?.MimeType;
+ if(mime is null)
+ throw new MediaCreateException("Unsupported file type");
+
+ // Read the image with ImageMagick to determine the width and height
+ fileData.Seek(0, SeekOrigin.Begin);
+ using var magickImage = new MagickImage(fileData);
+
+ var media = db.Media
+ .Include(m => m.UploadedFiles)
+ .Include(m => m.Tags)
+ .FirstOrDefault(m => m.UploadedFiles.Any(uf => uf.Checksum == hash));
+
+ var fileRecord = new UploadedFile() {
+ Filename = fileName,
+ Length = fileData.Length,
+ Checksum = hash,
+ ChecksumVerified = checksum is not null,
+ MimeType = mime,
+ Width = (int) magickImage.Width,
+ Height = (int) magickImage.Height,
+ UploadTime = DateTime.UtcNow,
+ LastAccessTime = lastAccessTime,
+ LastWriteTime = lastWriteTime,
+ CreateTime = createTime,
+ Path = pathType is null ? null : path,
+ PathType = pathType
+ };
+
+ var tags = Array.Empty<TagDefinition>();
+ if(tagIds is not null) {
+ tagIds = tagIds.Distinct().ToArray();
+
+ tags = db.TagDefinitions
+ .Where(td => tagIds.Contains(td.Guid))
+ .ToArray();
+
+ if(tags.Count() < tagIds.Count()) {
+ var badIds = tagIds
+ .Where(x => !tags.Select(td => td.Guid).Contains(x))
+ .Order();
+
+ throw new MediaCreateException(
+ $"Non-existent tags specified: {string.Join(", ", badIds)}");
+ }
+ }
+
+ if(media is null) {
+ var ingestTagDef = db.TagDefinitions
+ .First(td => td.Guid == HBContext.IngestTag);
+
+ media = new() {
+ UploadedFiles = new() {
+ fileRecord
+ },
+ Tags = tags is null ? [ new() { TagDefinition = ingestTagDef } ] : tags
+ .Select(td => new Tag() { TagDefinition = td })
+ .ToList()
+ };
+
+ using var newFile = File.Create(GetPath(media));
+
+ fileData.Seek(0, SeekOrigin.Begin);
+ fileData.CopyTo(newFile);
+ newFile.Flush();
+
+ db.Media.Add(media);
+ db.SaveChanges();
+ media.CurrentUploadedFile = fileRecord;
+ db.SaveChanges();
+ } else {
+ var fileHashes = media.UploadedFiles
+ .Select(uf => GetUploadedFileHash(uf));
+ // Only add the uploaded file record if it contains new information
+ if(!fileHashes.Contains(GetUploadedFileHash(fileRecord)))
+ media.UploadedFiles.Add(fileRecord);
+ // Add new tags if needed
+ var missingTags = tags
+ .Where(td => !media.Tags.Select(t => t.TagDefinition.Guid).Contains(td.Guid));
+ media.Tags.AddRange(missingTags.Select(td => new Tag() { TagDefinition = td }));
+ db.Update(media);
+ db.SaveChanges();
+ }
+
+ transaction.Commit();
+
+ return media;
+ }
+
+ public void Delete(Guid media) {
+ using var db = dbFactory.CreateDbContext();
+ var m = db.Media.First(m => m.Guid == media);
+
+ var path = Path.Join(
+ config.MediaBasePath,
+ m.Guid.ToString().Substring(0, 2),
+ m.Guid.ToString().Substring(2, 2),
+ m.Guid.ToString());
+
+ try {
+ var fileInfo = new FileInfo(path);
+ fileInfo.Delete();
+ fileInfo.Directory?.Delete();
+ fileInfo.Directory?.Parent?.Delete();
+ } catch(IOException) {}
+
+ try {
+ DeleteThumbnails(media);
+ } catch {}
+
+ db.Media.Remove(m);
+ db.SaveChanges();
+ }
+
+ public void Delete(Media media) =>
+ Delete(media.Guid);
+
+ public void DeleteThumbnails(Guid media) {
+ var dir = new DirectoryInfo(Path.Join(
+ config.ThumbnailBasePath,
+ media.ToString().Substring(0, 2),
+ media.ToString().Substring(2, 2)));
+
+ var pattern = new Regex($"^{media}-[0-9]+-[0-9]+$");
+ var toDelete = dir.GetFiles()
+ .Where(f => pattern.IsMatch(f.Name))
+ .ToList();
+
+ List<Exception> exceptions = new();
+
+ foreach(var file in toDelete) {
+ try {
+ file.Delete();
+ } catch(Exception e) {
+ exceptions.Add(e);
+ }
+ }
+
+ try {
+ dir.Delete();
+ dir.Parent?.Delete();
+ } catch(Exception e) {
+ exceptions.Add(e);
+ }
+
+ // TODO: wrap the AggregateException in a ThumbnailException
+ if(exceptions.Count() > 1)
+ throw new AggregateException(exceptions);
+ }
+
+ public void DeleteThumbnails(Media media) =>
+ DeleteThumbnails(media.Guid);
+
+ public Stream GetThumbnail(Guid mediaId, int? width, int? height) {
+ if(width is null && height is null)
+ throw new ThumbnailException(
+ "Both width and height cannot be null!",
+ mediaId);
+
+ var thumbPath = GetThumbnailPath(mediaId, width, height);
+
+ if(File.Exists(thumbPath))
+ return System.IO.File.OpenRead(thumbPath);
+
+ if(!File.Exists(GetPath(mediaId)))
+ throw new ObjectNotFoundException(mediaId);
+
+ using var image = new MagickImage(GetPath(mediaId));
+
+ if(width > image.Width || height > image.Height) {
+ width = (int) image.Width;
+ height = (int) image.Height;
+ }
+
+ image.Thumbnail((uint) (width ?? -1), (uint) (height ?? -1));
+ image.Write(thumbPath, MagickFormat.Jpeg);
+
+ return System.IO.File.OpenRead(thumbPath);
+ }
+
+ public Stream GetConverted(Guid mediaId, string mimeType) {
+ if(!FormatMap.TryGetValue(mimeType, out var format))
+ throw new MediaException($"Cannot convert to unknown format ({mimeType})", mediaId);
+
+ var convertedPath = GetConvertedPath(mediaId, mimeType);
+
+ if(File.Exists(convertedPath))
+ return System.IO.File.OpenRead(convertedPath);
+
+ if(!File.Exists(GetPath(mediaId)))
+ throw new ObjectNotFoundException(mediaId);
+
+ using var image = new MagickImage(GetPath(mediaId));
+ image.Write(convertedPath, format);
+
+ return System.IO.File.OpenRead(convertedPath);
+ }
+
+ public Stream GetThumbnail(Media media, int? width, int? height) =>
+ GetThumbnail(media.Guid, width, height);
+
+ public Stream GetConverted(Media media, string mimeType) =>
+ GetConverted(media.Guid, mimeType);
+
+ public string GetPath(Guid mediaId) {
+ var fileInfo = new FileInfo(
+ Path.Join(
+ config.MediaBasePath,
+ mediaId.ToString().Substring(0, 2),
+ mediaId.ToString().Substring(2, 2),
+ mediaId.ToString()));
+
+ Directory.CreateDirectory(fileInfo.Directory!.FullName);
+ return fileInfo.FullName;
+ }
+
+ public string GetThumbnailPath(Guid mediaId, int? width, int? height) {
+ if(width is null && height is null)
+ throw new ThumbnailException(
+ "Both width and height cannot be null!",
+ mediaId);
+
+ var fileInfo = new FileInfo(Path.Join(
+ config.ThumbnailBasePath,
+ mediaId.ToString().Substring(0, 2),
+ mediaId.ToString().Substring(2, 2),
+ $"{mediaId.ToString()}-{(width ?? 0)}-{(height ?? 0)}"));
+
+ Directory.CreateDirectory(fileInfo.Directory!.FullName);
+ return fileInfo.FullName;
+ }
+
+ public string GetConvertedPath(Guid mediaId, string mimeType) {
+ var fileInfo = new FileInfo(Path.Join(
+ config.ConvertedMediaBasePath,
+ mediaId.ToString().Substring(0, 2),
+ mediaId.ToString().Substring(2, 2),
+ $"{mediaId.ToString()}-{mimeType.Split('/')[1]}"));
+
+ Directory.CreateDirectory(fileInfo.Directory!.FullName);
+ return fileInfo.FullName;
+ }
+
+ public string GetPath(Media media) =>
+ GetPath(media.Guid);
+
+ public string GetThumbnailPath(Media media, int? width, int? height) =>
+ GetThumbnailPath(media.Guid, width, height);
+
+ public string GetConvertedPath(Media media, string mimeType) =>
+ GetConvertedPath(media.Guid, mimeType);
+
+ private int GetUploadedFileHash(UploadedFile uf) => (
+ uf.CreateTime,
+ uf.LastWriteTime,
+ uf.Filename,
+ uf.Length,
+ uf.Checksum).GetHashCode();
+}
diff --git a/Server/Services/OcrService.cs b/Server/Services/OcrService.cs
new file mode 100644
index 0000000..d43db2e
--- /dev/null
+++ b/Server/Services/OcrService.cs
@@ -0,0 +1,128 @@
+using HyperBooru.Util;
+using Microsoft.EntityFrameworkCore;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+using Tesseract;
+
+namespace HyperBooru.Services;
+
+public class OcrService : IHostedService {
+ private readonly string[] InvalidMimeTypes = [ "image/heic", "image/webp" ];
+
+ private readonly TimeSpan ProcessInterval = TimeSpan.FromMinutes(30);
+ private readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(30);
+
+ private readonly Regex SpaceRegex = new(@"[^0-9a-z]+", RegexOptions.Compiled);
+
+ private Task? task;
+ private CancellationTokenSource cts = new();
+
+ private Timer timer;
+
+ private IConfigService configService;
+ private IServiceScopeFactory scopeFactory;
+ private ILogger<OcrService> logger;
+ private IDbContextFactory<HBContext> dbFactory;
+
+ public OcrService(
+ IConfigService configService,
+ IServiceScopeFactory scopeFactory,
+ ILogger<OcrService> logger,
+ IDbContextFactory<HBContext> dbFactory) {
+
+ this.configService = configService;
+ this.scopeFactory = scopeFactory;
+ this.logger = logger;
+ this.dbFactory = dbFactory;
+
+ timer = new((object? state) => {
+ if(task is not null && !task.IsCompleted)
+ return;
+ cts = new();
+ task = ProcessAllAsync(cts.Token);
+ });
+ }
+
+ public Task StartAsync(CancellationToken ct) {
+ if(configService.EnableOcr) {
+ logger.LogInformation("Service starting...");
+ timer.Change(StartupDelay, ProcessInterval);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken ct) {
+ logger.LogInformation("Service stopping...");
+ timer.Change(Timeout.Infinite, Timeout.Infinite);
+ cts.Cancel();
+ return Task.CompletedTask;
+ }
+
+ async Task ProcessAllAsync(CancellationToken ct) {
+ using var scope = scopeFactory.CreateScope();
+ var mediaService = scope.ServiceProvider
+ .GetRequiredService<IMediaService>();
+
+ using var db = dbFactory.CreateDbContext();
+ Guid[] guids = db.Media
+ .AsNoTracking()
+ .Include(m => m.CurrentUploadedFile)
+ .Include(m => m.OcrData)
+ .Where(m => m.OcrData == null)
+ .Where(m => m.CurrentUploadedFile!.MimeType.Contains("image/"))
+ .Where(m => !InvalidMimeTypes.Contains(m.CurrentUploadedFile!.MimeType))
+ .Select(m => m.Guid)
+ .ToArray();
+ db.Dispose();
+
+ logger.LogInformation($"Performing OCR pass on {guids.Count()} media items");
+
+ var factory = new TaskFactory(new LimitedConcurrencyTaskScheduler());
+ var tasks = new List<Task>();
+
+ var stopwatch = Stopwatch.StartNew();
+
+ foreach(var guid in guids)
+ tasks.Add(factory.StartNew(() => Process(guid, mediaService), ct));
+
+ await Task.WhenAll(tasks);
+ stopwatch.Stop();
+
+ var time = stopwatch.Elapsed.ToStringHumanReadable();
+ logger.LogInformation(
+ $"Performed OCR pass on {guids.Count()} media items in {time}");
+ }
+
+ private void Process(Guid media, IMediaService mediaService) {
+ logger.LogDebug($"Performing OCR on media item {media}");
+
+ using var db = dbFactory.CreateDbContext();
+ var m = db.Media
+ .Include(m => m.OcrData)
+ .First(m => m.Guid == media);
+
+ OcrData o = m.OcrData ?? new();
+
+ using var engine = new TesseractEngine("tessdata", "eng", EngineMode.Default);
+ using var image = Pix.LoadFromFile(mediaService.GetPath(m));
+ engine.SetVariable("debug_file", NullFile);
+
+ o.Timestamp = DateTime.UtcNow;
+ o.Text = engine.Process(image).GetText().Trim();
+ o.SearchableText = SpaceRegex.Replace(o.Text.ToLower(), " ").Trim();
+
+ m.OcrData = o;
+ db.SaveChanges();
+ }
+
+ private string NullFile {
+ get {
+ if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return "NUL";
+ else
+ return "/dev/null";
+ }
+ }
+}
diff --git a/Server/Services/SourceService.cs b/Server/Services/SourceService.cs
new file mode 100644
index 0000000..d145346
--- /dev/null
+++ b/Server/Services/SourceService.cs
@@ -0,0 +1,20 @@
+using System.Text.RegularExpressions;
+
+namespace HyperBooru.Services;
+
+public interface ISourceService {
+ public string? GetUrlFromFilename(string filename);
+}
+
+public class SourceService : ISourceService {
+ private Regex PixivRegex =
+ new(@"^([0-9]+)_p[0-9]+(_master1200)?\.[^.]+$", RegexOptions.Compiled);
+
+ public string? GetUrlFromFilename(string filename) {
+ var pixivMatch = PixivRegex.Match(filename);
+ if(pixivMatch.Success)
+ return $"https://pixiv.net/en/artworks/{pixivMatch.Groups[1].Value}";
+
+ return null;
+ }
+}
diff --git a/Server/Services/TagService.cs b/Server/Services/TagService.cs
new file mode 100644
index 0000000..f7b91dc
--- /dev/null
+++ b/Server/Services/TagService.cs
@@ -0,0 +1,305 @@
+using HyperBooru.ApiModels;
+using Microsoft.EntityFrameworkCore;
+
+namespace HyperBooru.Services;
+
+public interface ITagService {
+ public void AddTag(Guid obj, Guid tagDef);
+ public void AddTag(HBObject obj, TagDefinition tagDef);
+ public void RemoveTag(Guid obj, Guid tagDef);
+ public void RemoveTag(HBObject obj, TagDefinition tagDef);
+ public void SetImplicitTags(TagDefinition tagDef, TagDefinition[] implicitTagDefs);
+ public void SetImplicitTags(Guid tagDef, Guid[] implicitTagDefs);
+ public void AddImplicitTag(Guid tagDef, Guid implicitTagDef);
+ public void AddImplicitTag(TagDefinition tagDef, TagDefinition implicitTagDef);
+ public void RemoveImplicitTag(Guid tagDef, Guid implicitTagDef);
+ public void RemoveImplicitTag(TagDefinition tagDef, TagDefinition implicitTagDef);
+ public void CreateTagDefinition(string name, string? @namespace = null, string? alias = null);
+ public void DeleteTagDefinition(Guid tagDef);
+ public void DeleteTagDefinition(TagDefinition tagDef);
+ public void UpdateTagDefinition(Guid tagDef, string name, string? @namespace = null, string? alias = null);
+ public void UpdateTagDefinition(TagDefinition tagDef, string name, string? @namespace = null, string? alias = null);
+ public (TagDefinition tagDefinition, bool isImplicit)[] GetAllTags(Guid obj);
+ public (TagDefinition tagDefinition, bool isImplicit)[] GetAllTags(HBObject obj);
+ public (TagDefinition tagDefinition, bool isImplicit)[] GetAllTags(TagDefinition tagDef);
+ public TagDefinition[] TagsThatImply(Guid tagDef);
+ public TagDefinition[] TagsThatImply(TagDefinition tagDef);
+}
+
+public class TagService : ITagService {
+ private IDbContextFactory<HBContext> dbFactory;
+
+ public TagService(IDbContextFactory<HBContext> dbFactory) =>
+ this.dbFactory = dbFactory;
+
+ public void AddTag(Guid obj, Guid tagDef) {
+ using var db = dbFactory.CreateDbContext();
+
+ var tag = db.TagDefinitions.First(td => td.Guid == tagDef);
+
+ db.Objects
+ .Include(o => o.Tags)
+ .ThenInclude(t => t.TagDefinition)
+ .Where(o => !o.Tags.Select(t => t.TagDefinition.Guid).Contains(tagDef))
+ .FirstOrDefault(o => o.Guid == obj)?
+ .Tags
+ .Add(new(tag));
+
+ db.SaveChanges();
+ }
+
+ public void AddTag(HBObject obj, TagDefinition tagDef) =>
+ AddTag(obj.Guid, tagDef.Guid);
+
+ public void RemoveTag(Guid obj, Guid tagDef) {
+ using var db = dbFactory.CreateDbContext();
+
+ db.Objects
+ .Include(o => o.Tags)
+ .ThenInclude(t => t.TagDefinition)
+ .First(o => o.Guid == obj)
+ .Tags
+ .RemoveAll(t => t.TagDefinition.Guid == tagDef);
+
+ db.SaveChanges();
+ }
+
+ public void RemoveTag(HBObject obj, TagDefinition tagDef) =>
+ RemoveTag(obj.Guid, tagDef.Guid);
+
+ public void SetImplicitTags(Guid tagDef, Guid[] implicitTagDefs) {
+ using var db = dbFactory.CreateDbContext();
+ using var transaction = db.Database.BeginTransaction();
+
+ var tag = db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .First(td => td.Guid == tagDef);
+
+ tag.ImplicitTags.RemoveAll(td => !implicitTagDefs.Contains(td.Guid));
+ tag.ImplicitTags.AddRange(
+ db.TagDefinitions
+ .Where(td => implicitTagDefs.Contains(td.Guid))
+ .Where(td => !tag.ImplicitTags
+ .Select(td => td.Guid)
+ .Contains(td.Guid)));
+
+ db.SaveChanges();
+ transaction.Commit();
+ }
+
+ public void SetImplicitTags(TagDefinition tagDef, TagDefinition[] implicitTagDefs) =>
+ SetImplicitTags(tagDef.Guid, implicitTagDefs.Select(td => td.Guid).ToArray());
+
+ public void AddImplicitTag(Guid tagDef, Guid implicitTagDef) {
+ using var db = dbFactory.CreateDbContext();
+
+ var tag = db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .First(td => td.Guid == tagDef);
+ var implicitTag = db.TagDefinitions.First(td => td.Guid == implicitTagDef);
+
+ tag.ImplicitTags.Add(implicitTag);
+ db.SaveChanges();
+ }
+
+ public void AddImplicitTag(TagDefinition tagDef, TagDefinition implicitTagDef) =>
+ AddImplicitTag(tagDef, implicitTagDef);
+
+ public void RemoveImplicitTag(Guid tagDef, Guid implicitTagDef) {
+ using var db = dbFactory.CreateDbContext();
+
+ var tag = db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .First(td => td.Guid == tagDef);
+
+ tag.ImplicitTags.RemoveAll(td => td.Guid == implicitTagDef);
+ db.SaveChanges();
+ }
+
+ public void RemoveImplicitTag(TagDefinition tagDef, TagDefinition implicitTagDef) =>
+ RemoveImplicitTag(tagDef, implicitTagDef);
+
+ public void CreateTagDefinition(string name, string? @namespace = null, string? alias = null) {
+ using var db = dbFactory.CreateDbContext();
+
+ if(string.IsNullOrEmpty(@namespace))
+ @namespace = null;
+ if(string.IsNullOrEmpty(alias))
+ alias = null;
+
+ // Remove leading and trailing whitespace
+ name = name.Trim();
+ @namespace = @namespace?.Trim();
+ alias = alias?.Trim();
+
+ TagDefinition tagDef = new() {
+ Source = TagSource.UserTag,
+ Namespace = @namespace,
+ Name = name,
+ Alias = alias
+ };
+
+ bool nameExists = db.TagDefinitions.Any(td => td.Name.ToLower() == name.ToLower());
+ bool aliasExists = false;
+ if(alias is not null)
+ aliasExists = db.TagDefinitions
+ .Where(td => td.Alias != null)
+ .Any(td => td.Alias!.ToLower() == alias.ToLower());
+ if(nameExists || aliasExists)
+ throw new TagDuplicateException(nameExists, aliasExists);
+
+ if(!db.TagDefinitions.Contains(tagDef))
+ db.TagDefinitions.Add(tagDef);
+ db.SaveChanges();
+ }
+
+ public void DeleteTagDefinition(Guid tagDef) {
+ using var db = dbFactory.CreateDbContext();
+
+ var tag = db.TagDefinitions.First(td => td.Guid == tagDef);
+
+ using var transaction = db.Database.BeginTransaction();
+
+ db.Tags.RemoveRange(
+ db.Tags
+ .Include(t => t.TagDefinition)
+ .Where(t => t.TagDefinition.Guid == tagDef));
+ db.TagDefinitions.Remove(tag);
+ db.SaveChanges();
+
+ transaction.Commit();
+ }
+
+ public void DeleteTagDefinition(TagDefinition tagDef) =>
+ DeleteTagDefinition(tagDef.Guid);
+
+ public void UpdateTagDefinition(Guid tagDef, string name, string? @namespace = null, string? alias = null) {
+ using var db = dbFactory.CreateDbContext();
+
+ if(string.IsNullOrEmpty(@namespace))
+ @namespace = null;
+ if(string.IsNullOrEmpty(alias))
+ alias = null;
+
+ // Remove leading and trailing whitespace
+ name = name.Trim();
+ @namespace = @namespace?.Trim();
+ alias = alias?.Trim();
+
+ var tag = db.TagDefinitions.First(td => td.Guid == tagDef);
+
+ TagDefinition? nameExisting = db.TagDefinitions.FirstOrDefault(td => td.Name.ToLower() == name.ToLower());
+ TagDefinition? aliasExisting = null;
+ if(alias is not null)
+ aliasExisting = db.TagDefinitions
+ .Where(td => td.Alias != null)
+ .FirstOrDefault(td => td.Alias!.ToLower() == alias.ToLower());
+ bool nameExists = nameExisting is not null && nameExisting != tag;
+ bool aliasExists = aliasExisting is not null && aliasExisting != tag;
+ if(nameExists || aliasExists)
+ throw new TagDuplicateException(nameExists, aliasExists);
+
+ tag.Name = name;
+ tag.Namespace = @namespace;
+ tag.Alias = alias;
+
+ db.SaveChanges();
+ }
+
+ public void UpdateTagDefinition(TagDefinition tagDef, string name, string? @namespace = null, string? alias = null) =>
+ UpdateTagDefinition(tagDef.Guid, name, @namespace, alias);
+
+ private (TagDefinition tagDefinition, bool isImplicit)[] GetAllTags(IEnumerable<TagDefinition> tagDefs) {
+ using var db = dbFactory.CreateDbContext();
+
+ var tagGuids = tagDefs
+ .Select(td => td.Guid)
+ .ToArray();
+
+ // Query all tag definitions
+ var allTags = db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .ToArray();
+
+ var tags = new List<TagDefinition>(
+ allTags.IntersectBy(
+ tagGuids,
+ td => td.Guid));
+
+ while(true) {
+ var toAdd = tags
+ .SelectMany(td => td.ImplicitTags)
+ .ExceptBy(tags.Select(td => td.Guid), td => td.Guid)
+ .ToArray();
+
+ if(toAdd.Count() == 0)
+ break;
+
+ tags.AddRange(toAdd);
+ }
+
+ return tags
+ .Select(td => new ValueTuple<TagDefinition, bool>(td, !tagGuids.Contains(td.Guid)))
+ .ToArray();
+ }
+
+ public (TagDefinition tagDefinition, bool isImplicit)[] GetAllTags(Guid obj) {
+ using var db = dbFactory.CreateDbContext();
+
+ // Query a list of tag GUIDs for this object
+ var tags = db.Objects
+ .Include(o => o.Tags)
+ .ThenInclude(t => t.TagDefinition)
+ .First(o => o.Guid == obj)
+ .Tags
+ .Select(t => t.TagDefinition)
+ .ToArray();
+
+ return GetAllTags(tags);
+ }
+
+
+ public (TagDefinition tagDefinition, bool isImplicit)[] GetAllTags(HBObject obj) =>
+ GetAllTags(obj.Guid);
+
+ public (TagDefinition tagDefinition, bool isImplicit)[] GetAllTags(TagDefinition tagDef) {
+ using var db = dbFactory.CreateDbContext();
+
+ var tags = db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .First(td => td.Guid == tagDef.Guid)
+ .ImplicitTags
+ .ToArray();
+
+ return GetAllTags(tags);
+ }
+
+ public TagDefinition[] TagsThatImply(Guid tagDef) {
+ using var db = dbFactory.CreateDbContext();
+
+ var tagDefs = db.TagDefinitions
+ .Include(td => td.ImplicitTags)
+ .ToArray();
+
+ var tags = new List<TagDefinition>() {
+ db.TagDefinitions.First(td => td.Guid == tagDef)
+ };
+
+ while(true) {
+ var toAdd = tagDefs
+ .Where(td => td.ImplicitTags.Select(it => it.Guid).Intersect(tags.Select(td => td.Guid)).Any())
+ .ExceptBy(tags.Select(td => td.Guid), td => td.Guid)
+ .ToArray();
+
+ if(toAdd.Count() == 0)
+ break;
+
+ tags.AddRange(toAdd);
+ }
+
+ return tags.ToArray();
+ }
+
+ public TagDefinition[] TagsThatImply(TagDefinition tagDef) =>
+ TagsThatImply(tagDef.Guid);
+}
diff --git a/Server/Services/UserService.cs b/Server/Services/UserService.cs
new file mode 100644
index 0000000..9e79dc6
--- /dev/null
+++ b/Server/Services/UserService.cs
@@ -0,0 +1,76 @@
+using Microsoft.AspNetCore.Cryptography.KeyDerivation;
+
+namespace HyperBooru.Services;
+
+public interface IUserService {
+ public UserSessionState UserSessionState { get; }
+}
+
+public class UserService : IUserService {
+ public UserSessionState UserSessionState =>
+ globalUserService.GetSessionState(httpContext.Session.Id);
+
+ private IHttpContextAccessor httpContextAccessor;
+ private IGlobalUserService globalUserService;
+
+ private HttpContext httpContext =>
+ httpContextAccessor.HttpContext!;
+
+ public UserService(
+ IHttpContextAccessor httpContextAccessor,
+ IGlobalUserService globalUserService) {
+
+ this.httpContextAccessor = httpContextAccessor;
+ this.globalUserService = globalUserService;
+
+ // HTTP context session states are discarded if no values
+ // are set. Set a dummy value so that the session state
+ // will not be discarded later when we actually need it.
+ httpContext.Session.SetInt32("Persist", 1);
+ }
+
+ public static string HashPassword(string password) =>
+ Convert.ToBase64String(
+ KeyDerivation.Pbkdf2(
+ password,
+ Array.Empty<byte>(),
+ KeyDerivationPrf.HMACSHA512,
+ 100_000,
+ 512 / 8));
+}
+
+public interface IGlobalUserService {
+ public UserSessionState GetSessionState(string id);
+}
+
+public class GlobalUserService : IGlobalUserService {
+ // TODO: prune this list periodically
+ private Dictionary<string, UserSessionState> sessionStates = new();
+
+ public UserSessionState GetSessionState(string id) {
+ sessionStates.TryGetValue(id, out var state);
+
+ if(state is null) {
+ state = new();
+ sessionStates[id] = state;
+ }
+
+ return state;
+ }
+}
+
+public record UserSessionState {
+ public event UserSessionStateChange OnStateChange;
+
+ public bool ShowNsfw {
+ get => showNsfw;
+ set {
+ showNsfw = value;
+ OnStateChange.Invoke(this);
+ }
+ }
+
+ private bool showNsfw = false;
+}
+
+public delegate void UserSessionStateChange(UserSessionState sessionState);