diff --git a/app/src/api.js b/app/src/api.js index 0cf521b2..142dc703 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -33,8 +33,14 @@ async function apiCall(url, method, body, ms = 10000) { try { // await the response or error. let response = await timeoutPromise(ms, promise); + if (200 <= response.status < 300) { + // Request was successful. + return response; + } + // Request encountered an error. let copy = response.clone(); - if (response.status === 403 && (await copy.json())['code'] === 17) { + let code = (await copy.json())['code']; + if (response.status === 403 && code === 17) { toast({ type: 'error', title: 'WROL Mode is enabled!', @@ -359,17 +365,24 @@ export async function killDownload(download_id) { export async function killDownloads() { let response = await apiPost(`${API_URI}/download/kill`); + console.log('killDownloads', response); return response; } export async function startDownloads() { let response = await apiPost(`${API_URI}/download/enable`); + console.log('startDownloads', response); return response; } export async function getDownloaders() { - let response = await apiGet(`${API_URI}/downloaders`); - return await response.json(); + try { + let response = await apiGet(`${API_URI}/downloaders`); + return await response.json(); + } catch (e) { + console.log('getDownloaders', e); + return {}; + } } const replaceFileDatetimes = (files) => { @@ -423,3 +436,54 @@ export async function getVersion() { let data = await response.json(); return data['version']; } + +export async function getHotspotStatus() { + let response = await apiGet(`${API_URI}/hotspot`); + if (response.status === 200) { + let {status} = await response.json(); + return status; + } else { + let code = (await response.json())['code']; + if (response.status === 400 && code === 34) { + // Must not be running on a RPi. Don't warn the user. + return null; + } else { + toast({ + type: 'error', + title: "Can't get Hotspot status", + description: 'Failed to get Hotspot status. Check client and server logs.', + time: 5000, + }); + } + } + return null; +} + +export async function setHotspot(on) { + let response; + if (on) { + response = await apiPost(`${API_URI}/hotspot/on`); + } else { + response = await apiPost(`${API_URI}/hotspot/off`); + } + if (response.status === 204) { + return null; + } else { + let code = (await response.json())['code']; + if (code === 34) { + toast({ + type: 'error', + title: 'Unsupported!', + description: 'Hotspot is only supported on a Raspberry Pi!', + time: 5000, + }); + } else { + toast({ + type: 'error', + title: 'Error!', + description: 'Could not modify hotspot. See server logs.', + time: 5000, + }); + } + } +} diff --git a/app/src/components/Admin.js b/app/src/components/Admin.js index 811c10f2..3ff10020 100644 --- a/app/src/components/Admin.js +++ b/app/src/components/Admin.js @@ -11,18 +11,18 @@ import { Placeholder, Table } from "semantic-ui-react"; -import { - getConfig, - getDownloaders, - getDownloads, - killDownload, - killDownloads, - postDownload, - saveConfig, - startDownloads -} from "../api"; +import {getConfig, getDownloads, killDownload, postDownload, saveConfig} from "../api"; import TimezoneSelect from 'react-timezone-select'; -import {PageContainer, secondsToDate, secondsToFrequency, TabLinks, textEllipsis, WROLModeMessage} from "./Common"; +import { + DisableDownloadsToggle, + HotspotToggle, + PageContainer, + secondsToDate, + secondsToFrequency, + TabLinks, + textEllipsis, + WROLModeMessage +} from "./Common"; import {Route} from "react-router-dom"; class Settings extends React.Component { @@ -78,6 +78,10 @@ class Settings extends React.Component { return ( + + + +

The global settings for your server.

@@ -305,17 +309,6 @@ class Downloads extends React.Component { async componentDidMount() { await this.fetchDownloads(); - await this.fetchStatus(); - } - - killDownloads = async () => { - await killDownloads(); - this.setState({stopOpen: false}, this.fetchStatus); - } - - startDownloads = async () => { - await startDownloads(); - this.setState({stopOpen: false}, this.fetchStatus); } closeStop = () => { @@ -335,11 +328,6 @@ class Downloads extends React.Component { }); } - fetchStatus = async () => { - let data = await getDownloaders(); - this.setState({disabled: data['manager_disabled']}); - } - render() { let tablePlaceholder = ( @@ -378,7 +366,9 @@ class Downloads extends React.Component { onceTable = ( {stoppableHeader} - {this.state.once_downloads.map((i) => )} + {this.state.once_downloads.map((i) => + + )}
); } @@ -390,46 +380,15 @@ class Downloads extends React.Component { recurringTable = ( {nonStoppableHeader} - {this.state.recurring_downloads.map((i) => )} + {this.state.recurring_downloads.map((i) => )}
); } - let {stopOpen} = this.state; - let allButton = (<> - - - - ); - if (this.state.disabled === true) { - allButton = (<> - - - ); - } - return (
- {allButton} +
Downloads
{onceTable} diff --git a/app/src/components/Common.js b/app/src/components/Common.js index cd6f2e8a..29ced8b3 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -1,10 +1,11 @@ import React, {useEffect, useState} from "react"; -import {Card, Container, Form, Icon, Image, Input, Menu, Pagination, Responsive} from 'semantic-ui-react'; +import {Card, Checkbox, Container, Form, Icon, Image, Input, Menu, Pagination, Responsive} from 'semantic-ui-react'; import {Link, NavLink} from "react-router-dom"; import Button from "semantic-ui-react/dist/commonjs/elements/Button"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; import {getConfig} from "../api"; +import {useDownloaders, useHotspot} from "../hooks/customHooks"; export const API_URI = `http://${window.location.host}/api`; export const VIDEOS_API = `${API_URI}/videos`; @@ -643,3 +644,23 @@ export function CardGroupCentered(props) { ) } + +export function HotspotToggle() { + let {on, setHotspot} = useHotspot(); + return setHotspot(data.checked)} + />; +} + +export function DisableDownloadsToggle() { + let {on, setDownloads} = useDownloaders(); + return setDownloads(data.checked)} + />; +} diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 3e22f71c..b6cbdc2f 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -1,5 +1,17 @@ import {useEffect, useState} from "react"; -import {fetchDomains, filesSearch, getFiles, getVersion, searchArchives, searchVideos} from "../api"; +import { + fetchDomains, + filesSearch, + getDownloaders, + getFiles, + getHotspotStatus, + getVersion, + killDownloads, + searchArchives, + searchVideos, + setHotspot, + startDownloads +} from "../api"; import {useHistory} from "react-router-dom"; export const useSearchParam = (key, defaultValue = null) => { @@ -178,3 +190,60 @@ export const useBrowseFiles = () => { return {browseFiles, openFolders, setOpenFolders, fetchFiles}; } + +export const useHotspot = () => { + const [on, setOn] = useState(null); + + const fetchHotspotStatus = async () => { + const status = await getHotspotStatus(); + if (status === 'connected') { + // Hotspot is on. + setOn(true); + } else if (status === 'disconnected' || status === 'unavailable') { + // Hotspot can be turned on. + setOn(false); + } else { + // Hotspot is not supported. API is probably running in a Docker container. + setOn(null); + } + } + + useEffect(() => { + fetchHotspotStatus(); + }, []); + + const localSetHotspot = async (on) => { + setOn(null); + await setHotspot(on); + await fetchHotspotStatus(); + } + + return {on, setOn, setHotspot: localSetHotspot}; +} + +export const useDownloaders = () => { + const [on, setOn] = useState(null); + const [downloaders, setDownloaders] = useState(null); + + const fetchDownloaders = async () => { + let data = await getDownloaders(); + setDownloaders(data['downloaders']); + setOn(!data['manager_disabled']); + } + + useEffect(() => { + fetchDownloaders(); + }, []); + + const localSetDownloads = async (on) => { + setOn(null); + if (on) { + await startDownloads(); + } else { + await killDownloads(); + } + await fetchDownloaders(); + } + + return {on, setOn, downloaders, setDownloads: localSetDownloads}; +} diff --git a/docker/archive/Dockerfile b/docker/archive/Dockerfile index 2b10d588..e393fc18 100644 --- a/docker/archive/Dockerfile +++ b/docker/archive/Dockerfile @@ -17,7 +17,4 @@ COPY docker/archive /app # Install Readability too RUN npm install -g 'git+https://github.com/pirate/readability-extractor' -# Block ads using /etc/hosts -#RUN wget https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts -O /etc/hosts - ENTRYPOINT [ "python3", "/app/main.py"] diff --git a/requirements.txt b/requirements.txt index d204d37d..12a12dc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,4 +19,4 @@ SQLAlchemy==1.3.22 srt==3.5.1 websockets==10.1 webvtt-py==0.4.6 -yt-dlp==2022.1.21 +yt-dlp==2022.2.4 diff --git a/wrolpi/admin.py b/wrolpi/admin.py new file mode 100644 index 00000000..4119ae15 --- /dev/null +++ b/wrolpi/admin.py @@ -0,0 +1,67 @@ +import enum +import pathlib +import subprocess + +from wrolpi.common import logger +from wrolpi.vars import PYTEST + +logger = logger.getChild(__name__) + +# Location on an RPi +SUDO_BIN = pathlib.Path('/usr/bin/sudo') +if not SUDO_BIN.is_file() and not PYTEST: + logger.error('COULD NOT FIND sudo!!!') +NMCLI_BIN = pathlib.Path('/usr/bin/nmcli') +if not NMCLI_BIN.is_file() and not PYTEST: + logger.error('COULD NOT FIND nmcli!!!') + + +class HotspotStatus(enum.Enum): + disconnected = enum.auto() # Radio is on, but Hotspot is not connected. + unavailable = enum.auto() # Radio is off. + connected = enum.auto() # Radio is on, Hotspot is on. + + +def hotspot_status() -> HotspotStatus: + cmd = (NMCLI_BIN,) + output = subprocess.check_output(cmd).decode().strip() + + for line in output.splitlines(): + if line.startswith('wlan0: connected'): + return HotspotStatus.connected + elif line.startswith('wlan0: disconnected'): + return HotspotStatus.disconnected + elif line.startswith('wlan0: unavailable'): + return HotspotStatus.unavailable + + raise Exception(f'Unknown status! {output=}') + + +def enable_hotspot(): + """ + Turn the wlan0 interface into a hotspot. If wlan0 is already running, replace that with a hotspot. + """ + status = hotspot_status() + logger.warning(f'Hotspot status: {status}') + + if status == HotspotStatus.connected: + disable_hotspot() + return enable_hotspot() + elif status == HotspotStatus.disconnected: + # Radio is on, but not connected. Good, turn it into a hotspot. + cmd = (SUDO_BIN, NMCLI_BIN, 'device', 'wifi', 'hotspot', 'ifname', 'wlan0', + 'ssid', 'WROLPi', 'password', 'wrolpi hotspot') + subprocess.check_call(cmd) + return True + elif status == HotspotStatus.unavailable: + # Radio is not on, turn it on. + cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'on') + subprocess.check_call(cmd) + return enable_hotspot() + + +def disable_hotspot(): + """Turn off the wlan0 interface.""" + cmd = (SUDO_BIN, NMCLI_BIN, 'radio', 'wifi', 'off') + subprocess.check_call(cmd) + return True diff --git a/wrolpi/common.py b/wrolpi/common.py index 21808a30..df217dd6 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -27,8 +27,9 @@ from sqlalchemy import types from sqlalchemy.ext.compiler import compiles from sqlalchemy.ext.declarative import declarative_base -from wrolpi.errors import WROLModeEnabled -from wrolpi.vars import CONFIG_PATH, EXAMPLE_CONFIG_PATH, PUBLIC_HOST, PUBLIC_PORT, PROJECT_DIR, PYTEST, MODULES_DIR +from wrolpi.errors import WROLModeEnabled, NativeOnly +from wrolpi.vars import CONFIG_PATH, EXAMPLE_CONFIG_PATH, PUBLIC_HOST, PUBLIC_PORT, PROJECT_DIR, PYTEST, MODULES_DIR, \ + DOCKERIZED logger = logging.getLogger() ch = logging.StreamHandler() @@ -655,3 +656,14 @@ INVALID_FILE_CHARS = re.compile(r'[/<>:\|"\\\?\*]') def escape_file_name(name: str) -> str: """Replace any invalid characters in a file name with "_".""" return INVALID_FILE_CHARS.sub('', name) + + +def native_only(func: callable): + """Wraps a function. Raises NativeOnly if run while Dockerized.""" + @wraps(func) + def wrapped(*a, **kw): + if DOCKERIZED: + raise NativeOnly('Only supported on a Raspberry Pi') + return func(*a, **kw) + + return wrapped diff --git a/wrolpi/errors.py b/wrolpi/errors.py index bccb667f..0d5eff62 100644 --- a/wrolpi/errors.py +++ b/wrolpi/errors.py @@ -140,6 +140,10 @@ class InvalidFile(APIError): pass +class NativeOnly(APIError): + pass + + error_codes = iter(range(1, 1000)) API_ERRORS = { @@ -307,5 +311,10 @@ API_ERRORS = { 'code': next(error_codes), 'message': 'File does not exist or is a directory', 'status': HTTPStatus.BAD_REQUEST, - } + }, + NativeOnly: { + 'code': next(error_codes), + 'message': 'This functionality is only supported outside a docker container', + 'status': HTTPStatus.BAD_REQUEST, + }, } diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 2f94f392..5e30fcc2 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -14,8 +14,10 @@ from sanic.response import HTTPResponse from sanic_ext import validate from sanic_ext.extensions.openapi import openapi +from wrolpi import admin +from wrolpi.admin import HotspotStatus from wrolpi.common import set_sanic_url_parts, logger, get_config, wrol_mode_enabled, save_settings_config, \ - Base, get_media_directory, wrol_mode_check + Base, get_media_directory, wrol_mode_check, native_only from wrolpi.dates import set_timezone from wrolpi.downloader import download_manager from wrolpi.errors import WROLModeEnabled, InvalidTimezone, API_ERRORS, APIError, ValidationError @@ -210,6 +212,38 @@ async def get_downloaders(_: Request): return json_response(ret) +@root_api.get('/hotspot') +@native_only +@openapi.description('Get the status of the hotspot') +async def hotspot(_: Request): + status = admin.hotspot_status() + # Get the name of the enum. + status = status.name + + ret = dict(status=status) + return json_response(ret) + + +@root_api.post('/hotspot/on') +@openapi.description('Turn on the hotspot') +@native_only +async def hotspot_on(_: Request): + result = admin.enable_hotspot() + if result: + return response.empty() + return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR) + + +@root_api.post('/hotspot/off') +@openapi.description('Turn off the hotspot') +@native_only +async def hotspot_off(_: Request): + result = admin.disable_hotspot() + if result: + return response.empty() + return response.empty(HTTPStatus.INTERNAL_SERVER_ERROR) + + class CustomJSONEncoder(json.JSONEncoder): def default(self, obj): diff --git a/wrolpi/schema.py b/wrolpi/schema.py index 3c41ed6f..2a471686 100644 --- a/wrolpi/schema.py +++ b/wrolpi/schema.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional @dataclass @@ -52,7 +52,7 @@ class EventsResponse: @dataclass class DownloadRequest: urls: str - downloader: str + downloader: Optional[str] = None @dataclass diff --git a/wrolpi/test/test_admin.py b/wrolpi/test/test_admin.py new file mode 100644 index 00000000..b5430ef4 --- /dev/null +++ b/wrolpi/test/test_admin.py @@ -0,0 +1,63 @@ +from pathlib import PosixPath +from unittest import mock +from unittest.mock import call + +from wrolpi import admin +from wrolpi.admin import HotspotStatus + + +def test_hotspot_status(): + with mock.patch('wrolpi.admin.subprocess') as mock_subprocess: + mock_subprocess.check_output.return_value = b'wlan0: unavailable' + assert admin.hotspot_status() == HotspotStatus.unavailable + mock_subprocess.check_output.return_value = b'wlan0: disconnected' + assert admin.hotspot_status() == HotspotStatus.disconnected + mock_subprocess.check_output.return_value = b'wlan0: connected' + assert admin.hotspot_status() == HotspotStatus.connected + + +def test_enable_hotspot_disconnected(): + """Enable hotspot with wifi on.""" + with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \ + mock.patch('wrolpi.admin.subprocess') as mock_subprocess: + mock_hotspot_status.return_value = HotspotStatus.disconnected + mock_subprocess.check_output.return_value = 0 + assert admin.enable_hotspot() is True + mock_subprocess.check_call.assert_called_once_with( + (PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', 'wlan0', + 'ssid', 'WROLPi', 'password', 'wrolpi hotspot')) + + +def test_enable_hotspot_unavailable(): + """Enable hotspot with wifi off.""" + with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \ + mock.patch('wrolpi.admin.subprocess') as mock_subprocess: + mock_hotspot_status.side_effect = [HotspotStatus.unavailable, HotspotStatus.disconnected] + mock_subprocess.check_output.return_value = 0 + assert admin.enable_hotspot() is True + mock_subprocess.check_call.assert_has_calls( + [ + call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'on')), + call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', + 'wlan0', 'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))] + ) + + +def test_enable_hotspot_connected(): + """Enable hotspot with the hotspot alaready enabled.""" + with mock.patch('wrolpi.admin.hotspot_status') as mock_hotspot_status, \ + mock.patch('wrolpi.admin.subprocess') as mock_subprocess: + mock_hotspot_status.side_effect = [ + HotspotStatus.connected, + HotspotStatus.unavailable, + HotspotStatus.disconnected, + ] + mock_subprocess.check_output.return_value = 0 + assert admin.enable_hotspot() is True + mock_subprocess.check_call.assert_has_calls( + [ + call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'off')), + call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'radio', 'wifi', 'on')), + call((PosixPath('/usr/bin/sudo'), PosixPath('/usr/bin/nmcli'), 'device', 'wifi', 'hotspot', 'ifname', + 'wlan0', 'ssid', 'WROLPi', 'password', 'wrolpi hotspot'))] + )