diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 07512a6e2..2a82cf77c 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -8,6 +8,7 @@ using API.Data.Repositories; using API.DTOs; using API.DTOs.JumpBar; using API.DTOs.Search; +using API.DTOs.System; using API.Entities; using API.Entities.Enums; using API.Extensions; @@ -89,11 +90,15 @@ namespace API.Controllers /// [Authorize(Policy = "RequireAdminRole")] [HttpGet("list")] - public ActionResult> GetDirectories(string path) + public ActionResult> GetDirectories(string path) { if (string.IsNullOrEmpty(path)) { - return Ok(Directory.GetLogicalDrives()); + return Ok(Directory.GetLogicalDrives().Select(d => new DirectoryDto() + { + Name = d, + FullPath = d + })); } if (!Directory.Exists(path)) return BadRequest("This is not a valid path"); diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 660ce0d12..c6937c3c4 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -206,6 +206,12 @@ namespace API.Controllers } } + if (setting.Key == ServerSettingKey.EnableSwaggerUi && updateSettingsDto.EnableSwaggerUi + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableSwaggerUi + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value) { setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl; diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index dbdcea877..153d52a69 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -40,5 +40,9 @@ namespace API.DTOs.Settings public string InstallVersion { get; set; } public bool ConvertBookmarkToWebP { get; set; } + /// + /// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT. + /// + public bool EnableSwaggerUi { get; set; } } } diff --git a/API/DTOs/System/DirectoryDto.cs b/API/DTOs/System/DirectoryDto.cs new file mode 100644 index 000000000..7f254c649 --- /dev/null +++ b/API/DTOs/System/DirectoryDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.System; + +public class DirectoryDto +{ + /// + /// Name of the directory + /// + public string Name { get; set; } + /// + /// Full Directory Path + /// + public string FullPath { get; set; } +} diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index bd13f7715..1ae69895e 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -101,6 +101,7 @@ namespace API.Data new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"}, + new() {Key = ServerSettingKey.EnableSwaggerUi, Value = "false"}, }.ToArray()); foreach (var defaultSetting in DefaultSettings) diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 3a7b4597d..55c13b629 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -81,5 +81,10 @@ namespace API.Entities.Enums /// [Description("ConvertBookmarkToWebP")] ConvertBookmarkToWebP = 14, + /// + /// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT. + /// + [Description("EnableSwaggerUi")] + EnableSwaggerUi = 15, } } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 7d898d0e7..12759c739 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -51,6 +51,9 @@ namespace API.Helpers.Converters case ServerSettingKey.ConvertBookmarkToWebP: destination.ConvertBookmarkToWebP = bool.Parse(row.Value); break; + case ServerSettingKey.EnableSwaggerUi: + destination.EnableSwaggerUi = bool.Parse(row.Value); + break; } } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index b1fb1a937..a69521f5a 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -6,6 +6,8 @@ using System.IO.Abstractions; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using API.DTOs.System; +using API.Entities.Enums; using API.Extensions; using Microsoft.Extensions.Logging; @@ -29,7 +31,7 @@ namespace API.Services /// /// Absolute path of directory to scan. /// List of folder names - IEnumerable ListDirectory(string rootPath); + IEnumerable ListDirectory(string rootPath); Task ReadFileAsync(string path); bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = ""); bool Exists(string directory); @@ -434,14 +436,18 @@ namespace API.Services /// /// /// - public IEnumerable ListDirectory(string rootPath) + public IEnumerable ListDirectory(string rootPath) { - if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList.Empty; + if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList.Empty; var di = FileSystem.DirectoryInfo.FromDirectoryName(rootPath); var dirs = di.GetDirectories() .Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System))) - .Select(d => d.Name).ToImmutableList(); + .Select(d => new DirectoryDto() + { + Name = d.Name, + FullPath = d.FullName, + }).ToImmutableList(); return dirs; } diff --git a/API/Startup.cs b/API/Startup.cs index 66f704489..ca6e3ea4d 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -176,13 +176,23 @@ namespace API app.UseMiddleware(); + Task.Run(async () => + { + var allowSwaggerUi = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()) + .EnableSwaggerUi; + + if (env.IsDevelopment() || allowSwaggerUi) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version); + }); + } + }); + if (env.IsDevelopment()) { - app.UseSwagger(); - app.UseSwaggerUI(c => - { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version); - }); app.UseHangfireDashboard(); } diff --git a/UI/Web/src/app/_models/system/directory-dto.ts b/UI/Web/src/app/_models/system/directory-dto.ts new file mode 100644 index 000000000..346993f80 --- /dev/null +++ b/UI/Web/src/app/_models/system/directory-dto.ts @@ -0,0 +1,4 @@ +export interface DirectoryDto { + name: string; + fullPath: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 8d0042ef3..ce03c2666 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -6,6 +6,7 @@ import { environment } from 'src/environments/environment'; import { JumpKey } from '../_models/jumpbar/jump-key'; import { Library, LibraryType } from '../_models/library'; import { SearchResultGroup } from '../_models/search/search-result-group'; +import { DirectoryDto } from '../_models/system/directory-dto'; @Injectable({ @@ -56,7 +57,7 @@ export class LibraryService { query = '?path=' + encodeURIComponent(rootPath); } - return this.httpClient.get(this.baseUrl + 'library/list' + query); + return this.httpClient.get(this.baseUrl + 'library/list' + query); } getJumpBar(libraryId: number) { diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html index 58a9ec9d3..4618a7c2d 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html @@ -3,49 +3,69 @@ diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss index eb27b2165..46e83b9d4 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss @@ -12,4 +12,8 @@ $breadcrumb-divider: quote(">"); .btn-outline-secondary { border: 1px solid #ced4da; +} + +.table { + background-color: lightgrey; } \ No newline at end of file diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts index fe3db20e3..3d335101d 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts @@ -1,6 +1,8 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { Component, Input, OnInit, ViewChild } from '@angular/core'; +import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; +import { catchError, debounceTime, distinctUntilChanged, filter, map, merge, Observable, of, OperatorFunction, Subject, switchMap, tap } from 'rxjs'; import { Stack } from 'src/app/shared/data-structures/stack'; +import { DirectoryDto } from 'src/app/_models/system/directory-dto'; import { LibraryService } from '../../../_services/library.service'; @@ -10,6 +12,7 @@ export interface DirectoryPickerResult { } + @Component({ selector: 'app-directory-picker', templateUrl: './directory-picker.component.html', @@ -24,9 +27,40 @@ export class DirectoryPickerComponent implements OnInit { @Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/first-time-setup#adding-a-library-to-kavita'; currentRoot = ''; - folders: string[] = []; + folders: DirectoryDto[] = []; routeStack: Stack = new Stack(); - filterQuery: string = ''; + + + path: string = ''; + @ViewChild('instance', {static: true}) instance!: NgbTypeahead; + focus$ = new Subject(); + click$ = new Subject(); + searching: boolean = false; + searchFailed: boolean = false; + + + search: OperatorFunction = (text$: Observable) => { + const debouncedText$ = text$.pipe(debounceTime(200), distinctUntilChanged()); + const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen())); + const inputFocus$ = this.focus$; + + return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$, text$).pipe( + debounceTime(300), + distinctUntilChanged(), + tap(() => this.searching = true), + switchMap(term => + this.libraryService.listDirectories(this.path).pipe( + tap(() => this.searchFailed = false), + tap((folders) => this.folders = folders), + map(folders => folders.map(f => f.fullPath)), + catchError(() => { + this.searchFailed = true; + return of([]); + })) + ), + tap(() => this.searching = false) + ) + } constructor(public modal: NgbActiveModal, private libraryService: LibraryService) { @@ -51,15 +85,16 @@ export class DirectoryPickerComponent implements OnInit { } } - filterFolder = (folder: string) => { - return folder.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0; + updateTable() { + this.loadChildren(this.path); } - selectNode(folderName: string) { - this.currentRoot = folderName; - this.routeStack.push(folderName); - const fullPath = this.routeStack.items.join('/'); - this.loadChildren(fullPath); + + selectNode(folder: DirectoryDto) { + this.currentRoot = folder.name; + this.routeStack.push(folder.name); + this.path = folder.fullPath; + this.loadChildren(this.path); } goBack() { @@ -77,7 +112,6 @@ export class DirectoryPickerComponent implements OnInit { loadChildren(path: string) { this.libraryService.listDirectories(path).subscribe(folders => { - this.filterQuery = ''; this.folders = folders; }, err => { // If there was an error, pop off last directory added to stack @@ -85,19 +119,17 @@ export class DirectoryPickerComponent implements OnInit { }); } - shareFolder(folderName: string, event: any) { + shareFolder(fullPath: string, event: any) { event.preventDefault(); event.stopPropagation(); - let fullPath = folderName; - if (this.routeStack.items.length > 0) { - const pathJoin = this.routeStack.items.join('/'); - fullPath = pathJoin + ((pathJoin.endsWith('/') || pathJoin.endsWith('\\')) ? '' : '/') + folderName; - } - this.modal.close({success: true, folderPath: fullPath}); } + share() { + this.modal.close({success: true, folderPath: this.path}); + } + close() { this.modal.close({success: false, folderPath: undefined}); } @@ -122,6 +154,9 @@ export class DirectoryPickerComponent implements OnInit { } const fullPath = this.routeStack.items.join('/'); + this.path = fullPath; this.loadChildren(fullPath); } } + + diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index 6a493a0aa..6600a8794 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -10,4 +10,5 @@ export interface ServerSettings { bookmarksDirectory: string; emailServiceUrl: string; convertBookmarkToWebP: boolean; + enableSwaggerUi: boolean; } diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index 2b30cadd6..dd20d02ca 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { AdminRoutingModule } from './admin-routing.module'; import { DashboardComponent } from './dashboard/dashboard.component'; -import { NgbDropdownModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbDropdownModule, NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; import { ManageLibraryComponent } from './manage-library/manage-library.component'; import { ManageUsersComponent } from './manage-users/manage-users.component'; import { LibraryEditorModalComponent } from './_modals/library-editor-modal/library-editor-modal.component'; @@ -53,11 +53,12 @@ import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tas FormsModule, NgbNavModule, NgbTooltipModule, + NgbTypeaheadModule, // Directory Picker NgbDropdownModule, SharedModule, PipeModule, SidenavModule, - UserSettingsModule // API-key componet + UserSettingsModule, // API-key componet ], providers: [] }) 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 c6b999671..baca9f382 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 @@ -47,6 +47,15 @@ +
+ +

Allows Swagger UI to be exposed via swagger/ on your server. Authentication is not required, but a valid JWT token is. Requires a restart to take effect.

+
+ + +
+
+
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 6be0935d0..fe463521b 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 @@ -3,8 +3,7 @@ import { FormGroup, FormControl, Validators } from '@angular/forms'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; -import { ConfirmService } from 'src/app/shared/confirm.service'; -import { EmailTestResult, SettingsService } from '../settings.service'; +import { SettingsService } from '../settings.service'; import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component'; import { ServerSettings } from '../_models/server-settings'; @@ -21,7 +20,7 @@ export class ManageSettingsComponent implements OnInit { taskFrequencies: Array = []; logLevels: Array = []; - constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService, + constructor(private settingsService: SettingsService, private toastr: ToastrService, private modalService: NgbModal) { } ngOnInit(): void { @@ -43,6 +42,7 @@ export class ManageSettingsComponent implements OnInit { 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('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required])); + this.settingsForm.addControl('enableSwaggerUi', new FormControl(this.serverSettings.enableSwaggerUi, [Validators.required])); }); } @@ -57,6 +57,7 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds); this.settingsForm.get('baseUrl')?.setValue(this.serverSettings.baseUrl); this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl); + this.settingsForm.get('enableSwaggerUi')?.setValue(this.serverSettings.enableSwaggerUi); } async saveSettings() { @@ -92,29 +93,4 @@ export class ManageSettingsComponent implements OnInit { } }); } - - // resetEmailServiceUrl() { - // this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => { - // this.serverSettings.emailServiceUrl = settings.emailServiceUrl; - // this.resetForm(); - // this.toastr.success('Email Service Reset'); - // }, (err: any) => { - // console.error('error: ', err); - // }); - // } - - // testEmailServiceUrl() { - // this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value || '').pipe(take(1)).subscribe(async (result: EmailTestResult) => { - // if (result.successful) { - // this.toastr.success('Email Service Url validated'); - // } else { - // this.toastr.error('Email Service Url did not respond. ' + result.errorMessage); - // } - - // }, (err: any) => { - // console.error('error: ', err); - // }); - - // } - }