summaryrefslogtreecommitdiff
path: root/Services
diff options
context:
space:
mode:
Diffstat (limited to 'Services')
-rw-r--r--Services/ConfigService.cs58
-rw-r--r--Services/MediaService.cs329
-rw-r--r--Services/OcrService.cs120
-rw-r--r--Services/SearchService.cs117
-rw-r--r--Services/SourceService.cs20
-rw-r--r--Services/TagService.cs309
-rw-r--r--Services/UserService.cs77
7 files changed, 1030 insertions, 0 deletions
diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs
new file mode 100644
index 0000000..b42b80c
--- /dev/null
+++ b/Services/ConfigService.cs
@@ -0,0 +1,58 @@
+namespace HyperBooru.Services;
+
+public interface IConfigService {
+ public string DataPath { get; }
+ public string DbConnectionString { get; }
+ public string MediaBasePath { get; }
+ public string ThumbnailBasePath { 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 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 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/Services/MediaService.cs b/Services/MediaService.cs
new file mode 100644
index 0000000..104d0db
--- /dev/null
+++ b/Services/MediaService.cs
@@ -0,0 +1,329 @@
+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);
+
+ 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 string GetPath(Media media);
+ public string GetPath(Media media, int width, int height);
+
+}
+
+public class MediaService : IMediaService {
+ 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) {
+
+ 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.UploadedFiles
+ .Include(uf => uf.Media)
+ .FirstOrDefault(uf => uf.Checksum == hash)?
+ .Media;
+
+ 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
+ };
+
+ if(media is null) {
+ var ingestTagDef = db.TagDefinitions
+ .First(td => td.Guid == HBContext.IngestTag);
+
+ media = new() {
+ UploadedFiles = new() {
+ fileRecord
+ },
+ Tags = new() {
+ new() { TagDefinition = ingestTagDef }
+ }
+ };
+
+ 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 {
+ db.Entry(media).Collection(m => m.UploadedFiles).Load();
+ 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);
+ 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 media, int? width, int? height) {
+ using var db = dbFactory.CreateDbContext();
+
+ var m = db.Media
+ .Include(m => m.CurrentUploadedFile)
+ .First(m => m.Guid == media);
+ if(m is null)
+ throw new ObjectNotFoundException(media);
+
+ if(m.CurrentUploadedFile.MimeType.Split("/")[0] != "image")
+ throw new ThumbnailException("Media object not an image", m);
+
+ using var image = new MagickImage(GetPath(m));
+
+ if(width is null && height is null)
+ throw new ThumbnailException("Both width and height cannot be null!", m);
+
+ if(width > image.Width || height > image.Height)
+ throw new ThumbnailException("Requested thumbnail size is larger than original media", m);
+
+ #pragma warning disable CS8629
+ int w = (int) (width is not null ? width : image.Width * height / image.Height);
+ int h = (int) (height is not null ? height : image.Height * width / image.Width);
+ #pragma warning restore CS8629
+
+ var thumbPath = GetPath(m, w, h);
+
+ if(!File.Exists(thumbPath)) {
+ image.Resize((uint) w, (uint) h);
+ image.Write(thumbPath);
+ }
+
+ return System.IO.File.OpenRead(thumbPath);
+ }
+
+ public Stream GetThumbnail(Media media, int? width, int? height) =>
+ GetThumbnail(media.Guid, width, height);
+
+ public string GetPath(Media media) {
+ var fileInfo = new FileInfo(
+ Path.Join(
+ config.MediaBasePath,
+ media.Guid.ToString().Substring(0, 2),
+ media.Guid.ToString().Substring(2, 2),
+ media.Guid.ToString()));
+
+ Directory.CreateDirectory(fileInfo.Directory.FullName);
+ return fileInfo.FullName;
+ }
+
+ public string GetPath(Media media, int width, int height) {
+ var fileInfo = new FileInfo(Path.Join(
+ config.ThumbnailBasePath,
+ media.Guid.ToString().Substring(0, 2),
+ media.Guid.ToString().Substring(2, 2),
+ $"{media.Guid.ToString()}-{width}-{height}"));
+
+ Directory.CreateDirectory(fileInfo.Directory.FullName);
+ return fileInfo.FullName;
+ }
+
+ private int GetUploadedFileHash(UploadedFile uf) => (
+ uf.CreateTime,
+ uf.LastWriteTime,
+ uf.Filename,
+ uf.Length,
+ uf.Checksum).GetHashCode();
+}
diff --git a/Services/OcrService.cs b/Services/OcrService.cs
new file mode 100644
index 0000000..4d21705
--- /dev/null
+++ b/Services/OcrService.cs
@@ -0,0 +1,120 @@
+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 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 IServiceScopeFactory scopeFactory;
+ private ILogger<OcrService> logger;
+ private IDbContextFactory<HBContext> dbFactory;
+
+ public OcrService(
+ IServiceScopeFactory scopeFactory,
+ ILogger<OcrService> logger,
+ IDbContextFactory<HBContext> dbFactory) {
+
+ 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) {
+ 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) {
+ return;
+
+ using var scope = scopeFactory.CreateScope();
+ var mediaService = scope.ServiceProvider
+ .GetRequiredService<IMediaService>();
+
+ using var db = dbFactory.CreateDbContext();
+ Guid[] guids = db.Media
+ .Include(m => m.CurrentUploadedFile)
+ .Include(m => m.OcrData)
+ .Where(m => m.OcrData == null)
+ .Where(m => m.CurrentUploadedFile.MimeType.Contains("image/"))
+ .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/Services/SearchService.cs b/Services/SearchService.cs
new file mode 100644
index 0000000..5ca12e1
--- /dev/null
+++ b/Services/SearchService.cs
@@ -0,0 +1,117 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace HyperBooru.Services;
+
+public interface ISearchService {
+ public Media[] Search(string query);
+}
+
+public class SearchService : ISearchService {
+ private ITagService tagService;
+
+ private IDbContextFactory<HBContext> dbFactory;
+
+ public SearchService(
+ IDbContextFactory<HBContext> dbFactory,
+ ITagService tagService) {
+
+ this.tagService = tagService;
+ this.dbFactory = dbFactory;
+ }
+
+ public Media[] Search(string query) {
+ var db = dbFactory.CreateDbContext();
+
+ query = query.ToLower().Trim();
+
+ int[] descriptionResults = SearchDescription(query);
+ int[] filenameResults = SearchFilenames(query);
+ int[] ocrResults = SearchOcr(query);
+
+ var matchedTag = db.TagDefinitions
+ .FirstOrDefault(td => td.Name.ToLower() == query);
+
+ int[] tags;
+ if(matchedTag is not null) {
+ tags = tagService
+ .TagsThatImply(matchedTag)
+ .Select(td => td.ObjectId)
+ .ToArray();
+ } else {
+ // TODO: Expand scope to all tags that imply
+ tags = db.TagDefinitions
+ .Where(td => td.Name.ToLower().Contains(query))
+ .Select(td => td.ObjectId)
+ .ToArray();
+ }
+
+ int[] tagResults = SearchTags(tags);
+
+ int[] mediaIds = descriptionResults
+ .Union(filenameResults)
+ .Union(ocrResults)
+ .Union(tagResults)
+ .OrderDescending()
+ .ToArray();
+
+ return db.Media
+ .Include(m => m.Tags)
+ .Include(m => m.CurrentUploadedFile)
+ .Where(m => mediaIds.Contains(m.ObjectId))
+ .ToArray();
+ }
+
+ // TODO: Make asynchronous
+ private int[] SearchDescription(string query) {
+ return Task.Run(() => {
+ using var db = dbFactory.CreateDbContext();
+ query = query.ToLower();
+ return db.Media
+ .Where(m =>
+ (m.ShortDescription != null && m.ShortDescription.ToLower().Contains(query)) ||
+ (m.LongDescription != null && m.LongDescription.ToLower().Contains(query)))
+ .Select(m => m.ObjectId)
+ .ToArray();
+ }).GetAwaiter().GetResult();
+ }
+
+ // TODO: Make asynchronous
+ private int[] SearchFilenames(string query) {
+ return Task.Run(() => {
+ using var db = dbFactory.CreateDbContext();
+ query = query.ToLower();
+ return db.UploadedFiles
+ .Include(uf => uf.Media)
+ .Where(uf => uf.Filename != null && uf.Filename.ToLower().Contains(query))
+ .Select(uf => uf.Media.ObjectId)
+ .Distinct()
+ .ToArray();
+ }).GetAwaiter().GetResult();
+ }
+
+ // TODO: Make asynchronous
+ private int[] SearchOcr(string query) {
+ return Task.Run(() => {
+ using var db = dbFactory.CreateDbContext();
+ query = query.ToLower();
+ return db.OcrData
+ .Include(o => o.Media)
+ .Where(o => o.SearchableText.Contains(query))
+ .Select(o => o.Media.ObjectId)
+ .ToArray();
+ }).GetAwaiter().GetResult();
+ }
+
+ // TODO: Make asynchronous
+ private int[] SearchTags(int[] tags) {
+ return Task.Run(() => {
+ using var db = dbFactory.CreateDbContext();
+ return db.Media
+ .Include(m => m.Tags)
+ .AsEnumerable()
+ .Where(m => m.Tags.IntersectBy(tags, t => t.TagDefinitionId).Any())
+ .Select(m => m.ObjectId)
+ .ToArray();
+ }).GetAwaiter().GetResult();
+ }
+}
diff --git a/Services/SourceService.cs b/Services/SourceService.cs
new file mode 100644
index 0000000..d145346
--- /dev/null
+++ b/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/Services/TagService.cs b/Services/TagService.cs
new file mode 100644
index 0000000..56aba04
--- /dev/null
+++ b/Services/TagService.cs
@@ -0,0 +1,309 @@
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Razor.TagHelpers;
+using Microsoft.EntityFrameworkCore;
+using System.Reflection;
+using System.Reflection.Metadata;
+
+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/Services/UserService.cs b/Services/UserService.cs
new file mode 100644
index 0000000..39b1963
--- /dev/null
+++ b/Services/UserService.cs
@@ -0,0 +1,77 @@
+using Microsoft.AspNetCore.Cryptography.KeyDerivation;
+using Microsoft.EntityFrameworkCore;
+
+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);