fix: fuck cloudflare

This commit is contained in:
Xymorot 2021-01-17 19:40:24 +01:00
parent 523bc7e75e
commit 31945cac08
17 changed files with 304 additions and 162 deletions

View File

@ -8,7 +8,6 @@ import { NhentaiApi } from '../modules/nhentai/nhentai-api';
import '../modules/nhentai/nhentai-ipc-controller'; import '../modules/nhentai/nhentai-ipc-controller';
import { NhentaiAppWindow } from '../modules/nhentai/nhentai-app-window'; import { NhentaiAppWindow } from '../modules/nhentai/nhentai-app-window';
import { NhentaiSourceGetter } from '../modules/nhentai/nhentai-source-getter'; import { NhentaiSourceGetter } from '../modules/nhentai/nhentai-source-getter';
import { SessionHelper } from '../modules/session/session-helper';
import { Store } from '../modules/store/store'; import { Store } from '../modules/store/store';
import BindingToSyntax = interfaces.BindingToSyntax; import BindingToSyntax = interfaces.BindingToSyntax;
@ -33,8 +32,6 @@ container.bind('dialog').to(Dialog);
container.bind('store').to(Store); container.bind('store').to(Store);
container.bind('session-helper').to(SessionHelper);
container.bind('nhentai-app-window').to(NhentaiAppWindow); container.bind('nhentai-app-window').to(NhentaiAppWindow);
container.bind('nhentai-api').to(NhentaiApi); container.bind('nhentai-api').to(NhentaiApi);
container.bind('nhentai-source-getter').to(NhentaiSourceGetter); container.bind('nhentai-source-getter').to(NhentaiSourceGetter);

View File

@ -2,7 +2,17 @@ import { BrowserWindow } from 'electron';
interface AppWindowInterface { interface AppWindowInterface {
window: BrowserWindow | null; window: BrowserWindow | null;
/**
* throws an Error when the window is null
*/
getWindow(): BrowserWindow;
open(): Promise<void>; open(): Promise<void>;
close(force?: boolean): void; close(force?: boolean): void;
isClosed(): boolean; isClosed(): boolean;
askForUserInteraction(): void;
} }

View File

@ -2,7 +2,7 @@ import { app, BrowserWindow, Event, LoadFileOptions, LoadURLOptions, NewWindowWe
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { isDev } from '../../core/env'; import { isDev } from '../../core/env';
import type { SessionHelperInterface } from '../session/session-helper-interface'; import { completeContentSecurityPolicy, setWindowCsp } from '../session/session-util';
import type { AppWindowInterface } from './app-window-interface'; import type { AppWindowInterface } from './app-window-interface';
import { WindowClosedError } from './window-closed-error'; import { WindowClosedError } from './window-closed-error';
import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions; import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions;
@ -38,22 +38,14 @@ export abstract class AppWindow implements AppWindowInterface {
protected readonly logger: LoggerInterface; protected readonly logger: LoggerInterface;
protected readonly sessionHelper: SessionHelperInterface;
protected options: BrowserWindowConstructorOptions; protected options: BrowserWindowConstructorOptions;
protected uri: string; protected uri: string;
protected abstract loadOptions: LoadFileOptions | LoadURLOptions; protected abstract loadOptions: LoadFileOptions | LoadURLOptions;
protected constructor( protected constructor(logger: LoggerInterface, uri: string, options: BrowserWindowConstructorOptions = {}) {
logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string,
options: BrowserWindowConstructorOptions = {}
) {
this.logger = logger; this.logger = logger;
this.sessionHelper = sessionHelper;
this.options = { ...defaultOptions, ...options }; this.options = { ...defaultOptions, ...options };
this.uri = uri; this.uri = uri;
} }
@ -62,10 +54,17 @@ export abstract class AppWindow implements AppWindowInterface {
return this._window; return this._window;
} }
public getWindow(): BrowserWindow {
if (!this._window) {
throw new WindowClosedError();
}
return this._window;
}
public open(): Promise<void> { public open(): Promise<void> {
this._window = new BrowserWindow(this.options); this._window = new BrowserWindow(this.options);
this.sessionHelper.setCsp(this._window, this.getCsp()); setWindowCsp(this._window, completeContentSecurityPolicy(this.getCsp()));
this._window.on('closed', () => { this._window.on('closed', () => {
this.onClosed(); this.onClosed();
@ -94,6 +93,15 @@ export abstract class AppWindow implements AppWindowInterface {
return !this._window; return !this._window;
} }
public askForUserInteraction(): void {
if (!this.getWindow().isFocused()) {
this.getWindow().on('focus', () => {
this.getWindow().flashFrame(false);
});
this.getWindow().flashFrame(true);
}
}
protected getCsp(): Session.ContentSecurityPolicy { protected getCsp(): Session.ContentSecurityPolicy {
return {}; return {};
} }
@ -111,11 +119,8 @@ export abstract class AppWindow implements AppWindowInterface {
protected getInnerHtml(selector: string): Promise<string> { protected getInnerHtml(selector: string): Promise<string> {
return new Promise<string>((resolve) => { return new Promise<string>((resolve) => {
if (!this._window) { this.getWindow()
throw new WindowClosedError(); .webContents.executeJavaScript(`document.querySelector('${selector}').innerHTML`)
}
this._window.webContents
.executeJavaScript(`document.querySelector('${selector}').innerHTML`)
.then((innerHtml) => { .then((innerHtml) => {
resolve(innerHtml); resolve(innerHtml);
}) })

View File

@ -1,5 +1,4 @@
import type { BrowserWindow, BrowserWindowConstructorOptions, LoadFileOptions } from 'electron'; import type { BrowserWindow, BrowserWindowConstructorOptions, LoadFileOptions } from 'electron';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import { AppWindow } from './app-window'; import { AppWindow } from './app-window';
export abstract class FileAppWindow extends AppWindow { export abstract class FileAppWindow extends AppWindow {
@ -7,12 +6,11 @@ export abstract class FileAppWindow extends AppWindow {
protected constructor( protected constructor(
logger: LoggerInterface, logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string, uri: string,
options: BrowserWindowConstructorOptions = {}, options: BrowserWindowConstructorOptions = {},
loadOptions: LoadFileOptions = {} loadOptions: LoadFileOptions = {}
) { ) {
super(logger, sessionHelper, uri, options); super(logger, uri, options);
this.loadOptions = loadOptions; this.loadOptions = loadOptions;
} }

View File

@ -1,15 +1,11 @@
import { injectable } from 'inversify'; import { injectable } from 'inversify';
import { inject } from '../../core/inject'; import { inject } from '../../core/inject';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import { FileAppWindow } from './file-app-window'; import { FileAppWindow } from './file-app-window';
@injectable() @injectable()
export class MainAppWindow extends FileAppWindow { export class MainAppWindow extends FileAppWindow {
public constructor( public constructor(@inject('logger') logger: LoggerInterface) {
@inject('logger') logger: LoggerInterface, super(logger, 'frontend/index.html', {
@inject('session-helper') sessionHelper: SessionHelperInterface
) {
super(logger, sessionHelper, 'frontend/index.html', {
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
}, },

View File

@ -1,6 +1,5 @@
import type { BrowserWindowConstructorOptions, LoadURLOptions } from 'electron'; import type { BrowserWindowConstructorOptions, LoadURLOptions } from 'electron';
import { SimpleMutex } from '../mutex/simple-mutex'; import { SimpleMutex } from '../mutex/simple-mutex';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import type { SiteAppWindowInterface } from './site-app-window-interface'; import type { SiteAppWindowInterface } from './site-app-window-interface';
import { UrlAppWindow } from './url-app-window'; import { UrlAppWindow } from './url-app-window';
@ -13,12 +12,11 @@ export abstract class SiteAppWindow extends UrlAppWindow implements SiteAppWindo
protected constructor( protected constructor(
logger: LoggerInterface, logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string, uri: string,
options: BrowserWindowConstructorOptions = {}, options: BrowserWindowConstructorOptions = {},
loadOptions: LoadURLOptions = {} loadOptions: LoadURLOptions = {}
) { ) {
super(logger, sessionHelper, uri, options, loadOptions); super(logger, uri, options, loadOptions);
this.windowLock = new SimpleMutex(); this.windowLock = new SimpleMutex();
} }

View File

@ -1,8 +1,21 @@
import type { LoadURLOptions } from 'electron'; import type { LoadURLOptions, WebContents } from 'electron';
import type { AppWindowInterface } from './app-window-interface'; import type { AppWindowInterface } from './app-window-interface';
interface UrlAppWindowInterface extends AppWindowInterface { interface UrlAppWindowInterface extends AppWindowInterface {
downloadUrlSafe(url: string, savePath: string, options?: LoadURLOptions): Promise<void>; downloadUrlSafe(url: string, savePath: string, options?: LoadURLOptions): Promise<void>;
loadUrlSafe(url: string, options?: LoadURLOptions): Promise<void>; /**
* safely loads a url, reloading on fail
*
* this functions also waits if the server returns 429
*
* @param url - the url to load
* @param readyCheck - a function to check if the site is ready to be consumed
* @param options - load url options to forward to electron
*/
loadUrlSafe(
url: string,
readyCheck?: (webContents: WebContents) => Promise<boolean>,
options?: LoadURLOptions
): Promise<void>;
} }

View File

@ -1,14 +1,15 @@
import type { BrowserWindow, BrowserWindowConstructorOptions, LoadURLOptions } from 'electron'; import type { WebContents } from 'electron';
import type { BrowserWindowConstructorOptions, LoadURLOptions } from 'electron';
import { promisify } from 'util'; import { promisify } from 'util';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import { AppWindow } from './app-window'; import { AppWindow } from './app-window';
import type { UrlAppWindowInterface } from './url-app-window-interface'; import type { UrlAppWindowInterface } from './url-app-window-interface';
import { WindowClosedError } from './window-closed-error';
import Timeout = NodeJS.Timeout; import Timeout = NodeJS.Timeout;
export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInterface { export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInterface {
protected loadOptions: LoadURLOptions; protected loadOptions: LoadURLOptions;
protected readyCheck?: (webContents: WebContents) => Promise<boolean>;
/** /**
* the wait interval after a failed load to try again * the wait interval after a failed load to try again
*/ */
@ -33,12 +34,11 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte
protected constructor( protected constructor(
logger: LoggerInterface, logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string, uri: string,
options: BrowserWindowConstructorOptions = {}, options: BrowserWindowConstructorOptions = {},
loadOptions: LoadURLOptions = {} loadOptions: LoadURLOptions = {}
) { ) {
super(logger, sessionHelper, uri, { super(logger, uri, {
...options, ...options,
...{ ...{
webPreferences: { webPreferences: {
@ -54,10 +54,7 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte
public downloadUrlSafe(url: string, savePath: string, options?: LoadURLOptions): Promise<void> { public downloadUrlSafe(url: string, savePath: string, options?: LoadURLOptions): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (!this._window) { this.getWindow().webContents.session.once('will-download', (event, item) => {
throw new WindowClosedError();
}
this._window.webContents.session.once('will-download', (event, item) => {
item.setSavePath(savePath); item.setSavePath(savePath);
item.once('done', (doneEvent, state) => { item.once('done', (doneEvent, state) => {
switch (state) { switch (state) {
@ -75,42 +72,63 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte
item.resume(); item.resume();
}); });
}); });
void this.loadUrlSafe(url, options); void this.loadUrlSafe(url, undefined, options);
}); });
} }
public async loadUrlSafe(url: string, options?: LoadURLOptions): Promise<void> { public async loadUrlSafe(
url: string,
readyCheck?: (webContents: WebContents) => Promise<boolean>,
options?: LoadURLOptions
): Promise<void> {
return this.loadWait.then(async () => { return this.loadWait.then(async () => {
let failedLoad = true; let failedLoad = true;
while (failedLoad) { while (failedLoad) {
await new Promise<void>((resolve) => { await this.loadUrl(url, options).then((httpResponseCode) => {
if (!this._window) { failedLoad = HttpCode.BAD_REQUEST <= httpResponseCode;
throw new WindowClosedError(); if (HttpCode.TOO_MANY_REQUESTS === httpResponseCode) {
// go slower
this.loadWaitTime += this.loadWaitTimeStep;
// but go faster again after a time
clearTimeout(this.loadWaitTimeStepResetTimeout);
this.loadWaitTimeStepResetTimeout = setTimeout(() => {
this.loadWaitTime = 0;
}, this.loadWaitTimeResetTimeoutTime);
} }
this._window.webContents.once('did-navigate', (event, navigationUrl, httpResponseCode) => {
failedLoad = HttpCode.BAD_REQUEST <= httpResponseCode;
if (HttpCode.TOO_MANY_REQUESTS === httpResponseCode) {
// go slower
this.loadWaitTime += this.loadWaitTimeStep;
// but go faster again after a time
clearTimeout(this.loadWaitTimeStepResetTimeout);
this.loadWaitTimeStepResetTimeout = setTimeout(() => {
this.loadWaitTime = 0;
}, this.loadWaitTimeResetTimeoutTime);
}
resolve();
});
void this._window.loadURL(url, options);
}); });
if (failedLoad) { if (failedLoad) {
await promisify(setTimeout)(this.waitInterval); await promisify(setTimeout)(this.waitInterval);
} }
} }
this.loadWait = promisify(setTimeout)(this.loadWaitTime); this.loadWait = promisify(setTimeout)(this.loadWaitTime);
if (readyCheck) {
let isReady = await readyCheck(this.getWindow().webContents);
do {
await promisify(setTimeout)(Milliseconds.TEN);
isReady = await readyCheck(this.getWindow().webContents);
} while (!isReady);
}
}); });
} }
protected load(window: BrowserWindow): Promise<void> { /**
return window.loadURL(this.uri, this.loadOptions); * This is the method used for loading specific URLs.
* It resolves when the url is loaded, successfully or not-
*
* It is meant to be overridden for site specific logic, e.g. C l o u d f l a r e
*
* @return a Promise of the http status code the url loaded with
*/
protected loadUrl(url: string, options?: LoadURLOptions): Promise<number> {
return new Promise((resolve) => {
this.getWindow().webContents.once('did-navigate', (event, navigationUrl, httpResponseCode) => {
resolve(httpResponseCode);
});
void this.getWindow().loadURL(url, options);
});
}
protected load(): Promise<void> {
return this.loadUrlSafe(this.uri, this.readyCheck, this.loadOptions).then();
} }
} }

View File

@ -0,0 +1,30 @@
import type { LoadURLOptions } from 'electron';
import { SiteAppWindow } from '../app-window/site-app-window';
import { mergeContentSecurityPolicy } from '../session/session-util';
import { cloudflareSiteCsp, humanInteractionRequired, isCloudFlareSite } from './cloudflare-util';
export abstract class CloudflareSiteAppWindow extends SiteAppWindow {
protected loadUrl(url: string, options?: LoadURLOptions): Promise<number> {
return new Promise((resolve) => {
const onDidNavigate: (event: Event, url: string, httpResponseCode: number) => void = async (
event,
navigationUrl,
httpResponseCode
) => {
if (!(await isCloudFlareSite(this.getWindow().webContents))) {
this.getWindow().webContents.removeListener('did-navigate', onDidNavigate);
resolve(httpResponseCode);
} else if (await humanInteractionRequired(this.getWindow().webContents)) {
this.askForUserInteraction();
}
};
this.getWindow().webContents.on('did-navigate', onDidNavigate);
void this.getWindow().loadURL(url, options);
});
}
protected getCsp(): Session.ContentSecurityPolicy {
return mergeContentSecurityPolicy(super.getCsp(), cloudflareSiteCsp);
}
}

View File

@ -0,0 +1,19 @@
import type { WebContents } from 'electron';
import ContentSecurityPolicy = Session.ContentSecurityPolicy;
export const cloudflareSiteCsp: ContentSecurityPolicy = {
'style-src': ['cdnjs.cloudflare.com'],
'script-src': ['hcaptcha.com'],
};
export function humanInteractionRequired(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(
"[...document.querySelectorAll('iframe')].map(iframe => (new URL(iframe.src)).hostname.match(/hcaptcha/)).some(e => e)"
) as Promise<boolean>;
}
export function isCloudFlareSite(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(
"!!document.querySelector('.cf-browser-verification, #cf-content')"
) as Promise<boolean>;
}

View File

@ -6,9 +6,8 @@ import { URL } from 'url';
import { createReadStream, remove } from 'fs-extra'; import { createReadStream, remove } from 'fs-extra';
import { injectable } from 'inversify'; import { injectable } from 'inversify';
import { inject } from '../../core/inject'; import { inject } from '../../core/inject';
import { SiteAppWindow } from '../app-window/site-app-window'; import { CloudflareSiteAppWindow } from '../cloudflare/cloudflare-site-app-window';
import { WindowClosedError } from '../app-window/window-closed-error'; import { mergeContentSecurityPolicy } from '../session/session-util';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import type { NhentaiAppWindowInterface } from './nhentai-app-window-interface'; import type { NhentaiAppWindowInterface } from './nhentai-app-window-interface';
import { import {
url as nhentaiUrl, url as nhentaiUrl,
@ -31,17 +30,20 @@ import {
tagLabelTags, tagLabelTags,
mainTitleSelector, mainTitleSelector,
postTitleSelector, postTitleSelector,
galleryPageIsReady,
loginPageIsReady,
favoritePageIsReady,
pageIsReady,
} from './nhentai-util'; } from './nhentai-util';
const waitInterval = 2000; const waitInterval = 2000;
@injectable() @injectable()
export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowInterface { export class NhentaiAppWindow extends CloudflareSiteAppWindow implements NhentaiAppWindowInterface {
public constructor( protected readyCheck = pageIsReady;
@inject('logger') logger: LoggerInterface,
@inject('session-helper') sessionHelper: SessionHelperInterface public constructor(@inject('logger') logger: LoggerInterface) {
) { super(logger, nhentaiUrl);
super(logger, sessionHelper, nhentaiUrl);
} }
public async getFavorites(): Promise<NodeJS.ReadableStream> { public async getFavorites(): Promise<NodeJS.ReadableStream> {
@ -95,9 +97,6 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
if (this.isClosed()) { if (this.isClosed()) {
await this.open(); await this.open();
} }
if (!this._window) {
throw new WindowClosedError();
}
const gallery: Nhentai.Gallery = { const gallery: Nhentai.Gallery = {
title: { title: {
@ -116,7 +115,7 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
const bookUrl = getBookUrl(identifier); const bookUrl = getBookUrl(identifier);
try { try {
await this.loadUrlSafe(bookUrl); await this.loadGalleryPageSafe(bookUrl);
await Promise.all([ await Promise.all([
this.getInnerHtml(preTitleSelector).then((preTitle) => { this.getInnerHtml(preTitleSelector).then((preTitle) => {
gallery.title.pre = preTitle.trim(); gallery.title.pre = preTitle.trim();
@ -155,24 +154,24 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
} }
protected getCsp(): Session.ContentSecurityPolicy { protected getCsp(): Session.ContentSecurityPolicy {
return { return mergeContentSecurityPolicy(super.getCsp(), {
'default-src': ['nhentai.net'], 'default-src': ['nhentai.net'],
'script-src': ['nhentai.net', "'unsafe-eval'"], 'script-src': ['nhentai.net', "'unsafe-eval'"],
'script-src-elem': ['*.nhentai.net', "'unsafe-inline'", '*.google.com', '*.gstatic.com'], 'script-src-elem': ['*.nhentai.net', 'nhentai.net', "'unsafe-inline'", '*.google.com', '*.gstatic.com'],
'style-src': [ 'style-src': [
'*.nhentai.net', '*.nhentai.net',
'nhentai.net',
"'unsafe-inline'", "'unsafe-inline'",
'fonts.googleapis.com', 'fonts.googleapis.com',
'cdnjs.cloudflare.com',
'maxcdn.bootstrapcdn.com', 'maxcdn.bootstrapcdn.com',
'*.gstatic.com', '*.gstatic.com',
], ],
'img-src': ['*.nhentai.net', 'data:', '*.gstatic.com', '*.google.com'], 'img-src': ['*.nhentai.net', 'nhentai.net', 'data:', '*.gstatic.com', '*.google.com'],
'font-src': ['fonts.gstatic.com', 'cdnjs.cloudflare.com', 'maxcdn.bootstrapcdn.com'], 'font-src': ['fonts.gstatic.com', 'cdnjs.cloudflare.com', 'maxcdn.bootstrapcdn.com'],
'frame-src': ['*.google.com'], 'frame-src': ['*.google.com'],
'connect-src': ['nhentai.net', '*.google.com'], 'connect-src': ['nhentai.net', '*.google.com'],
'worker-src': ['*.google.com'], 'worker-src': ['*.google.com'],
}; });
} }
protected onWillNavigate(event: Electron.Event, navigationUrl: string): void { protected onWillNavigate(event: Electron.Event, navigationUrl: string): void {
@ -184,11 +183,8 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
} }
private async isLoggedIn(): Promise<boolean> { private async isLoggedIn(): Promise<boolean> {
if (!this._window) { return this.getWindow()
throw new WindowClosedError(); .webContents.executeJavaScript(
}
return this._window.webContents
.executeJavaScript(
`fetch('${ `fetch('${
nhentaiUrl + nhentaiPaths.favorites nhentaiUrl + nhentaiPaths.favorites
}', {credentials: 'include', redirect: 'manual'}).then((res) => res.status)` }', {credentials: 'include', redirect: 'manual'}).then((res) => res.status)`
@ -196,14 +192,8 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
.then((status: number) => status === HttpCode.OK); .then((status: number) => status === HttpCode.OK);
} }
/**
* @throws WindowClosedError when this._window is null
*/
private async login(): Promise<void> { private async login(): Promise<void> {
if (!this._window) { await this.loadLoginPageSafe();
throw new WindowClosedError();
}
await this.loadUrlSafe(nhentaiUrl + nhentaiPaths.login);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setInterval(() => { const timeout = setInterval(() => {
@ -220,20 +210,17 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
} }
private async *getFavoritePageWebContentsGenerator(): AsyncGenerator<WebContents, undefined> { private async *getFavoritePageWebContentsGenerator(): AsyncGenerator<WebContents, undefined> {
if (!this._window) { await this.loadFavoritesPageSafe(getFavoritePageUrl());
throw new WindowClosedError();
}
await this.loadUrlSafe(getFavoritePageUrl());
while (true) { while (true) {
yield this._window.webContents; yield this.getWindow().webContents;
const hasNextPage = (await this._window.webContents.executeJavaScript( const hasNextPage = (await this.getWindow().webContents.executeJavaScript(
`!!document.querySelector('${nextFavoritePageSelector}')` `!!document.querySelector('${nextFavoritePageSelector}')`
)) as boolean; )) as boolean;
if (hasNextPage) { if (hasNextPage) {
const nextPageHref = (await this._window.webContents.executeJavaScript( const nextPageHref = (await this.getWindow().webContents.executeJavaScript(
`document.querySelector('${nextFavoritePageSelector}').href` `document.querySelector('${nextFavoritePageSelector}').href`
)) as string; )) as string;
await this.loadUrlSafe(nextPageHref); await this.loadFavoritesPageSafe(nextPageHref);
} else { } else {
break; break;
} }
@ -242,14 +229,11 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
} }
private async getBookTorrent(bookUrl: string): Promise<Nhentai.Favorite> { private async getBookTorrent(bookUrl: string): Promise<Nhentai.Favorite> {
if (!this._window) {
throw new WindowClosedError();
}
const galleryId = getGalleryId(bookUrl); const galleryId = getGalleryId(bookUrl);
const fileName = `${galleryId}.torrent`; const fileName = `${galleryId}.torrent`;
const filePath = path.resolve(os.tmpdir(), fileName); const filePath = path.resolve(os.tmpdir(), fileName);
await this.loadUrlSafe(bookUrl); await this.loadGalleryPageSafe(bookUrl);
const downloadLink: string = (await this._window.webContents.executeJavaScript( const downloadLink: string = (await this.getWindow().webContents.executeJavaScript(
`document.getElementById('${downloadLinkId}').href` `document.getElementById('${downloadLinkId}').href`
)) as string; )) as string;
await this.downloadUrlSafe(downloadLink, filePath); await this.downloadUrlSafe(downloadLink, filePath);
@ -266,11 +250,20 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
}; };
} }
private loadGalleryPageSafe(url: string): Promise<void> {
return this.loadUrlSafe(url, galleryPageIsReady);
}
private loadLoginPageSafe(): Promise<void> {
return this.loadUrlSafe(nhentaiUrl + nhentaiPaths.login, loginPageIsReady);
}
private loadFavoritesPageSafe(url: string): Promise<void> {
return this.loadUrlSafe(url, favoritePageIsReady);
}
private getTags(tagLabel: string): Promise<string[]> { private getTags(tagLabel: string): Promise<string[]> {
if (!this._window) { return this.getWindow().webContents.executeJavaScript(
throw new WindowClosedError();
}
return this._window.webContents.executeJavaScript(
`Array.from( `Array.from(
document.querySelectorAll('${labeledTagContainerSelector}') document.querySelectorAll('${labeledTagContainerSelector}')
).filter( ).filter(

View File

@ -1,3 +1,5 @@
import type { WebContents } from 'electron';
export const hostname = 'nhentai.net'; export const hostname = 'nhentai.net';
export const url = `https://${hostname}/`; export const url = `https://${hostname}/`;
@ -24,6 +26,22 @@ export const tagLabelTags = 'Tags';
export const tagLabelArtists = 'Artists'; export const tagLabelArtists = 'Artists';
export const tagLabelGroups = 'Groups'; export const tagLabelGroups = 'Groups';
export function pageIsReady(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(`!!document.getElementById('content')`) as Promise<boolean>;
}
export function galleryPageIsReady(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(`!!document.getElementById('${downloadLinkId}')`) as Promise<boolean>;
}
export function favoritePageIsReady(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(`!!document.getElementById('favorites-random-button')`) as Promise<boolean>;
}
export function loginPageIsReady(webContents: WebContents): Promise<boolean> {
return webContents.executeJavaScript(`!!document.getElementById('id_username_or_email')`) as Promise<boolean>;
}
export function getFavoritePageUrl(page?: number): string { export function getFavoritePageUrl(page?: number): string {
return `${url + paths.favorites}${page ? `?page=${page}` : ''}`; return `${url + paths.favorites}${page ? `?page=${page}` : ''}`;
} }

View File

@ -1,5 +0,0 @@
import { BrowserWindow } from 'electron';
interface SessionHelperInterface {
setCsp(window: BrowserWindow, csp: Session.ContentSecurityPolicy): void;
}

View File

@ -1,46 +0,0 @@
import { injectable } from 'inversify';
import { isDev } from '../../core/env';
import type { SessionHelperInterface } from './session-helper-interface';
const defaultCsp: Session.ContentSecurityPolicy = {
'default-src': ["'self'"],
'style-src': ["'unsafe-inline'"],
'object-src': ["'none'"],
};
@injectable()
export class SessionHelper implements SessionHelperInterface {
private static stringifyCspHeader(csp: Session.ContentSecurityPolicy): string {
return Object.entries(csp)
.map(
(directive: [string, Session.CspValue[] | undefined]) =>
`${directive[0]} ${directive[1] ? directive[1]?.join(' ') : ''}`
)
.join('; ');
}
public setCsp(window: Electron.BrowserWindow, csp: Session.ContentSecurityPolicy): void {
const mergedCsp: Session.ContentSecurityPolicy = { ...defaultCsp, ...csp };
if (isDev()) {
mergedCsp['default-src'] = ['devtools:'].concat(mergedCsp['default-src'] ?? []);
mergedCsp['script-src'] = ["'unsafe-eval'"].concat(mergedCsp['script-src'] ?? []);
mergedCsp['script-src-elem'] = ['file:', 'devtools:', "'unsafe-inline'"].concat(
mergedCsp['script-src-elem'] ?? []
);
mergedCsp['style-src'] = ['devtools:', "'unsafe-inline'"].concat(mergedCsp['style-src'] ?? []);
mergedCsp['img-src'] = ['devtools:'].concat(mergedCsp['img-src'] ?? []);
mergedCsp['connect-src'] = ['devtools:', 'data:'].concat(mergedCsp['connect-src'] ?? []);
mergedCsp['worker-src'] = ['devtools:'].concat(mergedCsp['worker-src'] ?? []);
}
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': SessionHelper.stringifyCspHeader(mergedCsp),
},
});
});
}
}

View File

@ -0,0 +1,78 @@
import { isDev } from '../../core/env';
import ContentSecurityPolicy = Session.ContentSecurityPolicy;
import CompleteContentSecurityPolicy = Session.CompleteContentSecurityPolicy;
const defaultCsp: Session.ContentSecurityPolicy = {
'default-src': ["'self'"],
'style-src': ["'unsafe-inline'"],
};
function stringifyCspHeader(csp: ContentSecurityPolicy): string {
return Object.entries(csp)
.map(
(directive: [string, Session.CspValue[] | undefined]) =>
`${directive[0]} ${directive[1] ? directive[1]?.join(' ') : ''}`
)
.join('; ');
}
export function mergeContentSecurityPolicy(...contentSecurityPolicies: ContentSecurityPolicy[]): ContentSecurityPolicy {
return contentSecurityPolicies.reduce((mergedCsp, contentSecurityPolicy) => {
Object.entries(contentSecurityPolicy).forEach(([policyName, policy]) => {
const mergedPolicy = mergedCsp[policyName as keyof ContentSecurityPolicy];
if (mergedPolicy && policy) {
mergedPolicy.push(...policy);
} else if (policy) {
mergedCsp[policyName as keyof ContentSecurityPolicy] = policy;
}
});
return mergedCsp;
}, {});
}
export function setWindowCsp(window: Electron.BrowserWindow, csp: CompleteContentSecurityPolicy): void {
const mergedCsp: ContentSecurityPolicy = { ...defaultCsp, ...csp };
if (isDev()) {
mergedCsp['default-src'] = ['devtools:'].concat(mergedCsp['default-src'] ?? []);
mergedCsp['script-src'] = ["'unsafe-eval'"].concat(mergedCsp['script-src'] ?? []);
mergedCsp['script-src-elem'] = ['file:', 'devtools:', "'unsafe-inline'"].concat(mergedCsp['script-src-elem'] ?? []);
mergedCsp['style-src'] = ['devtools:', "'unsafe-inline'"].concat(mergedCsp['style-src'] ?? []);
mergedCsp['img-src'] = ['devtools:'].concat(mergedCsp['img-src'] ?? []);
mergedCsp['connect-src'] = ['devtools:', 'data:'].concat(mergedCsp['connect-src'] ?? []);
mergedCsp['worker-src'] = ['devtools:'].concat(mergedCsp['worker-src'] ?? []);
}
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': stringifyCspHeader(mergedCsp),
},
});
});
}
/**
* fills script-src-elem and script-src-attr with script-src when not present,
* fills rest of missing policies with default-src or nothing when it is not present
*/
export function completeContentSecurityPolicy(csp: ContentSecurityPolicy): CompleteContentSecurityPolicy {
const defaultSrc = csp['default-src'] ?? [];
const scriptSrc = csp['script-src'] ?? [];
return {
'child-src': csp['child-src'] ?? defaultSrc,
'connect-src': csp['connect-src'] ?? defaultSrc,
'default-src': csp['default-src'] ?? defaultSrc,
'font-src': csp['font-src'] ?? defaultSrc,
'frame-src': csp['frame-src'] ?? defaultSrc,
'img-src': csp['img-src'] ?? defaultSrc,
'media-src': csp['media-src'] ?? defaultSrc,
'object-src': ["'none'"],
'script-src': csp['script-src'] ?? defaultSrc,
'script-src-elem': csp['script-src-elem'] ?? scriptSrc ?? defaultSrc,
'script-src-attr': csp['script-src-attr'] ?? scriptSrc ?? defaultSrc,
'style-src': csp['style-src'] ?? defaultSrc,
'worker-src': csp['worker-src'] ?? defaultSrc,
};
}

View File

@ -15,11 +15,29 @@ declare namespace Session {
'frame-src'?: CspValue[]; 'frame-src'?: CspValue[];
'img-src'?: CspValue[]; 'img-src'?: CspValue[];
'media-src'?: CspValue[]; 'media-src'?: CspValue[];
'object-src'?: ["'none'"];
'script-src'?: CspValue[]; 'script-src'?: CspValue[];
'script-src-elem'?: CspValue[]; 'script-src-elem'?: CspValue[];
'script-src-attr'?: CspValue[]; 'script-src-attr'?: CspValue[];
'style-src'?: CspValue[]; 'style-src'?: CspValue[];
'worker-src'?: CspValue[]; 'worker-src'?: CspValue[];
}; };
/**
* meant for usages where the browser shouldn't unexpectedly fall back to default-src
*/
type CompleteContentSecurityPolicy = {
'child-src': CspValue[];
'connect-src': CspValue[];
'default-src': CspValue[];
'font-src': CspValue[];
'frame-src': CspValue[];
'img-src': CspValue[];
'media-src': CspValue[];
'object-src': ["'none'"];
'script-src': CspValue[];
'script-src-elem': CspValue[];
'script-src-attr': CspValue[];
'style-src': CspValue[];
'worker-src': CspValue[];
};
} }

View File

@ -1,4 +1,6 @@
declare const enum Milliseconds { declare const enum Milliseconds {
TEN = 10,
TWO_HUNDRED = 200, TWO_HUNDRED = 200,
ONE_SECOND = 1000, ONE_SECOND = 1000,