mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Breaking Changes: Docker Parity (#698)
* Refactored all the config files for Kavita to be loaded from config/. This will allow docker to just mount one folder and for Update functionality to be trivial. * Cleaned up documentation around new update method. * Updated docker files to support config directory * Removed entrypoint, no longer needed * Update appsettings to point to config directory for logs * Updated message for docker users that are upgrading * Ensure that docker users that have not updated their mount points from upgrade cannot start the server * Code smells * More cleanup * Added entrypoint to fix bind mount issues * Updated README with new folder structure * Fixed build system for new setup * Updated string path if user is docker * Updated the migration flow for docker to work properly and Fixed LogFile configuration updating. * Migrating docker images is now working 100% * Fixed config from bad code * Code cleanup Co-authored-by: Chris Plaatjes <kizaing@gmail.com>
This commit is contained in:
parent
66b79e8cbe
commit
a29b11c366
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
labels: needs-triage
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
@ -24,7 +24,7 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- OS: [e.g. iOS, Docker]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22] (can be found on Server Settings -> System tab)
|
||||
|
||||
|
12
.gitignore
vendored
12
.gitignore
vendored
@ -501,6 +501,18 @@ API/stats/
|
||||
UI/Web/dist/
|
||||
/API.Tests/Extensions/Test Data/modified on run.txt
|
||||
/API/covers/
|
||||
# All config files in config except appsettings.json
|
||||
/API/config/covers/
|
||||
/API/config/logs/
|
||||
/API/config/backups/
|
||||
/API/config/cache/
|
||||
/API/config/temp/
|
||||
/API/config/kavita.db
|
||||
/API/config/kavita.db-shm
|
||||
/API/config/kavita.db-wal
|
||||
/API/config/Hangfire.db
|
||||
/API/config/Hangfire-log.db
|
||||
API/config/covers/
|
||||
API/config/*.db
|
||||
|
||||
UI/Web/.vscode/settings.json
|
||||
|
@ -36,7 +36,7 @@ namespace API.Tests.Services
|
||||
public void GetFiles_WithCustomRegex_ShouldPass_Test()
|
||||
{
|
||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/regex");
|
||||
var files = _directoryService.GetFiles(testDirectory, @"file\d*.txt");
|
||||
var files = DirectoryService.GetFiles(testDirectory, @"file\d*.txt");
|
||||
Assert.Equal(2, files.Count());
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ namespace API.Tests.Services
|
||||
public void GetFiles_TopLevel_ShouldBeEmpty_Test()
|
||||
{
|
||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService");
|
||||
var files = _directoryService.GetFiles(testDirectory);
|
||||
var files = DirectoryService.GetFiles(testDirectory);
|
||||
Assert.Empty(files);
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ namespace API.Tests.Services
|
||||
public void GetFilesWithExtensions_ShouldBeEmpty_Test()
|
||||
{
|
||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/extensions");
|
||||
var files = _directoryService.GetFiles(testDirectory, "*.txt");
|
||||
var files = DirectoryService.GetFiles(testDirectory, "*.txt");
|
||||
Assert.Empty(files);
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ namespace API.Tests.Services
|
||||
public void GetFilesWithExtensions_Test()
|
||||
{
|
||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/extension");
|
||||
var files = _directoryService.GetFiles(testDirectory, ".cbz|.rar");
|
||||
var files = DirectoryService.GetFiles(testDirectory, ".cbz|.rar");
|
||||
Assert.Equal(3, files.Count());
|
||||
}
|
||||
|
||||
@ -68,7 +68,7 @@ namespace API.Tests.Services
|
||||
public void GetFilesWithExtensions_BadDirectory_ShouldBeEmpty_Test()
|
||||
{
|
||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/doesntexist");
|
||||
var files = _directoryService.GetFiles(testDirectory, ".cbz|.rar");
|
||||
var files = DirectoryService.GetFiles(testDirectory, ".cbz|.rar");
|
||||
Assert.Empty(files);
|
||||
}
|
||||
|
||||
|
@ -164,7 +164,7 @@ namespace API.Controllers
|
||||
case MangaFormat.Archive:
|
||||
case MangaFormat.Pdf:
|
||||
_cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList());
|
||||
var originalFiles = _directoryService.GetFilesWithExtension(chapterExtractPath,
|
||||
var originalFiles = DirectoryService.GetFilesWithExtension(chapterExtractPath,
|
||||
Parser.Parser.ImageFileExtensions);
|
||||
_directoryService.CopyFilesToDirectory(originalFiles, chapterExtractPath, $"{chapterId}_");
|
||||
DirectoryService.DeleteFiles(originalFiles);
|
||||
@ -175,7 +175,7 @@ namespace API.Controllers
|
||||
return BadRequest("Series is not in a valid format. Please rescan series and try again.");
|
||||
}
|
||||
|
||||
var files = _directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
|
||||
var files = DirectoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
|
||||
// Filter out images that aren't in bookmarks
|
||||
Array.Sort(files, _numericComparer);
|
||||
totalFilePaths.AddRange(files.Where((_, i) => chapterPages.Contains(i)));
|
||||
|
@ -739,7 +739,7 @@ namespace API.Controllers
|
||||
[HttpGet("{apiKey}/favicon")]
|
||||
public async Task<ActionResult> GetFavicon(string apiKey)
|
||||
{
|
||||
var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico");
|
||||
var files = DirectoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico");
|
||||
if (files.Length == 0) return BadRequest("Cannot find icon");
|
||||
var path = files[0];
|
||||
var content = await _directoryService.ReadFileAsync(path);
|
||||
|
142
API/Data/MigrateConfigFiles.cs
Normal file
142
API/Data/MigrateConfigFiles.cs
Normal file
@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
|
||||
namespace API.Data
|
||||
{
|
||||
public static class MigrateConfigFiles
|
||||
{
|
||||
private static readonly List<string> LooseLeafFiles = new List<string>()
|
||||
{
|
||||
"appsettings.json",
|
||||
"appsettings.Development.json",
|
||||
"kavita.db",
|
||||
};
|
||||
|
||||
private static readonly List<string> AppFolders = new List<string>()
|
||||
{
|
||||
"covers",
|
||||
"stats",
|
||||
"logs",
|
||||
"backups",
|
||||
"cache",
|
||||
"temp"
|
||||
};
|
||||
|
||||
private static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config");
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// In v0.4.8 we moved all config files to config/ to match with how docker was setup. This will move all config files from current directory
|
||||
/// to config/
|
||||
/// </summary>
|
||||
public static void Migrate(bool isDocker)
|
||||
{
|
||||
Console.WriteLine("Checking if migration to config/ is needed");
|
||||
|
||||
if (isDocker)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
|
||||
|
||||
CopyAppFolders();
|
||||
DeleteAppFolders();
|
||||
|
||||
UpdateConfiguration();
|
||||
|
||||
Console.WriteLine("Migration complete. All config files are now in config/ directory");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!new FileInfo(Path.Join(Directory.GetCurrentDirectory(), "appsettings.json")).Exists)
|
||||
{
|
||||
Console.WriteLine("Migration to config/ not needed");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
|
||||
|
||||
Console.WriteLine($"Creating {ConfigDirectory}");
|
||||
DirectoryService.ExistOrCreate(ConfigDirectory);
|
||||
|
||||
CopyLooseLeafFiles();
|
||||
|
||||
CopyAppFolders();
|
||||
|
||||
// Then we need to update the config file to point to the new DB file
|
||||
UpdateConfiguration();
|
||||
|
||||
// Finally delete everything in the source directory
|
||||
Console.WriteLine("Removing old files");
|
||||
DeleteLooseFiles();
|
||||
DeleteAppFolders();
|
||||
Console.WriteLine("Removing old files...DONE");
|
||||
|
||||
Console.WriteLine("Migration complete. All config files are now in config/ directory");
|
||||
}
|
||||
|
||||
private static void DeleteAppFolders()
|
||||
{
|
||||
foreach (var folderToDelete in AppFolders)
|
||||
{
|
||||
if (!new DirectoryInfo(Path.Join(Directory.GetCurrentDirectory(), folderToDelete)).Exists) continue;
|
||||
|
||||
DirectoryService.ClearAndDeleteDirectory(Path.Join(Directory.GetCurrentDirectory(), folderToDelete));
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteLooseFiles()
|
||||
{
|
||||
var configFiles = LooseLeafFiles.Select(file => new FileInfo(Path.Join(Directory.GetCurrentDirectory(), file)))
|
||||
.Where(f => f.Exists);
|
||||
DirectoryService.DeleteFiles(configFiles.Select(f => f.FullName));
|
||||
}
|
||||
|
||||
private static void CopyAppFolders()
|
||||
{
|
||||
Console.WriteLine("Moving folders to config");
|
||||
foreach (var folderToMove in AppFolders)
|
||||
{
|
||||
if (new DirectoryInfo(Path.Join(ConfigDirectory, folderToMove)).Exists) continue;
|
||||
|
||||
DirectoryService.CopyDirectoryToDirectory(Path.Join(Directory.GetCurrentDirectory(), folderToMove),
|
||||
Path.Join(ConfigDirectory, folderToMove));
|
||||
}
|
||||
|
||||
Console.WriteLine("Moving folders to config...DONE");
|
||||
}
|
||||
|
||||
private static void CopyLooseLeafFiles()
|
||||
{
|
||||
var configFiles = LooseLeafFiles.Select(file => new FileInfo(Path.Join(Directory.GetCurrentDirectory(), file)))
|
||||
.Where(f => f.Exists);
|
||||
// First step is to move all the files
|
||||
Console.WriteLine("Moving files to config/");
|
||||
foreach (var fileInfo in configFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
fileInfo.CopyTo(Path.Join(ConfigDirectory, fileInfo.Name));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow exception when already exists */
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("Moving files to config...DONE");
|
||||
}
|
||||
|
||||
private static void UpdateConfiguration()
|
||||
{
|
||||
Console.WriteLine("Updating appsettings.json to new paths");
|
||||
Configuration.DatabasePath = "config//kavita.db";
|
||||
Configuration.LogPath = "config//logs/kavita.log";
|
||||
Console.WriteLine("Updating appsettings.json to new paths...DONE");
|
||||
}
|
||||
}
|
||||
}
|
@ -41,11 +41,11 @@ namespace API.Data
|
||||
|
||||
IList<ServerSetting> defaultSettings = new List<ServerSetting>()
|
||||
{
|
||||
new() {Key = ServerSettingKey.CacheDirectory, Value = CacheService.CacheDirectory},
|
||||
new() {Key = ServerSettingKey.CacheDirectory, Value = DirectoryService.CacheDirectory},
|
||||
new () {Key = ServerSettingKey.TaskScan, Value = "daily"},
|
||||
new () {Key = ServerSettingKey.LoggingLevel, Value = "Information"}, // Not used from DB, but DB is sync with appSettings.json
|
||||
new () {Key = ServerSettingKey.TaskBackup, Value = "weekly"},
|
||||
new () {Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "backups/"))},
|
||||
new () {Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory)},
|
||||
new () {Key = ServerSettingKey.Port, Value = "5000"}, // Not used from DB, but DB is sync with appSettings.json
|
||||
new () {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
|
||||
new () {Key = ServerSettingKey.EnableOpds, Value = "false"},
|
||||
@ -69,6 +69,8 @@ namespace API.Data
|
||||
Configuration.Port + string.Empty;
|
||||
context.ServerSetting.First(s => s.Key == ServerSettingKey.LoggingLevel).Value =
|
||||
Configuration.LogLevel + string.Empty;
|
||||
context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value =
|
||||
DirectoryService.CacheDirectory + string.Empty;
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
|
@ -12,21 +12,9 @@ namespace API.Interfaces.Services
|
||||
/// <param name="rootPath">Absolute path of directory to scan.</param>
|
||||
/// <returns>List of folder names</returns>
|
||||
IEnumerable<string> ListDirectory(string rootPath);
|
||||
/// <summary>
|
||||
/// Gets files in a directory. If searchPatternExpression is passed, will match the regex against for filtering.
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <param name="searchPatternExpression"></param>
|
||||
/// <returns></returns>
|
||||
string[] GetFilesWithExtension(string path, string searchPatternExpression = "");
|
||||
Task<byte[]> ReadFileAsync(string path);
|
||||
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "");
|
||||
bool Exists(string directory);
|
||||
|
||||
IEnumerable<string> GetFiles(string path, string searchPatternExpression = "",
|
||||
SearchOption searchOption = SearchOption.TopDirectoryOnly);
|
||||
|
||||
void CopyFileToDirectory(string fullFilePath, string targetDirectory);
|
||||
public bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "*");
|
||||
}
|
||||
}
|
||||
|
145
API/Program.cs
145
API/Program.cs
@ -1,98 +1,127 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Entities;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
private static readonly int HttpPort = Configuration.Port;
|
||||
public class Program
|
||||
{
|
||||
private static readonly int HttpPort = Configuration.Port;
|
||||
|
||||
protected Program()
|
||||
{
|
||||
}
|
||||
protected Program()
|
||||
{
|
||||
}
|
||||
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
var isDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker;
|
||||
|
||||
// Before anything, check if JWT has been generated properly or if user still has default
|
||||
if (!Configuration.CheckIfJwtTokenSet() &&
|
||||
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development)
|
||||
{
|
||||
Console.WriteLine("Generating JWT TokenKey for encrypting user sessions...");
|
||||
var rBytes = new byte[128];
|
||||
using (var crypto = new RNGCryptoServiceProvider()) crypto.GetBytes(rBytes);
|
||||
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
|
||||
}
|
||||
MigrateConfigFiles.Migrate(isDocker);
|
||||
|
||||
var host = CreateHostBuilder(args).Build();
|
||||
// Before anything, check if JWT has been generated properly or if user still has default
|
||||
if (!Configuration.CheckIfJwtTokenSet() &&
|
||||
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development)
|
||||
{
|
||||
Console.WriteLine("Generating JWT TokenKey for encrypting user sessions...");
|
||||
var rBytes = new byte[128];
|
||||
using (var crypto = new RNGCryptoServiceProvider()) crypto.GetBytes(rBytes);
|
||||
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
|
||||
}
|
||||
|
||||
using var scope = host.Services.CreateScope();
|
||||
var services = scope.ServiceProvider;
|
||||
var host = CreateHostBuilder(args).Build();
|
||||
|
||||
try
|
||||
{
|
||||
var context = services.GetRequiredService<DataContext>();
|
||||
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
||||
using var scope = host.Services.CreateScope();
|
||||
var services = scope.ServiceProvider;
|
||||
|
||||
var requiresCoverImageMigration = !Directory.Exists(DirectoryService.CoverImageDirectory);
|
||||
try
|
||||
{
|
||||
// If this is a new install, tables wont exist yet
|
||||
var context = services.GetRequiredService<DataContext>();
|
||||
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
||||
|
||||
if (isDocker && new FileInfo("data/appsettings.json").Exists)
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<Startup>>();
|
||||
logger.LogCritical("WARNING! Mount point is incorrect, nothing here will persist. Please change your container mount from /kavita/data to /kavita/config");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var requiresCoverImageMigration = !Directory.Exists(DirectoryService.CoverImageDirectory);
|
||||
try
|
||||
{
|
||||
// If this is a new install, tables wont exist yet
|
||||
if (requiresCoverImageMigration)
|
||||
{
|
||||
MigrateCoverImages.ExtractToImages(context);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
requiresCoverImageMigration = false;
|
||||
}
|
||||
|
||||
// Apply all migrations on startup
|
||||
await context.Database.MigrateAsync();
|
||||
|
||||
if (requiresCoverImageMigration)
|
||||
{
|
||||
MigrateCoverImages.ExtractToImages(context);
|
||||
await MigrateCoverImages.UpdateDatabaseWithImages(context);
|
||||
}
|
||||
|
||||
await Seed.SeedRoles(roleManager);
|
||||
await Seed.SeedSettings(context);
|
||||
await Seed.SeedUserApiKeys(context);
|
||||
}
|
||||
catch (Exception )
|
||||
catch (Exception ex)
|
||||
{
|
||||
requiresCoverImageMigration = false;
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogError(ex, "An error occurred during migration");
|
||||
}
|
||||
|
||||
// Apply all migrations on startup
|
||||
await context.Database.MigrateAsync();
|
||||
await host.RunAsync();
|
||||
}
|
||||
|
||||
if (requiresCoverImageMigration)
|
||||
{
|
||||
await MigrateCoverImages.UpdateDatabaseWithImages(context);
|
||||
}
|
||||
private static IHostBuilder CreateHostBuilder(string[] args) =>
|
||||
Host.CreateDefaultBuilder(args)
|
||||
.ConfigureAppConfiguration((hostingContext, config) =>
|
||||
{
|
||||
config.Sources.Clear();
|
||||
|
||||
await Seed.SeedRoles(roleManager);
|
||||
await Seed.SeedSettings(context);
|
||||
await Seed.SeedUserApiKeys(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogError(ex, "An error occurred during migration");
|
||||
}
|
||||
var env = hostingContext.HostingEnvironment;
|
||||
|
||||
await host.RunAsync();
|
||||
}
|
||||
config.AddJsonFile("config/appsettings.json", optional: true, reloadOnChange: false)
|
||||
.AddJsonFile($"config/appsettings.{env.EnvironmentName}.json",
|
||||
optional: true, reloadOnChange: false);
|
||||
})
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseKestrel((opts) =>
|
||||
{
|
||||
opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; });
|
||||
});
|
||||
|
||||
private static IHostBuilder CreateHostBuilder(string[] args) =>
|
||||
Host.CreateDefaultBuilder(args)
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseKestrel((opts) =>
|
||||
{
|
||||
opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; });
|
||||
});
|
||||
webBuilder.UseStartup<Startup>();
|
||||
});
|
||||
|
||||
webBuilder.UseStartup<Startup>();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,6 @@ namespace API.Services
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IBookService _bookService;
|
||||
private readonly NumericComparer _numericComparer;
|
||||
public static readonly string CacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "cache/"));
|
||||
|
||||
public CacheService(ILogger<CacheService> logger, IUnitOfWork unitOfWork, IArchiveService archiveService,
|
||||
IDirectoryService directoryService, IBookService bookService)
|
||||
@ -38,7 +37,7 @@ namespace API.Services
|
||||
{
|
||||
if (!DirectoryService.ExistOrCreate(DirectoryService.CacheDirectory))
|
||||
{
|
||||
_logger.LogError("Cache directory {CacheDirectory} is not accessible or does not exist. Creating...", CacheDirectory);
|
||||
_logger.LogError("Cache directory {CacheDirectory} is not accessible or does not exist. Creating...", DirectoryService.CacheDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,7 +101,7 @@ namespace API.Services
|
||||
}
|
||||
else
|
||||
{
|
||||
_directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath,
|
||||
DirectoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath,
|
||||
Parser.Parser.ImageFileExtensions);
|
||||
}
|
||||
|
||||
@ -147,7 +146,7 @@ namespace API.Services
|
||||
|
||||
try
|
||||
{
|
||||
DirectoryService.ClearDirectory(CacheDirectory);
|
||||
DirectoryService.ClearDirectory(DirectoryService.CacheDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -198,7 +197,7 @@ namespace API.Services
|
||||
if (page <= (mangaFile.Pages + pagesSoFar))
|
||||
{
|
||||
var path = GetCachePath(chapter.Id);
|
||||
var files = _directoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions);
|
||||
var files = DirectoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions);
|
||||
Array.Sort(files, _numericComparer);
|
||||
|
||||
if (files.Length == 0)
|
||||
|
@ -16,11 +16,12 @@ namespace API.Services
|
||||
private static readonly Regex ExcludeDirectories = new Regex(
|
||||
@"@eaDir|\.DS_Store",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
public static readonly string TempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
|
||||
public static readonly string LogDirectory = Path.Join(Directory.GetCurrentDirectory(), "logs");
|
||||
public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "cache");
|
||||
public static readonly string CoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), "covers");
|
||||
public static readonly string StatsDirectory = Path.Join(Directory.GetCurrentDirectory(), "stats");
|
||||
public static readonly string TempDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "temp");
|
||||
public static readonly string LogDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "logs");
|
||||
public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "cache");
|
||||
public static readonly string CoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "covers");
|
||||
public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups");
|
||||
public static readonly string StatsDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "stats");
|
||||
|
||||
public DirectoryService(ILogger<DirectoryService> logger)
|
||||
{
|
||||
@ -47,7 +48,6 @@ namespace API.Services
|
||||
reSearchPattern.IsMatch(Path.GetExtension(file)) && !Path.GetFileName(file).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns a list of folders from end of fullPath to rootPath. If a file is passed at the end of the fullPath, it will be ignored.
|
||||
///
|
||||
@ -96,7 +96,7 @@ namespace API.Services
|
||||
return di.Exists;
|
||||
}
|
||||
|
||||
public IEnumerable<string> GetFiles(string path, string searchPatternExpression = "",
|
||||
public static IEnumerable<string> GetFiles(string path, string searchPatternExpression = "",
|
||||
SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
{
|
||||
if (searchPatternExpression != string.Empty)
|
||||
@ -132,10 +132,10 @@ namespace API.Services
|
||||
/// </summary>
|
||||
/// <param name="sourceDirName"></param>
|
||||
/// <param name="destDirName"></param>
|
||||
/// <param name="searchPattern">Defaults to *, meaning all files</param>
|
||||
/// <param name="searchPattern">Defaults to empty string, meaning all files</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="DirectoryNotFoundException"></exception>
|
||||
public bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "*")
|
||||
public static bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "")
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourceDirName)) return false;
|
||||
|
||||
@ -177,7 +177,13 @@ namespace API.Services
|
||||
|
||||
|
||||
|
||||
public string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
|
||||
/// <summary>
|
||||
/// Get files with a file extension
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
/// <param name="searchPatternExpression">Regex to use for searching on regex. Defaults to empty string for all files</param>
|
||||
/// <returns></returns>
|
||||
public static string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
|
||||
{
|
||||
if (searchPatternExpression != string.Empty)
|
||||
{
|
||||
|
@ -13,7 +13,6 @@ namespace API.Services
|
||||
public class ImageService : IImageService
|
||||
{
|
||||
private readonly ILogger<ImageService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
public const string ChapterCoverImageRegex = @"v\d+_c\d+";
|
||||
public const string SeriesCoverImageRegex = @"seres\d+";
|
||||
public const string CollectionTagCoverImageRegex = @"tag\d+";
|
||||
@ -24,10 +23,9 @@ namespace API.Services
|
||||
/// </summary>
|
||||
private const int ThumbnailWidth = 320;
|
||||
|
||||
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService)
|
||||
public ImageService(ILogger<ImageService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -44,9 +42,9 @@ namespace API.Services
|
||||
return null;
|
||||
}
|
||||
|
||||
var firstImage = _directoryService.GetFilesWithExtension(directory, Parser.Parser.ImageFileExtensions)
|
||||
var firstImage = DirectoryService.GetFilesWithExtension(directory, Parser.Parser.ImageFileExtensions)
|
||||
.OrderBy(f => f, new NaturalSortComparer()).FirstOrDefault();
|
||||
|
||||
|
||||
return firstImage;
|
||||
}
|
||||
|
||||
|
@ -138,8 +138,7 @@ namespace API.Services
|
||||
|
||||
public void CleanupTemp()
|
||||
{
|
||||
var tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
|
||||
BackgroundJob.Enqueue(() => DirectoryService.ClearDirectory(tempDirectory));
|
||||
BackgroundJob.Enqueue(() => DirectoryService.ClearDirectory(DirectoryService.TempDirectory));
|
||||
}
|
||||
|
||||
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true)
|
||||
|
@ -20,8 +20,8 @@ namespace API.Services.Tasks
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<BackupService> _logger;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly string _tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
|
||||
private readonly string _logDirectory = Path.Join(Directory.GetCurrentDirectory(), "logs");
|
||||
private readonly string _tempDirectory = DirectoryService.TempDirectory;
|
||||
private readonly string _logDirectory = DirectoryService.LogDirectory;
|
||||
|
||||
private readonly IList<string> _backupFiles;
|
||||
|
||||
@ -72,7 +72,7 @@ namespace API.Services.Tasks
|
||||
var fi = new FileInfo(logFileName);
|
||||
|
||||
var files = maxRollingFiles > 0
|
||||
? _directoryService.GetFiles(_logDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log")
|
||||
? DirectoryService.GetFiles(_logDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log")
|
||||
: new[] {"kavita.log"};
|
||||
return files;
|
||||
}
|
||||
@ -148,7 +148,7 @@ namespace API.Services.Tasks
|
||||
// Swallow exception. This can be a duplicate cover being copied as chapter and volumes can share same file.
|
||||
}
|
||||
|
||||
if (!_directoryService.GetFiles(outputTempDir).Any())
|
||||
if (!DirectoryService.GetFiles(outputTempDir).Any())
|
||||
{
|
||||
DirectoryService.ClearAndDeleteDirectory(outputTempDir);
|
||||
}
|
||||
@ -164,7 +164,7 @@ namespace API.Services.Tasks
|
||||
var backupDirectory = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Result.Value;
|
||||
if (!_directoryService.Exists(backupDirectory)) return;
|
||||
var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(dayThreshold));
|
||||
var allBackups = _directoryService.GetFiles(backupDirectory).ToList();
|
||||
var allBackups = DirectoryService.GetFiles(backupDirectory).ToList();
|
||||
var expiredBackups = allBackups.Select(filename => new FileInfo(filename))
|
||||
.Where(f => f.CreationTime > deltaTime)
|
||||
.ToList();
|
||||
|
@ -16,16 +16,14 @@ namespace API.Services.Tasks
|
||||
private readonly ILogger<CleanupService> _logger;
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
|
||||
public CleanupService(ICacheService cacheService, ILogger<CleanupService> logger,
|
||||
IBackupService backupService, IUnitOfWork unitOfWork, IDirectoryService directoryService)
|
||||
IBackupService backupService, IUnitOfWork unitOfWork)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_logger = logger;
|
||||
_backupService = backupService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
public void CleanupCacheDirectory()
|
||||
@ -42,7 +40,7 @@ namespace API.Services.Tasks
|
||||
{
|
||||
_logger.LogInformation("Starting Cleanup");
|
||||
_logger.LogInformation("Cleaning temp directory");
|
||||
var tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
|
||||
var tempDirectory = DirectoryService.TempDirectory;
|
||||
DirectoryService.ClearDirectory(tempDirectory);
|
||||
CleanupCacheDirectory();
|
||||
_logger.LogInformation("Cleaning old database backups");
|
||||
@ -57,7 +55,7 @@ namespace API.Services.Tasks
|
||||
private async Task DeleteSeriesCoverImages()
|
||||
{
|
||||
var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync();
|
||||
var files = _directoryService.GetFiles(DirectoryService.CoverImageDirectory, ImageService.SeriesCoverImageRegex);
|
||||
var files = DirectoryService.GetFiles(DirectoryService.CoverImageDirectory, ImageService.SeriesCoverImageRegex);
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (images.Contains(Path.GetFileName(file))) continue;
|
||||
@ -69,7 +67,7 @@ namespace API.Services.Tasks
|
||||
private async Task DeleteChapterCoverImages()
|
||||
{
|
||||
var images = await _unitOfWork.ChapterRepository.GetAllCoverImagesAsync();
|
||||
var files = _directoryService.GetFiles(DirectoryService.CoverImageDirectory, ImageService.ChapterCoverImageRegex);
|
||||
var files = DirectoryService.GetFiles(DirectoryService.CoverImageDirectory, ImageService.ChapterCoverImageRegex);
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (images.Contains(Path.GetFileName(file))) continue;
|
||||
@ -81,7 +79,7 @@ namespace API.Services.Tasks
|
||||
private async Task DeleteTagCoverImages()
|
||||
{
|
||||
var images = await _unitOfWork.CollectionTagRepository.GetAllCoverImagesAsync();
|
||||
var files = _directoryService.GetFiles(DirectoryService.CoverImageDirectory, ImageService.CollectionTagCoverImageRegex);
|
||||
var files = DirectoryService.GetFiles(DirectoryService.CoverImageDirectory, ImageService.CollectionTagCoverImageRegex);
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (images.Contains(Path.GetFileName(file))) continue;
|
||||
|
@ -24,6 +24,7 @@ using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.OpenApi.Models;
|
||||
|
||||
namespace API
|
||||
@ -217,6 +218,15 @@ namespace API
|
||||
applicationLifetime.ApplicationStopping.Register(OnShutdown);
|
||||
applicationLifetime.ApplicationStarted.Register(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var logger = serviceProvider.GetRequiredService<ILogger<Startup>>();
|
||||
logger.LogInformation("Kavita - v{Version}", BuildInfo.Version);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow Exception */
|
||||
}
|
||||
Console.WriteLine($"Kavita - v{BuildInfo.Version}");
|
||||
});
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data source=kavita.db"
|
||||
"DefaultConnection": "Data source=config//kavita.db"
|
||||
},
|
||||
"TokenKey": "super secret unguessable key",
|
||||
"Logging": {
|
||||
@ -12,7 +12,7 @@
|
||||
"Microsoft.AspNetCore.Hosting.Internal.WebHost": "Information"
|
||||
},
|
||||
"File": {
|
||||
"Path": "logs/kavita.log",
|
||||
"Path": "config//logs/kavita.log",
|
||||
"Append": "True",
|
||||
"FileSizeLimitBytes": 26214400,
|
||||
"MaxRollingFiles": 2
|
1
API/config/stats/app_stats - Copy.json
Normal file
1
API/config/stats/app_stats - Copy.json
Normal file
@ -0,0 +1 @@
|
||||
{"InstallId":"2c158339","LastUpdate":"2021-07-26T00:32:01.7509137Z","UsageInfo":null,"ServerInfo":null,"ClientsInfo":[{"KavitaUiVersion":"0.4.2","ScreenResolution":"1920 x 1080","PlatformType":"desktop","Browser":{"Name":"Chrome","Version":"91.0.4472.124"},"Os":{"Name":"Windows","Version":"NT 10.0"},"CollectedAt":"2021-07-26T00:32:01.7289388Z","UsingDarkTheme":true}]}
|
1
API/config/stats/app_stats.json
Normal file
1
API/config/stats/app_stats.json
Normal file
@ -0,0 +1 @@
|
||||
{"InstallId":"2c158339","LastUpdate":"2021-10-20T12:44:40.269713Z","UsageInfo":{"UsersCount":6,"FileTypes":[".epub",".pdf",".cbr",".cbz",".zip",".rar",".jpg",".7z",".png"],"LibraryTypesCreated":[{"Type":0,"Count":2},{"Type":1,"Count":2},{"Type":2,"Count":1}]},"ServerInfo":{"Os":"Microsoft Windows 10.0.19042","DotNetVersion":"5.0.9","RunTimeVersion":".NET 5.0.9","KavitaVersion":"0.4.7.16","BuildBranch":"Debug","Culture":"en-US","IsDocker":false,"NumOfCores":12},"ClientsInfo":[{"KavitaUiVersion":"0.4.2","ScreenResolution":"1920 x 1080","PlatformType":"desktop","Browser":{"Name":"Chrome","Version":"94.0.4606.81"},"Os":{"Name":"Windows","Version":"NT 10.0"},"CollectedAt":"2021-10-20T12:36:27.2838212Z","UsingDarkTheme":true}]}
|
@ -20,19 +20,14 @@ COPY --from=copytask /files/wwwroot /kavita/wwwroot
|
||||
|
||||
#Installs program dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y libicu-dev libssl1.1 pwgen libgdiplus \
|
||||
&& apt-get install -y libicu-dev libssl1.1 libgdiplus \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
#Creates the data directory
|
||||
RUN mkdir /kavita/data
|
||||
|
||||
RUN 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"]
|
||||
ENTRYPOINT [ "/bin/bash" ]
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
@ -2,4 +2,6 @@
|
||||
1. Unzip the archive to a directory that is writable. If on windows, do not place in Program Files.
|
||||
2. (Linux only) Chmod and Chown so Kavita can write to the directory you placed in.
|
||||
3. Run Kavita executable.
|
||||
4. Open localhost:5000 and setup your account and libraries in the UI.
|
||||
4. Open localhost:5000 and setup your account and libraries in the UI.
|
||||
|
||||
If updating, copy everything but the config/ directory over. Restart Kavita.
|
||||
|
7
Kavita.Common/AppSettingsConfig.cs
Normal file
7
Kavita.Common/AppSettingsConfig.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Kavita.Common
|
||||
{
|
||||
public class AppSettingsConfig
|
||||
{
|
||||
|
||||
}
|
||||
}
|
@ -6,236 +6,349 @@ using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Kavita.Common
|
||||
{
|
||||
public static class Configuration
|
||||
{
|
||||
private static readonly string AppSettingsFilename = GetAppSettingFilename();
|
||||
public static string Branch
|
||||
{
|
||||
get => GetBranch(GetAppSettingFilename());
|
||||
set => SetBranch(GetAppSettingFilename(), value);
|
||||
}
|
||||
public static class Configuration
|
||||
{
|
||||
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
|
||||
|
||||
public static int Port
|
||||
{
|
||||
get => GetPort(GetAppSettingFilename());
|
||||
set => SetPort(GetAppSettingFilename(), value);
|
||||
}
|
||||
public static string Branch
|
||||
{
|
||||
get => GetBranch(GetAppSettingFilename());
|
||||
set => SetBranch(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
public static string JwtToken
|
||||
{
|
||||
get => GetJwtToken(GetAppSettingFilename());
|
||||
set => SetJwtToken(GetAppSettingFilename(), value);
|
||||
}
|
||||
public static int Port
|
||||
{
|
||||
get => GetPort(GetAppSettingFilename());
|
||||
set => SetPort(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
public static string LogLevel
|
||||
{
|
||||
get => GetLogLevel(GetAppSettingFilename());
|
||||
set => SetLogLevel(GetAppSettingFilename(), value);
|
||||
}
|
||||
public static string JwtToken
|
||||
{
|
||||
get => GetJwtToken(GetAppSettingFilename());
|
||||
set => SetJwtToken(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
private static string GetAppSettingFilename()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(AppSettingsFilename))
|
||||
{
|
||||
return AppSettingsFilename;
|
||||
}
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
|
||||
var isDevelopment = environment == Environments.Development;
|
||||
return "appsettings" + (isDevelopment ? ".Development" : "") + ".json";
|
||||
}
|
||||
public static string LogLevel
|
||||
{
|
||||
get => GetLogLevel(GetAppSettingFilename());
|
||||
set => SetLogLevel(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
#region JWT Token
|
||||
public static string LogPath
|
||||
{
|
||||
get => GetLoggingFile(GetAppSettingFilename());
|
||||
set => SetLoggingFile(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
private static string GetJwtToken(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
const string key = "TokenKey";
|
||||
public static string DatabasePath
|
||||
{
|
||||
get => GetDatabasePath(GetAppSettingFilename());
|
||||
set => SetDatabasePath(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
||||
private static string GetAppSettingFilename()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(AppSettingsFilename))
|
||||
{
|
||||
return tokenElement.GetString();
|
||||
return AppSettingsFilename;
|
||||
}
|
||||
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
|
||||
var isDevelopment = environment == Environments.Development;
|
||||
return "appsettings" + (isDevelopment ? ".Development" : string.Empty) + ".json";
|
||||
}
|
||||
|
||||
#region JWT Token
|
||||
|
||||
private static string GetJwtToken(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
const string key = "TokenKey";
|
||||
|
||||
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
||||
{
|
||||
return tokenElement.GetString();
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error reading app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error reading app settings: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
private static void SetJwtToken(string filePath, string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentToken = GetJwtToken(filePath);
|
||||
var json = File.ReadAllText(filePath)
|
||||
.Replace("\"TokenKey\": \"" + currentToken, "\"TokenKey\": \"" + token);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow exception */
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetJwtToken(string filePath, string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentToken = GetJwtToken(filePath);
|
||||
var json = File.ReadAllText(filePath)
|
||||
.Replace("\"TokenKey\": \"" + currentToken, "\"TokenKey\": \"" + token);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow exception */
|
||||
}
|
||||
}
|
||||
public static bool CheckIfJwtTokenSet()
|
||||
{
|
||||
try
|
||||
{
|
||||
return GetJwtToken(GetAppSettingFilename()) != "super secret unguessable key";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error writing app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
public static bool CheckIfJwtTokenSet()
|
||||
{
|
||||
try
|
||||
{
|
||||
return GetJwtToken(GetAppSettingFilename()) != "super secret unguessable key";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error writing app settings: " + ex.Message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Port
|
||||
|
||||
#endregion
|
||||
private static void SetPort(string filePath, int port)
|
||||
{
|
||||
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
#region Port
|
||||
try
|
||||
{
|
||||
var currentPort = GetPort(filePath);
|
||||
var json = File.ReadAllText(filePath).Replace("\"Port\": " + currentPort, "\"Port\": " + port);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow Exception */
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetPort(string filePath, int port)
|
||||
{
|
||||
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
||||
{
|
||||
return;
|
||||
}
|
||||
private static int GetPort(string filePath)
|
||||
{
|
||||
const int defaultPort = 5000;
|
||||
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
||||
{
|
||||
return defaultPort;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var currentPort = GetPort(filePath);
|
||||
var json = File.ReadAllText(filePath).Replace("\"Port\": " + currentPort, "\"Port\": " + port);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow Exception */
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
const string key = "Port";
|
||||
|
||||
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
||||
{
|
||||
return tokenElement.GetInt32();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error writing app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
private static int GetPort(string filePath)
|
||||
{
|
||||
const int defaultPort = 5000;
|
||||
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
||||
{
|
||||
return defaultPort;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
const string key = "Port";
|
||||
#endregion
|
||||
|
||||
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
||||
#region LogLevel
|
||||
|
||||
private static void SetLogLevel(string filePath, string logLevel)
|
||||
{
|
||||
try
|
||||
{
|
||||
return tokenElement.GetInt32();
|
||||
var currentLevel = GetLogLevel(filePath);
|
||||
var json = File.ReadAllText(filePath)
|
||||
.Replace($"\"Default\": \"{currentLevel}\"", $"\"Default\": \"{logLevel}\"");
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error writing app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
return defaultPort;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region LogLevel
|
||||
|
||||
private static void SetLogLevel(string filePath, string logLevel)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentLevel = GetLogLevel(filePath);
|
||||
var json = File.ReadAllText(filePath)
|
||||
.Replace($"\"Default\": \"{currentLevel}\"", $"\"Default\": \"{logLevel}\"");
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow Exception */
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetLogLevel(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
|
||||
if (jsonObj.TryGetProperty("Logging", out JsonElement tokenElement))
|
||||
catch (Exception)
|
||||
{
|
||||
foreach (var property in tokenElement.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Equals("LogLevel")) continue;
|
||||
foreach (var logProperty in property.Value.EnumerateObject())
|
||||
{
|
||||
if (logProperty.Name.Equals("Default"))
|
||||
{
|
||||
return logProperty.Value.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Swallow Exception */
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error writing app settings: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return "Information";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static string GetBranch(string filePath)
|
||||
{
|
||||
const string defaultBranch = "main";
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
const string key = "Branch";
|
||||
|
||||
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
||||
private static string GetLogLevel(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return tokenElement.GetString();
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
|
||||
if (jsonObj.TryGetProperty("Logging", out JsonElement tokenElement))
|
||||
{
|
||||
foreach (var property in tokenElement.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Equals("LogLevel")) continue;
|
||||
foreach (var logProperty in property.Value.EnumerateObject())
|
||||
{
|
||||
if (logProperty.Name.Equals("Default"))
|
||||
{
|
||||
return logProperty.Value.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error writing app settings: " + ex.Message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error reading app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
return defaultBranch;
|
||||
}
|
||||
return "Information";
|
||||
}
|
||||
|
||||
private static void SetBranch(string filePath, string updatedBranch)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentBranch = GetBranch(filePath);
|
||||
var json = File.ReadAllText(filePath)
|
||||
.Replace("\"Branch\": " + currentBranch, "\"Branch\": " + updatedBranch);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow Exception */
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
private static string GetBranch(string filePath)
|
||||
{
|
||||
const string defaultBranch = "main";
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
const string key = "Branch";
|
||||
|
||||
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
||||
{
|
||||
return tokenElement.GetString();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error reading app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
return defaultBranch;
|
||||
}
|
||||
|
||||
private static void SetBranch(string filePath, string updatedBranch)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentBranch = GetBranch(filePath);
|
||||
var json = File.ReadAllText(filePath)
|
||||
.Replace("\"Branch\": " + currentBranch, "\"Branch\": " + updatedBranch);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow Exception */
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetLoggingFile(string filePath)
|
||||
{
|
||||
const string defaultFile = "config/logs/kavita.log";
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
|
||||
if (jsonObj.TryGetProperty("Logging", out JsonElement tokenElement))
|
||||
{
|
||||
foreach (var property in tokenElement.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Equals("File")) continue;
|
||||
foreach (var logProperty in property.Value.EnumerateObject())
|
||||
{
|
||||
if (logProperty.Name.Equals("Path"))
|
||||
{
|
||||
return logProperty.Value.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error writing app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
return defaultFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This should NEVER be called except by <see cref="MigrateConfigFiles"/>
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="directory"></param>
|
||||
private static void SetLoggingFile(string filePath, string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentFile = GetLoggingFile(filePath);
|
||||
var json = File.ReadAllText(filePath)
|
||||
.Replace("\"Path\": \"" + currentFile + "\"", "\"Path\": \"" + directory + "\"");
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
/* Swallow Exception */
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDatabasePath(string filePath)
|
||||
{
|
||||
const string defaultFile = "config/kavita.db";
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||
|
||||
if (jsonObj.TryGetProperty("ConnectionStrings", out JsonElement tokenElement))
|
||||
{
|
||||
foreach (var property in tokenElement.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Equals("DefaultConnection")) continue;
|
||||
return property.Value.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error writing app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
return defaultFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This should NEVER be called except by <see cref="MigrateConfigFiles"/>
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="updatedPath"></param>
|
||||
private static void SetDatabasePath(string filePath, string updatedPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existingString = GetDatabasePath(filePath);
|
||||
var json = File.ReadAllText(filePath)
|
||||
.Replace(existingString,
|
||||
"Data source=" + updatedPath);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow Exception */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,4 +2,5 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=1BC0273F_002DFEBE_002D4DA1_002DBC04_002D3A3167E4C86C_002Fd_003AData_002Fd_003AMigrations/@EntryIndexedValue">ExplicitlyExcluded</s:String>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunLongAnalysisInSwa/@EntryValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunValueAnalysisInNullableWarningsEnabledContext2/@EntryValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Opds/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Opds/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=rewinded/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
11
README.md
11
README.md
@ -48,7 +48,7 @@ Password: Demouser64
|
||||
- Place in a directory that is writable. If on windows, do not place in Program Files
|
||||
- Linux users must ensure the directory & kavita.db is writable by Kavita (might require starting server once)
|
||||
- Run Kavita
|
||||
- If you are updating, do not copy appsettings.json from the new version over. It will override your TokenKey and you will have to reauthenticate on your devices.
|
||||
- If you are updating, copy everything over into install location. All Kavita data is stored in config/, so nothing will be overwritten.
|
||||
- Open localhost:5000 and setup your account and libraries in the UI.
|
||||
### Docker
|
||||
Running your Kavita server in docker is super easy! Barely an inconvenience. You can run it with this command:
|
||||
@ -56,7 +56,7 @@ Running your Kavita server in docker is super easy! Barely an inconvenience. You
|
||||
```
|
||||
docker run --name kavita -p 5000:5000 \
|
||||
-v /your/manga/directory:/manga \
|
||||
-v /kavita/data/directory:/kavita/data \
|
||||
-v /kavita/data/directory:/kavita/config \
|
||||
--restart unless-stopped \
|
||||
-d kizaing/kavita:latest
|
||||
```
|
||||
@ -64,19 +64,20 @@ docker run --name kavita -p 5000:5000 \
|
||||
You can also run it via the docker-compose file:
|
||||
|
||||
```
|
||||
version: '3.9'
|
||||
version: '3'
|
||||
services:
|
||||
kavita:
|
||||
image: kizaing/kavita:latest
|
||||
container_name: kavita
|
||||
volumes:
|
||||
- ./manga:/manga
|
||||
- ./data:/kavita/data
|
||||
- ./config:/kavita/config
|
||||
ports:
|
||||
- "5000:5000"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
**Note: Kavita is under heavy development and is being updated all the time, so the tag for current builds is `:nightly`. The `:latest` tag will be the latest stable release. There is also the `:alpine` tag if you want a smaller image, but it is only available for x64 systems.**
|
||||
**Note: Kavita is under heavy development and is being updated all the time, so the tag for current builds is `:nightly`. The `:latest` tag will be the latest stable release.**
|
||||
|
||||
## Feature Requests
|
||||
Got a great idea? Throw it up on the FeatHub or vote on another idea. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects) first for a list of planned features.
|
||||
|
2
build.sh
2
build.sh
@ -105,7 +105,7 @@ Package()
|
||||
fi
|
||||
|
||||
echo "Copying appsettings.json"
|
||||
cp appsettings.Development.json $lOutputFolder/appsettings.json
|
||||
cp config/appsettings.Development.json $lOutputFolder/config/appsettings.json
|
||||
|
||||
echo "Creating tar"
|
||||
cd ../$outputFolder/"$runtime"/
|
||||
|
@ -1,10 +1,11 @@
|
||||
version: '3.9'
|
||||
version: '3'
|
||||
services:
|
||||
kavita:
|
||||
image: kizaing/kavita:latest
|
||||
container_name: kavita
|
||||
volumes:
|
||||
- ./manga:/manga
|
||||
- ./data:/kavita/data
|
||||
- ./config:/kavita/config
|
||||
ports:
|
||||
- "5000:5000"
|
||||
restart: unless-stopped
|
||||
|
130
entrypoint.sh
Executable file → Normal file
130
entrypoint.sh
Executable file → Normal file
@ -1,107 +1,31 @@
|
||||
#!/bin/bash
|
||||
#! /bin/bash
|
||||
|
||||
#Checks if a token has been set, and then generates a new token if not
|
||||
if grep -q 'super secret unguessable key' /kavita/appsettings.json
|
||||
then
|
||||
export TOKEN_KEY="$(pwgen -s 16 1)"
|
||||
sed -i "s/super secret unguessable key/${TOKEN_KEY}/g" /kavita/appsettings.json
|
||||
if [ ! -f "/kavita/config/appsettings.json" ]; then
|
||||
echo "Kavita configuration file does not exist, creating..."
|
||||
echo '{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data source=config//kavita.db"
|
||||
},
|
||||
"TokenKey": "super secret unguessable key",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Error",
|
||||
"Hangfire": "Information",
|
||||
"Microsoft.AspNetCore.Hosting.Internal.WebHost": "Information"
|
||||
},
|
||||
"File": {
|
||||
"Path": "config//logs/kavita.log",
|
||||
"Append": "True",
|
||||
"FileSizeLimitBytes": 26214400,
|
||||
"MaxRollingFiles": 2
|
||||
}
|
||||
},
|
||||
"Port": 5000
|
||||
}' >> /kavita/config/appsettings.json
|
||||
fi
|
||||
|
||||
#Checks if the appsettings.json already exists in bind mount
|
||||
if test -f "/kavita/data/appsettings.json"
|
||||
then
|
||||
rm /kavita/appsettings.json
|
||||
ln -s /kavita/data/appsettings.json /kavita/
|
||||
else
|
||||
mv /kavita/appsettings.json /kavita/data/ || true
|
||||
ln -s /kavita/data/appsettings.json /kavita/
|
||||
fi
|
||||
chmod +x Kavita
|
||||
|
||||
#Checks if the data folders exist
|
||||
if [ -d /kavita/data/temp ]
|
||||
then
|
||||
if [ -d /kavita/temp ]
|
||||
then
|
||||
unlink /kavita/temp
|
||||
ln -s /kavita/data/temp /kavita/temp
|
||||
else
|
||||
ln -s /kavita/data/temp /kavita/temp
|
||||
fi
|
||||
else
|
||||
mkdir /kavita/data/temp
|
||||
ln -s /kavita/data/temp /kavita/temp
|
||||
fi
|
||||
|
||||
if [ -d /kavita/data/cache ]
|
||||
then
|
||||
if [ -d /kavita/cache ]
|
||||
then
|
||||
unlink /kavita/cache
|
||||
ln -s /kavita/data/cache /kavita/cache
|
||||
else
|
||||
ln -s /kavita/data/cache /kavita/cache
|
||||
fi
|
||||
else
|
||||
mkdir /kavita/data/cache
|
||||
ln -s /kavita/data/cache /kavita/cache
|
||||
fi
|
||||
|
||||
if [ -d /kavita/data/logs ]
|
||||
then
|
||||
if [ -d /kavita/logs ]
|
||||
then
|
||||
unlink /kavita/logs
|
||||
ln -s /kavita/data/logs /kavita/logs
|
||||
else
|
||||
ln -s /kavita/data/logs /kavita/logs
|
||||
fi
|
||||
else
|
||||
mkdir /kavita/data/logs
|
||||
ln -s /kavita/data/logs /kavita/logs
|
||||
fi
|
||||
|
||||
if [ -d /kavita/data/backups ]
|
||||
then
|
||||
if [ -d /kavita/backups ]
|
||||
then
|
||||
unlink /kavita/backups
|
||||
ln -s /kavita/data/backups /kavita/backups
|
||||
else
|
||||
ln -s /kavita/data/backups /kavita/backups
|
||||
fi
|
||||
else
|
||||
mkdir /kavita/data/backups
|
||||
ln -s /kavita/data/backups /kavita/backups
|
||||
fi
|
||||
|
||||
if [ -d /kavita/data/stats ]
|
||||
then
|
||||
if [ -d /kavita/stats ]
|
||||
then
|
||||
unlink /kavita/stats
|
||||
ln -s /kavita/data/stats /kavita/stats
|
||||
else
|
||||
ln -s /kavita/data/stats /kavita/stats
|
||||
fi
|
||||
else
|
||||
mkdir /kavita/data/stats
|
||||
ln -s /kavita/data/stats /kavita/stats
|
||||
fi
|
||||
|
||||
if [ -d /kavita/data/covers ]
|
||||
then
|
||||
if [ -d /kavita/covers ]
|
||||
then
|
||||
unlink /kavita/covers
|
||||
ln -s /kavita/data/covers /kavita/covers
|
||||
else
|
||||
ln -s /kavita/data/covers /kavita/covers
|
||||
fi
|
||||
else
|
||||
mkdir /kavita/data/covers
|
||||
ln -s /kavita/data/covers /kavita/covers
|
||||
fi
|
||||
|
||||
chmod +x ./Kavita
|
||||
|
||||
./Kavita
|
||||
./Kavita
|
@ -1,5 +1,5 @@
|
||||
# Added
|
||||
- New features
|
||||
- Added: New features
|
||||
|
||||
# Changed
|
||||
- Changed: Changed how something existing works (Closes #bug number)
|
||||
@ -9,6 +9,9 @@
|
||||
|
||||
|
||||
# Checklist (delete section)
|
||||
- Ensure your issues are not generic and instead talk about the feature or area they fix/enhance.
|
||||
- DONT: Fixed: Fixed a styling issue on top of screen
|
||||
- DO: Fixed: Fixed a styling issue on top of the book reader which caused content to be pushed down on smaller devices
|
||||
- Please delete any that are not relevant.
|
||||
- You MUST use Fixed:, Changed:, Added: in front of any bullet points.
|
||||
- Do not use double quotes, use ' instead.
|
||||
|
Loading…
x
Reference in New Issue
Block a user