Working authentication for funimation

This commit is contained in:
Izuco 2022-01-26 22:14:55 +01:00
parent 0d5d0f0102
commit 75888c23a5
No known key found for this signature in database
GPG key ID: E9CBE9E4EF3A1BFA
18 changed files with 1681 additions and 18572 deletions

View file

@ -4,6 +4,7 @@ import type { AvailableMuxer } from '../modules/module.args';
export interface MessageHandler {
auth: (data: AuthData) => Promise<AuthResponse>;
checkToken: () => Promise<CheckTokenResponse>;
}
export type FuniEpisodeData = {
@ -25,6 +26,7 @@ export type AuthResponse = ResponseBase<undefined>;
export type FuniSearchReponse = ResponseBase<FunimationSearch>;
export type FuniShowResponse = ResponseBase<FuniEpisodeData[]>;
export type FuniGetEpisodeResponse = ResponseBase<undefined>;
export type CheckTokenResponse = ResponseBase<undefined>;
export type ResponseBase<T> = ({
isOk: true,

View file

@ -37,7 +37,7 @@ import { FunimationMediaDownload } from './@types/funiTypes';
import * as langsData from './modules/module.langsData';
import { TitleElement } from './@types/episode';
import { AvailableFilenameVars } from './modules/module.args';
import { AuthData, AuthResponse, FuniGetEpisodeData, FuniGetEpisodeResponse, FuniGetShowData, FuniSearchData, FuniSearchReponse, FuniShowResponse, FuniStreamData, FuniSubsData } from './@types/messageHandler';
import { AuthData, AuthResponse, CheckTokenResponse, FuniGetEpisodeData, FuniGetEpisodeResponse, FuniGetShowData, FuniSearchData, FuniSearchReponse, FuniShowResponse, FuniStreamData, FuniSubsData } from './@types/messageHandler';
// check page
// fn variables
@ -59,6 +59,11 @@ export default class Funi {
this.token = yamlCfg.loadFuniToken();
}
public checkToken(): CheckTokenResponse {
const isOk = typeof this.token === 'string';
return isOk ? { isOk, value: undefined } : { isOk, reason: new Error('Not authenticated') };
}
public async init() {
this.cfg.bin = await yamlCfg.loadBinCfg();
}
@ -141,6 +146,7 @@ export default class Funi {
if(resJSON.token){
console.log('[INFO] Authentication success, your token: %s%s\n', resJSON.token.slice(0,8),'*'.repeat(32));
yamlCfg.saveFuniToken({'token': resJSON.token});
this.token = resJSON.token;
return { isOk: true, value: undefined };
} else {
console.log('[ERROR]%s\n', ' No token found');

1
gui/electron/src/.env Normal file
View file

@ -0,0 +1 @@
USE_BROWSER=true

View file

@ -3,13 +3,15 @@ import path from 'path/posix';
import json from '../../../package.json';
import registerMessageHandler from './messageHandler';
import fs from "fs";
import dotenv from "dotenv";
if (fs.existsSync(path.join(__dirname, '.env')))
dotenv.config({ path: path.join(__dirname, '.env'), debug: true });
if (require('electron-squirrel-startup')) {
app.quit();
}
console.log(process.argv, process.env);
const createWindow = (): void => {
registerMessageHandler();
// Create the browser window.
@ -25,7 +27,7 @@ const createWindow = (): void => {
const htmlFile = path.join(__dirname, '..', 'build', 'index.html');
if (fs.existsSync(htmlFile)) {
if (!process.env.USE_BROWSER) {
mainWindow.loadFile(htmlFile);
} else {
mainWindow.loadURL('http://localhost:3000');

View file

@ -5,7 +5,7 @@ import Funimation from './serviceHandler/funimation';
export default () => {
let handler: MessageHandler|undefined;
ipcMain.handle('setup', (ev, data) => {
ipcMain.handle('setup', (_, data) => {
if (data === 'funi') {
handler = new Funimation();
} else if (data === 'crunchy') {
@ -13,5 +13,6 @@ export default () => {
}
});
ipcMain.handle('auth', async (ev, data) => handler?.auth(data));
ipcMain.handle('auth', async (_, data) => handler?.auth(data));
ipcMain.handle('checkToken', async () => handler?.checkToken());
}

View file

@ -1,4 +1,4 @@
import { AuthData, AuthResponse, MessageHandler } from "../../../../@types/messageHandler";
import { AuthData, AuthResponse, CheckTokenResponse, MessageHandler } from "../../../../@types/messageHandler";
import Funimation from '../../../../funi';
@ -8,6 +8,10 @@ class FunimationHandler implements MessageHandler {
this.funi = new Funimation();
}
public async checkToken(): Promise<CheckTokenResponse> {
return this.funi.checkToken();
}
public auth(data: AuthData) {
return this.funi.auth(data);
}

View file

@ -10,6 +10,7 @@
"dependencies": {
"@emotion/react": "^11.7.1",
"@emotion/styled": "^11.6.0",
"@mui/icons-material": "^5.3.1",
"@mui/material": "^5.3.1",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
@ -19,6 +20,7 @@
"@types/node": "^16.11.21",
"@types/react": "^17.0.38",
"@types/react-dom": "^17.0.11",
"notistack": "^2.0.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "5.0.0",
@ -2849,6 +2851,31 @@
}
}
},
"node_modules/@mui/icons-material": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.3.1.tgz",
"integrity": "sha512-8zBWCaE8DHjIGZhGgMod92p6Rm38EhXrS+cZtaV0+jOTMeWh7z+mvswXzb/rVKc0ZYqw6mQYBcn2uEs2yclI9w==",
"dependencies": {
"@babel/runtime": "^7.16.7"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui"
},
"peerDependencies": {
"@mui/material": "^5.0.0",
"@types/react": "^16.8.6 || ^17.0.0",
"react": "^17.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.3.1.tgz",
@ -11880,6 +11907,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/notistack": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/notistack/-/notistack-2.0.3.tgz",
"integrity": "sha512-krmVFtTO9kEY1Pa4NrbyexrjiRcV6TqBM2xLx8nuDea1g96Z/OZfkvVLmYKkTvoSJ3jyQntWK16z86ssW5kt4A==",
"dependencies": {
"clsx": "^1.1.0",
"hoist-non-react-statics": "^3.3.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/notistack"
},
"peerDependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@mui/material": "^5.0.0",
"react": "^16.8.0 || ^17.0.0",
"react-dom": "^16.8.0 || ^17.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/npm-conf": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz",
@ -18894,6 +18949,14 @@
"react-is": "^17.0.2"
}
},
"@mui/icons-material": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.3.1.tgz",
"integrity": "sha512-8zBWCaE8DHjIGZhGgMod92p6Rm38EhXrS+cZtaV0+jOTMeWh7z+mvswXzb/rVKc0ZYqw6mQYBcn2uEs2yclI9w==",
"requires": {
"@babel/runtime": "^7.16.7"
}
},
"@mui/material": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.3.1.tgz",
@ -25470,6 +25533,15 @@
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="
},
"notistack": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/notistack/-/notistack-2.0.3.tgz",
"integrity": "sha512-krmVFtTO9kEY1Pa4NrbyexrjiRcV6TqBM2xLx8nuDea1g96Z/OZfkvVLmYKkTvoSJ3jyQntWK16z86ssW5kt4A==",
"requires": {
"clsx": "^1.1.0",
"hoist-non-react-statics": "^3.3.0"
}
},
"npm-conf": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz",

View file

@ -5,6 +5,7 @@
"dependencies": {
"@emotion/react": "^11.7.1",
"@emotion/styled": "^11.6.0",
"@mui/icons-material": "^5.3.1",
"@mui/material": "^5.3.1",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
@ -14,6 +15,7 @@
"@types/node": "^16.11.21",
"@types/react": "^17.0.38",
"@types/react-dom": "^17.0.11",
"notistack": "^2.0.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "5.0.0",

View file

@ -1,21 +1,11 @@
import React from 'react';
import { Button, TextField, Box } from '@mui/material';
import { messageChannelContext } from './provider/MessageChannel';
import Layout from './Layout';
function App() {
const channel = React.useContext(messageChannelContext);
const [data, setData] = React.useState<{username: string|undefined, password: string|undefined}>({ username: undefined, password: undefined });
const App: React.FC = () => {
return (
<Box>
<TextField variant='outlined' value={data.username ?? ''} onChange={(e) => setData({ password: data.password, username: e.target.value })} />
<TextField variant='outlined' value={data.password ?? ''} onChange={(e) => setData({ password: e.target.value, username: data.username })} />
<Button variant='contained' size='large' onClick={async () => {
console.log(await channel?.auth({ username: data.username ?? '', password: data.password ?? ''}));
}}>
Test auth
</Button>
</Box>
<Layout />
);
}

13
gui/react/src/Layout.tsx Normal file
View file

@ -0,0 +1,13 @@
import React from "react";
import AuthButton from "./components/AuthButton";
import { Box } from "@mui/material";
const Layout: React.FC = () => {
return <Box>
<Box sx={{ height: 50, mb: 4 }}>
<AuthButton />
</Box>
</Box>;
}
export default Layout;

View file

@ -0,0 +1,114 @@
import { Avatar, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, List, ListItem, ListItemAvatar, TextField } from "@mui/material";
import { grey } from '@mui/material/colors'
import { Check, Close, CloseOutlined, PortraitOutlined } from '@mui/icons-material'
import React from "react";
import { messageChannelContext } from "../provider/MessageChannel";
import Require from "./Require";
import { useSnackbar } from "notistack";
const AuthButton: React.FC = () => {
const snackbar = useSnackbar();
const [open, setOpen] = React.useState(false);
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [usernameError, setUsernameError] = React.useState(false);
const [passwordError, setPasswordError] = React.useState(false);
const messageChannel = React.useContext(messageChannelContext);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<Error|undefined>(undefined);
const [authed, setAuthed] = React.useState(false);
const checkAuth = async () => {
console.log(await messageChannel?.checkToken());
setAuthed((await messageChannel?.checkToken())?.isOk ?? false);
}
React.useEffect(() => { checkAuth(); return () => {}; }, []);
const handleSubmit = async () => {
if (!messageChannel)
throw new Error('Invalid state'); //The components to confirm only render if the messageChannel is not undefinded
if (username.trim().length === 0)
return setUsernameError(true);
if (password.trim().length === 0)
return setPasswordError(true);
setUsernameError(false);
setPasswordError(false);
setLoading(true);
const res = await messageChannel.auth({ username, password });
if (res.isOk) {
setOpen(false);
snackbar.enqueueSnackbar('Logged in', {
variant: 'success'
});
setUsername('');
setPassword('');
} else {
setError(res.reason);
}
await checkAuth();
setLoading(false);
}
return <Require value={messageChannel}>
<Dialog open={open}>
<Dialog open={!!error}>
<DialogTitle>Error during Authentication</DialogTitle>
<DialogContentText>
{error?.name}
{error?.message}
</DialogContentText>
<DialogActions>
<Button onClick={() => setError(undefined)}>Close</Button>
</DialogActions>
</Dialog>
<DialogTitle sx={{ flexGrow: 1 }}>Authentication</DialogTitle>
<DialogContent>
<DialogContentText>
Here, you need to enter your username (most likely your Email) and your password.<br />
These information are not stored anywhere and are only used to authenticate with the service once.
</DialogContentText>
<TextField
error={usernameError}
helperText={usernameError ? 'Please enter something before submiting' : undefined}
margin="dense"
id="username"
label="Username"
type="text"
fullWidth
variant="standard"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
/>
<TextField
error={passwordError}
helperText={passwordError ? 'Please enter something before submiting' : undefined}
margin="dense"
id="password"
label="Password"
type="password"
fullWidth
variant="standard"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
</DialogContent>
<DialogActions>
{loading && <CircularProgress size={30}/>}
<Button disabled={loading} onClick={() => setOpen(false)}>Close</Button>
<Button disabled={loading} onClick={() => handleSubmit()}>Authenticate</Button>
</DialogActions>
</Dialog>
<Button startIcon={authed ? <Check />: <Close />} variant="contained" onClick={() => setOpen(true)}>Authenticate</Button>
</Require>
}
export default AuthButton;

View file

@ -0,0 +1,14 @@
import React from "react";
import { Box, Backdrop, CircularProgress } from "@mui/material";
export type RequireType<T> = {
value?: T
}
const Require = <T, >(props: React.PropsWithChildren<RequireType<T>>) => {
return props.value === undefined ? <Backdrop open>
<CircularProgress />
</Backdrop> : <Box>{props.children}</Box>
}
export default Require;

View file

@ -4,16 +4,34 @@ import App from './App';
import ServiceProvider from './provider/ServiceProvider';
import Style from './Style';
import MessageChannel from './provider/MessageChannel';
import { IconButton } from "@mui/material";
import { CloseOutlined } from "@mui/icons-material";
import { SnackbarProvider, SnackbarKey } from 'notistack';
const notistackRef = React.createRef<SnackbarProvider>();
const onClickDismiss = (key: SnackbarKey | undefined) => () => {
if (notistackRef.current)
notistackRef.current.closeSnackbar(key);
};
ReactDOM.render(
<React.StrictMode>
<Style>
<ServiceProvider>
<MessageChannel>
<App />
</MessageChannel>
</ServiceProvider>
</Style>
<SnackbarProvider
ref={notistackRef}
action={(key) => (
<IconButton onClick={onClickDismiss(key)} color="inherit">
<CloseOutlined />
</IconButton>
)}
>
<Style>
<ServiceProvider>
<MessageChannel>
<App />
</MessageChannel>
</ServiceProvider>
</Style>
</SnackbarProvider>
</React.StrictMode>,
document.getElementById('root')
);

View file

@ -16,7 +16,8 @@ const MessageChannelProvider: React.FC = ({ children }) => {
}, [service])
const messageHandler: MessageHandler = {
auth: async (data) => await ipcRenderer.invoke('auth', data)
auth: async (data) => await ipcRenderer.invoke('auth', data),
checkToken: async () => await ipcRenderer.invoke('checkToken')
}
return <messageChannelContext.Provider value={messageHandler}>

View file

@ -18,7 +18,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"downlevelIteration": true
},
"include": [
"./src"

19907
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@
"main": "gui/electron/src/index.js",
"dependencies": {
"cheerio": "^1.0.0-rc.10",
"dotenv": "^14.3.2",
"electron-squirrel-startup": "^1.0.0",
"eslint-plugin-import": "^2.25.4",
"form-data": "^4.0.0",
@ -68,7 +69,7 @@
"typescript": "^4.5.5"
},
"scripts": {
"prestart": "npm run tsc",
"prestart": "npm run tsc test",
"start": "cd lib && electron-forge start",
"docs": "ts-node modules/build-docs.ts",
"tsc": "ts-node tsc.ts",

44
tsc.ts
View file

@ -3,22 +3,30 @@ import fs from 'fs';
import path from 'path';
import { removeSync, copyFileSync } from 'fs-extra';
const argv = process.argv.slice(2);
let buildIgnore: string[] = [];
if (argv.length > 0 && argv[0] !== 'test')
buildIgnore = [
'*/\\.env'
];
const ignore = [
'*SEP\\.git*',
'*SEPlib*',
'*SEPnode_modules*',
'*SEP@types*',
'*SEPout*',
'*SEPbinSEPmkvtoolnix*',
'*SEPtoken.yml$',
'*SEPupdates.json$',
'*SEPcr_token.yml$',
'*SEPfuni_token.yml$',
'*SEP\\.eslint*',
'*SEP*\\.tsx?$',
'SEP*fonts',
'SEPreact*',
].map(a => a.replace(/\*/g, '[^]*').replace(/SEP/g, path.sep === '\\' ? '\\\\' : '/')).map(a => new RegExp(a, 'i'));
...buildIgnore,
'*/\\.git*',
'./lib*',
'*/@types*',
'./out*',
'./bin/mkvtoolnix*',
'./config/token.yml$',
'./config/updates.json$',
'./config/cr_token.yml$',
'./config/funi_token.yml$',
'*/\\.eslint*',
'*/*\\.tsx?$',
'./fonts*',
'./gui/react*',
].map(a => a.replace(/\*/g, '[^]*').replace(/\.\//g, escapeRegExp(__dirname) + '/').replace(/\//g, path.sep === '\\' ? '\\\\' : '/')).map(a => new RegExp(a, 'i'));
export { ignore };
@ -46,10 +54,10 @@ export { ignore };
});
await waitForProcess(react);
process.stdout.write('✓\nCopying files... ');
copyDir(path.join(__dirname, 'gui', 'react', 'build'), path.join(__dirname, 'lib', 'gui', 'electron', 'build'));
process.stdout.write('✓\nCopying files... ');
const files = readDir(__dirname);
files.forEach(item => {
const itemPath = path.join(__dirname, 'lib', item.path.replace(__dirname, ''));
@ -102,4 +110,8 @@ async function copyDir(src: string, dest: string) {
await copyDir(srcPath, destPath) :
await fs.promises.copyFile(srcPath, destPath);
}
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}