OPDS Support (#526)

* Added some basic OPDS implementation

* Fixed an issue with feed href

* More changes

* Added library routes and moved user code to a method so we can hack in fixed code without authentication

* Images now load on the OPDS reusing our existing Image infrastructure.

* Added the ability to download and moved some download code to a dedicated service

* Download is working, pagination is implemented.

* Refactored libraries to use pagination

* Laid foundation for OpenSearch implementation

* Fixed up some serialization issues and some old code that wasn't referencing helper methods

* Ensure chapters are sorted when we send them over OPDS

* OpenSearch implemented

* Removed any support for OPDS-PS due to lack of apps supporting it.

* Don't distribute development.json nor stats directory on build.

* Implemented In Progress feed as well.

* Ability to enable OPDS for server. OPDS now accepts initial call as POST in case app uses username/password.

* UI now properly renders state for OPDS enablement. Added Collections routes.

* Fixed pagination startIndex on OPDS feeds when there is less than 1 page.

* Chunky Reader now works. It only accepts UTF-8 encodings

* More Chunky fixes

* More chunky changes, such a fussy client.

* Implemented the ability to have a custom api key assigned to a user and use that api key as your authentication token against OPDS routing.

* Implemented the ability to reset your API Key

* Fixed favicon not being sent back correctly

* Fixed an issue where images wouldn't send on OPDS feed.

* Implemented Page streaming and fixed a pagination bug

* Hooked in the ability to save progress in Kavita when Page Streaming
This commit is contained in:
Joseph Milazzo 2021-08-27 10:19:25 -07:00 committed by GitHub
parent 2a63e5e9e2
commit 6069d93c38
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 2409 additions and 116 deletions

View file

@ -6,4 +6,5 @@ export interface User {
token: string;
roles: string[];
preferences: Preferences;
apiKey: string;
}

View file

@ -130,4 +130,21 @@ export class AccountService implements OnDestroy {
return undefined;
}
resetApiKey() {
return this.httpClient.post<string>(this.baseUrl + 'account/reset-api-key', {}, {responseType: 'text' as 'json'}).pipe(map(key => {
const user = this.getUserFromLocalStorage();
if (user) {
user.apiKey = key;
localStorage.setItem(this.userKey, JSON.stringify(user));
this.currentUserSource.next(user);
this.currentUser = user;
}
return key;
}));
}
}

View file

@ -5,4 +5,5 @@ export interface ServerSettings {
loggingLevel: string;
port: number;
allowStatCollection: boolean;
enableOpds: boolean;
}

View file

@ -33,7 +33,7 @@ import { ChangelogComponent } from './changelog/changelog.component';
FilterPipe,
EditRbsModalComponent,
ManageSystemComponent,
ChangelogComponent
ChangelogComponent,
],
imports: [
CommonModule,

View file

@ -28,11 +28,20 @@
<label for="stat-collection" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<p class="accent" id="collection-info">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features, bug fixes, and preformance tuning. Requires restart to take effect.</p>
<div class="form-check">
<input id="stat-collection" type="checkbox" aria-label="Admin" class="form-check-input" formControlName="allowStatCollection">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div>
</div>
<div class="form-group">
<label for="opds" aria-describedby="opds-info">OPDS</label>
<p class="accent" id="opds-info">OPDS support will allow all users to use OPDS to read and download content from the server. If OPDS is enabled, a user will not need download permissions to download media while using it.</p>
<div class="form-check">
<input id="opds" type="checkbox" aria-label="OPDS Support" class="form-check-input" formControlName="enableOpds">
<label for="opds" class="form-check-label">Enable OPDS</label>
</div>
</div>
<h4>Reoccuring Tasks</h4>
<div class="form-group">
<label for="settings-tasks-scan">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>

View file

@ -34,6 +34,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('port', new FormControl(this.serverSettings.port, [Validators.required]));
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]));
});
}
@ -44,6 +45,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('port')?.setValue(this.serverSettings.port);
this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel);
this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection);
this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds);
}
saveSettings() {

View file

@ -31,4 +31,8 @@ export class SettingsService {
getLibraryTypes() {
return this.http.get<string[]>(this.baseUrl + 'settings/library-types');
}
getOpdsEnabled() {
return this.http.get<boolean>(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'});
}
}

View file

@ -0,0 +1,11 @@
<div class="form-group">
<label for="api-key--{{title}}">{{title}}</label><span *ngIf="tooltipText.length > 0">&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i></span>
<ng-template #tooltip>{{tooltipText}}</ng-template>
<div class="input-group">
<input #apiKey type="text" readonly class="form-control" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<div class="input-group-append" id="button-addon4">
<button class="btn btn-outline-secondary" type="button" (click)="copy()"><span class="sr-only">Copy</span><i class="fa fa-copy" aria-hidden="true"></i></button>
<button class="btn btn-danger" type="button" (click)="refresh()" *ngIf="showRefresh"><span class="sr-only">Regenerate</span><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
</div>
</div>
</div>

View file

@ -0,0 +1,66 @@
import { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { AccountService } from 'src/app/_services/account.service';
@Component({
selector: 'app-api-key',
templateUrl: './api-key.component.html',
styleUrls: ['./api-key.component.scss']
})
export class ApiKeyComponent implements OnInit, OnDestroy {
@Input() title: string = 'API Key';
@Input() showRefresh: boolean = true;
@Input() transform: (val: string) => string = (val: string) => val;
@Input() tooltipText: string = '';
@ViewChild('apiKey') inputElem!: ElementRef;
key: string = '';
private readonly onDestroy = new Subject<void>();
constructor(private confirmService: ConfirmService, private accountService: AccountService, private toastr: ToastrService) { }
ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
let key = '';
if (user) {
key = user.apiKey;
} else {
key = 'ERROR - KEY NOT SET';
}
if (this.transform != undefined) {
this.key = this.transform(key);
}
});
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
async copy() {
await navigator.clipboard.writeText(this.key);
}
async refresh() {
if (!await this.confirmService.confirm('This will invalidate any OPDS configurations you have setup. Are you sure you want to continue?')) {
return;
}
this.accountService.resetApiKey().subscribe(newKey => {
this.key = newKey;
this.toastr.success('API Key reset');
});
}
selectAll() {
if (this.inputElem) {
this.inputElem.nativeElement.setSelectionRange(0, this.key.length);
}
}
}

View file

@ -190,6 +190,17 @@
</form>
</ng-template>
</ngb-panel>
<ngb-panel id="api-panel" title="OPDS">
<ng-template ngbPanelContent>
<p class="alert alert-danger" role="alert" *ngIf="!opdsEnabled">
OPDS is not enabled on this server!
</p>
<ng-container *ngIf="opdsEnabled">
<app-api-key tooltipText="The API key is like a password. Keep it secret, Keep it safe."></app-api-key>
<app-api-key title="OPDS URL" [showRefresh]="false" [transform]="makeUrl"></app-api-key>
</ng-container>
</ng-template>
</ngb-panel>
</ngb-accordion>
</ng-container>
<ng-container *ngIf="tab.fragment === 'bookmarks'">

View file

@ -10,6 +10,9 @@ import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { NavService } from 'src/app/_services/nav.service';
import { ActivatedRoute } from '@angular/router';
import { SettingsService } from 'src/app/admin/settings.service';
import { keyframes } from '@angular/animations';
import { environment } from 'src/environments/environment';
@Component({
selector: 'app-user-preferences',
@ -49,15 +52,16 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
};
fontFamilies: Array<string> = [];
//tabs = ['Preferences', 'Bookmarks'];
tabs: Array<{title: string, fragment: string}> = [
{title: 'Preferences', fragment: ''},
{title: 'Bookmarks', fragment: 'bookmarks'},
];
active = this.tabs[0];
opdsEnabled: boolean = false;
makeUrl: (val: string) => string = (val: string) => {return this.transformKeyToOpdsUrl(val)};
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
private navService: NavService, private titleService: Title, private route: ActivatedRoute) {
private navService: NavService, private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService) {
this.fontFamilies = this.bookService.getFontFamilies();
this.route.fragment.subscribe(frag => {
@ -68,6 +72,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.active = this.tabs[0]; // Default to first tab
}
});
this.settingsService.getOpdsEnabled().subscribe(res => {
this.opdsEnabled = res;
})
}
ngOnInit(): void {
@ -177,4 +185,15 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.resetPasswordErrors = err;
}));
}
transformKeyToOpdsUrl(key: string) {
let apiUrl = environment.apiUrl;
if (environment.production) {
apiUrl = `${location.protocol}//${location.origin}`;
if (location.port != '80') {
apiUrl += ':' + location.port;
}
}
return `${apiUrl}opds/${key}`;
}
}

View file

@ -6,13 +6,15 @@ import { NgbAccordionModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstra
import { ReactiveFormsModule } from '@angular/forms';
import { NgxSliderModule } from '@angular-slider/ngx-slider';
import { UserSettingsRoutingModule } from './user-settings-routing.module';
import { ApiKeyComponent } from './api-key/api-key.component';
@NgModule({
declarations: [
SeriesBookmarksComponent,
UserPreferencesComponent
UserPreferencesComponent,
ApiKeyComponent
],
imports: [
CommonModule,