Merge branch 'feature/user-fonts' into feature/font-manager

This commit is contained in:
Joseph Milazzo 2024-07-13 08:24:00 -05:00
commit 85c8a13beb
32 changed files with 4209 additions and 78 deletions

View file

@ -191,6 +191,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="config\fonts\" />
<Folder Include="config\themes" />
<Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View file

@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Font;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MimeTypes;
using Serilog;
namespace API.Controllers;
public class FontController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IDirectoryService _directoryService;
private readonly ITaskScheduler _taskScheduler;
private readonly IFontService _fontService;
private readonly IMapper _mapper;
private readonly Regex _fontFileExtensionRegex = new(Parser.FontFileExtensions, RegexOptions.IgnoreCase, Parser.RegexTimeout);
public FontController(IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, IDirectoryService directoryService,
IFontService fontService, IMapper mapper)
{
_unitOfWork = unitOfWork;
_directoryService = directoryService;
_taskScheduler = taskScheduler;
_fontService = fontService;
_mapper = mapper;
}
[ResponseCache(CacheProfileName = "10Minute")]
[HttpGet("all")]
public async Task<ActionResult<IEnumerable<EpubFontDto>>> GetFonts()
{
return Ok(await _unitOfWork.EpubFontRepository.GetFontDtosAsync());
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> GetFont(int fontId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
if (font == null) return NotFound();
var contentType = MimeTypeMap.GetMimeType(Path.GetExtension(font.FileName));
var path = Path.Join(_directoryService.EpubFontDirectory, font.FileName);
return PhysicalFile(path, contentType);
}
[HttpDelete]
public async Task<IActionResult> DeleteFont(int fontId)
{
await _fontService.Delete(fontId);
return Ok();
}
[HttpPost("upload")]
public async Task<ActionResult<EpubFontDto>> UploadFont(IFormFile formFile)
{
if (!_fontFileExtensionRegex.IsMatch(Path.GetExtension(formFile.FileName)))
return BadRequest("Invalid file");
if (formFile.FileName.Contains(".."))
return BadRequest("Invalid file");
var tempFile = await UploadToTemp(formFile);
var font = await _fontService.CreateFontFromFileAsync(tempFile);
return Ok(_mapper.Map<EpubFontDto>(font));
}
[HttpPost("upload-url")]
public async Task<ActionResult<EpubFontDto>> UploadFontByUrl(string url)
{
throw new NotImplementedException();
}
private async Task<string> UploadToTemp(IFormFile file)
{
var outputFile = Path.Join(_directoryService.TempDirectory, file.FileName);
await using var stream = System.IO.File.Create(outputFile);
await file.CopyToAsync(stream);
stream.Close();
return outputFile;
}
}

View file

@ -0,0 +1,13 @@
using System;
using API.Entities.Enums.Font;
namespace API.DTOs.Font;
public class EpubFontDto
{
public int Id { get; set; }
public string Name { get; set; }
public FontProvider Provider { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
}

View file

@ -66,6 +66,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!;
public DbSet<AppUserCollection> AppUserCollection { get; set; } = null!;
public DbSet<EpubFont> EpubFont { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class EpubFontInitial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EpubFont",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", nullable: true),
FileName = table.Column<string>(type: "TEXT", nullable: true),
Provider = table.Column<int>(type: "INTEGER", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EpubFont", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EpubFont");
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.4");
modelBuilder.HasAnnotation("ProductVersion", "8.0.6");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -907,6 +907,41 @@ namespace API.Data.Migrations
b.ToTable("Device");
});
modelBuilder.Entity("API.Entities.EpubFont", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("EpubFont");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")

View file

@ -0,0 +1,98 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Font;
using API.Entities;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IEpubFontRepository
{
void Add(EpubFont font);
void Remove(EpubFont font);
void Update(EpubFont font);
Task<IEnumerable<EpubFontDto>> GetFontDtosAsync();
Task<EpubFontDto?> GetFontDtoAsync(int fontId);
Task<EpubFontDto?> GetFontDtoByNameAsync(string name);
Task<IEnumerable<EpubFont>> GetFontsAsync();
Task<EpubFont?> GetFontAsync(int fontId);
Task<bool> IsFontInUseAsync(int fontId);
}
public class EpubFontRepository: IEpubFontRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public EpubFontRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Add(EpubFont font)
{
_context.Add(font);
}
public void Remove(EpubFont font)
{
_context.Remove(font);
}
public void Update(EpubFont font)
{
_context.Entry(font).State = EntityState.Modified;
}
public async Task<IEnumerable<EpubFontDto>> GetFontDtosAsync()
{
return await _context.EpubFont
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<EpubFontDto?> GetFontDtoAsync(int fontId)
{
return await _context.EpubFont
.Where(f => f.Id == fontId)
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<EpubFontDto?> GetFontDtoByNameAsync(string name)
{
return await _context.EpubFont
.Where(f => f.Name.Equals(name))
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<EpubFont>> GetFontsAsync()
{
return await _context.EpubFont
.ToListAsync();
}
public async Task<EpubFont?> GetFontAsync(int fontId)
{
return await _context.EpubFont
.Where(f => f.Id == fontId)
.FirstOrDefaultAsync();
}
public async Task<bool> IsFontInUseAsync(int fontId)
{
return await _context.AppUserPreferences
.Join(_context.EpubFont,
preference => preference.BookReaderFontFamily,
font => font.Name,
(preference, font) => new { preference, font })
.AnyAsync(joined => joined.font.Id == fontId);
}
}

View file

@ -76,6 +76,7 @@ public interface IUserRepository
Task<IList<AppUserBookmark>> GetAllBookmarksByIds(IList<int> bookmarkIds);
Task<AppUser?> GetUserByEmailAsync(string email, AppUserIncludes includes = AppUserIncludes.None);
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByFontAsync(string fontName);
Task<bool> HasAccessToLibrary(int libraryId, int userId);
Task<bool> HasAccessToSeries(int userId, int seriesId);
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None);
@ -260,6 +261,14 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByFontAsync(string fontName)
{
return await _context.AppUserPreferences
.Where(p => p.BookReaderFontFamily == fontName)
.AsSplitQuery()
.ToListAsync();
}
public async Task<bool> HasAccessToLibrary(int libraryId, int userId)
{
return await _context.Library

View file

@ -9,9 +9,11 @@ using API.Constants;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.Font;
using API.Entities.Enums.Theme;
using API.Extensions;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Identity;
@ -26,6 +28,20 @@ public static class Seed
/// </summary>
public static ImmutableArray<ServerSetting> DefaultSettings;
public static readonly ImmutableArray<EpubFont> DefaultFonts =
[
..new List<EpubFont>
{
new ()
{
Name = "Merriweather",
NormalizedName = Parser.Normalize("Merriweather"),
Provider = FontProvider.System,
FileName = "Merriweather-Regular.woff2",
}
}
];
public static readonly ImmutableArray<SiteTheme> DefaultThemes = [
..new List<SiteTheme>
{
@ -153,6 +169,21 @@ public static class Seed
await context.SaveChangesAsync();
}
public static async Task SeedFonts(DataContext context)
{
await context.Database.EnsureCreatedAsync();
foreach (var font in DefaultFonts)
{
var existing = context.SiteTheme.FirstOrDefaultAsync(f => f.Name.Equals(font.Name));
if (existing == null)
{
await context.EpubFont.AddAsync(font);
}
}
await context.SaveChangesAsync();
}
public static async Task SeedDefaultStreams(IUnitOfWork unitOfWork)
{
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.DashboardStreams);

View file

@ -31,6 +31,7 @@ public interface IUnitOfWork
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
IEpubFontRepository EpubFontRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -74,6 +75,7 @@ public class UnitOfWork : IUnitOfWork
public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper);
public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper);
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository => new ExternalSeriesMetadataRepository(_context, _mapper);
public IEpubFontRepository EpubFontRepository => new EpubFontRepository(_context, _mapper);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View file

@ -0,0 +1,13 @@
namespace API.Entities.Enums.Font;
public enum FontProvider
{
/// <summary>
/// Font is provider by System, always avaible
/// </summary>
System = 1,
/// <summary>
/// Font provider by the User
/// </summary>
User = 2,
}

37
API/Entities/EpubFont.cs Normal file
View file

@ -0,0 +1,37 @@
using System;
using API.Entities.Enums.Font;
using API.Entities.Interfaces;
using API.Services;
namespace API.Entities;
/// <summary>
/// Represents a user provider font to be used in the epub reader
/// </summary>
public class EpubFont: IEntityDate
{
public int Id { get; set; }
/// <summary>
/// Name of the font
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Normalized name for lookups
/// </summary>
public required string NormalizedName { get; set; }
/// <summary>
/// Filename of the font, stored under <see cref="DirectoryService.EpubFontDirectory"/>
/// </summary>
/// <remarks>System provided fonts use an alternative location as they are packaged with the app</remarks>
public required string FileName { get; set; }
/// <summary>
/// Where the font came from
/// </summary>
public FontProvider Provider { get; set; }
public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModified { get; set; }
public DateTime LastModifiedUtc { get; set; }
}

View file

@ -53,6 +53,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IRecommendationService, RecommendationService>();
services.AddScoped<IStreamService, StreamService>();
services.AddScoped<IFontService, FontService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>();

View file

@ -10,6 +10,7 @@ using API.DTOs.Dashboard;
using API.DTOs.Device;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Font;
using API.DTOs.MediaErrors;
using API.DTOs.Metadata;
using API.DTOs.Progress;
@ -262,6 +263,8 @@ public class AutoMapperProfiles : Profile
opt =>
opt.MapFrom(src => src.BookReaderLayoutMode));
CreateMap<EpubFont, EpubFontDto>();
CreateMap<AppUserBookmark, BookmarkDto>();

View file

@ -126,6 +126,7 @@ public class Program
await Seed.SeedRoles(services.GetRequiredService<RoleManager<AppRole>>());
await Seed.SeedSettings(context, directoryService);
await Seed.SeedThemes(context);
await Seed.SeedFonts(context);
await Seed.SeedDefaultStreams(unitOfWork);
await Seed.SeedDefaultSideNavStreams(unitOfWork);
await Seed.SeedUserApiKeys(context);

View file

@ -33,6 +33,7 @@ public interface IDirectoryService
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
/// </summary>
string BookmarkDirectory { get; }
string EpubFontDirectory { get; }
/// <summary>
/// Lists out top-level folders for a given directory. Filters out System and Hidden folders.
/// </summary>
@ -88,6 +89,8 @@ public class DirectoryService : IDirectoryService
public string LocalizationDirectory { get; }
public string CustomizedTemplateDirectory { get; }
public string TemplateDirectory { get; }
public string EpubFontDirectory { get; }
private readonly ILogger<DirectoryService> _logger;
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
@ -125,6 +128,8 @@ public class DirectoryService : IDirectoryService
ExistOrCreate(CustomizedTemplateDirectory);
TemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "EmailTemplates");
ExistOrCreate(TemplateDirectory);
EpubFontDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "fonts");
ExistOrCreate(EpubFontDirectory);
}
/// <summary>

View file

@ -0,0 +1,115 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Entities.Enums.Font;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Services.Tasks;
public interface IFontService
{
Task<EpubFont> CreateFontFromFileAsync(string path);
Task Delete(int fontId);
}
public class FontService: IFontService
{
public static readonly string DefaultFont = "default";
private readonly IDirectoryService _directoryService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<FontService> _logger;
public FontService(IDirectoryService directoryService, IUnitOfWork unitOfWork, ILogger<FontService> logger)
{
_directoryService = directoryService;
_unitOfWork = unitOfWork;
_logger = logger;
}
public async Task<EpubFont> CreateFontFromFileAsync(string path)
{
if (!_directoryService.FileSystem.File.Exists(path))
{
_logger.LogInformation("Unable to create font from manual upload as font not in temp");
throw new KavitaException("errors.font-manual-upload");
}
var fileName = _directoryService.FileSystem.FileInfo.New(path).Name;
var nakedFileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(fileName);
var fontName = Parser.PrettifyFileName(nakedFileName);
var normalizedName = Parser.Normalize(nakedFileName);
if (await _unitOfWork.EpubFontRepository.GetFontDtoByNameAsync(fontName) != null)
{
throw new KavitaException("errors.font-already-in-use");
}
_directoryService.CopyFileToDirectory(path, _directoryService.EpubFontDirectory);
var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.EpubFontDirectory, fileName);
var font = new EpubFont()
{
Name = fontName,
NormalizedName = normalizedName,
FileName = Path.GetFileName(finalLocation),
Provider = FontProvider.User
};
_unitOfWork.EpubFontRepository.Add(font);
await _unitOfWork.CommitAsync();
// TODO: Send update to UI
return font;
}
public async Task Delete(int fontId)
{
if (await _unitOfWork.EpubFontRepository.IsFontInUseAsync(fontId))
{
throw new KavitaException("errors.delete-font-in-use");
}
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
if (font == null)
return;
await RemoveFont(font);
}
public async Task RemoveFont(EpubFont font)
{
if (font.Provider == FontProvider.System)
return;
var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByFontAsync(font.Name);
foreach (var pref in prefs)
{
pref.BookReaderFontFamily = DefaultFont;
_unitOfWork.UserRepository.Update(pref);
}
try
{
// Copy the theme file to temp for nightly removal (to give user time to reclaim if made a mistake)
var existingLocation =
_directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, font.FileName);
var newLocation =
_directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, font.FileName);
_directoryService.CopyFileToDirectory(existingLocation, newLocation);
_directoryService.DeleteFiles([existingLocation]);
}
catch (Exception) { /* Swallow */ }
_unitOfWork.EpubFontRepository.Remove(font);
await _unitOfWork.CommitAsync();
}
}

View file

@ -32,6 +32,7 @@ public static class Parser
private const string BookFileExtensions = EpubFileExtension + "|" + PdfFileExtension;
private const string XmlRegexExtensions = @"\.xml";
public const string MacOsMetadataFileStartsWith = @"._";
public const string FontFileExtensions = @"\.[woff2|tff|otf|woff]";
public const string SupportedExtensions =
ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions;
@ -1259,4 +1260,12 @@ public static class Parser
}
return filename;
}
/**
* Replaced non-alphanumerical chars with a space
*/
public static string PrettifyFileName(string name)
{
return Regex.Replace(name, "[^a-zA-Z0-9]", " ");
}
}