From c8adaee3eb3ba35cbd44481e5a24858853cac1ad Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Tue, 11 May 2021 14:45:18 -0500 Subject: [PATCH] Sentry Integration (#212) * Fixed a parsing case * Integrated Sentry into the solution with anonymous users. Fixed some parsing issues and added BuildInfo into a separate project. * Fixed some bad parser regex * Removed bad reference to NLog * Cleanup of some files not needed --- .github/workflows/nightly-docker.yml | 33 ++++ API.Tests/Parser/MangaParserTests.cs | 3 +- API/API.csproj | 22 +++ API/Controllers/SeriesController.cs | 5 +- API/Dockerfile | 54 +++++-- API/Parser/Parser.cs | 8 +- API/Program.cs | 38 +++++ API/Startup.cs | 8 +- Kavita.Common/EnvironmentInfo/BuildInfo.cs | 56 +++++++ Kavita.Common/EnvironmentInfo/IOsInfo.cs | 148 ++++++++++++++++++ .../EnvironmentInfo/IOsVersionAdapter.cs | 8 + .../EnvironmentInfo/OsVersionModel.cs | 27 ++++ Kavita.Common/HashUtil.cs | 37 +++++ Kavita.Common/Kavita.Common.csproj | 24 +++ Kavita.sln | 14 ++ README.md | 7 +- 16 files changed, 463 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/nightly-docker.yml create mode 100644 Kavita.Common/EnvironmentInfo/BuildInfo.cs create mode 100644 Kavita.Common/EnvironmentInfo/IOsInfo.cs create mode 100644 Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs create mode 100644 Kavita.Common/EnvironmentInfo/OsVersionModel.cs create mode 100644 Kavita.Common/HashUtil.cs create mode 100644 Kavita.Common/Kavita.Common.csproj diff --git a/.github/workflows/nightly-docker.yml b/.github/workflows/nightly-docker.yml new file mode 100644 index 000000000..5129ba8b2 --- /dev/null +++ b/.github/workflows/nightly-docker.yml @@ -0,0 +1,33 @@ +name: ci + +on: + push: + branches: + - 'master' + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - + name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - + name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + push: true + tags: user/app:latest + - + name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} \ No newline at end of file diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index fa932dfeb..39437a2a1 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -61,7 +61,6 @@ namespace API.Tests.Parser [InlineData("[Hidoi]_Amaenaideyo_MS_vol01_chp02.rar", "1")] [InlineData("NEEDLESS_Vol.4_-_Simeon_6_v2_[SugoiSugoi].rar", "4")] [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "1")] - public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename)); @@ -137,6 +136,7 @@ namespace API.Tests.Parser [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "Okusama wa Shougakusei")] [InlineData("VanDread-v01-c001[MD].zip", "VanDread")] [InlineData("Momo The Blood Taker - Chapter 027 Violent Emotion.cbz", "Momo The Blood Taker")] + [InlineData("Green Worldz - Chapter 112 Final Chapter (End).cbz", "Green Worldz")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); @@ -225,6 +225,7 @@ namespace API.Tests.Parser [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", true)] [InlineData("Ani-Hina Art Collection.cbz", true)] [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", true)] + [InlineData("Yuki Merry - 4-Komga Anthology", true)] public void ParseMangaSpecialTest(string input, bool expected) { Assert.Equal(expected, !string.IsNullOrEmpty(API.Parser.Parser.ParseMangaSpecial(input))); diff --git a/API/API.csproj b/API/API.csproj index 8c96cb129..458830ca1 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -12,6 +12,23 @@ ../favicon.ico + + + Kavita + kareadita.github.io + Copyright 2020-$([System.DateTime]::Now.ToString('yyyy')) kareadita.github.io (GNU General Public v3) + + + 0.4.1 + $(Configuration)-dev + + false + false + false + + False + + @@ -33,6 +50,7 @@ + all @@ -65,4 +83,8 @@ <_ContentIncludedByDefault Remove="logs\kavita.json" /> + + + + diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 0654f7d70..cde3d9c0f 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading.Tasks; using API.DTOs; using API.Entities; @@ -148,6 +149,7 @@ namespace API.Controllers public async Task>> GetRecentlyAdded(int libraryId = 0, int limit = 20) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Ok(Array.Empty()); return Ok(await _unitOfWork.SeriesRepository.GetRecentlyAdded(user.Id, libraryId, limit)); } @@ -155,6 +157,7 @@ namespace API.Controllers public async Task>> GetInProgress(int libraryId = 0, int limit = 20) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Ok(Array.Empty()); return Ok(await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, limit)); } diff --git a/API/Dockerfile b/API/Dockerfile index d813139f8..4289aaa3d 100644 --- a/API/Dockerfile +++ b/API/Dockerfile @@ -1,20 +1,40 @@ -FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base -WORKDIR /app -EXPOSE 80 -EXPOSE 443 +#This Dockerfile pulls the latest git commit and builds Kavita from source +FROM mcr.microsoft.com/dotnet/sdk:5.0-focal AS builder -FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build -WORKDIR /src -COPY ["API/API.csproj", "API/"] -RUN dotnet restore "API/API.csproj" -COPY . . -WORKDIR "/src/API" -RUN dotnet build "API.csproj" -c Release -o /app/build +ENV DEBIAN_FRONTEND=noninteractive +ARG TARGETPLATFORM -FROM build AS publish -RUN dotnet publish "API.csproj" -c Release -o /app/publish +#Installs nodejs and npm +RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "API.dll"] +#Builds app based on platform +COPY build_target.sh /build_target.sh +RUN /build_target.sh + +#Production image +FROM ubuntu:focal + +#Move the output files to where they need to be +COPY --from=builder /Projects/Kavita/_output/build/Kavita /kavita + +#Installs program dependencies +RUN apt-get update \ + && apt-get install -y libicu-dev libssl1.1 pwgen \ + && rm -rf /var/lib/apt/lists/* + +#Creates the manga storage directory +RUN mkdir /manga /kavita/data + +RUN cp /kavita/appsettings.Development.json /kavita/appsettings.json \ + && sed -i 's/Data source=kavita.db/Data source=data\/kavita.db/g' /kavita/appsettings.json + +COPY entrypoint.sh /entrypoint.sh + +EXPOSE 5000 + +WORKDIR /kavita + +ENTRYPOINT ["/bin/bash"] +CMD ["/entrypoint.sh"] \ No newline at end of file diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 29d584954..71e01785c 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -92,7 +92,7 @@ namespace API.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Momo The Blood Taker - Chapter 027 Violent Emotion.cbz new Regex( - @"(?.*) (\b|_|-)(?:chapter)", + @"(?.*)(\b|_|-|\s)(?:chapter)(\b|_|-|\s)\d", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Historys Strongest Disciple Kenichi_v11_c90-98.zip, Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) new Regex( @@ -101,7 +101,7 @@ namespace API.Parser //Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip must be before [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip // due to duplicate version identifiers in file. new Regex( - @"(?.*)(v|s)\d+(-\d+)?(_| )", + @"(?.*)(v|s)\d+(-\d+)?(_|\s)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip new Regex( @@ -121,7 +121,7 @@ namespace API.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Tonikaku Kawaii (Ch 59-67) (Ongoing) new Regex( - @"(?.*)( |_)\((c |ch |chapter )", + @"(?.*)(\s|_)\((c\s|ch\s|chapter\s)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Black Bullet (This is very loose, keep towards bottom) new Regex( @@ -364,7 +364,7 @@ namespace API.Parser { // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. new Regex( - @"(?Specials?|OneShot|One\-Shot|Omake|Extra( Chapter)?|Art Collection|Side( |_)Stories)", + @"(?Specials?|OneShot|One\-Shot|Omake|Extra( Chapter)?|Art Collection|Side( |_)Stories|(? + { + options.Dsn = "https://40f4e7b49c094172a6f99d61efb2740f@o641015.ingest.sentry.io/5757423"; + options.MaxBreadcrumbs = 200; + options.AttachStacktrace = true; + options.Debug = false; + options.SendDefaultPii = false; + options.DiagnosticLevel = SentryLevel.Debug; + options.ShutdownTimeout = TimeSpan.FromSeconds(5); + options.Release = BuildInfo.Version.ToString(); + options.AddExceptionFilterForType(); + options.AddExceptionFilterForType(); + options.AddExceptionFilterForType(); + options.ConfigureScope(scope => + { + scope.User = new User() + { + Id = HashUtil.AnonymousToken() + }; + scope.Contexts.App.Name = BuildInfo.AppName; + scope.Contexts.App.Version = BuildInfo.Version.ToString(); + scope.Contexts.App.StartTime = DateTime.UtcNow; + scope.Contexts.App.Hash = HashUtil.AnonymousToken(); + scope.Contexts.App.Build = BuildInfo.Release; + scope.SetTag("culture", Thread.CurrentThread.CurrentCulture.Name); + scope.SetTag("branch", BuildInfo.Branch); + }); + + }); webBuilder.UseStartup(); }); } diff --git a/API/Startup.cs b/API/Startup.cs index 07aeb09d5..f030fda92 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -1,12 +1,14 @@ using System; using System.IO.Compression; using System.Linq; +using System.Reflection; using API.Extensions; using API.Interfaces; using API.Middleware; using API.Services; using Hangfire; using Hangfire.MemoryStorage; +using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -131,7 +133,7 @@ namespace API applicationLifetime.ApplicationStopping.Register(OnShutdown); applicationLifetime.ApplicationStarted.Register(() => { - Console.WriteLine("Kavita - v0.4.1"); + Console.WriteLine("Kavita - v" + BuildInfo.Version); }); // Any services that should be bootstrapped go here @@ -140,10 +142,10 @@ namespace API private void OnShutdown() { - Console.WriteLine("Server is shutting down. Going to dispose Hangfire"); - //this code is called when the application stops + Console.WriteLine("Server is shutting down. Please allow a few seconds to stop any background jobs..."); TaskScheduler.Client.Dispose(); System.Threading.Thread.Sleep(1000); + Console.WriteLine("You may now close the application window."); } } } diff --git a/Kavita.Common/EnvironmentInfo/BuildInfo.cs b/Kavita.Common/EnvironmentInfo/BuildInfo.cs new file mode 100644 index 000000000..32b3e60d8 --- /dev/null +++ b/Kavita.Common/EnvironmentInfo/BuildInfo.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace Kavita.Common.EnvironmentInfo +{ + public static class BuildInfo + { + static BuildInfo() + { + var assembly = Assembly.GetExecutingAssembly(); + + Version = assembly.GetName().Version; + + var attributes = assembly.GetCustomAttributes(true); + + Branch = "unknown"; + + var config = attributes.OfType().FirstOrDefault(); + if (config != null) + { + Branch = config.Configuration; + } + + Release = $"{Version}-{Branch}"; + } + + public static string AppName { get; } = "Kavita"; + + public static Version Version { get; } + public static string Branch { get; } + public static string Release { get; } + + public static DateTime BuildDateTime + { + get + { + var fileLocation = Assembly.GetCallingAssembly().Location; + return new FileInfo(fileLocation).LastWriteTimeUtc; + } + } + + public static bool IsDebug + { + get + { +#if DEBUG + return true; +#else + return false; +#endif + } + } + } +} \ No newline at end of file diff --git a/Kavita.Common/EnvironmentInfo/IOsInfo.cs b/Kavita.Common/EnvironmentInfo/IOsInfo.cs new file mode 100644 index 000000000..f93e4781c --- /dev/null +++ b/Kavita.Common/EnvironmentInfo/IOsInfo.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace Kavita.Common.EnvironmentInfo +{ + public class OsInfo : IOsInfo + { + public static Os Os { get; } + + public static bool IsNotWindows => !IsWindows; + public static bool IsLinux => Os == Os.Linux || Os == Os.LinuxMusl || Os == Os.Bsd; + public static bool IsOsx => Os == Os.Osx; + public static bool IsWindows => Os == Os.Windows; + + // this needs to not be static so we can mock it + public bool IsDocker { get; } + + public string Version { get; } + public string Name { get; } + public string FullName { get; } + + static OsInfo() + { + var platform = Environment.OSVersion.Platform; + + switch (platform) + { + case PlatformID.Win32NT: + { + Os = Os.Windows; + break; + } + + case PlatformID.MacOSX: + case PlatformID.Unix: + { + Os = GetPosixFlavour(); + break; + } + } + } + + public OsInfo(IEnumerable versionAdapters) + { + OsVersionModel osInfo = null; + + foreach (var osVersionAdapter in versionAdapters.Where(c => c.Enabled)) + { + try + { + osInfo = osVersionAdapter.Read(); + } + catch (Exception e) + { + Console.WriteLine("Couldn't get OS Version info: " + e.Message); + } + + if (osInfo != null) + { + break; + } + } + + if (osInfo != null) + { + Name = osInfo.Name; + Version = osInfo.Version; + FullName = osInfo.FullName; + } + else + { + Name = Os.ToString(); + FullName = Name; + } + + if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")) + { + IsDocker = true; + } + } + + private static Os GetPosixFlavour() + { + var output = RunAndCapture("uname", "-s"); + + if (output.StartsWith("Darwin")) + { + return Os.Osx; + } + else if (output.Contains("BSD")) + { + return Os.Bsd; + } + else + { +#if ISMUSL + return Os.LinuxMusl; +#else + return Os.Linux; +#endif + } + } + + private static string RunAndCapture(string filename, string args) + { + var p = new Process + { + StartInfo = + { + FileName = filename, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true + } + }; + + p.Start(); + + // To avoid deadlocks, always read the output stream first and then wait. + var output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1000); + + return output; + } + } + + public interface IOsInfo + { + string Version { get; } + string Name { get; } + string FullName { get; } + + bool IsDocker { get; } + } + + public enum Os + { + Windows, + Linux, + Osx, + LinuxMusl, + Bsd + } +} \ No newline at end of file diff --git a/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs b/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs new file mode 100644 index 000000000..fbf4403d3 --- /dev/null +++ b/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs @@ -0,0 +1,8 @@ +namespace Kavita.Common.EnvironmentInfo +{ + public interface IOsVersionAdapter + { + bool Enabled { get; } + OsVersionModel Read(); + } +} \ No newline at end of file diff --git a/Kavita.Common/EnvironmentInfo/OsVersionModel.cs b/Kavita.Common/EnvironmentInfo/OsVersionModel.cs new file mode 100644 index 000000000..9e91daa18 --- /dev/null +++ b/Kavita.Common/EnvironmentInfo/OsVersionModel.cs @@ -0,0 +1,27 @@ +namespace Kavita.Common.EnvironmentInfo +{ + public class OsVersionModel + { + public OsVersionModel(string name, string version, string fullName = null) + { + Name = Trim(name); + Version = Trim(version); + + if (string.IsNullOrWhiteSpace(fullName)) + { + fullName = $"{Name} {Version}"; + } + + FullName = Trim(fullName); + } + + private static string Trim(string source) + { + return source.Trim().Trim('"', '\''); + } + + public string Name { get; } + public string FullName { get; } + public string Version { get; } + } +} \ No newline at end of file diff --git a/Kavita.Common/HashUtil.cs b/Kavita.Common/HashUtil.cs new file mode 100644 index 000000000..ff02d7d32 --- /dev/null +++ b/Kavita.Common/HashUtil.cs @@ -0,0 +1,37 @@ +using System; +using System.Text; + +namespace Kavita.Common +{ + public static class HashUtil + { + public static string CalculateCrc(string input) + { + uint mCrc = 0xffffffff; + byte[] bytes = Encoding.UTF8.GetBytes(input); + foreach (byte myByte in bytes) + { + mCrc ^= (uint)myByte << 24; + for (var i = 0; i < 8; i++) + { + if ((Convert.ToUInt32(mCrc) & 0x80000000) == 0x80000000) + { + mCrc = (mCrc << 1) ^ 0x04C11DB7; + } + else + { + mCrc <<= 1; + } + } + } + + return $"{mCrc:x8}"; + } + + public static string AnonymousToken() + { + var seed = $"{Environment.ProcessorCount}_{Environment.OSVersion.Platform}_{Environment.MachineName}_{Environment.UserName}"; + return HashUtil.CalculateCrc(seed); + } + } +} \ No newline at end of file diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj new file mode 100644 index 000000000..736bcdb7a --- /dev/null +++ b/Kavita.Common/Kavita.Common.csproj @@ -0,0 +1,24 @@ + + + + net5.0 + kareadita.github.io + Kavita + 0.4.1 + en + + + + + + + + + D:\Program Files\JetBrains\JetBrains Rider 2020.3.2\lib\ReSharperHost\TestRunner\netcoreapp2.0\JetBrains.ReSharper.TestRunner.Merged.dll + + + ..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.5\Microsoft.Win32.Registry.dll + + + + diff --git a/Kavita.sln b/Kavita.sln index 74927a34f..e49484b02 100644 --- a/Kavita.sln +++ b/Kavita.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{1 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API.Tests", "API.Tests\API.Tests.csproj", "{6F7910F2-1B95-4570-A490-519C8935B9D1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kavita.Common", "Kavita.Common\Kavita.Common.csproj", "{165A86F5-9E74-4C05-9305-A6F0BA32C9EE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,5 +46,17 @@ Global {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x64.Build.0 = Release|Any CPU {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x86.ActiveCfg = Release|Any CPU {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x86.Build.0 = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|x64.ActiveCfg = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|x64.Build.0 = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|x86.Build.0 = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|Any CPU.Build.0 = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|x64.ActiveCfg = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|x64.Build.0 = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|x86.ActiveCfg = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 021232415..83229889c 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,13 @@ your manga collection with your friends and family! ## Goals: -* Serve up Manga (cbr, cbz, zip/rar, raw images) and Books (epub, mobi, azw, djvu, pdf) -* Provide Reader for Manga and Books (Light Novels) via web app that is responsive -* Provide customization themes (server installed) for web app +* Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar, raw images) and Books (epub, mobi, azw, djvu, pdf) +* Provide Readers via web app that is responsive +* Provide a dark theme for web app * Provide hooks into metadata providers to fetch Manga data * Metadata should allow for collections, want to read integration from 3rd party services, genres. * Ability to manage users, access, and ratings +* Ability to sync ratings and reviews to external services ## How to Build - Ensure you've cloned Kavita-webui. You should have Projects/Kavita and Projects/Kavita-webui