diff --git a/.eslintignore b/.eslintignore index 29e1718..193c8a3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ +node_modules dist frontend diff --git a/.eslintrc.json b/.eslintrc.json index cf18507..0782ca6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,5 +13,13 @@ "no-shadow": "warn", "no-magic-numbers": ["warn", { "ignore": [0, 1, -1] }], "no-param-reassign": "warn" - } + }, + "overrides": [ + { + "files": ["src/renderer/**/*.*"], + "parserOptions": { + "sourceType": "module" + } + } + ] } diff --git a/src/main.ts b/src/main.ts index cc6d64f..268d271 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,22 +1,41 @@ import { app, BrowserWindow } from 'electron'; +import os from 'os'; import './main/controllers/api'; import './main/services/database'; -import session from './main/services/session'; +import * as session from './main/services/session'; +import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions; let mainWindow: Electron.BrowserWindow; async function createWindow(): Promise { - session.init(); + session.setHeaders(); - // Create the browser window. - mainWindow = new BrowserWindow({ + // universal options + let options: BrowserWindowConstructorOptions = { width: 1600, height: 900, webPreferences: { nodeIntegration: true, }, - }); + }; + + // platform specifics + switch (os.platform()) { + case 'win32': + options = { + ...options, + ...{ + icon: 'resources/icon.ico', + }, + }; + break; + default: + break; + } + + // Create the browser window. + mainWindow = new BrowserWindow(options); // and load the index.html of the app. await mainWindow.loadFile('index.html'); diff --git a/src/main/controllers/api.ts b/src/main/controllers/api.ts index 729a633..856e68e 100644 --- a/src/main/controllers/api.ts +++ b/src/main/controllers/api.ts @@ -1,13 +1,32 @@ import { ipcMain } from 'electron'; -import nhentai from './../services/nhentai-crawler'; +import { isLoggedIn, login } from '../services/nhentai-crawler'; -ipcMain.on(IpcChannels.Credentials, (event: IpcEvent, args: ICredentials) => { - nhentai - .login(args.name, args.password) - .then(() => { - console.log('success'); - }) - .catch(() => { - console.log('fail'); +const ipcServer: IIpcServer = { + answer: (channel: IpcChannels, handler: (data?: any) => Promise): void => { + ipcMain.on(channel, (event: IpcEvent, payload: IIpcPayload) => { + handler(payload.data) + .then((result: any) => { + const response: IIpcResponse = { + success: true, + data: result, + }; + event.reply(channel, response); + }) + .catch((reason: any) => { + const response: IIpcResponse = { + success: false, + error: reason.toString(), + }; + event.reply(channel, response); + }); }); + }, +}; + +ipcServer.answer(IpcChannels.LOGIN, (credentials: ICredentials) => { + return login(credentials.name, credentials.password); +}); + +ipcServer.answer(IpcChannels.LOGGED_IN, () => { + return isLoggedIn(); }); diff --git a/src/main/services/database.ts b/src/main/services/database.ts index fe1b274..837e220 100644 --- a/src/main/services/database.ts +++ b/src/main/services/database.ts @@ -1,22 +1,21 @@ import 'reflect-metadata'; import { Connection, createConnection } from 'typeorm'; -export let library: Connection; - -function init(): void { - initConnection(); +export const enum Databases { + LIBRARY = 'library', } -function initConnection(): void { - // createConnection method will automatically read connection options - // from your ormconfig file or environment variables - createConnection('library') - .then((c: Connection) => { - library = c; - }) - .catch((reason: any) => { - throw reason; +const connections: { + [key in Databases]?: Connection; +} = {}; + +export function getConnection(database: Databases): Promise { + if (connections[database] === undefined) { + return createConnection(database).then((connection: Connection) => { + connections[database] = connection; + return connection; }); + } else { + return Promise.resolve(connections[database]); + } } - -init(); diff --git a/src/main/services/nhentai-crawler.ts b/src/main/services/nhentai-crawler.ts index d7f7871..f5d469f 100644 --- a/src/main/services/nhentai-crawler.ts +++ b/src/main/services/nhentai-crawler.ts @@ -1,9 +1,10 @@ import { JSDOM } from 'jsdom'; import { RequestInit, Response } from 'node-fetch'; -import RenaiError, { Errors } from '../../types/error'; -import fetch from './web-crawler'; +import { Errors, RenaiError } from '../../types/error'; +import { fetch } from './web-crawler'; -const url = 'https://nhentai.net/'; +const domain = 'nhentai.net'; +const url = `https://${domain}/`; const paths = { books: 'g/', @@ -25,33 +26,6 @@ interface ILoginAuth { interface ILoginParams extends ILoginMeta, ILoginAuth {} -function login(name: string, password: string): Promise { - return getLoginMeta() - .then((meta: ILoginMeta) => { - const loginParams: ILoginParams = { - ...meta, - ...{ - // tslint:disable-next-line: object-literal-sort-keys - username_or_email: name, - password, - }, - }; - - return postNHentai(paths.login, { - body: encodeURI( - Object.keys(loginParams) - .map((key: keyof ILoginParams) => `${key}=${loginParams[key]}`) - .join('&') - ), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); - }) - .then(() => {}) - .catch(() => Promise.reject(new RenaiError(Errors.ELOGINFAIL))); -} - function getNHentai(path: string): Promise { return fetch(`${url}${path}`) .then((res: Response) => { @@ -63,8 +37,21 @@ function getNHentai(path: string): Promise { }); } -function postNHentai(path: string, init: RequestInit = {}): Promise { - return fetch(`${url}${path}`, { ...init, ...{ method: 'post' } }); +function postNHentai(path: string, requestInit: RequestInit = {}): Promise { + const postUrl = `${url}${path}`; + return fetch(postUrl, { + ...requestInit, + ...{ + headers: { + ...requestInit.headers, + ...{ + Host: domain, + Referer: postUrl, + }, + }, + }, + method: 'post', + }); } function getLoginMeta(): Promise { @@ -93,6 +80,36 @@ function getLoginMeta(): Promise { }); } -export default { - login, -}; +export function isLoggedIn(): Promise { + return fetch(`${url}${paths.favorites}`, { redirect: 'manual' }).then((res: Response) => { + return res.status === HttpCode.OK; + }); +} + +export function login(name: string, password: string): Promise { + return getLoginMeta() + .then((meta: ILoginMeta) => { + const loginParams: ILoginParams = { + ...meta, + ...{ + // tslint:disable-next-line: object-literal-sort-keys + username_or_email: name, + password, + }, + }; + + return postNHentai(paths.login, { + body: encodeURI( + Object.keys(loginParams) + .map((key: keyof ILoginParams) => `${key}=${loginParams[key]}`) + .join('&') + ), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + redirect: 'manual', + }); + }) + .then(() => {}) + .catch(() => Promise.reject(new RenaiError(Errors.ELOGINFAIL))); +} diff --git a/src/main/services/session.ts b/src/main/services/session.ts index 3fc450c..d81e9f6 100644 --- a/src/main/services/session.ts +++ b/src/main/services/session.ts @@ -1,8 +1,8 @@ import { session } from 'electron'; import OnHeadersReceivedDetails = Electron.OnHeadersReceivedDetails; -function init(): void { - // these headers only work on webrequests, file:// protocol is handled via meta tags in index.html +export function setHeaders(): void { + // these headers only work on web requests, file:// protocol is handled via meta tags in index.html session.defaultSession.webRequest.onHeadersReceived( (details: OnHeadersReceivedDetails, callback: (response: {}) => void) => { callback({ @@ -14,5 +14,3 @@ function init(): void { } ); } - -export default { init }; diff --git a/src/main/services/web-crawler.ts b/src/main/services/web-crawler.ts index 1688df8..d09ab55 100644 --- a/src/main/services/web-crawler.ts +++ b/src/main/services/web-crawler.ts @@ -1,27 +1,55 @@ +import { CookieJar } from 'jsdom'; import nodeFetch, { RequestInit, Response } from 'node-fetch'; -import { Cookie, CookieJar } from 'tough-cookie'; +import { Errors, RenaiError } from '../../types/error'; +import { load, save, StoreKeys } from './store'; -const cookieJar: CookieJar = new CookieJar(); +export let cookieJar: CookieJar = new CookieJar(); -function fetch(url: string, init: RequestInit = {}): Promise { - const headers: HeadersInit = {}; - cookieJar.getCookiesSync(url).forEach((cookie: Cookie) => { - headers[cookie.key] = cookie.value; - }); +let error: Error; + +function init(): void { + load(StoreKeys.COOKIES) + .then((cookies: any) => { + if (cookies !== undefined) { + cookieJar = CookieJar.deserializeSync(cookies); + } + }) + .catch((reason: any) => { + error = new RenaiError(Errors.EINITFAIL, reason); + }); +} + +export function fetch(url: string, requestInit: RequestInit = {}): Promise { + if (error !== undefined) { + return Promise.reject(error); + } const cookiedInit = { - ...init, - ...{ headers: { ...init.headers, ...headers } }, + ...requestInit, + ...{ + headers: { + ...requestInit.headers, + ...{ + Cookie: cookieJar.getCookieStringSync(url), + }, + }, + }, }; return nodeFetch(url, cookiedInit).then((res: Response) => { - setCookies(res.headers.raw()['set-cookie'], url); + setCookies(res.headers.raw()['set-cookie'], url).catch((reason: any) => { + error = new Error(reason); + }); return res; }); } -function setCookies(header: string[], url: string): void { - header.forEach((cookie: string) => { - cookieJar.setCookieSync(cookie, url); - }); +function setCookies(header: string[], url: string): Promise { + if (header) { + header.forEach((cookie: string) => { + cookieJar.setCookieSync(cookie, url); + }); + return save(StoreKeys.COOKIES, cookieJar.serializeSync()); + } + return Promise.resolve(); } -export default fetch; +init(); diff --git a/src/renderer/App.svelte b/src/renderer/App.svelte index 89f482c..675daa3 100644 --- a/src/renderer/App.svelte +++ b/src/renderer/App.svelte @@ -1,16 +1,7 @@ +{Divide,NhentaiLogin;} + + diff --git a/src/renderer/services/api.ts b/src/renderer/services/api.ts index 626de74..47f6f29 100644 --- a/src/renderer/services/api.ts +++ b/src/renderer/services/api.ts @@ -1,9 +1,31 @@ import { ipcRenderer } from 'electron'; +import IpcMessageEvent = Electron.IpcMessageEvent; -function sendCredentials(credentials: ICredentials): void { - ipcRenderer.send(IpcChannels.Credentials, credentials); +const ipcClient: IIpcClient = { + ask: (channel: IpcChannels, data?: any): Promise => { + const payload: IIpcPayload = { + data, + }; + + return new Promise((resolve: (value?: any) => void, reject: (reason?: any) => void): void => { + const listener = (event: IpcMessageEvent, response: IIpcResponse): void => { + if (response.success) { + resolve(response.data); + } else { + reject(response.error); + } + ipcRenderer.removeListener(channel, listener); + }; + ipcRenderer.send(channel, payload); + ipcRenderer.on(channel, listener); + }); + }, +}; + +export function login(credentials: ICredentials): Promise { + return ipcClient.ask(IpcChannels.LOGIN, credentials); } -export default { - sendCredentials, -}; +export function isLoggedIn(): Promise { + return ipcClient.ask(IpcChannels.LOGGED_IN); +} diff --git a/src/renderer/services/store.ts b/src/renderer/services/store.ts new file mode 100644 index 0000000..4d40554 --- /dev/null +++ b/src/renderer/services/store.ts @@ -0,0 +1,18 @@ +import { writable } from 'svelte/store'; +import * as api from './api'; + +const { subscribe, set } = writable(false); + +export const loggedIn = { + subscribe, + fetchIsLoggedIn(): Promise { + return api.isLoggedIn().then((isLoggedIn: boolean) => { + set(isLoggedIn); + }); + }, + fetchLogin(credentials: ICredentials): Promise { + return api.login(credentials).then(() => { + return this.fetchIsLoggedIn(); + }); + }, +}; diff --git a/src/renderer/services/utils.ts b/src/renderer/services/utils.ts index 390efae..f1d9e05 100644 --- a/src/renderer/services/utils.ts +++ b/src/renderer/services/utils.ts @@ -11,3 +11,8 @@ export function s(styles: object): string { .map((key: keyof object) => `${key}:${styles[key]}`) .join(';'); } + +export function t(text: string): string { + // If you want to implement frontend translation, begin here. + return text; +} diff --git a/src/types/error.ts b/src/types/error.ts index 3510485..a57f6f5 100644 --- a/src/types/error.ts +++ b/src/types/error.ts @@ -2,16 +2,18 @@ export const enum Errors { ERROR = 'ERROR', ENOLOGIN = 'ENOLOGIN', ELOGINFAIL = 'ELOGINFAIL', + EINITFAIL = 'EINITFAIL', } const messages = { - [Errors.ERROR]: 'error', + [Errors.ERROR]: 'generic error', [Errors.ENOLOGIN]: 'no login form found', [Errors.ELOGINFAIL]: 'login failed', + [Errors.EINITFAIL]: 'initialization failed', }; -export default class RenaiError extends Error { +export class RenaiError extends Error { constructor(eno: Errors = Errors.ERROR, msg: string = '') { - super(`Error ${eno}: ${messages[eno]}.${msg ? ` ${msg}` : ''}`); + super(`${messages[eno]}.${msg ? ` ${msg}` : ''}`); } } diff --git a/src/types/http.ts b/src/types/http.ts new file mode 100644 index 0000000..af9206f --- /dev/null +++ b/src/types/http.ts @@ -0,0 +1,65 @@ +const enum HttpCode { + // 100 + 'CONTINUE' = 100, + 'SWITCHING_PROTOCOLS' = 101, + 'PROCESSING' = 102, + + // 200 + 'OK' = 200, + 'CREATED' = 201, + 'ACCEPTED' = 202, + 'NON_AUTHORITATIVE_INFORMATION' = 203, + 'NO_CONTENT' = 204, + 'RESET_CONTENT' = 205, + 'PARTIAL_CONTENT' = 206, + 'MULTI_STATUS' = 207, + + // 300 + 'MULTIPLE_CHOICES' = 300, + 'MOVED_PERMANENTLY' = 301, + 'MOVED_TEMPORARILY' = 302, + 'SEE_OTHER' = 303, + 'NOT_MODIFIED' = 304, + 'USE_PROXY' = 305, + 'TEMPORARY_REDIRECT' = 307, + 'PERMANENT_REDIRECT' = 308, + + // 400 + 'BAD_REQUEST' = 400, + 'UNAUTHORIZED' = 401, + 'PAYMENT_REQUIRED' = 402, + 'FORBIDDEN' = 403, + 'NOT_FOUND' = 404, + 'METHOD_NOT_ALLOWED' = 405, + 'NOT_ACCEPTABLE' = 406, + 'PROXY_AUTHENTICATION_REQUIRED' = 407, + 'REQUEST_TIMEOUT' = 408, + 'CONFLICT' = 409, + 'GONE' = 410, + 'LENGTH_REQUIRED' = 411, + 'PRECONDITION_FAILED' = 412, + 'REQUEST_TOO_LONG' = 413, + 'REQUEST_URI_TOO_LONG' = 414, + 'UNSUPPORTED_MEDIA_TYPE' = 415, + 'REQUESTED_RANGE_NOT_SATISFIABLE' = 416, + 'EXPECTATION_FAILED' = 417, + 'IM_A_TEAPOT' = 418, + 'INSUFFICIENT_SPACE_ON_RESOURCE' = 419, + 'METHOD_FAILURE' = 420, // nice + 'UNPROCESSABLE_ENTITY' = 422, + 'LOCKED' = 423, + 'FAILED_DEPENDENCY' = 424, + 'PRECONDITION_REQUIRED' = 428, + 'TOO_MANY_REQUESTS' = 429, + 'REQUEST_HEADER_FIELDS_TOO_LARGE' = 431, + + // 500 + 'INTERNAL_SERVER_ERROR' = 500, + 'NOT_IMPLEMENTED' = 501, + 'BAD_GATEWAY' = 502, + 'SERVICE_UNAVAILABLE' = 503, + 'GATEWAY_TIMEOUT' = 504, + 'HTTP_VERSION_NOT_SUPPORTED' = 505, + 'INSUFFICIENT_STORAGE' = 507, + 'NETWORK_AUTHENTICATION_REQUIRED' = 511, +} diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 8920e22..9e79782 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -1,8 +1,27 @@ const enum IpcChannels { - Credentials = 'CREDENTIALS', + LOGIN = 'LOGIN', + LOGGED_IN = 'LOGGED_IN', +} + +interface IIpcPayload { + data: any; +} + +interface IIpcResponse { + success: boolean; + data?: any; + error?: any; } interface ICredentials { name: string; password: string; } + +interface IIpcClient { + ask: (channel: IpcChannels, data?: any) => Promise; +} + +interface IIpcServer { + answer: (channel: IpcChannels, handler: (data?: any) => Promise) => void; +} diff --git a/tslint.json b/tslint.json index 06304da..579d7bb 100644 --- a/tslint.json +++ b/tslint.json @@ -25,7 +25,8 @@ "no-empty": [true, "allow-empty-functions"], "no-magic-numbers": true, "no-parameter-reassignment": true, - "arrow-return-shorthand": true + "arrow-return-shorthand": true, + "no-default-export": true }, "jsRules": true } diff --git a/webpack.config.js b/webpack.config.js index 3aaeabe..1c3d906 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -27,10 +27,15 @@ module.exports = { }, ], }, + { + test: /\.mjs$/, + include: /node_modules/, + type: 'javascript/auto', + }, ], }, resolve: { - extensions: ['.js', '.ts'], + extensions: ['.js', '.ts', '.mjs'], alias: { atoms: path.resolve(__dirname, 'src/renderer/components/1-atoms'), molecules: path.resolve(__dirname, 'src/renderer/components/2-molecules'),