diff --git a/frontend/package.json b/frontend/package.json index 48cea88..a6f06c7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@mui/icons-material": "^5.11.16", "@mui/material": "^5.13.2", "@reduxjs/toolkit": "^1.9.5", + "fp-ts": "^2.16.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^8.0.5", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ea32cdb..234862d 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,208 +1,12 @@ -import { ThemeProvider } from '@emotion/react' - -import ChevronLeft from '@mui/icons-material/ChevronLeft' -import Dashboard from '@mui/icons-material/Dashboard' -import Menu from '@mui/icons-material/Menu' -import SettingsIcon from '@mui/icons-material/Settings' -import SettingsEthernet from '@mui/icons-material/SettingsEthernet' -import Storage from '@mui/icons-material/Storage' - -import { Box, createTheme } from '@mui/material' - -import CircularProgress from '@mui/material/CircularProgress' -import CssBaseline from '@mui/material/CssBaseline' -import Divider from '@mui/material/Divider' -import IconButton from '@mui/material/IconButton' -import List from '@mui/material/List' -import ListItemButton from '@mui/material/ListItemButton' -import ListItemIcon from '@mui/material/ListItemIcon' -import ListItemText from '@mui/material/ListItemText' -import Toolbar from '@mui/material/Toolbar' -import Typography from '@mui/material/Typography' -import DownloadIcon from '@mui/icons-material/Download'; - -import { grey } from '@mui/material/colors' - -import { Suspense, lazy, useMemo, useState } from 'react' -import { Provider, useDispatch, useSelector } from 'react-redux' - -import { BrowserRouter, Link, Route, Routes } from 'react-router-dom' -import { RootState, store } from './stores/store' - -import AppBar from './components/AppBar' -import Drawer from './components/Drawer' - -import Archive from './Archive' -import { formatGiB } from './utils' - -function AppContent() { - const [open, setOpen] = useState(false) - - const settings = useSelector((state: RootState) => state.settings) - const status = useSelector((state: RootState) => state.status) - - const mode = settings.theme - const theme = useMemo(() => - createTheme({ - palette: { - mode: settings.theme, - background: { - default: settings.theme === 'light' ? grey[50] : '#121212' - }, - }, - }), [settings.theme] - ) - - const toggleDrawer = () => { - setOpen(!open) - } - - const Home = lazy(() => import('./Home')) - const Settings = lazy(() => import('./Settings')) - - return ( - - - - - - - - - - - yt-dlp WebUI - - { - status.freeSpace ? -
- -  {formatGiB(status.freeSpace)}  -
- : null - } -
- -  {status.connected ? settings.serverAddr : 'not connected'} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - }> - - - } /> - }> - - - } /> - }> - - - } /> - - - - - - ) -} +import { Provider } from 'react-redux' +import { RouterProvider } from 'react-router-dom' +import { router } from './router' +import { store } from './stores/store' export function App() { return ( - + ) } \ No newline at end of file diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx new file mode 100644 index 0000000..29fde64 --- /dev/null +++ b/frontend/src/Layout.tsx @@ -0,0 +1,181 @@ +import { ThemeProvider } from '@emotion/react' + +import ChevronLeft from '@mui/icons-material/ChevronLeft' +import Dashboard from '@mui/icons-material/Dashboard' +import Menu from '@mui/icons-material/Menu' +import SettingsIcon from '@mui/icons-material/Settings' +import SettingsEthernet from '@mui/icons-material/SettingsEthernet' +import Storage from '@mui/icons-material/Storage' + +import { Box, createTheme } from '@mui/material' + +import DownloadIcon from '@mui/icons-material/Download' +import CssBaseline from '@mui/material/CssBaseline' +import Divider from '@mui/material/Divider' +import IconButton from '@mui/material/IconButton' +import List from '@mui/material/List' +import ListItemButton from '@mui/material/ListItemButton' +import ListItemIcon from '@mui/material/ListItemIcon' +import ListItemText from '@mui/material/ListItemText' +import Toolbar from '@mui/material/Toolbar' +import Typography from '@mui/material/Typography' + +import { grey } from '@mui/material/colors' + +import { useMemo, useState } from 'react' +import { useSelector } from 'react-redux' + +import { Link, Outlet } from 'react-router-dom' +import { RootState } from './stores/store' + +import AppBar from './components/AppBar' +import Drawer from './components/Drawer' + +import Logout from './components/Logout' +import { formatGiB } from './utils' +import ThemeToggler from './components/ThemeToggler' + +export default function Layout() { + const [open, setOpen] = useState(false) + + const settings = useSelector((state: RootState) => state.settings) + const status = useSelector((state: RootState) => state.status) + + const mode = settings.theme + const theme = useMemo(() => + createTheme({ + palette: { + mode: settings.theme, + background: { + default: settings.theme === 'light' ? grey[50] : '#121212' + }, + }, + }), [settings.theme] + ) + + const toggleDrawer = () => { + setOpen(state => !state) + } + + return ( + + + + + + + + + + yt-dlp WebUI + + { + status.freeSpace ? +
+ +  {formatGiB(status.freeSpace)}  +
+ : null + } +
+ +  {status.connected ? settings.serverAddr : 'not connected'} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/Logout.tsx b/frontend/src/components/Logout.tsx new file mode 100644 index 0000000..beb312b --- /dev/null +++ b/frontend/src/components/Logout.tsx @@ -0,0 +1,24 @@ +import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material' +import LogoutIcon from '@mui/icons-material/Logout' +import { getHttpEndpoint } from '../utils' +import { useNavigate } from 'react-router-dom' + +export default function Logout() { + const navigate = useNavigate() + + const logout = async () => { + const res = await fetch(`${getHttpEndpoint()}/auth/logout`) + if (res.ok) { + navigate('/login') + } + } + + return ( + + + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/ThemeToggler.tsx b/frontend/src/components/ThemeToggler.tsx new file mode 100644 index 0000000..229b00a --- /dev/null +++ b/frontend/src/components/ThemeToggler.tsx @@ -0,0 +1,27 @@ +import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material' +import { useDispatch, useSelector } from 'react-redux' +import { setTheme } from '../features/settings/settingsSlice' +import { RootState } from '../stores/store' +import { Brightness4, Brightness5 } from '@mui/icons-material' + +export default function ThemeToggler() { + const settings = useSelector((state: RootState) => state.settings) + const dispatch = useDispatch() + + return ( + { + settings.theme === 'light' + ? dispatch(setTheme('dark')) + : dispatch(setTheme('light')) + }}> + + { + settings.theme === 'light' + ? + : + } + + + + ) +} \ No newline at end of file diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index bfbb735..0e5cae8 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -3,8 +3,9 @@ import { createRoot } from 'react-dom/client' import { App } from './App' const root = createRoot(document.getElementById('root')!) + root.render( - - - + + + ) diff --git a/frontend/src/features/core/argsParser.ts b/frontend/src/lib/argsParser.ts similarity index 50% rename from frontend/src/features/core/argsParser.ts rename to frontend/src/lib/argsParser.ts index cddc021..fec1fb2 100644 --- a/frontend/src/features/core/argsParser.ts +++ b/frontend/src/lib/argsParser.ts @@ -1,39 +1,39 @@ export class CliArguments { - private _extractAudio: boolean; - private _noMTime: boolean; - private _proxy: string; + private _extractAudio: boolean + private _noMTime: boolean + private _proxy: string - constructor(extractAudio = false, noMTime = false) { - this._extractAudio = extractAudio; - this._noMTime = noMTime; + constructor(extractAudio = false, noMTime = true) { + this._extractAudio = extractAudio + this._noMTime = noMTime this._proxy = "" } public get extractAudio(): boolean { - return this._extractAudio; + return this._extractAudio } public toggleExtractAudio() { - this._extractAudio = !this._extractAudio; - return this; + this._extractAudio = !this._extractAudio + return this } public disableExtractAudio() { - this._extractAudio = false; - return this; + this._extractAudio = false + return this } public get noMTime(): boolean { - return this._noMTime; + return this._noMTime } public toggleNoMTime() { - this._noMTime = !this._noMTime; - return this; + this._noMTime = !this._noMTime + return this } public toString(): string { - let args = ''; + let args = '' if (this._extractAudio) { args += '-x ' @@ -43,19 +43,19 @@ export class CliArguments { args += '--no-mtime ' } - return args.trim(); + return args.trim() } public fromString(str: string): CliArguments { if (str) { if (str.includes('-x')) { - this._extractAudio = true; + this._extractAudio = true } if (str.includes('--no-mtime')) { - this._noMTime = true; + this._noMTime = true } } - return this; + return this } } \ No newline at end of file diff --git a/frontend/src/features/core/events.ts b/frontend/src/lib/events.ts similarity index 100% rename from frontend/src/features/core/events.ts rename to frontend/src/lib/events.ts diff --git a/frontend/src/lib/httpClient.ts b/frontend/src/lib/httpClient.ts new file mode 100644 index 0000000..3d1ee55 --- /dev/null +++ b/frontend/src/lib/httpClient.ts @@ -0,0 +1,21 @@ +import * as E from 'fp-ts/Either' +import { pipe } from 'fp-ts/function' + +type FetchInit = { + url: string, + opt?: RequestInit +} + +export async function ffetch( + url: string, + onSuccess: (res: T) => void, + onError: (err: string) => void, + opt?: RequestInit, +) { + const res = await fetch(url, opt) + if (!res.ok) { + onError(await res.text()) + return + } + onSuccess(await res.json() as T) +} \ No newline at end of file diff --git a/frontend/src/features/core/intl.ts b/frontend/src/lib/intl.ts similarity index 93% rename from frontend/src/features/core/intl.ts rename to frontend/src/lib/intl.ts index a152848..760a86c 100644 --- a/frontend/src/features/core/intl.ts +++ b/frontend/src/lib/intl.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import i18n from "../../assets/i18n.yaml" +import i18n from "../assets/i18n.yaml" export default class I18nBuilder { private language: string diff --git a/frontend/src/features/core/rpcClient.ts b/frontend/src/lib/rpcClient.ts similarity index 93% rename from frontend/src/features/core/rpcClient.ts rename to frontend/src/lib/rpcClient.ts index 92e0996..d32dd20 100644 --- a/frontend/src/features/core/rpcClient.ts +++ b/frontend/src/lib/rpcClient.ts @@ -1,7 +1,7 @@ -import type { DLMetadata, RPCRequest, RPCResponse } from '../../types' +import type { DLMetadata, RPCRequest, RPCResponse } from '../types' import { webSocket } from 'rxjs/webSocket' -import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../../utils' +import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../utils' export const socket$ = webSocket(getWebSocketEndpoint()) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 0000000..17f9cd1 --- /dev/null +++ b/frontend/src/router.tsx @@ -0,0 +1,50 @@ +import { CircularProgress } from '@mui/material' +import { Suspense, lazy } from 'react' +import { createBrowserRouter } from 'react-router-dom' +import Layout from './Layout' + +const Home = lazy(() => import('./views/Home')) +const Login = lazy(() => import('./views/Login')) +const Archive = lazy(() => import('./views/Archive')) +const Settings = lazy(() => import('./views/Settings')) + +export const router = createBrowserRouter([ + { + path: '/', + Component: () => , + children: [ + { + path: '/', + element: ( + }> + + + ) + }, + { + path: '/settings', + element: ( + }> + + + ) + }, + { + path: '/archive', + element: ( + }> + + + ) + }, + { + path: '/login', + element: ( + }> + + + ) + }, + ] + }, +]) \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index b9d7ba3..9b06aff 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -4,8 +4,8 @@ * @returns ip validity test */ export function validateIP(ipAddr: string): boolean { - let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm - return ipRegex.test(ipAddr) + let ipRegex = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/gm + return ipRegex.test(ipAddr) } /** @@ -18,8 +18,8 @@ export function validateIP(ipAddr: string): boolean { * @returns domain validity test */ export function validateDomain(domainName: string): boolean { - let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/ - return domainRegex.test(domainName) || domainName === 'localhost' + let domainRegex = /[^@ \t\r\n]+.[^@ \t\r\n]+\.[^@ \t\r\n]+/ + return domainRegex.test(domainName) || domainName === 'localhost' } /** @@ -34,15 +34,15 @@ export function validateDomain(domainName: string): boolean { * @returns url validity test */ export function isValidURL(url: string): boolean { - let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/ - return urlRegex.test(url) + let urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/ + return urlRegex.test(url) } export function ellipsis(str: string, lim: number): string { - if (str) { - return str.length > lim ? `${str.substring(0, lim)}...` : str - } - return '' + if (str) { + return str.length > lim ? `${str.substring(0, lim)}...` : str + } + return '' } /** @@ -51,43 +51,43 @@ export function ellipsis(str: string, lim: number): string { * @returns download speed in KiB/s */ export function detectSpeed(str: string): number { - let effective = str.match(/[\d,]+(\.\d+)?/)![0] - const unit = str.replace(effective, '') - switch (unit) { - case 'MiB/s': - return Number(effective) * 1000 - case 'KiB/s': - return Number(effective) - default: - return 0 - } + let effective = str.match(/[\d,]+(\.\d+)?/)![0] + const unit = str.replace(effective, '') + switch (unit) { + case 'MiB/s': + return Number(effective) * 1000 + case 'KiB/s': + return Number(effective) + default: + return 0 + } } export function toFormatArgs(codes: string[]): string { - if (codes.length > 1) { - return codes.reduce((v, a) => ` -f ${v}+${a}`) - } - if (codes.length === 1) { - return ` -f ${codes[0]}`; - } - return ''; + if (codes.length > 1) { + return codes.reduce((v, a) => ` -f ${v}+${a}`) + } + if (codes.length === 1) { + return ` -f ${codes[0]}`; + } + return ''; } export function getWebSocketEndpoint() { - const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' - return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/ws-rpc` + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' + return `${protocol}://${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/ws` } export function getHttpRPCEndpoint() { - return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/http-rpc` + return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}/rpc/http` } export function getHttpEndpoint() { - return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}` + return `${window.location.protocol}//${localStorage.getItem('server-addr') || window.location.hostname}:${localStorage.getItem('server-port') || window.location.port}` } export function formatGiB(bytes: number) { - return `${(bytes / 1_000_000_000).toFixed(0)}GiB` + return `${(bytes / 1_000_000_000).toFixed(0)}GiB` } export const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB` diff --git a/frontend/src/Archive.tsx b/frontend/src/views/Archive.tsx similarity index 88% rename from frontend/src/Archive.tsx rename to frontend/src/views/Archive.tsx index b59346a..d52981e 100644 --- a/frontend/src/Archive.tsx +++ b/frontend/src/views/Archive.tsx @@ -30,13 +30,16 @@ import { Buffer } from 'buffer' import { useEffect, useMemo, useState, useTransition } from 'react' import { useSelector } from 'react-redux' import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs' -import { useObservable } from './hooks/observable' -import { RootState } from './stores/store' -import { DeleteRequest, DirectoryEntry } from './types' -import { roundMiB } from './utils' +import { useObservable } from '../hooks/observable' +import { RootState } from '../stores/store' +import { DeleteRequest, DirectoryEntry } from '../types' +import { roundMiB } from '../utils' +import { useNavigate } from 'react-router-dom' +import { ffetch } from '../lib/httpClient' export default function Downloaded() { const settings = useSelector((state: RootState) => state.settings) + const navigate = useNavigate() const [openDialog, setOpenDialog] = useState(false) @@ -48,17 +51,20 @@ export default function Downloaded() { const [isPending, startTransition] = useTransition() - const fetcher = () => fetch(`${serverAddr}/downloaded`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - subdir: '', - }) - }) - .then(res => res.json()) - .then(data => files$.next(data)) + const fetcher = () => ffetch( + `${serverAddr}/archive/downloaded`, + (d) => files$.next(d), + () => navigate('/login'), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + subdir: '', + }) + } + ) const fetcherSubfolder = (sub: string) => { const folders = sub.startsWith('/') @@ -74,7 +80,7 @@ export default function Downloaded() { ? ['.', ..._upperLevel].join('/') : _upperLevel.join('/') - fetch(`${serverAddr}/downloaded`, { + fetch(`${serverAddr}/archive/downloaded`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -114,7 +120,7 @@ export default function Downloaded() { const deleteSelected = () => { Promise.all(selectable .filter(entry => entry.selected) - .map(entry => fetch(`${serverAddr}/delete`, { + .map(entry => fetch(`${serverAddr}/archive/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -133,7 +139,7 @@ export default function Downloaded() { const onFileClick = (path: string) => startTransition(() => { - window.open(`${serverAddr}/d/${Buffer.from(path).toString('hex')}`) + window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`) }) const onFolderClick = (path: string) => startTransition(() => { diff --git a/frontend/src/Home.tsx b/frontend/src/views/Home.tsx similarity index 87% rename from frontend/src/Home.tsx rename to frontend/src/views/Home.tsx index a680ef1..1cbd2f4 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/views/Home.tsx @@ -24,17 +24,17 @@ import { import { Buffer } from 'buffer' import { useEffect, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { DownloadsCardView } from './components/DownloadsCardView' -import { DownloadsListView } from './components/DownloadsListView' -import FormatsGrid from './components/FormatsGrid' -import { CliArguments } from './features/core/argsParser' -import I18nBuilder from './features/core/intl' -import { RPCClient, socket$ } from './features/core/rpcClient' -import { toggleListView } from './features/settings/settingsSlice' -import { connected, setFreeSpace } from './features/status/statusSlice' -import { RootState } from './stores/store' -import type { DLMetadata, RPCResponse, RPCResult } from './types' -import { isValidURL, toFormatArgs } from './utils' +import { DownloadsCardView } from '../components/DownloadsCardView' +import { DownloadsListView } from '../components/DownloadsListView' +import FormatsGrid from '../components/FormatsGrid' +import { CliArguments } from '../lib/argsParser' +import I18nBuilder from '../lib/intl' +import { RPCClient, socket$ } from '../lib/rpcClient' +import { toggleListView } from '../features/settings/settingsSlice' +import { connected, setFreeSpace } from '../features/status/statusSlice' +import { RootState } from '../stores/store' +import type { DLMetadata, RPCResponse, RPCResult } from '../types' +import { isValidURL, toFormatArgs } from '../utils' export default function Home() { // redux state @@ -76,24 +76,24 @@ export default function Home() { /* WebSocket connect event handler*/ useEffect(() => { - if (!status.connected) { - const sub = socket$.subscribe({ - next: () => { - dispatch(connected()) - setCustomArgs(localStorage.getItem('last-input-args') ?? '') - setFilenameOverride(localStorage.getItem('last-filename-override') ?? '') - }, - error: () => { - setSocketHasError(true) - setShowBackdrop(false) - }, - complete: () => { - setSocketHasError(true) - setShowBackdrop(false) - }, - }) - return () => sub.unsubscribe() - } + if (status.connected) { return } + + const sub = socket$.subscribe({ + next: () => { + dispatch(connected()) + setCustomArgs(localStorage.getItem('last-input-args') ?? '') + setFilenameOverride(localStorage.getItem('last-filename-override') ?? '') + }, + error: () => { + setSocketHasError(true) + setShowBackdrop(false) + }, + complete: () => { + setSocketHasError(true) + setShowBackdrop(false) + }, + }) + return () => sub.unsubscribe() }, [socket$, status.connected]) useEffect(() => { @@ -109,22 +109,22 @@ export default function Home() { }, []) useEffect(() => { - if (status.connected) { - const sub = socket$.subscribe((event: RPCResponse) => { - switch (typeof event.result) { - case 'object': - setActiveDownloads( - (event.result ?? []) - .filter((r) => !!r.info.url) - .sort((a, b) => a.info.title.localeCompare(b.info.title)) - ) - break - default: - break - } - }) - return () => sub.unsubscribe() - } + if (!status.connected) { return } + + const sub = socket$.subscribe((event: RPCResponse) => { + switch (typeof event.result) { + case 'object': + setActiveDownloads( + (event.result ?? []) + .filter((r) => !!r.info.url) + .sort((a, b) => a.info.title.localeCompare(b.info.title)) + ) + break + default: + break + } + }) + return () => sub.unsubscribe() }, [socket$, status.connected]) useEffect(() => { @@ -166,7 +166,7 @@ export default function Home() { resetInput() setShowBackdrop(true) setDownloadFormats(undefined) - }, 250); + }, 250) } /** @@ -246,7 +246,7 @@ export default function Home() { const resetInput = () => { urlInputRef.current!.value = ''; if (customFilenameInputRef.current) { - customFilenameInputRef.current!.value = ''; + customFilenameInputRef.current!.value = '' } } @@ -254,7 +254,7 @@ export default function Home() { const Input = styled('input')({ display: 'none', - }); + }) return ( @@ -423,5 +423,5 @@ export default function Home() { /> - ); + ) } diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx new file mode 100644 index 0000000..e5f6933 --- /dev/null +++ b/frontend/src/views/Login.tsx @@ -0,0 +1,78 @@ +/* + Login view component +*/ + +import styled from '@emotion/styled' +import { + Button, + Container, + Paper, + Stack, + TextField, + Typography +} from '@mui/material' +import { getHttpEndpoint } from '../utils' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' + +const LoginContainer = styled(Container)({ + display: 'flex', + minWidth: '100%', + minHeight: '100vh', + alignItems: 'center', + justifyContent: 'center', +}) + +const Title = styled(Typography)({ + display: 'flex', + width: '100%', + alignItems: 'center', + justifyContent: 'center', + paddingBottom: '0.5rem' +}) + +export default function Login() { + const [secret, setSecret] = useState('') + const [formHasError, setFormHasError] = useState(false) + + const navigate = useNavigate() + + const login = async () => { + const res = await fetch(`${getHttpEndpoint()}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + secret + }) + }) + res.ok ? navigate('/') : setFormHasError(true) + } + + return ( + + + + + yt-dlp WebUI + + + Authentication token will expire after 30 days. + + setSecret(e.currentTarget.value)} + /> + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/Settings.tsx b/frontend/src/views/Settings.tsx similarity index 96% rename from frontend/src/Settings.tsx rename to frontend/src/views/Settings.tsx index 190ada9..7fa0958 100644 --- a/frontend/src/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -26,9 +26,9 @@ import { map, takeWhile } from 'rxjs' -import { CliArguments } from './features/core/argsParser' -import I18nBuilder from './features/core/intl' -import { RPCClient } from './features/core/rpcClient' +import { CliArguments } from '../lib/argsParser' +import I18nBuilder from '../lib/intl' +import { RPCClient } from '../lib/rpcClient' import { LanguageUnion, ThemeUnion, @@ -41,10 +41,10 @@ import { setServerAddr, setServerPort, setTheme -} from './features/settings/settingsSlice' -import { updated } from './features/status/statusSlice' -import { RootState } from './stores/store' -import { validateDomain, validateIP } from './utils' +} from '../features/settings/settingsSlice' +import { updated } from '../features/status/statusSlice' +import { RootState } from '../stores/store' +import { validateDomain, validateIP } from '../utils' export default function Settings() { const dispatch = useDispatch() diff --git a/go.mod b/go.mod index 35f9947..9902091 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,24 @@ module github.com/marcopeocchi/yt-dlp-web-ui -go 1.19 +go 1.20 require ( github.com/goccy/go-json v0.10.2 - github.com/gofiber/fiber/v2 v2.43.0 - github.com/gofiber/websocket/v2 v2.1.5 + github.com/gofiber/fiber/v2 v2.47.0 + github.com/gofiber/websocket/v2 v2.2.1 + github.com/golang-jwt/jwt/v5 v5.0.0 github.com/google/uuid v1.3.0 github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa - golang.org/x/sys v0.7.0 + golang.org/x/sys v0.9.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/andybalholm/brotli v1.0.5 // indirect - github.com/fasthttp/websocket v1.5.2 // indirect - github.com/klauspost/compress v1.16.4 // indirect + github.com/fasthttp/websocket v1.5.3 // indirect + github.com/klauspost/compress v1.16.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/philhofer/fwd v1.1.2 // indirect github.com/rivo/uniseg v0.4.4 // indirect @@ -25,6 +26,6 @@ require ( github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect github.com/tinylib/msgp v1.1.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.45.0 // indirect + github.com/valyala/fasthttp v1.48.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index a3daa5b..eb9af97 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,26 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/fasthttp/websocket v1.5.2 h1:KdCb0EpLpdJpfE3IPA5YLK/aYBO3dhZcvwxz6tXe2LQ= -github.com/fasthttp/websocket v1.5.2/go.mod h1:S0KC1VBlx1SaXGXq7yi1wKz4jMub58qEnHQG9oHuqBw= +github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= +github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/gofiber/fiber/v2 v2.43.0 h1:yit3E4kHf178B60p5CQBa/3v+WVuziWMa/G2ZNyLJB0= -github.com/gofiber/fiber/v2 v2.43.0/go.mod h1:mpS1ZNE5jU+u+BA4FbM+KKnUzJ4wzTK+FT2tG3tU+6I= -github.com/gofiber/websocket/v2 v2.1.5 h1:2weAMr0Shb2ubhZ3+P4bkeWL+uCZ/NlgjSa1siEcvFM= -github.com/gofiber/websocket/v2 v2.1.5/go.mod h1:BZZEk+XsjjF0V6/sAw00iGcB69dFb6Hb85ER9gr/xaU= +github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs= +github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU= +github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= +github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.16.4 h1:91KN02FnsOYhuunwU4ssRe8lc2JosWmizWa91B5v1PU= -github.com/klauspost/compress v1.16.4/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk= +github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa h1:uaAQLGhN4SesB9inOQ1Q6EH+BwTWHQOvwhR0TIJvnYc= github.com/marcopeocchi/fazzoletti v0.0.0-20230308161120-c545580f79fa/go.mod h1:RvfVo/6Sbnfra9kkvIxDW8NYOOaYsHjF0DdtMCs9cdo= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= @@ -37,8 +39,8 @@ github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.45.0 h1:zPkkzpIn8tdHZUrVa6PzYd0i5verqiPSkgTd3bSUcpA= -github.com/valyala/fasthttp v1.45.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc= +github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -70,8 +72,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= diff --git a/main.go b/main.go index 7726081..95f4c3e 100644 --- a/main.go +++ b/main.go @@ -16,15 +16,22 @@ var ( downloadPath string downloaderPath string + requireAuth bool + rpcSecret string //go:embed frontend/dist frontend embed.FS ) func init() { flag.IntVar(&port, "port", 3033, "Port where server will listen at") + flag.StringVar(&configFile, "conf", "", "yt-dlp-WebUI config file path") - flag.StringVar(&downloadPath, "out", ".", "Directory where files will be saved") + flag.StringVar(&downloadPath, "out", ".", "Where files will be saved") flag.StringVar(&downloaderPath, "driver", "yt-dlp", "yt-dlp executable path") + + flag.BoolVar(&requireAuth, "auth", false, "Enable RPC authentication") + flag.StringVar(&rpcSecret, "secret", "", "Secret required for auth") + flag.Parse() } @@ -41,6 +48,9 @@ func main() { c.DownloadPath(downloadPath) c.DownloaderPath(downloaderPath) + c.RequireAuth(requireAuth) + c.RPCSecret(rpcSecret) + if configFile != "" { c.LoadFromFile(configFile) } diff --git a/server/config/parser.go b/server/config/parser.go index 38dfe69..8cd98c2 100644 --- a/server/config/parser.go +++ b/server/config/parser.go @@ -13,6 +13,8 @@ type serverConfig struct { Port int `yaml:"port"` DownloadPath string `yaml:"downloadPath"` DownloaderPath string `yaml:"downloaderPath"` + RequireAuth bool `yaml:"require_auth"` + RPCSecret string `yaml:"rpc_secret"` } type config struct { @@ -46,6 +48,13 @@ func (c *config) DownloaderPath(path string) { c.cfg.DownloaderPath = path } +func (c *config) RequireAuth(value bool) { + c.cfg.RequireAuth = value +} +func (c *config) RPCSecret(secret string) { + c.cfg.RPCSecret = secret +} + var instance *config func Instance() *config { diff --git a/server/middleware/jwt.go b/server/middleware/jwt.go new file mode 100644 index 0000000..f91a435 --- /dev/null +++ b/server/middleware/jwt.go @@ -0,0 +1,50 @@ +package middlewares + +import ( + "fmt" + "os" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/marcopeocchi/yt-dlp-web-ui/server/config" +) + +const ( + TOKEN_COOKIE_NAME = "jwt" +) + +var Authenticated = func(c *fiber.Ctx) error { + if !config.Instance().GetConfig().RequireAuth { + return c.Next() + } + + cookie := c.Cookies(TOKEN_COOKIE_NAME) + + if cookie == "" { + return c.Status(fiber.StatusUnauthorized).SendString("invalid token") + } + + token, _ := jwt.Parse(cookie, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return []byte(os.Getenv("JWTSECRET")), nil + }) + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + expiresAt, err := time.Parse(time.RFC3339, claims["expiresAt"].(string)) + + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + if time.Now().After(expiresAt) { + return c.Status(fiber.StatusBadRequest).SendString("expired token") + } + } else { + return c.Status(fiber.StatusUnauthorized).SendString("invalid token") + } + + return c.Next() +} diff --git a/server/rest/handlers.go b/server/rest/handlers.go index d203079..c72a686 100644 --- a/server/rest/handlers.go +++ b/server/rest/handlers.go @@ -11,10 +11,15 @@ import ( "time" "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" "github.com/marcopeocchi/yt-dlp-web-ui/server/config" "github.com/marcopeocchi/yt-dlp-web-ui/server/utils" ) +const ( + TOKEN_COOKIE_NAME = "jwt" +) + type DirectoryEntry struct { Name string `json:"name"` Path string `json:"path"` @@ -139,3 +144,52 @@ func SendFile(ctx *fiber.Ctx) error { return ctx.SendStatus(fiber.StatusUnauthorized) } + +type LoginRequest struct { + Secret string `json:"secret"` +} + +func Login(ctx *fiber.Ctx) error { + req := new(LoginRequest) + err := ctx.BodyParser(req) + if err != nil { + return ctx.SendStatus(fiber.StatusInternalServerError) + } + + if config.Instance().GetConfig().RPCSecret != req.Secret { + return ctx.SendStatus(fiber.StatusBadRequest) + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "expiresAt": time.Now().Add(time.Minute * 30), + }) + + tokenString, err := token.SignedString([]byte(os.Getenv("JWTSECRET"))) + if err != nil { + return ctx.SendStatus(fiber.StatusInternalServerError) + } + + ctx.Cookie(&fiber.Cookie{ + Name: TOKEN_COOKIE_NAME, + HTTPOnly: true, + Secure: false, + Expires: time.Now().Add(time.Hour * 24 * 30), // 30 days + Value: tokenString, + Path: "/", + }) + + return ctx.SendStatus(fiber.StatusOK) +} + +func Logout(ctx *fiber.Ctx) error { + ctx.Cookie(&fiber.Cookie{ + Name: TOKEN_COOKIE_NAME, + HTTPOnly: true, + Secure: false, + Expires: time.Now(), + Value: "", + Path: "/", + }) + + return ctx.SendStatus(fiber.StatusOK) +} diff --git a/server/server.go b/server/server.go index 0b49819..f46cb4d 100644 --- a/server/server.go +++ b/server/server.go @@ -17,6 +17,7 @@ import ( "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/websocket/v2" + middlewares "github.com/marcopeocchi/yt-dlp-web-ui/server/middleware" "github.com/marcopeocchi/yt-dlp-web-ui/server/rest" ) @@ -35,21 +36,32 @@ func RunBlocking(port int, frontend fs.FS) { Root: http.FS(frontend), })) + // Client side routes app.Get("/settings", func(c *fiber.Ctx) error { return c.Redirect("/") }) app.Get("/archive", func(c *fiber.Ctx) error { return c.Redirect("/") }) + app.Get("/login", func(c *fiber.Ctx) error { + return c.Redirect("/") + }) - app.Post("/downloaded", rest.ListDownloaded) + // Archive routes + archive := app.Group("archive", middlewares.Authenticated) + archive.Post("/downloaded", rest.ListDownloaded) + archive.Post("/delete", rest.DeleteFile) + archive.Get("/d/:id", rest.SendFile) - app.Post("/delete", rest.DeleteFile) - app.Get("/d/:id", rest.SendFile) + // Authentication routes + app.Post("/auth/login", rest.Login) + app.Get("/auth/logout", rest.Logout) // RPC handlers // websocket - app.Get("/ws-rpc", websocket.New(func(c *websocket.Conn) { + rpc := app.Group("/rpc", middlewares.Authenticated) + + rpc.Get("/ws", websocket.New(func(c *websocket.Conn) { c.WriteMessage(websocket.TextMessage, []byte(`{ "status": "connected" }`)) @@ -69,7 +81,7 @@ func RunBlocking(port int, frontend fs.FS) { } })) // http-post - app.Post("/http-rpc", func(c *fiber.Ctx) error { + rpc.Post("/http", func(c *fiber.Ctx) error { reader := c.Context().RequestBodyStream() writer := c.Response().BodyWriter()