From c751709b1b4fe6f16fd84647e8e071455e7b78d6 Mon Sep 17 00:00:00 2001 From: Jake Mannens Date: Tue, 17 Mar 2026 03:04:36 +1100 Subject: v0.1a --- Services/ConfigService.cs | 58 ++++++++ Services/MediaService.cs | 329 ++++++++++++++++++++++++++++++++++++++++++++++ Services/OcrService.cs | 120 +++++++++++++++++ Services/SearchService.cs | 117 +++++++++++++++++ Services/SourceService.cs | 20 +++ Services/TagService.cs | 309 +++++++++++++++++++++++++++++++++++++++++++ Services/UserService.cs | 77 +++++++++++ 7 files changed, 1030 insertions(+) create mode 100644 Services/ConfigService.cs create mode 100644 Services/MediaService.cs create mode 100644 Services/OcrService.cs create mode 100644 Services/SearchService.cs create mode 100644 Services/SourceService.cs create mode 100644 Services/TagService.cs create mode 100644 Services/UserService.cs (limited to 'Services') 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 dbFactory; + private IConfigService config; + + private IContentInspector inspector; + + public MediaService(IDbContextFactory 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 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 logger; + private IDbContextFactory dbFactory; + + public OcrService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IDbContextFactory 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(); + + 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(); + + 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 dbFactory; + + public SearchService( + IDbContextFactory 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 dbFactory; + + public TagService(IDbContextFactory 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 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( + 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(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() { + 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(), + 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 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); -- cgit v1.3