Compare commits
No commits in common. "0.1.0" and "master" have entirely different histories.
|
@ -0,0 +1,5 @@
|
|||
/dist/*
|
||||
/bot.z_web/node_modules/*
|
||||
/.*yaml
|
||||
/.*yml
|
||||
/Makefile
|
|
@ -1,15 +0,0 @@
|
|||
* Bot_Z version:
|
||||
* Python version:
|
||||
* Operating System:
|
||||
|
||||
### Description
|
||||
|
||||
Describe what you were trying to get done.
|
||||
Tell us what happened, what went wrong, and what you expected to happen.
|
||||
|
||||
### What I Did
|
||||
|
||||
```
|
||||
Paste the command(s) you ran and the output.
|
||||
If there was a crash, please include the traceback here.
|
||||
```
|
|
@ -1,5 +1,8 @@
|
|||
# Postinstall bineries
|
||||
# Postinstall binaries and assets
|
||||
/bot_z/bin/*
|
||||
/api/assets/*
|
||||
# debug config file
|
||||
/.botz.yaml
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
FROM node AS node
|
||||
|
||||
FROM selenium/standalone-firefox AS ff
|
||||
|
||||
FROM python:3.7-buster
|
||||
|
||||
LABEL author="Blallo"
|
||||
LABEL email="blallo@autistici.org"
|
||||
LABEL io.troubles.botz.release-date="2019-09-25"
|
||||
LABEL io.troubles.botz.version="1.1.3"
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --from=node /usr/local /usr/local
|
||||
COPY --from=node /opt /opt
|
||||
COPY --from=ff /opt/firefox-latest /opt/firefox-latest
|
||||
COPY --from=ff /opt/geckodriver* /opt/
|
||||
COPY . /app
|
||||
COPY entrypoint.sh /srv/
|
||||
WORKDIR /app/bot.z_web
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y $(apt-cache depends firefox-esr| awk '/Depends:/{print$2}') \
|
||||
&& ln -s /opt/firefox-latest/firefox /usr/bin/firefox \
|
||||
&& ln -s /opt/geckodriver* /usr/bin/geckodriver \
|
||||
&& yarn install \
|
||||
&& yarn build \
|
||||
&& mkdir -p /app/api/assets \
|
||||
&& cp -r build/* /app/api/assets/ \
|
||||
&& rm -r node_modules/ /var/lib/apt/lists/* /var/cache/apt/* /tmp/*
|
||||
WORKDIR /app
|
||||
RUN python setup.py develop
|
||||
|
||||
EXPOSE 3003
|
||||
VOLUME ["/app/bot.z_web/node_modules"]
|
||||
|
||||
ENTRYPOINT ["/srv/entrypoint.sh"]
|
96
Makefile
96
Makefile
|
@ -1,40 +1,16 @@
|
|||
.PHONY: clean clean-test clean-pyc clean-build docs help
|
||||
.DEFAULT_GOAL := help
|
||||
define BROWSER_PYSCRIPT
|
||||
import os, webbrowser, sys
|
||||
try:
|
||||
from urllib import pathname2url
|
||||
except:
|
||||
from urllib.request import pathname2url
|
||||
.PHONY: clean clean-test clean-pyc clean-build
|
||||
|
||||
webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1])))
|
||||
endef
|
||||
export BROWSER_PYSCRIPT
|
||||
|
||||
define PRINT_HELP_PYSCRIPT
|
||||
import re, sys
|
||||
|
||||
for line in sys.stdin:
|
||||
match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
|
||||
if match:
|
||||
target, help = match.groups()
|
||||
print("%-20s %s" % (target, help))
|
||||
endef
|
||||
export PRINT_HELP_PYSCRIPT
|
||||
BROWSER := python -c "$$BROWSER_PYSCRIPT"
|
||||
|
||||
help:
|
||||
@python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
|
||||
VERSION = 1.1.3
|
||||
|
||||
clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts
|
||||
|
||||
|
||||
clean-build: ## remove build artifacts
|
||||
rm -fr build/
|
||||
rm -fr dist/
|
||||
rm -fr .eggs/
|
||||
find . -name '*.egg-info' -exec rm -fr {} +
|
||||
find . -name '*.egg' -exec rm -f {} +
|
||||
rm -fr bot_z.web/build/
|
||||
|
||||
clean-pyc: ## remove Python file artifacts
|
||||
find . -name '*.pyc' -exec rm -f {} +
|
||||
|
@ -47,41 +23,41 @@ clean-test: ## remove test and coverage artifacts
|
|||
rm -f .coverage
|
||||
rm -fr htmlcov/
|
||||
|
||||
lint: ## check style with flake8
|
||||
flake8 bot_z tests
|
||||
release: clean build docker-release
|
||||
|
||||
test: ## run tests quickly with the default Python
|
||||
|
||||
python setup.py test
|
||||
|
||||
test-all: ## run tests on every Python version with tox
|
||||
tox
|
||||
|
||||
coverage: ## check code coverage quickly with the default Python
|
||||
coverage run --source bot_z setup.py test
|
||||
coverage report -m
|
||||
coverage html
|
||||
$(BROWSER) htmlcov/index.html
|
||||
|
||||
docs: ## generate Sphinx HTML documentation, including API docs
|
||||
rm -f docs/bot_z.rst
|
||||
rm -f docs/modules.rst
|
||||
sphinx-apidoc -o docs/ bot_z
|
||||
$(MAKE) -C docs clean
|
||||
$(MAKE) -C docs html
|
||||
$(BROWSER) docs/_build/html/index.html
|
||||
|
||||
servedocs: docs ## compile the docs watching for changes
|
||||
watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D .
|
||||
|
||||
release: clean ## package and upload a release
|
||||
python setup.py sdist upload
|
||||
python setup.py bdist_wheel upload
|
||||
|
||||
dist: clean ## builds source and wheel package
|
||||
sdist:
|
||||
python setup.py sdist
|
||||
python setup.py bdist_wheel
|
||||
ls -l dist
|
||||
|
||||
build: sdist build-linux64 build-linux32 build-win32 build-win64 build-macos
|
||||
|
||||
build-linux64:
|
||||
python setup.py bdist_wheel --plat-name manylinux1-x86_64
|
||||
|
||||
sdist/bot_z-$(VERSION)-py3-none-manylinux1_x86_64.whl: build-linux64
|
||||
|
||||
build-linux32:
|
||||
python setup.py bdist_wheel --plat-name manylinux1-i686
|
||||
|
||||
sdist/bot_z-$(VERSION)-py3-none-manylinux1_i686.whl: build-linux32
|
||||
|
||||
build-win32:
|
||||
python setup.py bdist_wheel --plat-name win32
|
||||
|
||||
sdist/bot_z-$(VERSION)-py3-none-win32.whl: build-win32
|
||||
|
||||
build-win64:
|
||||
python setup.py bdist_wheel --plat-name win-amd64
|
||||
|
||||
sdist/bot_z-$(VERSION)-py3-none-manylinux1_win-amd64.whl: build-win64
|
||||
|
||||
build-macos:
|
||||
python setup.py bdist_wheel --plat-name macosx
|
||||
|
||||
sdist/bot_z-$(VERSION)-py3-none-macosx.whl: build-macos
|
||||
|
||||
install: clean ## install the package to the active Python's site-packages
|
||||
python setup.py install
|
||||
|
||||
docker-release:
|
||||
docker build -t botz:latest .
|
||||
docker tag botz:latest botz:$(VERSION)
|
||||
|
|
64
README.md
64
README.md
|
@ -13,6 +13,62 @@ Features
|
|||
|
||||
* Login/Logout
|
||||
* Check in/Check out
|
||||
* web ui
|
||||
|
||||
|
||||
Develop
|
||||
-------
|
||||
|
||||
|
||||
To allow local development you either need a configuration file named
|
||||
`.botz.yaml` in the root of the project with something like this:
|
||||
|
||||
```
|
||||
---
|
||||
base_uri: "<your_target_uri>"
|
||||
debug: true
|
||||
headless: true
|
||||
log:
|
||||
level: DEBUG
|
||||
syslog: false
|
||||
http:
|
||||
bind_addr:
|
||||
- "127.0.0.1"
|
||||
port: 3003
|
||||
cookie_secure: false
|
||||
cors_allow: "http://localhost:3000"
|
||||
```
|
||||
|
||||
Read the docstring in `api/conf.py` to understand the menaning of the
|
||||
various parameters.
|
||||
|
||||
*Do not `pip install -e .`*. It will miss the geckodriver download step.
|
||||
Take a look at the provided `Dockerfile`.
|
||||
|
||||
You can either run it:
|
||||
|
||||
```
|
||||
$ docker build -t botz:latest .
|
||||
$ docker run -v $PWD:/app -p "3003:3003" botz
|
||||
```
|
||||
|
||||
and find a working app at `http://localhost:3003`.
|
||||
Or you can `python setup.py bdist_wheel && python setup.py develop`.
|
||||
You will need:
|
||||
|
||||
- `python >= 3.7`
|
||||
- `yarn` (`npm` support coming soon...)
|
||||
|
||||
If you want to develop the ui, you can also serve it via yarn (as it
|
||||
supports hot reload):
|
||||
|
||||
```
|
||||
$ cd bot.z_web
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
You will find a working ui at `localhost:3000` (but you need the last two
|
||||
lines of the previous example config file).
|
||||
|
||||
|
||||
Install
|
||||
|
@ -33,7 +89,7 @@ You can either proceed in one of the following ways:
|
|||
TODO
|
||||
----
|
||||
|
||||
[x] Check in/out
|
||||
[ ] systemd {unit, timer}
|
||||
[ ] APIs
|
||||
[ ] Mailer
|
||||
- [x] Check in/out
|
||||
- [ ] systemd {unit, timer}
|
||||
- [x] APIs
|
||||
- [ ] Mailer
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
from distutils.util import strtobool
|
||||
import os
|
||||
|
||||
# TODO: create config module and put this constant there.
|
||||
BASE_URI = os.environ.get("BOTZ_BASE_URI", "http://localhost")
|
||||
DEBUG = strtobool(os.environ.get("BOTZ_DEBUG", "False"))
|
|
@ -0,0 +1,108 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
|
||||
"""
|
||||
The application entrypoint.
|
||||
"""
|
||||
|
||||
from aiohttp import web
|
||||
import base64
|
||||
from cryptography import fernet
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import typing as T
|
||||
|
||||
import click
|
||||
from aiohttp_session import setup, session_middleware
|
||||
from aiohttp_session.cookie_storage import EncryptedCookieStorage
|
||||
|
||||
from bot_z.async_operator import AsyncOperator
|
||||
from api.rest import routes, add_static_routes
|
||||
from api.conf import read_conf
|
||||
|
||||
|
||||
def setup_log(level: str, syslog: bool) -> logging.Logger:
|
||||
alog = logging.getLogger("api")
|
||||
alog.setLevel(os.environ.get("BOTZ_LOGLEVEL", level))
|
||||
h = logging.StreamHandler()
|
||||
f = logging.Formatter("%(levelname)s [%(name)s] -> %(message)s")
|
||||
h.setFormatter(f)
|
||||
alog.addHandler(h)
|
||||
if syslog:
|
||||
sh = logging.handlers.SysLogHandler()
|
||||
alog.addHandler(sh)
|
||||
return alog
|
||||
|
||||
|
||||
def init_secret() -> bytes:
|
||||
fernet_key = fernet.Fernet.generate_key()
|
||||
return base64.urlsafe_b64decode(fernet_key)
|
||||
|
||||
|
||||
def setup_session(app: web.Application, secure: bool, max_age: int):
|
||||
secret = init_secret()
|
||||
setup(
|
||||
app,
|
||||
EncryptedCookieStorage(
|
||||
secret_key=secret,
|
||||
cookie_name="BOTZ_SESSION",
|
||||
httponly=False,
|
||||
secure=secure,
|
||||
max_age=max_age,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def run(
|
||||
address: T.Optional[T.Text], port: T.Optional[int], conf_path: T.Optional[T.Text]
|
||||
) -> None:
|
||||
"""Application entrypoint."""
|
||||
conf = read_conf(conf_path)
|
||||
|
||||
# This closure is needed to intercept the to-be-prepared response
|
||||
# and add the right CORS headers
|
||||
async def on_prepare_cors(request, response):
|
||||
response.headers["Access-Control-Allow-Origin"] = conf["http"].get("cors_allow")
|
||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||
|
||||
alog = setup_log(conf["log"]["level"], conf["log"]["syslog"])
|
||||
alog.debug("conf %s", conf)
|
||||
app = web.Application(logger=alog)
|
||||
app["base_uri"] = conf["base_uri"]
|
||||
app["debug"] = conf["debug"]
|
||||
app["headless"] = conf["headless"]
|
||||
if conf["http"].get("cors_allow"):
|
||||
app.on_response_prepare.append(on_prepare_cors)
|
||||
setup_session(app, conf["http"]["cookie_secure"], conf["http"]["session_timeout"])
|
||||
add_static_routes(alog)
|
||||
app.add_routes(routes)
|
||||
addr = []
|
||||
if address is not None:
|
||||
addr = [address]
|
||||
elif conf["http"].get("bind_addr"):
|
||||
addr.extend(conf["http"]["bind_addr"])
|
||||
else:
|
||||
addr = ["127.0.0.1"]
|
||||
if port is None:
|
||||
port = conf["http"]["port"]
|
||||
alog.debug("Starting app with: addr -> %s, port -> %d", addr, port)
|
||||
web.run_app(app, host=addr, port=port)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"-a", "--address", type=click.STRING, help="Address to bind the server to."
|
||||
)
|
||||
@click.option("-p", "--port", type=click.INT, help="Port to bind to.")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--conf",
|
||||
type=click.Path(exists=False),
|
||||
help="A path to a configuration file.",
|
||||
)
|
||||
def cli(
|
||||
address: T.Optional[T.Text],
|
||||
port: T.Optional[int] = None,
|
||||
conf: T.Optional[T.Text] = None,
|
||||
) -> None:
|
||||
run(address, port, conf)
|
|
@ -0,0 +1,92 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Asyncio wrapper for AsyncOperator methods.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
import typing as T
|
||||
|
||||
from bot_z.async_operator import AsyncOperator
|
||||
from bot_z.exceptions import OperationFailed
|
||||
|
||||
|
||||
@contextmanager
|
||||
def this_loop(loop):
|
||||
_loop = asyncio.get_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
yield
|
||||
asyncio.set_event_loop(_loop)
|
||||
|
||||
|
||||
def init(base_url: T.Text, name: T.Text) -> AsyncOperator:
|
||||
"""
|
||||
Initialize the stateful object.
|
||||
"""
|
||||
return AsyncOperator(base_url, name)
|
||||
|
||||
|
||||
async def login(op: AsyncOperator, username: T.Text, password: T.Text) -> bool:
|
||||
"""
|
||||
Executes the login asynchronously and returns
|
||||
the AsyncOperator object.
|
||||
"""
|
||||
if not op.logged_in:
|
||||
try:
|
||||
await op.login(username, password)
|
||||
except OperationFailed:
|
||||
pass
|
||||
return op.logged_in
|
||||
|
||||
|
||||
async def logout(op: AsyncOperator) -> bool:
|
||||
"""
|
||||
Executes the logout asynchronously and returns
|
||||
a bool showing the success or failure of the operation.
|
||||
"""
|
||||
if op.logged_in:
|
||||
try:
|
||||
await op.logout()
|
||||
except OperationFailed:
|
||||
pass
|
||||
return op.logged_in
|
||||
|
||||
|
||||
async def checkin(op: AsyncOperator) -> bool:
|
||||
"""
|
||||
Executes the check in asynchronously and returns
|
||||
a bool showing the success or failure of the operation.
|
||||
"""
|
||||
if op.logged_in and not op.checked_in:
|
||||
try:
|
||||
await op.checkin()
|
||||
except OperationFailed:
|
||||
pass
|
||||
return op.checked_in
|
||||
|
||||
|
||||
async def checkout(op: AsyncOperator) -> bool:
|
||||
"""
|
||||
Executes the checkout asynchronously and returns
|
||||
a bool showing the success or failure of the operation.
|
||||
"""
|
||||
if op.logged_in and op.checked_in:
|
||||
try:
|
||||
await op.checkout()
|
||||
except OperationFailed:
|
||||
pass
|
||||
return op.checked_in
|
||||
|
||||
|
||||
async def status(op: AsyncOperator) -> T.List[T.Optional[T.Tuple[T.Text, T.Text]]]:
|
||||
"""
|
||||
Asks the list of movements asynchronously and returns a list
|
||||
of tuples containing the succession of movements. If the operation
|
||||
fails, the list is empty.
|
||||
"""
|
||||
movements = []
|
||||
if op.logged_in:
|
||||
res = await op.get_movements()
|
||||
movements.extend(res)
|
||||
return movements
|
|
@ -0,0 +1,99 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
import os
|
||||
from pprint import pprint
|
||||
import typing as T
|
||||
|
||||
import yaml
|
||||
|
||||
from api import DEBUG, BASE_URI
|
||||
|
||||
|
||||
def read_conf(path: T.Optional[T.Text]) -> T.Dict:
|
||||
"""
|
||||
Read the configuration from the provided path.
|
||||
Such configuration may provide the following information:
|
||||
---
|
||||
base_uri: <base uri of the target instance>
|
||||
debug: <bool, set debug on, defaults to false>
|
||||
headless: <bool, use headless mode, defaults to true>
|
||||
log:
|
||||
level: <may be DEBUG, INFO, WARN, ..., defaults at INFO>
|
||||
syslog: <bool, whether to redirect to standard syslog, defaults to false>
|
||||
http:
|
||||
bind_addr: <a list of addresses to bind to>
|
||||
port: <int, the port to bind to>
|
||||
cookie_name: <defaults to BOTZ_SESSION>
|
||||
cookie_secure: <bool, whether to set Secure cookie flag, defaults to true>
|
||||
session_timeout: <int, the expiration time of the session ins secs, defaults to 300>
|
||||
cors_allow: <an optional single allowed Cross Origin domain>
|
||||
"""
|
||||
if path is None:
|
||||
path = seek_path()
|
||||
if path is not None:
|
||||
with open(path) as f:
|
||||
conf = yaml.safe_load(f)
|
||||
else:
|
||||
conf = {}
|
||||
if "base_uri" not in conf:
|
||||
if BASE_URI is None:
|
||||
raise RuntimeError("Missing base_uri")
|
||||
conf["base_uri"] = BASE_URI
|
||||
if "debug" not in conf:
|
||||
conf["debug"] = DEBUG
|
||||
if "headless" not in conf:
|
||||
conf["headless"] = True
|
||||
conf = validate_log_conf(conf)
|
||||
conf = validate_http_log(conf)
|
||||
pprint(conf)
|
||||
return conf
|
||||
|
||||
|
||||
def seek_path() -> T.Optional[T.Text]:
|
||||
"""
|
||||
Seeks the path to a config file, in the following order:
|
||||
- $BOTZ_CONFIG
|
||||
- $PWD/.botz.yaml
|
||||
- ~/.botz.yaml
|
||||
- /etc/botz/conf.yaml
|
||||
"""
|
||||
paths = [
|
||||
os.path.join(os.path.curdir, ".botz.yaml"),
|
||||
os.path.expanduser("~/.botz.yaml"),
|
||||
"/etc/botz/conf.yaml",
|
||||
]
|
||||
env = os.environ.get("BOTZ_CONFIG")
|
||||
if env is not None:
|
||||
paths.insert(0, env)
|
||||
for path in paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def validate_log_conf(conf: T.Dict[T.Text, T.Any]) -> T.Dict[T.Text, T.Any]:
|
||||
if "log" not in conf:
|
||||
conf["log"] = {}
|
||||
if conf["log"].get("level") is None:
|
||||
conf["log"]["level"] = "INFO"
|
||||
if conf["log"].get("syslog") is None:
|
||||
conf["log"]["syslog"] = False
|
||||
return conf
|
||||
|
||||
|
||||
def validate_http_log(conf: T.Dict[T.Text, T.Any]) -> T.Dict[T.Text, T.Any]:
|
||||
if "http" not in conf:
|
||||
conf["http"] = {}
|
||||
if conf["http"].get("bind_addr") is None:
|
||||
conf["http"]["bind_addr"] = ["127.0.0.1"]
|
||||
if conf["http"].get("port") is None:
|
||||
conf["http"]["port"] = 3003
|
||||
if conf["http"].get("cookie_name") is None:
|
||||
conf["http"]["cookie_name"] = "BOTZ_SESSION"
|
||||
if conf["http"].get("cookie_secure") is None:
|
||||
conf["http"]["cookie_secure"] = True
|
||||
if conf["http"].get("session_timeout") is None:
|
||||
conf["http"]["session_timeout"] = 300
|
||||
elif isinstance(conf["http"]["session_timeout"], str):
|
||||
conf["http"]["session_timeout"] = int(conf["http"]["session_timeout"])
|
||||
|
||||
return conf
|
|
@ -0,0 +1,82 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
|
||||
from aiohttp import web
|
||||
import logging
|
||||
|
||||
import click
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(message)s", level=logging.INFO, handlers=[logging.StreamHandler()]
|
||||
)
|
||||
alog = logging.getLogger(__name__)
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
|
||||
@routes.get("/{tail:.*}")
|
||||
async def get_handler(request: web.Request) -> web.Response:
|
||||
alog.info("GET -> [%s]: %s", request.path, request.query)
|
||||
return web.json_response(
|
||||
{"method": request.method, "path": request.path},
|
||||
headers={"Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
|
||||
|
||||
@routes.post("/{tail:.*}")
|
||||
async def post_handler(request: web.Request) -> web.Response:
|
||||
data = await request.post()
|
||||
alog.info("POST -> [%s]: %s", request.path, data)
|
||||
return web.json_response(
|
||||
{"method": request.method, "path": request.path},
|
||||
headers={"Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
|
||||
|
||||
@routes.put("/{tail:.*}")
|
||||
async def put_handler(request: web.Request) -> web.Response:
|
||||
data = await request.post()
|
||||
alog.info("PUT -> [%s]: %s", request.path, data)
|
||||
return web.json_response(
|
||||
{"method": request.method, "path": request.path},
|
||||
headers={"Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
|
||||
|
||||
@routes.delete("/{tail:.*}")
|
||||
async def delete_handler(request: web.Request) -> web.Response:
|
||||
data = await request.post()
|
||||
alog.info("DELETE -> [%s]: %s", request.path, data)
|
||||
return web.json_response(
|
||||
{"method": request.method, "path": request.path},
|
||||
headers={"Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
|
||||
|
||||
async def options_handler(request: web.Request) -> web.Response:
|
||||
alog.info("OPTIONS -> [%s]", request.path)
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
def run(address: str, port: int) -> None:
|
||||
"""Application entrypoint."""
|
||||
app = web.Application(logger=alog)
|
||||
app.add_routes(routes)
|
||||
app.router.add_route("OPTIONS", "/{tail:.*}", options_handler)
|
||||
web.run_app(app, host=address, port=port)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"-a",
|
||||
"--address",
|
||||
type=click.STRING,
|
||||
help="Address to bind the server to.",
|
||||
default="127.0.0.1",
|
||||
)
|
||||
@click.option("-p", "--port", type=click.INT, help="Port to bind to", default=3003)
|
||||
def cli(address: str, port: int) -> None:
|
||||
run(address, port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
|
@ -0,0 +1,224 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
"""
|
||||
The REST endpoints.
|
||||
"""
|
||||
|
||||
from aiohttp import web
|
||||
import asyncio
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import pkg_resources
|
||||
import typing as T
|
||||
import weakref
|
||||
|
||||
from aiohttp_session import new_session, get_session, Session
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
from bot_z.async_operator import AsyncOperator, push_to_loop
|
||||
from api.async_bot import login, logout, checkin, checkout, status
|
||||
from api import BASE_URI, DEBUG
|
||||
|
||||
alog = logging.getLogger("api")
|
||||
routes = web.RouteTableDef()
|
||||
OPERATORS = weakref.WeakKeyDictionary(
|
||||
{}
|
||||
) # type: weakref.WeakKeyDictionary[UserSession, AsyncOperator]
|
||||
USERS = {} # type: T.Dict[T.Text, UserSession]
|
||||
BASE_PATH = pkg_resources.resource_filename(__name__, "assets")
|
||||
EXECUTOR = ProcessPoolExecutor()
|
||||
# WARN: the default il 12 rounds; both the server and the client shall compute
|
||||
# this hash. The target client is a smartphone with poor performance on
|
||||
# crypto computations, so we should keep this low, as the verification step
|
||||
# is optionally enforced by the client.
|
||||
ROUNDS = 6
|
||||
|
||||
|
||||
class UserSession(object):
|
||||
"""
|
||||
Placeholder object to manipulate session life.
|
||||
"""
|
||||
|
||||
def __init__(self, user):
|
||||
self._user = user
|
||||
|
||||
|
||||
def _reckon_token_response(base_uri: T.Text) -> T.Text:
|
||||
return bcrypt.using(rounds=ROUNDS, truncate_error=True).hash(base_uri)
|
||||
|
||||
|
||||
async def reckon_token_response(
|
||||
base_uri: T.Text, loop: asyncio.AbstractEventLoop
|
||||
) -> T.Text:
|
||||
"""
|
||||
A client and the server should agree if the pairing is adequate.
|
||||
This could be accomplished calculating on both sides an cryptographic
|
||||
secret. The current implementation uses bcrypt to compute the hash on
|
||||
server side and the client should verify the secret on its side.
|
||||
"""
|
||||
return await push_to_loop(loop, EXECUTOR, _reckon_token_response, base_uri)
|
||||
|
||||
|
||||
async def get_set_operator(
|
||||
request: web.Request, user: T.Text, password: T.Text
|
||||
) -> T.Tuple[AsyncOperator, Session]:
|
||||
session = await get_session(request)
|
||||
op = None
|
||||
if "async_operator" in session:
|
||||
user_session = USERS.get(session["async_operator"])
|
||||
op = OPERATORS.get(user_session)
|
||||
else:
|
||||
session = await new_session(request)
|
||||
|
||||
if op is None or session.new:
|
||||
base_uri = request.app["base_uri"]
|
||||
debug = request.app["debug"]
|
||||
headless = request.app["headless"]
|
||||
op = AsyncOperator(base_uri, name=user, headless=headless, debug=debug)
|
||||
USERS[user] = UserSession(user)
|
||||
session["async_operator"] = user
|
||||
OPERATORS[USERS[user]] = op
|
||||
|
||||
return op, session
|
||||
|
||||
|
||||
def add_static_routes(log: logging.Logger) -> None:
|
||||
static_assets = [
|
||||
os.path.abspath(os.path.join(BASE_PATH, path))
|
||||
for path in os.listdir(BASE_PATH)
|
||||
if os.path.isdir(os.path.join(BASE_PATH, path))
|
||||
]
|
||||
for asset in static_assets:
|
||||
asset_path = os.path.relpath(asset, BASE_PATH)
|
||||
log.debug(f"Linking: {asset_path} -> {asset}")
|
||||
routes.static(f"/{asset_path}", asset)
|
||||
|
||||
|
||||
@routes.get("/")
|
||||
async def home_handle(request: web.Request) -> web.Response:
|
||||
return web.FileResponse(os.path.join(BASE_PATH, "index.html"))
|
||||
|
||||
|
||||
@routes.get("/favicon.ico")
|
||||
async def favicon_handle(request: web.Request) -> web.Response:
|
||||
return web.FileResponse(os.path.join(BASE_PATH, "favicon.ico"))
|
||||
|
||||
|
||||
@routes.get("/api/login")
|
||||
@routes.get("/api/badge")
|
||||
@routes.get("/api/status")
|
||||
async def routing_handler(request: web.Request) -> web.Response:
|
||||
alog.debug("(%s) %s", request.method, request.path)
|
||||
session = await get_session(request)
|
||||
op = session.get("async_operator")
|
||||
_logged_in = True if op else False
|
||||
alog.info("%s - is%s in session", request.path, " NOT" if not _logged_in else "")
|
||||
return web.json_response({"logged_in": _logged_in})
|
||||
|
||||
|
||||
@routes.get("/api/ping")
|
||||
async def ping_handler(request: web.Request) -> web.Response:
|
||||
alog.debug("pinged on %s", request.path)
|
||||
resp_data = await reckon_token_response(request.app["base_uri"], request.app.loop)
|
||||
alog.debug("ping response: %s", resp_data)
|
||||
return web.json_response({"hash": resp_data})
|
||||
|
||||
|
||||
@routes.post("/api/login")
|
||||
async def login_handler(request: web.Request) -> web.Response:
|
||||
data = await request.json()
|
||||
user = data.get("username")
|
||||
password = data.get("password")
|
||||
if not user or not password:
|
||||
alog.debug("login - missing username or password: %s", data)
|
||||
return web.json_response({"error": "Missing username or password"}, status=401)
|
||||
op, session = await get_set_operator(request, user, password)
|
||||
alog.debug("login - user: %s, password: %s", user, password)
|
||||
res = await login(op, user, password)
|
||||
alog.debug("login result: %s", res)
|
||||
if not res:
|
||||
session.invalidate()
|
||||
alog.info("Login failed; session invalidated.")
|
||||
return web.json_response({"logged_in": res}, status=200)
|
||||
|
||||
|
||||
@routes.post("/api/logout")
|
||||
async def logout_handler(request: web.Request) -> web.Response:
|
||||
alog.debug("logout")
|
||||
session = await get_session(request)
|
||||
user_session = USERS.get(session["async_operator"], UserSession("NOONE"))
|
||||
op = OPERATORS.get(user_session)
|
||||
if not op:
|
||||
return web.json_response(
|
||||
{"error": "No session", "logged_in": False}, status=401
|
||||
)
|
||||
res = await logout(op)
|
||||
session.invalidate()
|
||||
alog.debug("logout result: %s", res)
|
||||
# FIX: assess if better to invalidate session and dump the browser instance.
|
||||
del user_session
|
||||
return web.json_response({"logged_in": res}, status=200)
|
||||
|
||||
|
||||
@routes.post("/api/checkin")
|
||||
async def checkin_handler(request: web.Request) -> web.Response:
|
||||
alog.debug("checkin")
|
||||
session = await get_session(request)
|
||||
user_session = USERS.get(session["async_operator"], UserSession("NOONE"))
|
||||
op = OPERATORS.get(user_session)
|
||||
if not op:
|
||||
return web.json_response(
|
||||
{"error": "No session", "logged_in": False}, status=401
|
||||
)
|
||||
res = await checkin(op)
|
||||
alog.debug("checkin result: %s", res)
|
||||
return web.json_response({"checked_in": res, "logged_in": True}, status=200)
|
||||
|
||||
|
||||
@routes.post("/api/checkout")
|
||||
async def checkout_handler(request: web.Request) -> web.Response:
|
||||
alog.debug("checkout")
|
||||
session = await get_session(request)
|
||||
user_session = USERS.get(session["async_operator"], UserSession("NOONE"))
|
||||
op = OPERATORS.get(user_session)
|
||||
if not op:
|
||||
return web.json_response(
|
||||
{"error": "No session", "logged_in": False}, status=401
|
||||
)
|
||||
res = await checkout(op)
|
||||
alog.debug("checkout result: %s", res)
|
||||
return web.json_response({"checked_in": res, "logged_in": True}, status=200)
|
||||
|
||||
|
||||
@routes.get("/api/movements")
|
||||
async def movements_handle(request: web.Request) -> web.Response:
|
||||
alog.debug("movements")
|
||||
session = await get_session(request)
|
||||
user_session = USERS.get(session.get("async_operator"), UserSession("NOONE"))
|
||||
op = OPERATORS.get(user_session)
|
||||
if not op:
|
||||
alog.debug("Missing session")
|
||||
return web.json_response(
|
||||
{"error": "No session", "logged_in": False}, status=401
|
||||
)
|
||||
res = await status(op)
|
||||
alog.debug("movements result: %s", res)
|
||||
if not res:
|
||||
return web.json_response(
|
||||
{"error": "No movements found", "logged_in": True, "checked_in": False},
|
||||
status=404,
|
||||
)
|
||||
movements = []
|
||||
for r in res:
|
||||
if r and len(r) == 2:
|
||||
movements.append({"time": r[1], "type": r[0]})
|
||||
resp_data: T.Dict[T.Text, T.Any] = {"movements": movements}
|
||||
resp_data["logged_in"] = True
|
||||
try:
|
||||
last_movement = list(movements[-1])
|
||||
resp_data["checked_in"] = True if last_movement == "Entrata" else False
|
||||
except IndexError:
|
||||
alog.info("No movements found")
|
||||
|
||||
return web.json_response(resp_data, status=200)
|
|
@ -0,0 +1,23 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
|
@ -0,0 +1,68 @@
|
|||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.<br>
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br>
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br>
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br>
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br>
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "bot.z_web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"bootstrap": "^4.3.1",
|
||||
"react": "^16.8.6",
|
||||
"react-bootstrap": "^1.0.0-beta.10",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-router-dom": "^5.0.1",
|
||||
"react-scripts": "3.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 59 KiB |
|
@ -0,0 +1,38 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>BotZ</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"short_name": "BotZ",
|
||||
"name": "BotZ web interface",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
body {
|
||||
padding-top: 15%;
|
||||
padding-bottom: 20%;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
#main-form {
|
||||
max-width: 330px;
|
||||
padding: 15px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import IndexPage from './IndexPage.js';
|
||||
|
||||
|
||||
class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.state = {targetUrl: props.targetUrl, loggedIn: false}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetch(this.targetUrl + '/status', {credentials: 'include'})
|
||||
.then(response => response.json())
|
||||
.then(data => this.setState({loggedIn: data.logged_in}));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="main" className="container">
|
||||
<IndexPage targetUrl={this.state.targetUrl} loggedIn={this.state.loggedIn} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default App;
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(<App />, div);
|
||||
ReactDOM.unmountComponentAtNode(div);
|
||||
});
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react';
|
||||
import ModalBox from './ModalBox.js';
|
||||
import { Button, Spinner } from 'react-bootstrap';
|
||||
import './App.css';
|
||||
|
||||
|
||||
function isCheckedIn(movements) {
|
||||
if (movements.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const lastMov = movements[movements.length - 1].type;
|
||||
if (lastMov === "Entrata") {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class BadgePage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {loggedIn: props.loggedIn, checkedIn: false, loading: false};
|
||||
}
|
||||
|
||||
_isMounted = false
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
|
||||
fetch(this.props.targetUrl + '/movements', {credentials: 'include'})
|
||||
.then(response => response.json())
|
||||
.then(data => {if (this._isMounted) {this.setState({
|
||||
checkedIn: isCheckedIn(data.movements), loggedIn: data.logged_in
|
||||
})}})
|
||||
.catch(error => console.log(error));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
handleToggle = (event) => {
|
||||
this.setState({loading: true})
|
||||
|
||||
var path;
|
||||
if (this.state.checkedIn) {
|
||||
path = '/checkout';
|
||||
} else {
|
||||
path = '/checkin';
|
||||
}
|
||||
fetch(this.props.targetUrl + path, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => this.setState({
|
||||
checkedIn: data.checked_in,
|
||||
loggedIn: data.logged_in,
|
||||
loading: false
|
||||
}));
|
||||
}
|
||||
|
||||
buttonValue() {
|
||||
if (this.state.checkedIn) {
|
||||
return "CHECKOUT";
|
||||
} else {
|
||||
return "CHECKIN";
|
||||
}
|
||||
}
|
||||
|
||||
dynButton() {
|
||||
const badge = (
|
||||
<ModalBox
|
||||
title="Badge"
|
||||
data={<Button variant="primary" type="button" onClick={this.handleToggle}>{this.buttonValue()}</Button>}
|
||||
/>
|
||||
);
|
||||
|
||||
const loading =
|
||||
<ModalBox
|
||||
title="Badge"
|
||||
data=<Spinner animation="border" variant="primary" />
|
||||
/>;
|
||||
|
||||
if (this.state.loading) {
|
||||
return loading;
|
||||
} else {
|
||||
return badge;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.loggedIn) {
|
||||
return this.dynButton();
|
||||
} else {
|
||||
return (<ModalBox title="Badge" data=<h2>You have to login first!</h2> />);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BadgePage;
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Link, Switch } from "react-router-dom";
|
||||
import { Nav, Navbar, Container } from "react-bootstrap";
|
||||
import LoginForm from './LoginForm.js';
|
||||
import MovementsPage from './Movements.js';
|
||||
import BadgePage from './Badge.js';
|
||||
import LogoffPage from './Logoff.js';
|
||||
|
||||
class IndexPage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {loggedIn: props.loggedIn};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetch(this.props.targetUrl + '/status', {credentials: 'include'})
|
||||
.then(response => response.json())
|
||||
.then(data => this.setState({loggedIn: data.logged_in}))
|
||||
.catch(error => {console.log(error)});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Router>
|
||||
<Navbar bg="light" expand="lg">
|
||||
<Navbar.Brand href="/">BotZ</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
<Navbar.Collapse id="basic-navbar-nav">
|
||||
<Nav variant="tabs" className="mr-auto">
|
||||
<Nav.Item>
|
||||
<Nav.Link as='div' eventKey="login">
|
||||
<Link to='/login'>Login</Link>
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link as='div' eventKey="movements">
|
||||
<Link to='/movements'>Movements</Link>
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link as='div' eventKey="badge">
|
||||
<Link to='/badge'>Badge</Link>
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link as='div' eventKey="logout">
|
||||
<Link to='/logout'>Logout</Link>
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Navbar>
|
||||
<div>
|
||||
<Container>
|
||||
<Switch>
|
||||
<Route path="/login" component={() => <LoginForm targetUrl={this.props.targetUrl} loggedIn={this.state.loggedIn} />} />
|
||||
<Route path="/movements" component={() => <MovementsPage targetUrl={this.props.targetUrl} loggedIn={this.state.loggedIn} />} />
|
||||
<Route path="/badge" component={() => <BadgePage targetUrl={this.props.targetUrl} loggedIn={this.state.loggedIn} />} />
|
||||
<Route path="/logout" component={() => <LogoffPage targetUrl={this.props.targetUrl} loggedIn={this.state.loggedIn} />} />
|
||||
</Switch>
|
||||
</Container>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default IndexPage;
|
|
@ -0,0 +1,111 @@
|
|||
import React from 'react';
|
||||
import { Form, Button, Spinner } from 'react-bootstrap';
|
||||
import ModalBox from './ModalBox.js';
|
||||
import './App.css';
|
||||
|
||||
class LoginForm extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
user: '', password: '',
|
||||
loggedIn: props.loggedIn,
|
||||
loading: false,
|
||||
error: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.state.error) {
|
||||
console.log(this.state);
|
||||
this.timeout = setTimeout(this.resetComponentState, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
fetch(this.props.targetUrl + '/status', {credentials: 'include'})
|
||||
.then(response => response.json())
|
||||
.then(data => this.setState({
|
||||
loggedIn: data.logged_in
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.timeout)
|
||||
}
|
||||
|
||||
handleUserChange = (event) => {
|
||||
this.setState({user: event.target.value});
|
||||
};
|
||||
|
||||
handlePasswordChange = (event) => {
|
||||
this.setState({password: event.target.value});
|
||||
};
|
||||
|
||||
handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
this.setState({loading: true});
|
||||
|
||||
const formData = event.target.elements;
|
||||
var data = {username: formData["username"].value, password: formData["password"].value};
|
||||
|
||||
fetch(this.props.targetUrl + '/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(jsonData => this.setState({loggedIn: jsonData.logged_in, loading: false}))
|
||||
.catch((error) => {console.log(error); this.setState({error: true})})
|
||||
}
|
||||
|
||||
dynForm() {
|
||||
const form =
|
||||
<ModalBox
|
||||
title=<h2>Login Form</h2>
|
||||
data=
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Form.Group controlId="username">
|
||||
<Form.Control onChange={this.handleUserChange} type="text" name="username" placeholder="Username" />
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="password">
|
||||
<Form.Control onChange={this.handlePasswordChange} type="password" name="password" placeholder="Password" />
|
||||
</Form.Group>
|
||||
|
||||
<Button variant="primary" type="submit">Send</Button>
|
||||
</Form>
|
||||
/>;
|
||||
|
||||
const loading =
|
||||
<ModalBox
|
||||
title="Login"
|
||||
data=<Spinner animation="border" variant="primary" />
|
||||
/>;
|
||||
|
||||
if (this.state.loading) {
|
||||
return loading;
|
||||
} else {
|
||||
return form;
|
||||
}
|
||||
}
|
||||
|
||||
resetComponentState = () => {
|
||||
this.setState({error: false, loading: false, username: '', password: ''})
|
||||
}
|
||||
|
||||
render() {
|
||||