fix: fuck cloudflare
This commit is contained in:
parent
523bc7e75e
commit
31945cac08
|
@ -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);
|
||||
|
|
|
@ -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<void>;
|
||||
|
||||
close(force?: boolean): void;
|
||||
|
||||
isClosed(): boolean;
|
||||
|
||||
askForUserInteraction(): void;
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
||||
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<string> {
|
||||
return new Promise<string>((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);
|
||||
})
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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<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>;
|
||||
}
|
||||
|
|
|
@ -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<boolean>;
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
return new Promise<void>((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<void> {
|
||||
public async loadUrlSafe(
|
||||
url: string,
|
||||
readyCheck?: (webContents: WebContents) => Promise<boolean>,
|
||||
options?: LoadURLOptions
|
||||
): Promise<void> {
|
||||
return this.loadWait.then(async () => {
|
||||
let failedLoad = true;
|
||||
while (failedLoad) {
|
||||
await new Promise<void>((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<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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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<NodeJS.ReadableStream> {
|
||||
|
@ -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<boolean> {
|
||||
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<void> {
|
||||
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<WebContents, undefined> {
|
||||
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<Nhentai.Favorite> {
|
||||
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<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[]> {
|
||||
if (!this._window) {
|
||||
throw new WindowClosedError();
|
||||
}
|
||||
return this._window.webContents.executeJavaScript(
|
||||
return this.getWindow().webContents.executeJavaScript(
|
||||
`Array.from(
|
||||
document.querySelectorAll('${labeledTagContainerSelector}')
|
||||
).filter(
|
||||
|
|
|
@ -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<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 {
|
||||
return `${url + paths.favorites}${page ? `?page=${page}` : ''}`;
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
|
||||
interface SessionHelperInterface {
|
||||
setCsp(window: BrowserWindow, csp: Session.ContentSecurityPolicy): void;
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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[];
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
declare const enum Milliseconds {
|
||||
TEN = 10,
|
||||
|
||||
TWO_HUNDRED = 200,
|
||||
|
||||
ONE_SECOND = 1000,
|
||||
|
|
Loading…
Reference in New Issue