diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index e8d7156e2..0749deee9 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -191,6 +191,7 @@ public class SettingsController : BaseApiController ? $"{path}/" : path; setting.Value = path; + Configuration.BaseUrl = updateSettingsDto.BaseUrl; _unitOfWork.SettingsRepository.Update(setting); } diff --git a/API/Startup.cs b/API/Startup.cs index f2c96afe5..81e88510c 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -19,6 +19,7 @@ using API.Services.HostedServices; using API.Services.Tasks; using API.SignalR; using Hangfire; +using HtmlAgilityPack; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Builder; @@ -277,6 +278,11 @@ public class Startup app.UseForwardedHeaders(); + var basePath = Configuration.BaseUrl; + + app.UsePathBase(basePath); + UpdateBaseUrlInIndex(basePath); + app.UseRouting(); // Ordering is important. Cors, authentication, authorization @@ -351,6 +357,20 @@ public class Startup } Console.WriteLine($"Kavita - v{BuildInfo.Version}"); }); + + var _logger = serviceProvider.GetRequiredService>(); + _logger.LogInformation("Starting with base url as {baseUrl}", basePath); + } + + private static void UpdateBaseUrlInIndex(string baseUrl) + { + var htmlDoc = new HtmlDocument(); + var indexHtmlPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"); + htmlDoc.Load(indexHtmlPath); + + var baseNode = htmlDoc.DocumentNode.SelectSingleNode("/html/head/base"); + baseNode.SetAttributeValue("href", baseUrl); + htmlDoc.Save(indexHtmlPath); } private static void OnShutdown() diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index b7ed0b29a..486fc8d39 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -1,5 +1,6 @@ { "TokenKey": "super secret unguessable key", "Port": 5000, - "IpAddresses": "" + "IpAddresses": "", + "BaseUrl": "/" } diff --git a/API/config/appsettings.json b/API/config/appsettings.json index b7ed0b29a..486fc8d39 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -1,5 +1,6 @@ { "TokenKey": "super secret unguessable key", "Port": 5000, - "IpAddresses": "" + "IpAddresses": "", + "BaseUrl": "/" } diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 3fa425919..476775ca2 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -9,6 +9,7 @@ namespace Kavita.Common; public static class Configuration { public const string DefaultIpAddresses = "0.0.0.0,::"; + public const string DefaultBaseUrl = "/"; private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); public static int Port @@ -29,6 +30,12 @@ public static class Configuration set => SetJwtToken(GetAppSettingFilename(), value); } + public static string BaseUrl + { + get => GetBaseUrl(GetAppSettingFilename()); + set => SetBaseUrl(GetAppSettingFilename(), value); + } + private static string GetAppSettingFilename() { if (!string.IsNullOrEmpty(AppSettingsFilename)) @@ -200,10 +207,81 @@ public static class Configuration } #endregion + #region BaseUrl + private static string GetBaseUrl(string filePath) + { + if (new OsInfo(Array.Empty()).IsDocker) + { + return DefaultBaseUrl; + } + + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + const string key = "BaseUrl"; + + if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) + { + var baseUrl = tokenElement.GetString(); + if (!String.IsNullOrEmpty(baseUrl)) + { + baseUrl = !baseUrl.StartsWith("/") + ? $"/{baseUrl}" + : baseUrl; + + baseUrl = !baseUrl.EndsWith("/") + ? $"{baseUrl}/" + : baseUrl; + + return baseUrl; + } + return DefaultBaseUrl; + } + } + catch (Exception ex) + { + Console.WriteLine("Error reading app settings: " + ex.Message); + } + + return DefaultBaseUrl; + } + + private static void SetBaseUrl(string filePath, string value) + { + if (new OsInfo(Array.Empty()).IsDocker) + { + return; + } + + var baseUrl = !value.StartsWith("/") + ? $"/{value}" + : value; + + baseUrl = !baseUrl.EndsWith("/") + ? $"{baseUrl}/" + : baseUrl; + + try + { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + jsonObj.BaseUrl = baseUrl; + json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(filePath, json); + } + catch (Exception) + { + /* Swallow exception */ + } + } + #endregion + private class AppSettings { public string TokenKey { get; set; } public int Port { get; set; } public string IpAddresses { get; set; } + public string BaseUrl { get; set; } } } diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index d77dc147c..9eecc3d8d 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -1,6 +1,6 @@
-

Changing Port requires a manual restart of Kavita to take effect.

+

Changing Port or Base Url requires a manual restart of Kavita to take effect.

  Where the server places temporary files when reading. This will be cleaned up on a regular basis. @@ -24,7 +24,7 @@   Domain Name (of Reverse Proxy). If set, email generation will always use this. Domain Name (of Reverse Proxy). If set, email generation will always use this. -
@@ -33,6 +33,19 @@
+
+   + Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita + Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita + +
+
+ Base URL must start and end with / +
+
+
+
  diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts index 53d697db1..1666f7744 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts @@ -47,7 +47,7 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required])); this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required])); this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required])); - this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.required])); + this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.pattern(/^(\/[\w-]+)*\/$/)])); this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required])); this.settingsForm.addControl('totalBackups', new FormControl(this.serverSettings.totalBackups, [Validators.required, Validators.min(1), Validators.max(30)])); this.settingsForm.addControl('totalLogs', new FormControl(this.serverSettings.totalLogs, [Validators.required, Validators.min(1), Validators.max(30)])); diff --git a/UI/Web/src/app/base-url.provider.ts b/UI/Web/src/app/base-url.provider.ts new file mode 100644 index 000000000..d16dd02f7 --- /dev/null +++ b/UI/Web/src/app/base-url.provider.ts @@ -0,0 +1,3 @@ +export function getBaseUrl() : string { + return document.getElementsByTagName('base')[0]?.getAttribute('href') || '/'; +} diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index 1960e033b..bd4d61e63 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -22,6 +22,7 @@ import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mod import { forkJoin, Subject } from 'rxjs'; import { bookColorThemes } from 'src/app/book-reader/_components/reader-settings/reader-settings.component'; import { BookService } from 'src/app/book-reader/_services/book.service'; +import { environment } from 'src/environments/environment'; enum AccordionPanelID { ImageReader = 'image-reader', @@ -252,6 +253,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { transformKeyToOpdsUrl(key: string) { + if (environment.production) { + return `${location.origin}${environment.apiUrl}opds/${key}`; + } + return `${location.origin}/api/opds/${key}`; } diff --git a/UI/Web/src/environments/environment.prod.ts b/UI/Web/src/environments/environment.prod.ts index 011772854..eb399e6ff 100644 --- a/UI/Web/src/environments/environment.prod.ts +++ b/UI/Web/src/environments/environment.prod.ts @@ -1,5 +1,8 @@ +import { getBaseUrl } from "src/app/base-url.provider"; +const BASE_URL = getBaseUrl(); + export const environment = { production: true, - apiUrl: '/api/', - hubUrl: '/hubs/' + apiUrl: `${BASE_URL}api/`, + hubUrl:`${BASE_URL}hubs/` }; diff --git a/UI/Web/src/index.html b/UI/Web/src/index.html index 2d717ffae..4ec267af3 100644 --- a/UI/Web/src/index.html +++ b/UI/Web/src/index.html @@ -18,7 +18,7 @@ - + diff --git a/openapi.json b/openapi.json index a860d0e35..611e28f53 100644 --- a/openapi.json +++ b/openapi.json @@ -15305,4 +15305,4 @@ "description": "Responsible for all things Want To Read" } ] -} \ No newline at end of file +}