From 31945cac0873ccd5864f1f5de60639fc4c49dceb Mon Sep 17 00:00:00 2001 From: Xymorot Date: Sun, 17 Jan 2021 19:40:24 +0100 Subject: [PATCH] fix: fuck cloudflare --- src/main/core/container.ts | 3 - .../app-window/app-window-interface.d.ts | 10 +++ src/main/modules/app-window/app-window.ts | 37 ++++---- .../modules/app-window/file-app-window.ts | 4 +- .../modules/app-window/main-app-window.ts | 8 +- .../modules/app-window/site-app-window.ts | 4 +- .../app-window/url-app-window-interface.d.ts | 17 +++- src/main/modules/app-window/url-app-window.ts | 78 ++++++++++------- .../cloudflare/cloudflare-site-app-window.ts | 30 +++++++ .../modules/cloudflare/cloudflare-util.ts | 19 ++++ .../modules/nhentai/nhentai-app-window.ts | 87 +++++++++---------- src/main/modules/nhentai/nhentai-util.ts | 18 ++++ .../session/session-helper-interface.d.ts | 5 -- src/main/modules/session/session-helper.ts | 46 ---------- src/main/modules/session/session-util.ts | 78 +++++++++++++++++ src/main/modules/session/session.d.ts | 20 ++++- src/shared/types/milliseconds.d.ts | 2 + 17 files changed, 304 insertions(+), 162 deletions(-) create mode 100644 src/main/modules/cloudflare/cloudflare-site-app-window.ts create mode 100644 src/main/modules/cloudflare/cloudflare-util.ts delete mode 100644 src/main/modules/session/session-helper-interface.d.ts delete mode 100644 src/main/modules/session/session-helper.ts create mode 100644 src/main/modules/session/session-util.ts diff --git a/src/main/core/container.ts b/src/main/core/container.ts index 1ff8412..f8e0dec 100644 --- a/src/main/core/container.ts +++ b/src/main/core/container.ts @@ -8,7 +8,6 @@ import { NhentaiApi } from '../modules/nhentai/nhentai-api'; import '../modules/nhentai/nhentai-ipc-controller'; import { NhentaiAppWindow } from '../modules/nhentai/nhentai-app-window'; import { NhentaiSourceGetter } from '../modules/nhentai/nhentai-source-getter'; -import { SessionHelper } from '../modules/session/session-helper'; import { Store } from '../modules/store/store'; import BindingToSyntax = interfaces.BindingToSyntax; @@ -33,8 +32,6 @@ container.bind('dialog').to(Dialog); container.bind('store').to(Store); -container.bind('session-helper').to(SessionHelper); - container.bind('nhentai-app-window').to(NhentaiAppWindow); container.bind('nhentai-api').to(NhentaiApi); container.bind('nhentai-source-getter').to(NhentaiSourceGetter); diff --git a/src/main/modules/app-window/app-window-interface.d.ts b/src/main/modules/app-window/app-window-interface.d.ts index 9e62999..d3108e2 100644 --- a/src/main/modules/app-window/app-window-interface.d.ts +++ b/src/main/modules/app-window/app-window-interface.d.ts @@ -2,7 +2,17 @@ import { BrowserWindow } from 'electron'; interface AppWindowInterface { window: BrowserWindow | null; + + /** + * throws an Error when the window is null + */ + getWindow(): BrowserWindow; + open(): Promise; + close(force?: boolean): void; + isClosed(): boolean; + + askForUserInteraction(): void; } diff --git a/src/main/modules/app-window/app-window.ts b/src/main/modules/app-window/app-window.ts index 4f67ba8..4f38955 100644 --- a/src/main/modules/app-window/app-window.ts +++ b/src/main/modules/app-window/app-window.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, Event, LoadFileOptions, LoadURLOptions, NewWindowWe import os from 'os'; import path from 'path'; 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 { WindowClosedError } from './window-closed-error'; import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions; @@ -38,22 +38,14 @@ export abstract class AppWindow implements AppWindowInterface { protected readonly logger: LoggerInterface; - protected readonly sessionHelper: SessionHelperInterface; - protected options: BrowserWindowConstructorOptions; protected uri: string; protected abstract loadOptions: LoadFileOptions | LoadURLOptions; - protected constructor( - logger: LoggerInterface, - sessionHelper: SessionHelperInterface, - uri: string, - options: BrowserWindowConstructorOptions = {} - ) { + protected constructor(logger: LoggerInterface, uri: string, options: BrowserWindowConstructorOptions = {}) { this.logger = logger; - this.sessionHelper = sessionHelper; this.options = { ...defaultOptions, ...options }; this.uri = uri; } @@ -62,10 +54,17 @@ export abstract class AppWindow implements AppWindowInterface { return this._window; } + public getWindow(): BrowserWindow { + if (!this._window) { + throw new WindowClosedError(); + } + return this._window; + } + public open(): Promise { this._window = new BrowserWindow(this.options); - this.sessionHelper.setCsp(this._window, this.getCsp()); + setWindowCsp(this._window, completeContentSecurityPolicy(this.getCsp())); this._window.on('closed', () => { this.onClosed(); @@ -94,6 +93,15 @@ export abstract class AppWindow implements AppWindowInterface { 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 { return {}; } @@ -111,11 +119,8 @@ export abstract class AppWindow implements AppWindowInterface { protected getInnerHtml(selector: string): Promise { return new Promise((resolve) => { - if (!this._window) { - throw new WindowClosedError(); - } - this._window.webContents - .executeJavaScript(`document.querySelector('${selector}').innerHTML`) + this.getWindow() + .webContents.executeJavaScript(`document.querySelector('${selector}').innerHTML`) .then((innerHtml) => { resolve(innerHtml); }) diff --git a/src/main/modules/app-window/file-app-window.ts b/src/main/modules/app-window/file-app-window.ts index 09eb27a..b17e608 100644 --- a/src/main/modules/app-window/file-app-window.ts +++ b/src/main/modules/app-window/file-app-window.ts @@ -1,5 +1,4 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, LoadFileOptions } from 'electron'; -import type { SessionHelperInterface } from '../session/session-helper-interface'; import { AppWindow } from './app-window'; export abstract class FileAppWindow extends AppWindow { @@ -7,12 +6,11 @@ export abstract class FileAppWindow extends AppWindow { protected constructor( logger: LoggerInterface, - sessionHelper: SessionHelperInterface, uri: string, options: BrowserWindowConstructorOptions = {}, loadOptions: LoadFileOptions = {} ) { - super(logger, sessionHelper, uri, options); + super(logger, uri, options); this.loadOptions = loadOptions; } diff --git a/src/main/modules/app-window/main-app-window.ts b/src/main/modules/app-window/main-app-window.ts index 4c1c573..f0b7615 100644 --- a/src/main/modules/app-window/main-app-window.ts +++ b/src/main/modules/app-window/main-app-window.ts @@ -1,15 +1,11 @@ import { injectable } from 'inversify'; import { inject } from '../../core/inject'; -import type { SessionHelperInterface } from '../session/session-helper-interface'; import { FileAppWindow } from './file-app-window'; @injectable() export class MainAppWindow extends FileAppWindow { - public constructor( - @inject('logger') logger: LoggerInterface, - @inject('session-helper') sessionHelper: SessionHelperInterface - ) { - super(logger, sessionHelper, 'frontend/index.html', { + public constructor(@inject('logger') logger: LoggerInterface) { + super(logger, 'frontend/index.html', { webPreferences: { nodeIntegration: true, }, diff --git a/src/main/modules/app-window/site-app-window.ts b/src/main/modules/app-window/site-app-window.ts index 321b88d..7a03970 100644 --- a/src/main/modules/app-window/site-app-window.ts +++ b/src/main/modules/app-window/site-app-window.ts @@ -1,6 +1,5 @@ import type { BrowserWindowConstructorOptions, LoadURLOptions } from 'electron'; import { SimpleMutex } from '../mutex/simple-mutex'; -import type { SessionHelperInterface } from '../session/session-helper-interface'; import type { SiteAppWindowInterface } from './site-app-window-interface'; import { UrlAppWindow } from './url-app-window'; @@ -13,12 +12,11 @@ export abstract class SiteAppWindow extends UrlAppWindow implements SiteAppWindo protected constructor( logger: LoggerInterface, - sessionHelper: SessionHelperInterface, uri: string, options: BrowserWindowConstructorOptions = {}, loadOptions: LoadURLOptions = {} ) { - super(logger, sessionHelper, uri, options, loadOptions); + super(logger, uri, options, loadOptions); this.windowLock = new SimpleMutex(); } diff --git a/src/main/modules/app-window/url-app-window-interface.d.ts b/src/main/modules/app-window/url-app-window-interface.d.ts index 62a296e..e83746f 100644 --- a/src/main/modules/app-window/url-app-window-interface.d.ts +++ b/src/main/modules/app-window/url-app-window-interface.d.ts @@ -1,8 +1,21 @@ -import type { LoadURLOptions } from 'electron'; +import type { LoadURLOptions, WebContents } from 'electron'; import type { AppWindowInterface } from './app-window-interface'; interface UrlAppWindowInterface extends AppWindowInterface { downloadUrlSafe(url: string, savePath: string, options?: LoadURLOptions): Promise; - loadUrlSafe(url: string, options?: LoadURLOptions): Promise; + /** + * 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, + options?: LoadURLOptions + ): Promise; } diff --git a/src/main/modules/app-window/url-app-window.ts b/src/main/modules/app-window/url-app-window.ts index 5e031e8..4d5dad1 100644 --- a/src/main/modules/app-window/url-app-window.ts +++ b/src/main/modules/app-window/url-app-window.ts @@ -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 type { SessionHelperInterface } from '../session/session-helper-interface'; import { AppWindow } from './app-window'; import type { UrlAppWindowInterface } from './url-app-window-interface'; -import { WindowClosedError } from './window-closed-error'; import Timeout = NodeJS.Timeout; export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInterface { protected loadOptions: LoadURLOptions; + protected readyCheck?: (webContents: WebContents) => Promise; + /** * the wait interval after a failed load to try again */ @@ -33,12 +34,11 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte protected constructor( logger: LoggerInterface, - sessionHelper: SessionHelperInterface, uri: string, options: BrowserWindowConstructorOptions = {}, loadOptions: LoadURLOptions = {} ) { - super(logger, sessionHelper, uri, { + super(logger, uri, { ...options, ...{ webPreferences: { @@ -54,10 +54,7 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte public downloadUrlSafe(url: string, savePath: string, options?: LoadURLOptions): Promise { return new Promise((resolve, reject) => { - if (!this._window) { - throw new WindowClosedError(); - } - this._window.webContents.session.once('will-download', (event, item) => { + this.getWindow().webContents.session.once('will-download', (event, item) => { item.setSavePath(savePath); item.once('done', (doneEvent, state) => { switch (state) { @@ -75,42 +72,63 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte item.resume(); }); }); - void this.loadUrlSafe(url, options); + void this.loadUrlSafe(url, undefined, options); }); } - public async loadUrlSafe(url: string, options?: LoadURLOptions): Promise { + public async loadUrlSafe( + url: string, + readyCheck?: (webContents: WebContents) => Promise, + options?: LoadURLOptions + ): Promise { return this.loadWait.then(async () => { let failedLoad = true; while (failedLoad) { - await new Promise((resolve) => { - if (!this._window) { - throw new WindowClosedError(); + await this.loadUrl(url, options).then((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); } - 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) { await promisify(setTimeout)(this.waitInterval); } } 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 { - 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 { + return new Promise((resolve) => { + this.getWindow().webContents.once('did-navigate', (event, navigationUrl, httpResponseCode) => { + resolve(httpResponseCode); + }); + void this.getWindow().loadURL(url, options); + }); + } + + protected load(): Promise { + return this.loadUrlSafe(this.uri, this.readyCheck, this.loadOptions).then(); } } diff --git a/src/main/modules/cloudflare/cloudflare-site-app-window.ts b/src/main/modules/cloudflare/cloudflare-site-app-window.ts new file mode 100644 index 0000000..cb7ca7f --- /dev/null +++ b/src/main/modules/cloudflare/cloudflare-site-app-window.ts @@ -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 { + 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); + } +} diff --git a/src/main/modules/cloudflare/cloudflare-util.ts b/src/main/modules/cloudflare/cloudflare-util.ts new file mode 100644 index 0000000..682c33a --- /dev/null +++ b/src/main/modules/cloudflare/cloudflare-util.ts @@ -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 { + return webContents.executeJavaScript( + "[...document.querySelectorAll('iframe')].map(iframe => (new URL(iframe.src)).hostname.match(/hcaptcha/)).some(e => e)" + ) as Promise; +} + +export function isCloudFlareSite(webContents: WebContents): Promise { + return webContents.executeJavaScript( + "!!document.querySelector('.cf-browser-verification, #cf-content')" + ) as Promise; +} diff --git a/src/main/modules/nhentai/nhentai-app-window.ts b/src/main/modules/nhentai/nhentai-app-window.ts index 955fbf8..d1dff40 100644 --- a/src/main/modules/nhentai/nhentai-app-window.ts +++ b/src/main/modules/nhentai/nhentai-app-window.ts @@ -6,9 +6,8 @@ import { URL } from 'url'; import { createReadStream, remove } from 'fs-extra'; import { injectable } from 'inversify'; import { inject } from '../../core/inject'; -import { SiteAppWindow } from '../app-window/site-app-window'; -import { WindowClosedError } from '../app-window/window-closed-error'; -import type { SessionHelperInterface } from '../session/session-helper-interface'; +import { CloudflareSiteAppWindow } from '../cloudflare/cloudflare-site-app-window'; +import { mergeContentSecurityPolicy } from '../session/session-util'; import type { NhentaiAppWindowInterface } from './nhentai-app-window-interface'; import { url as nhentaiUrl, @@ -31,17 +30,20 @@ import { tagLabelTags, mainTitleSelector, postTitleSelector, + galleryPageIsReady, + loginPageIsReady, + favoritePageIsReady, + pageIsReady, } from './nhentai-util'; const waitInterval = 2000; @injectable() -export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowInterface { - public constructor( - @inject('logger') logger: LoggerInterface, - @inject('session-helper') sessionHelper: SessionHelperInterface - ) { - super(logger, sessionHelper, nhentaiUrl); +export class NhentaiAppWindow extends CloudflareSiteAppWindow implements NhentaiAppWindowInterface { + protected readyCheck = pageIsReady; + + public constructor(@inject('logger') logger: LoggerInterface) { + super(logger, nhentaiUrl); } public async getFavorites(): Promise { @@ -95,9 +97,6 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI if (this.isClosed()) { await this.open(); } - if (!this._window) { - throw new WindowClosedError(); - } const gallery: Nhentai.Gallery = { title: { @@ -116,7 +115,7 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI const bookUrl = getBookUrl(identifier); try { - await this.loadUrlSafe(bookUrl); + await this.loadGalleryPageSafe(bookUrl); await Promise.all([ this.getInnerHtml(preTitleSelector).then((preTitle) => { gallery.title.pre = preTitle.trim(); @@ -155,24 +154,24 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI } protected getCsp(): Session.ContentSecurityPolicy { - return { + return mergeContentSecurityPolicy(super.getCsp(), { 'default-src': ['nhentai.net'], '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': [ '*.nhentai.net', + 'nhentai.net', "'unsafe-inline'", 'fonts.googleapis.com', - 'cdnjs.cloudflare.com', 'maxcdn.bootstrapcdn.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'], 'frame-src': ['*.google.com'], 'connect-src': ['nhentai.net', '*.google.com'], 'worker-src': ['*.google.com'], - }; + }); } protected onWillNavigate(event: Electron.Event, navigationUrl: string): void { @@ -184,11 +183,8 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI } private async isLoggedIn(): Promise { - if (!this._window) { - throw new WindowClosedError(); - } - return this._window.webContents - .executeJavaScript( + return this.getWindow() + .webContents.executeJavaScript( `fetch('${ nhentaiUrl + nhentaiPaths.favorites }', {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); } - /** - * @throws WindowClosedError when this._window is null - */ private async login(): Promise { - if (!this._window) { - throw new WindowClosedError(); - } - await this.loadUrlSafe(nhentaiUrl + nhentaiPaths.login); + await this.loadLoginPageSafe(); return new Promise((resolve, reject) => { const timeout = setInterval(() => { @@ -220,20 +210,17 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI } private async *getFavoritePageWebContentsGenerator(): AsyncGenerator { - if (!this._window) { - throw new WindowClosedError(); - } - await this.loadUrlSafe(getFavoritePageUrl()); + await this.loadFavoritesPageSafe(getFavoritePageUrl()); while (true) { - yield this._window.webContents; - const hasNextPage = (await this._window.webContents.executeJavaScript( + yield this.getWindow().webContents; + const hasNextPage = (await this.getWindow().webContents.executeJavaScript( `!!document.querySelector('${nextFavoritePageSelector}')` )) as boolean; if (hasNextPage) { - const nextPageHref = (await this._window.webContents.executeJavaScript( + const nextPageHref = (await this.getWindow().webContents.executeJavaScript( `document.querySelector('${nextFavoritePageSelector}').href` )) as string; - await this.loadUrlSafe(nextPageHref); + await this.loadFavoritesPageSafe(nextPageHref); } else { break; } @@ -242,14 +229,11 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI } private async getBookTorrent(bookUrl: string): Promise { - if (!this._window) { - throw new WindowClosedError(); - } const galleryId = getGalleryId(bookUrl); const fileName = `${galleryId}.torrent`; const filePath = path.resolve(os.tmpdir(), fileName); - await this.loadUrlSafe(bookUrl); - const downloadLink: string = (await this._window.webContents.executeJavaScript( + await this.loadGalleryPageSafe(bookUrl); + const downloadLink: string = (await this.getWindow().webContents.executeJavaScript( `document.getElementById('${downloadLinkId}').href` )) as string; await this.downloadUrlSafe(downloadLink, filePath); @@ -266,11 +250,20 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI }; } + private loadGalleryPageSafe(url: string): Promise { + return this.loadUrlSafe(url, galleryPageIsReady); + } + + private loadLoginPageSafe(): Promise { + return this.loadUrlSafe(nhentaiUrl + nhentaiPaths.login, loginPageIsReady); + } + + private loadFavoritesPageSafe(url: string): Promise { + return this.loadUrlSafe(url, favoritePageIsReady); + } + private getTags(tagLabel: string): Promise { - if (!this._window) { - throw new WindowClosedError(); - } - return this._window.webContents.executeJavaScript( + return this.getWindow().webContents.executeJavaScript( `Array.from( document.querySelectorAll('${labeledTagContainerSelector}') ).filter( diff --git a/src/main/modules/nhentai/nhentai-util.ts b/src/main/modules/nhentai/nhentai-util.ts index 77bc5da..e875395 100644 --- a/src/main/modules/nhentai/nhentai-util.ts +++ b/src/main/modules/nhentai/nhentai-util.ts @@ -1,3 +1,5 @@ +import type { WebContents } from 'electron'; + export const hostname = 'nhentai.net'; export const url = `https://${hostname}/`; @@ -24,6 +26,22 @@ export const tagLabelTags = 'Tags'; export const tagLabelArtists = 'Artists'; export const tagLabelGroups = 'Groups'; +export function pageIsReady(webContents: WebContents): Promise { + return webContents.executeJavaScript(`!!document.getElementById('content')`) as Promise; +} + +export function galleryPageIsReady(webContents: WebContents): Promise { + return webContents.executeJavaScript(`!!document.getElementById('${downloadLinkId}')`) as Promise; +} + +export function favoritePageIsReady(webContents: WebContents): Promise { + return webContents.executeJavaScript(`!!document.getElementById('favorites-random-button')`) as Promise; +} + +export function loginPageIsReady(webContents: WebContents): Promise { + return webContents.executeJavaScript(`!!document.getElementById('id_username_or_email')`) as Promise; +} + export function getFavoritePageUrl(page?: number): string { return `${url + paths.favorites}${page ? `?page=${page}` : ''}`; } diff --git a/src/main/modules/session/session-helper-interface.d.ts b/src/main/modules/session/session-helper-interface.d.ts deleted file mode 100644 index a5abdca..0000000 --- a/src/main/modules/session/session-helper-interface.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { BrowserWindow } from 'electron'; - -interface SessionHelperInterface { - setCsp(window: BrowserWindow, csp: Session.ContentSecurityPolicy): void; -} diff --git a/src/main/modules/session/session-helper.ts b/src/main/modules/session/session-helper.ts deleted file mode 100644 index 59ce6c7..0000000 --- a/src/main/modules/session/session-helper.ts +++ /dev/null @@ -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), - }, - }); - }); - } -} diff --git a/src/main/modules/session/session-util.ts b/src/main/modules/session/session-util.ts new file mode 100644 index 0000000..b6768d9 --- /dev/null +++ b/src/main/modules/session/session-util.ts @@ -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, + }; +} diff --git a/src/main/modules/session/session.d.ts b/src/main/modules/session/session.d.ts index e58d7f4..1f92f0d 100644 --- a/src/main/modules/session/session.d.ts +++ b/src/main/modules/session/session.d.ts @@ -15,11 +15,29 @@ declare namespace Session { '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[]; }; + + /** + * 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[]; + }; } diff --git a/src/shared/types/milliseconds.d.ts b/src/shared/types/milliseconds.d.ts index 794740d..1a99bc3 100644 --- a/src/shared/types/milliseconds.d.ts +++ b/src/shared/types/milliseconds.d.ts @@ -1,4 +1,6 @@ declare const enum Milliseconds { + TEN = 10, + TWO_HUNDRED = 200, ONE_SECOND = 1000,