Setup + Passwordless

This commit is contained in:
AnidlSupport 2023-02-28 17:23:42 +01:00
parent d872bc1fdb
commit 50d48ca7cd
10 changed files with 230 additions and 28 deletions

6
@types/ws.d.ts vendored
View file

@ -1,3 +1,4 @@
import { GUIConfig } from "../modules/module.cfg-loader"
import { AuthResponse, CheckTokenResponse, EpisodeListResponse, FolderTypes, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from "./messageHandler"
export type WSMessage<T extends keyof MessageTypes, P extends 0|1 = 0> = {
@ -32,5 +33,8 @@ export type MessageTypes = {
'type': [undefined, 'funi'|'crunchy'|undefined],
'setup': ['funi'|'crunchy'|undefined, undefined],
'openFile': [[FolderTypes, string], undefined],
'openURL': [string, undefined]
'openURL': [string, undefined],
'setuped': [undefined, boolean],
'setupServer': [GUIConfig, boolean],
'requirePassword': [undefined, boolean]
}

View file

@ -15,13 +15,13 @@ RUN echo 'ffmpeg: "./bin/ffmpeg/ffmpeg"\nmkvmerge: "./bin/mkvtoolnix/mkvmerge"'
RUN npm install -g pnpm
RUN pnpm i
RUN pnpm run build-ubuntu-cli
RUN pnpm run build-ubuntu-gui
# Move build to new Clean Image
FROM node
WORKDIR "/app"
COPY --from=builder /app/lib/_builds/multi-downloader-nx-ubuntu64-cli ./
COPY --from=builder /app/lib/_builds/multi-downloader-nx-ubuntu64-gui ./
# Install mkvmerge and ffmpeg
@ -35,4 +35,4 @@ RUN apt-get install ffmpeg -y
RUN mv /usr/bin/mkvmerge /app/bin/mkvtoolnix/mkvmerge
RUN mv /usr/bin/ffmpeg /app/bin/ffmpeg/ffmpeg
CMD [ "/bin/bash" ]
CMD [ "node", "/app/gui.js" ]

View file

@ -1,2 +1 @@
port: 3000
password: "default"
port: 3000

3
config/setup.json Normal file
View file

@ -0,0 +1,3 @@
{
"setuped": true
}

View file

@ -1,11 +1,13 @@
import React from 'react';
import type { AuthData, AuthResponse, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from '../../../../@types/messageHandler';
import type { MessageHandler } from '../../../../@types/messageHandler';
import useStore from '../hooks/useStore';
import type { MessageTypes, WSMessage, WSMessageWithID } from '../../../../@types/ws';
import type { Handler, RandomEvent, RandomEvents } from '../../../../@types/randomEvents';
import { Backdrop, Typography } from '@mui/material';
import { Avatar, Box, Button, TextField, Typography } from '@mui/material';
import { v4 } from "uuid";
import { useSnackbar } from "notistack";
import { LockOutlined, PowerSettingsNew } from '@mui/icons-material'
import { GUIConfig } from '../../../../modules/module.cfg-loader';
export type FrontEndMessanges = (MessageHandler & { randomEvents: RandomEventHandler, logout: () => Promise<boolean> });
@ -58,22 +60,80 @@ async function messageAndResponse<T extends keyof MessageTypes>(socket: WebSocke
const MessageChannelProvider: FCWithChildren = ({ children }) => {
const [store, dispatch] = useStore();
const [socket, setSocket] = React.useState<undefined|WebSocket|null>();
const [socket, setSocket] = React.useState<undefined|WebSocket>();
const [publicWS, setPublicWS] = React.useState<undefined|WebSocket>();
const [usePassword, setUsePassword] = React.useState<'waiting'|'yes'|'no'>('waiting');
const [isSetuped, setIsSetuped] = React.useState<'waiting'|'yes'|'no'>('waiting');
const { enqueueSnackbar } = useSnackbar();
React.useEffect(() => {
const wws = new WebSocket(`ws://localhost:3000/ws?${new URLSearchParams({
password: prompt('This website requires a password') ?? ''
})}`, );
const wss = new WebSocket(`ws://${process.env.NODE_ENV === 'development' ? 'localhost:3000' : window.location.host}/public`);
wss.addEventListener('open', () => {
setPublicWS(wss);
});
wss.addEventListener('error', () => {
enqueueSnackbar('Unable to connect to server. Please reload the page to try again.', { variant: 'error' });
});
}, []);
React.useEffect(() => {
(async () => {
if (!publicWS)
return;
setUsePassword((await messageAndResponse(publicWS, { name: 'requirePassword', data: undefined })).data ? 'yes' : 'no');
setIsSetuped((await messageAndResponse(publicWS, { name: 'setuped', data: undefined })).data ? 'yes' : 'no');
})();
}, [publicWS]);
const connect = (ev?: React.FormEvent<HTMLFormElement>) => {
let search = new URLSearchParams();
if (ev) {
ev.preventDefault();
const formData = new FormData(ev.currentTarget);
const password = formData.get('password')?.toString();
if (!password)
return enqueueSnackbar('Please provide both a username and password', {
variant: 'error'
});
search = new URLSearchParams({
password
});
}
const wws = new WebSocket(`ws://${process.env.NODE_ENV === 'development' ? 'localhost:3000' : window.location.host}/ws?${search}`, );
wws.addEventListener('open', () => {
console.log('[INFO] [WS] Connected');
setSocket(wws);
});
wws.addEventListener('error', (er) => {
console.error(`[ERROR] [WS]`, er);
setSocket(null);
enqueueSnackbar('Unable to connect to server. Please check the password and try again.', {
variant: 'error'
});
})
}, []);
};
const setup = async (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
if (!socket)
return enqueueSnackbar('Invalid state: socket not found', { variant: 'error' });
const formData = new FormData(ev.currentTarget);
const password = formData.get('password');
const data = {
port: parseInt(formData.get('port')?.toString() ?? '') ?? 3000,
password: password ? password.toString() : undefined
} as GUIConfig;
await messageAndResponse(socket!, { name: 'setupServer', data });
enqueueSnackbar(`The following settings have been set: Port=${data.port}, Password=${data.password ?? 'noPasswordRequired'}`, {
variant: 'success',
persist: true
});
enqueueSnackbar('Please restart the server now.', {
variant: 'info',
persist: true
});
}
const randomEventHandler = React.useMemo(() => new RandomEventHandler(), []);
@ -103,8 +163,51 @@ const MessageChannelProvider: FCWithChildren = ({ children }) => {
};
}, [ socket ]);
if (socket === undefined || socket === null)
return <Typography color='primary'>{socket === undefined ? 'Loading...' : 'WebSocket Error. Please try to reload and make sure the password ist correct.'}</Typography>;
if (usePassword === 'waiting')
return <></>;
if (socket === undefined) {
if (usePassword === 'no') {
connect(undefined)
return <></>;
}
return <Box sx={{ mt: 3, display: 'flex', flexDirection: 'column', justifyItems: 'center', alignItems: 'center' }}>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
<LockOutlined />
</Avatar>
<Typography component="h1" variant="h5" color="text.primary">
Login
</Typography>
<Box component="form" onSubmit={connect} sx={{ mt: 1 }}>
<TextField name="password" margin='normal' type="password" fullWidth variant="filled" required label={'Password'} />
<Button type='submit' variant='contained' sx={{ mt: 3, mb: 2 }} fullWidth>Login</Button>
<Typography color="text.secondary" align='center' component="p" variant='body2'>
You need to login in order to use this tool.
</Typography>
</Box>
</Box>;
}
if (isSetuped === 'no') {
return <Box sx={{ mt: 3, display: 'flex', flexDirection: 'column', justifyItems: 'center', alignItems: 'center' }}>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
<PowerSettingsNew />
</Avatar>
<Typography component="h1" variant="h5" color="text.primary">
Confirm
</Typography>
<Box component="form" onSubmit={setup} sx={{ mt: 1 }}>
<TextField name="port" margin='normal' type="number" fullWidth variant="filled" required label={'Port'} defaultValue={3000} />
<TextField name="password" margin='normal' type="password" fullWidth variant="filled" label={'Password'} />
<Button type='submit' variant='contained' sx={{ mt: 3, mb: 2 }} fullWidth>Confirm</Button>
<Typography color="text.secondary" align='center' component="p" variant='body2'>
Please enter data that will be set to use this tool.
<br />
Leave blank to use no password (NOT RECOMMENDED)!
</Typography>
</Box>
</Box>;
}
const messageHandler: FrontEndMessanges = {
auth: async (data) => (await messageAndResponse(socket, { name: 'auth', data })).data,

View file

@ -4,6 +4,7 @@ import cors from 'cors';
import ServiceHandler from './serviceHandler';
import open from 'open';
import path from 'path';
import { PublicWebSocket } from './websocket';
process.title = 'AniDL';
@ -23,7 +24,7 @@ const server = app.listen(cfg.gui.port, () => {
console.log(`[INFO] GUI server started on port ${cfg.gui.port}`);
});
new PublicWebSocket(server);
new ServiceHandler(server);
open(`http://localhost:${cfg.gui.port}`);

View file

@ -3,6 +3,7 @@ import { Server } from 'http';
import { IncomingMessage } from 'http';
import { MessageHandler } from '../../@types/messageHandler';
import Funi from '../../funi';
import { setSetuped, writeYamlCfgFile } from '../../modules/module.cfg-loader';
import CrunchyHandler from './services/crunchyroll';
import FunimationHandler from './services/funimation';
import WebSocketHandler from './websocket';
@ -18,6 +19,13 @@ export default class ServiceHandler {
}
private handleMessanges() {
this.ws.events.on('setupServer', ({ data }, respond) => {
writeYamlCfgFile('gui', data);
setSetuped(true);
respond(true);
process.exit(0);
});
this.ws.events.on('setup', ({ data }) => {
if (data === 'funi') {
this.service = new FunimationHandler(this.ws);

View file

@ -4,6 +4,7 @@ import { RandomEvent, RandomEvents } from "../../@types/randomEvents";
import { MessageTypes, UnknownWSMessage, WSMessage } from "../../@types/ws";
import { EventEmitter } from "events";
import { cfg } from ".";
import { isSetuped } from "../../modules/module.cfg-loader";
declare interface ExternalEvent {
on<T extends keyof MessageTypes>(event: T, listener: (msg: WSMessage<T>, respond: (data: MessageTypes[T][1]) => void) => void): this;
@ -44,9 +45,12 @@ export default class WebSocketHandler {
});
server.on('upgrade', (request, socket, head) => {
if (!this.wsServer.shouldHandle(request))
return;
if (!this.authenticate(request)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
console.log(`[INFO] [WS] ${request.socket.remoteAddress} tried to connect but used a wrong password.`)
return;
}
this.wsServer.handleUpgrade(request, socket, head, socket => {
@ -67,8 +71,51 @@ export default class WebSocketHandler {
}
private authenticate(request: IncomingMessage): boolean {
return cfg.gui.password === new URL(`http://${request.headers.host}${request.url}`).searchParams.get('password');
const search = new URL(`http://${request.headers.host}${request.url}`).searchParams;
return cfg.gui.password === (search.get('password') ?? undefined);
}
}
export class PublicWebSocket {
private wsServer: ws.Server;
constructor(server: Server) {
this.wsServer = new ws.WebSocketServer({ noServer: true, path: '/public' });
this.wsServer.on('connection', (socket, req) => {
console.log(`[INFO] [WS] Connection to public ws from '${req.socket.remoteAddress}'`);
socket.on('error', (er) => console.log(`[ERROR] [WS] ${er}`));
socket.on('message', (msg) => {
const data = JSON.parse(msg.toString()) as UnknownWSMessage
switch (data.name) {
case 'setuped':
this.send(socket, data.id, data.name, isSetuped());
break;
case 'requirePassword':
this.send(socket, data.id, data.name, cfg.gui.password !== undefined);
break;
}
});
});
server.on('upgrade', (request, socket, head) => {
if (!this.wsServer.shouldHandle(request))
return;
this.wsServer.handleUpgrade(request, socket, head, socket => {
this.wsServer.emit('connection', socket, request);
});
});
}
private send(client: ws.WebSocket, id: string, name: string, data: any) {
client.send(JSON.stringify({
data,
id,
name
}), (er) => {
if (er)
console.log(`[ERROR] [WS] ${er}`)
});
}
}

View file

@ -15,6 +15,7 @@ const dirCfgFile = path.join(workingDir, 'config', 'dir-path');
const guiCfgFile = path.join(workingDir, 'config', 'gui')
const cliCfgFile = path.join(workingDir, 'config', 'cli-defaults');
const sessCfgFile = path.join(workingDir, 'config', 'session');
const setupFile = path.join(workingDir, 'config', 'setup');
const tokenFile = {
funi: path.join(workingDir, 'config', 'funi_token'),
cr: path.join(workingDir, 'config', 'cr_token')
@ -47,6 +48,22 @@ const loadYamlCfgFile = <T extends Record<string, any>>(file: string, isSess?: b
return {} as T;
};
export type WriteObjects = {
gui: GUIConfig
}
const writeYamlCfgFile = <T extends keyof WriteObjects>(file: T, data: WriteObjects[T]) => {
const fn = path.join(workingDir, 'config', `${file}.yml`);
if (fs.existsSync(fn))
fs.removeSync(fn);
fs.writeFileSync(fn, yaml.stringify(data));
}
export type GUIConfig = {
port: number,
password?: string
};
export type ConfigObject = {
dir: {
content: string,
@ -61,10 +78,7 @@ export type ConfigObject = {
cli: {
[key: string]: any
},
gui: {
port: number,
password: string
}
gui: GUIConfig
}
const loadCfg = () : ConfigObject => {
@ -80,10 +94,7 @@ const loadCfg = () : ConfigObject => {
cli: loadYamlCfgFile<{
[key: string]: any
}>(cliCfgFile),
gui: loadYamlCfgFile<{
port: number,
password: string
}>(guiCfgFile)
gui: loadYamlCfgFile<GUIConfig>(guiCfgFile)
};
const defaultDirs = {
fonts: '${wdir}/fonts/',
@ -221,6 +232,28 @@ const saveFuniToken = (data: {
const cfgDir = path.join(workingDir, 'config');
const isSetuped = (): boolean => {
const fn = `${setupFile}.json`;
if (!fs.existsSync(fn))
return false;
return JSON.parse(fs.readFileSync(fn).toString()).setuped;
}
const setSetuped = (bool: boolean) => {
const fn = `${setupFile}.json`;
if (bool) {
fs.writeFileSync(fn, JSON.stringify({
setuped: true
}, null, 2))
} else {
if (fs.existsSync(fn)) {
fs.removeSync(fn);
}
}
}
export {
loadBinCfg,
loadCfg,
@ -230,6 +263,9 @@ export {
saveCRToken,
loadCRToken,
loadCRSession,
isSetuped,
setSetuped,
writeYamlCfgFile,
sessCfgFile,
cfgDir
};

1
tsc.ts
View file

@ -13,6 +13,7 @@ const isGUI = !(argv.length > 1 && argv[1] === 'false');
if (!isTest)
buildIgnore = [
'*/\\.env',
'./config/setup.json'
];
if (!isGUI)