Security Event Logging & Bugfixes (#1882)

* Fixed bookmarking failing to convert to webp

* Brought the ag-swipe/ng-swipe code into Kavita due to being abandoned by developer and angular requirements.

* Fixed average reading time per week finally

* Cleaned up some extra decimals on time duration pipe

* Don't try to update index.html for base url on local. Fixed ag-swipe on prod mode.

* Updated a link on theme manager to point to the new github

* Range knobs should be primary color on firefox too

* Implemented the ability to get thumbnails of pages inside an archive or pdf.

* Updated packages and fixed opds-ps 1.2 issue

* Fixed lock file

* Allow Kavita's Swagger to hit instances with CORS

* Added IP/Request logging for Security Audits

* Linked up Summary tag from CBL into Kavita.

* Redid the migration so SecurityEvent now has UTC date as well.

* Split security logging to a separate file

* Update to new versions of checkout and setup

* Added a PR check on PR body to ensure that it doesn't contain any characters that break our discord hook.

* Updating action

* optimize regex in action

* Fixed an issue where fit to width would cause the actual height of the image to be shown for pagination bars, instead of rendered.

* Added some new code in GetPageFromFiles to ensure pages that exceed array map down to last file.

* Added comment about robots

* Fixed up unit tests for new ReaderService signature

* Kavita now cleans up empty reading lists at night

* Don't allow nightly cleanup to run if we are running media conversion tasks

* Fixed some bugs in typeahead, it should behave much more reliably.

* Fix an issue where emulate comic book wasn't extending to the bottom properly

* Added support for Series Chapter 001 Volume 001

* Refactor XFrameOptions="SameOrigins" out to allow users to override in appsettings.json.

* Added a rate limiter for some endpoints, but it doesn't seem to be triggering

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2023-03-16 15:57:34 -05:00 committed by GitHub
parent 21203414f0
commit c10acb1279
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 2890 additions and 302 deletions

View file

@ -101,6 +101,10 @@ export class ReaderService {
return this.baseUrl + 'reader/image?chapterId=' + chapterId + '&page=' + page;
}
getThumbnailUrl(chapterId: number, page: number) {
return this.baseUrl + 'reader/thumbnail?chapterId=' + chapterId + '&page=' + page;
}
getBookmarkPageUrl(seriesId: number, apiKey: string, page: number) {
return this.baseUrl + 'reader/bookmark-image?seriesId=' + seriesId + '&page=' + page + '&apiKey=' + encodeURIComponent(apiKey);
}

View file

@ -32,7 +32,7 @@
</div>
<div class="d-flex align-items-center justify-content-between text-center row g-0">
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter()" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
<div class="col-1">{{pageNum}}</div>
<div class="col-1" (click)="goToPage(0)">{{pageNum}}</div>
<div class="col-8">
<ngb-progressbar style="cursor: pointer" title="Go to page" (click)="goToPage()" type="primary" height="5px" [value]="pageNum" [max]="maxPages - 1"></ngb-progressbar>
</div>

View file

@ -379,10 +379,12 @@
</div>
<div class="row g-0 mb-2" *ngIf="metadata">
<div class="col-md-6">
Max Items: {{metadata.maxCount}} <i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Max of Volume/Issue field in ComicInfo. Used in conjunction with total items to determine publication status." role="button" tabindex="0"></i>
Max Items: {{metadata.maxCount}}
<i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Max of Volume/Issue field in ComicInfo. Used in conjunction with total items to determine publication status." role="button" tabindex="0"></i>
</div>
<div class="col-md-6" title="">
Total Items: {{metadata.totalCount}} <i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Total number of issues/volumes in the series" role="button" tabindex="0"></i>
<div class="col-md-6">
Total Items: {{metadata.totalCount}}
<i class="fa fa-info-circle ms-1" placement="right" ngbTooltip="Total number of issues/volumes in the series" role="button" tabindex="0"></i>
</div>
<div class="col-md-6">Publication Status: {{metadata.publicationStatus | publicationStatus}}</div>
<div class="col-md-6">Total Pages: {{series.pages}}</div>

View file

@ -278,6 +278,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.collectionTagSettings.compareFn = (options: CollectionTag[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.collectionTagSettings.compareFnForAdd = (options: CollectionTag[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.collectionTagSettings.selectionCompareFn = (a: CollectionTag, b: CollectionTag) => {
return a.title === b.title;
}
@ -310,6 +313,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.tagsSettings.selectionCompareFn = (a: Tag, b: Tag) => {
return a.id == b.id;
}
this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
if (this.metadata.tags) {
this.tagsSettings.savedData = this.metadata.tags;
@ -331,6 +337,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.genreSettings.compareFnForAdd = (options: Genre[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => {
return a.title == b.title;
}
@ -372,6 +381,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.languageSettings.compareFn = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
@ -426,6 +438,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
personSettings.compareFn = (options: Person[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
personSettings.compareFnForAdd = (options: Person[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.name, filter));
}
personSettings.selectionCompareFn = (a: Person, b: Person) => {
return a.name == b.name && a.role == b.role;

View file

@ -1,6 +1,8 @@
@use '../../../../manga-reader-common';
.image-container {
height: calc(100vh); // override as on single, we -34px for the potential scrollbar
#image-1 {
&.double {
margin: 0 0 0 auto;

View file

@ -2,6 +2,8 @@
// Overrides for reverse
.image-container {
height: calc(100vh); // override as on single, we -34px for the potential scrollbar
&.reverse {
overflow: unset;
display: flex;

View file

@ -31,8 +31,8 @@ import { DoubleRendererComponent } from '../double-renderer/double-renderer.comp
import { DoubleReverseRendererComponent } from '../double-reverse-renderer/double-reverse-renderer.component';
import { SingleRendererComponent } from '../single-renderer/single-renderer.component';
import { ChapterInfo } from '../../_models/chapter-info';
import { SwipeEvent } from 'ng-swipe';
import { DoubleNoCoverRendererComponent } from '../double-renderer-no-cover/double-no-cover-renderer.component';
import { SwipeEvent } from 'src/app/ng-swipe/ag-swipe.core';
const PREFETCH_PAGES = 10;
@ -379,7 +379,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
// This is for the pagination area
get MaxHeight() {
if (this.FittingOption !== FITTING_OPTION.HEIGHT) {
return this.mangaReaderService.getPageDimensions(this.pageNum)?.height + 'px';
return Math.min(this.readingArea?.nativeElement?.clientHeight, this.mangaReaderService.getPageDimensions(this.pageNum)?.height!) + 'px';
}
return 'calc(var(--vh) * 100)';
}

View file

@ -17,8 +17,8 @@ import { DoubleRendererComponent } from './_components/double-renderer/double-re
import { DoubleReverseRendererComponent } from './_components/double-reverse-renderer/double-reverse-renderer.component';
import { MangaReaderComponent } from './_components/manga-reader/manga-reader.component';
import { FittingIconPipe } from './_pipes/fitting-icon.pipe';
import { SwipeModule } from 'ng-swipe';
import { DoubleNoCoverRendererComponent } from './_components/double-renderer-no-cover/double-no-cover-renderer.component';
import { NgSwipeModule } from '../ng-swipe/ng-swipe.module';
@NgModule({
declarations: [
@ -45,7 +45,7 @@ import { DoubleNoCoverRendererComponent } from './_components/double-renderer-no
SharedModule,
ReaderSharedModule,
SwipeModule
NgSwipeModule
],
exports: [
MangaReaderComponent

View file

@ -329,6 +329,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.ageRatingSettings.selectionCompareFn = (a: AgeRatingDto, b: AgeRatingDto) => {
return a.title == b.title;

View file

@ -0,0 +1,103 @@
import { fromEvent, Observable, race, Subscription } from 'rxjs';
import { elementAt, map, switchMap, takeUntil, tap } from 'rxjs/operators';
export interface SwipeCoordinates {
x: number;
y: number;
}
export enum SwipeDirection {
X = 'x',
Y = 'y'
}
export interface SwipeStartEvent {
x: number;
y: number;
direction: SwipeDirection;
}
export interface SwipeEvent {
direction: SwipeDirection;
distance: number;
}
export interface SwipeSubscriptionConfig {
domElement: HTMLElement;
onSwipeMove?: (event: SwipeEvent) => void;
onSwipeEnd?: (event: SwipeEvent) => void;
}
export function createSwipeSubscription({ domElement, onSwipeMove, onSwipeEnd }: SwipeSubscriptionConfig): Subscription {
if (!(domElement instanceof HTMLElement)) {
throw new Error('Provided domElement should be an instance of HTMLElement');
}
if ((typeof onSwipeMove !== 'function') && (typeof onSwipeEnd !== 'function')) {
throw new Error('At least one of the following swipe event handler functions should be provided: onSwipeMove and/or onSwipeEnd');
}
const touchStarts$ = fromEvent<TouchEvent>(domElement, 'touchstart').pipe(map(getTouchCoordinates));
const touchMoves$ = fromEvent<TouchEvent>(domElement, 'touchmove').pipe(map(getTouchCoordinates));
const touchEnds$ = fromEvent<TouchEvent>(domElement, 'touchend').pipe(map(getTouchCoordinates));
const touchCancels$ = fromEvent<TouchEvent>(domElement, 'touchcancel');
const touchStartsWithDirection$: Observable<SwipeStartEvent> = touchStarts$.pipe(
switchMap((touchStartEvent: SwipeCoordinates) => touchMoves$.pipe(
elementAt(3),
map((touchMoveEvent: SwipeCoordinates) => ({
x: touchStartEvent.x,
y: touchStartEvent.y,
direction: getTouchDirection(touchStartEvent, touchMoveEvent)
})
))
)
);
return touchStartsWithDirection$.pipe(
switchMap(touchStartEvent => touchMoves$.pipe(
map(touchMoveEvent => getTouchDistance(touchStartEvent, touchMoveEvent)),
tap((coordinates: SwipeCoordinates) => {
if (typeof onSwipeMove !== 'function') { return; }
onSwipeMove(getSwipeEvent(touchStartEvent, coordinates));
}),
takeUntil(race(
touchEnds$.pipe(
map(touchEndEvent => getTouchDistance(touchStartEvent, touchEndEvent)),
tap((coordinates: SwipeCoordinates) => {
if (typeof onSwipeEnd !== 'function') { return; }
onSwipeEnd(getSwipeEvent(touchStartEvent, coordinates));
})
),
touchCancels$
))
))
).subscribe();
}
function getTouchCoordinates(touchEvent: TouchEvent): SwipeCoordinates {
return {
x: touchEvent.changedTouches[0].clientX,
y: touchEvent.changedTouches[0].clientY
};
}
function getTouchDistance(startCoordinates: SwipeCoordinates, moveCoordinates: SwipeCoordinates): SwipeCoordinates {
return {
x: moveCoordinates.x - startCoordinates.x,
y: moveCoordinates.y - startCoordinates.y
};
}
function getTouchDirection(startCoordinates: SwipeCoordinates, moveCoordinates: SwipeCoordinates): SwipeDirection {
const { x, y } = getTouchDistance(startCoordinates, moveCoordinates);
return Math.abs(x) < Math.abs(y) ? SwipeDirection.Y : SwipeDirection.X;
}
function getSwipeEvent(touchStartEvent: SwipeStartEvent, coordinates: SwipeCoordinates): SwipeEvent {
return {
direction: touchStartEvent.direction,
distance: coordinates[touchStartEvent.direction]
};
}

View file

@ -0,0 +1,32 @@
import { Directive, ElementRef, EventEmitter, NgZone, OnDestroy, OnInit, Output } from '@angular/core';
import { Subscription } from 'rxjs';
import { createSwipeSubscription, SwipeEvent } from './ag-swipe.core';
@Directive({
selector: '[ngSwipe]'
})
export class SwipeDirective implements OnInit, OnDestroy {
private swipeSubscription: Subscription | undefined;
@Output() swipeMove: EventEmitter<SwipeEvent> = new EventEmitter<SwipeEvent>();
@Output() swipeEnd: EventEmitter<SwipeEvent> = new EventEmitter<SwipeEvent>();
constructor(
private elementRef: ElementRef,
private zone: NgZone
) {}
ngOnInit() {
this.zone.runOutsideAngular(() => {
this.swipeSubscription = createSwipeSubscription({
domElement: this.elementRef.nativeElement,
onSwipeMove: (swipeMoveEvent: SwipeEvent) => this.swipeMove.emit(swipeMoveEvent),
onSwipeEnd: (swipeEndEvent: SwipeEvent) => this.swipeEnd.emit(swipeEndEvent)
});
});
}
ngOnDestroy() {
this.swipeSubscription?.unsubscribe?.();
}
}

View file

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SwipeDirective } from './ng-swipe.directive';
// All code in this module is based on https://github.com/aGoncharuks/ag-swipe and may contain further enhancements or bugfixes.
@NgModule({
declarations: [
SwipeDirective
],
imports: [
CommonModule
],
exports: [
SwipeDirective
]
})
export class NgSwipeModule { }

View file

@ -13,7 +13,7 @@ export class TimeDurationPipe implements PipeTransform {
if (hours < 1) {
return `${(hours * 60).toFixed(1)} minutes`;
} else if (hours < 24) {
return `${hours} hours`;
return `${hours.toFixed(1)} hours`;
} else if (hours < 720) {
return `${(hours / 24).toFixed(1)} days`;
} else if (hours < 8760) {

View file

@ -92,11 +92,17 @@ export class UtilityService {
}
filter(input: string, filter: string): boolean {
if (input === null || filter === null) return false;
if (input === null || filter === null || input === undefined || filter === undefined) return false;
const reg = /[_\.\-]/gi;
return input.toUpperCase().replace(reg, '').includes(filter.toUpperCase().replace(reg, ''));
}
filterMatches(input: string, filter: string): boolean {
if (input === null || filter === null || input === undefined || filter === undefined) return false;
const reg = /[_\.\-]/gi;
return input.toUpperCase().replace(reg, '') === filter.toUpperCase().replace(reg, '');
}
isVolume(d: any) {
return d != null && d.hasOwnProperty('chapters');
}

View file

@ -29,7 +29,7 @@
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Average Reading / Week" [clickable]="false" fontClasses="fas fa-eye">
{{avgHoursPerWeekSpentReading | timeDuration}}
{{avgHoursPerWeekSpentReading | timeDuration}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>

View file

@ -236,16 +236,16 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
this.filteredOptions = this.typeaheadForm.get('typeahead')!.valueChanges
.pipe(
// Adjust input box to grow
tap(val => {
tap((val: string) => {
if (this.inputElem != null && this.inputElem.nativeElement != null) {
this.renderer2.setStyle(this.inputElem.nativeElement, 'width', 15 * (val.trim().length + 1) + 'px');
this.focusedIndex = 0;
}
}),
map(val => val.trim()),
map((val: string) => val.trim()),
auditTime(this.settings.debounce),
//distinctUntilChanged(), // ?!: BUG Doesn't trigger the search to run when filtered array changes
filter(val => {
filter((val: string) => {
// If minimum filter characters not met, do not filter
if (this.settings.minCharacters === 0) return true;
@ -256,11 +256,11 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
return true;
}),
switchMap(val => {
switchMap((val: string) => {
this.isLoadingOptions = true;
return this.settings.fetchFn(val.trim()).pipe(takeUntil(this.onDestroy), map((items: any[]) => items.filter(item => this.filterSelected(item))));
}),
tap((filteredOptions) => {
tap((filteredOptions: any[]) => {
this.isLoadingOptions = false;
this.focusedIndex = 0;
this.cdRef.markForCheck();
@ -398,6 +398,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
}
this.toggleSelection(opt);
console.log('Selected ', opt);
this.resetField();
this.onInputFocus();
@ -410,6 +411,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
const newItem = this.settings.addTransformFn(title);
this.newItemAdded.emit(newItem);
this.toggleSelection(newItem);
console.log('Selected ', newItem);
this.resetField();
this.onInputFocus();
@ -482,14 +484,43 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
updateShowAddItem(options: any[]) {
// ?! BUG This will still technicially allow you to add the same thing as a previously added item. (Code will just toggle it though)
this.showAddItem = this.settings.addIfNonExisting && this.typeaheadControl.value.trim()
&& this.typeaheadControl.value.trim().length >= Math.max(this.settings.minCharacters, 1)
&& this.typeaheadControl.dirty
&& (typeof this.settings.compareFn == 'function' && this.settings.compareFn(options, this.typeaheadControl.value.trim()).length === 0);
this.showAddItem = false;
this.cdRef.markForCheck();
if (!this.settings.addIfNonExisting) return;
const inputText = this.typeaheadControl.value.trim();
if (inputText.length < Math.max(this.settings.minCharacters, 1)) return;
if (!this.typeaheadControl.dirty) return; // Do we need this?
// Check if this new option will interfere with any existing ones not shown
if (typeof this.settings.compareFnForAdd == 'function') {
console.log('filtered options: ', this.optionSelection.selected());
const willDuplicateExist = this.settings.compareFnForAdd(this.optionSelection.selected(), inputText);
console.log('duplicate check: ', willDuplicateExist);
if (willDuplicateExist.length > 0) {
console.log("can't show add, duplicates will exist");
return;
}
}
if (typeof this.settings.compareFn == 'function') {
// The problem here is that compareFn can report that duplicate will exist as it does contains not match
const matches = this.settings.compareFn(options, inputText);
console.log('matches for ', inputText, ': ', matches);
console.log('matches include input string: ', matches.includes(this.settings.addTransformFn(inputText)));
if (matches.length > 0 && matches.includes(this.settings.addTransformFn(inputText))) {
console.log("can't show add, there are still ");
return;
}
}
this.showAddItem = true;
if (this.showAddItem) {
this.hasFocus = true;
}
this.cdRef.markForCheck();
}
toggleLock(event: any) {

View file

@ -29,9 +29,13 @@ export class TypeaheadSettings<T> {
savedData!: T[] | T;
/**
* Function to compare the elements. Should return all elements that fit the matching criteria.
* This is only used with non-Observable based fetchFn, but must be defined for all uses of typeahead (TODO)
* This is only used with non-Observable based fetchFn, but must be defined for all uses of typeahead.
*/
compareFn!: ((optionList: T[], filter: string) => T[]);
/**
* Must be defined when addIfNonExisting is true. Used to ensure no duplicates exist when adding.
*/
compareFnForAdd!: ((optionList: T[], filter: string) => T[]);
/**
* Function which is used for comparing objects when keeping track of state.
* Useful over shallow equal when you have image urls that have random numbers on them.

View file

@ -9,7 +9,7 @@
</div>
<p *ngIf="isAdmin">
Looking for a light or e-ink theme? We have some custom themes you can use on our <a href="https://wiki.kavitareader.com/en/guides/settings/themes" target="_blank" rel="noopener noreferrer">wiki</a>.
Looking for a light or e-ink theme? We have some custom themes you can use on our <a href="https://github.com/Kareadita/Themes" target="_blank" rel="noopener noreferrer">theme github</a>.
</p>
<div class="row g-0">

View file

@ -14,8 +14,8 @@
<meta name="msapplication-config" content="assets/icons/browserconfig.xml">
<meta name="theme-color" content="#000000">
<meta name="apple-mobile-web-app-status-bar-style" content="#000000">
<meta name='robots' content='noindex,follow' />
<!-- Don't allow indexing from Bots -->
<meta name='robots' content='none' />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">

View file

@ -24,10 +24,10 @@ input:not([type="range"]), .form-control {
}
}
.form-range::-webkit-slider-thumb:active {
.form-range::-webkit-slider-thumb:active, .form-range::-moz-range-thumb:active {
background-color: var(--input-range-active-color);
}
.form-range::-webkit-slider-thumb {
.form-range::-webkit-slider-thumb, .form-range::-moz-range-thumb {
background-color: var(--input-range-color);
}