Merge branch 'master' into snyk-upgrade-ba475f1c345b09c787ee96292badb5b6
This commit is contained in:
commit
a4a56fdbee
776 changed files with 24116 additions and 6254 deletions
|
@ -6,8 +6,18 @@
|
|||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Clone the repository
|
||||
git remote add github git@github.com:/BlackLight/platypush.git
|
||||
git pull --rebase github "$(git branch | head -1 | awk '{print $2}')" || echo "No such branch on Github"
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ -z "${branch}" ]; then
|
||||
echo "No branch checked out"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git remote add github git@github.com:/blacklight/platypush.git
|
||||
|
||||
if (( "$branch" == "master" )); then
|
||||
git pull --rebase github "${branch}" || echo "No such branch on Github"
|
||||
fi
|
||||
|
||||
# Push the changes to the GitHub mirror
|
||||
git push --all -v github
|
||||
git push -f --all -v github
|
||||
git push --tags -v github
|
||||
|
|
|
@ -16,7 +16,7 @@ apt install -y curl dpkg-dev gpg git python3 python3-pip python3-setuptools
|
|||
echo "--- Parsing metadata"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
git pull --rebase origin master --tags
|
||||
export VERSION=$(python3 setup.py --version)
|
||||
export VERSION=$(grep -e '^__version__' "${SRCDIR}/version.py" | sed -r -e 's/^__version__\s*=\s*"([^"]+)"$/\1/')
|
||||
export GIT_VERSION="$VERSION-$(git log --pretty=oneline HEAD...v$VERSION | wc -l)"
|
||||
export GIT_BUILD_DIR="$WORKDIR/${PKG_NAME}_${GIT_VERSION}_all"
|
||||
export GIT_DEB="$WORKDIR/${PKG_NAME}_${GIT_VERSION}_all.deb"
|
||||
|
|
|
@ -26,7 +26,7 @@ mkdir -p "$RPM_ROOT"
|
|||
echo "--- Parsing metadata"
|
||||
git config --global --add safe.directory $PWD
|
||||
git pull --rebase origin master --tags
|
||||
export VERSION=$(python3 setup.py --version)
|
||||
export VERSION=$(grep -e '^__version__' "${SRCDIR}/version.py" | sed -r -e 's/^__version__\s*=\s*"([^"]+)"$/\1/')
|
||||
export RELNUM="$(git log --pretty=oneline HEAD...v$VERSION | wc -l)"
|
||||
export SPECFILE="$WORKDIR/$PKG_NAME.spec"
|
||||
export BUILD_DIR="$WORKDIR/build"
|
||||
|
|
1
.ignore
Normal file
1
.ignore
Normal file
|
@ -0,0 +1 @@
|
|||
dist/
|
115
CHANGELOG.md
115
CHANGELOG.md
|
@ -1,5 +1,120 @@
|
|||
# Changelog
|
||||
|
||||
## [1.3.1]
|
||||
|
||||
- [[#344](https://git.platypush.tech/platypush/platypush/issues/344)]: removed
|
||||
`marshmallow_dataclass` dependency. That package isn't included in the
|
||||
package managers of any supported distros and requires to be installed via
|
||||
pip. Making the Platypush' system packages depend on a pip-only package is
|
||||
not a good idea. Plus, the library seems to be still in heavy development and
|
||||
it has already broken compatibility with at least the `system` package.
|
||||
|
||||
## [1.3.0]
|
||||
|
||||
- [[#333](https://git.platypush.tech/platypush/platypush/issues/333)]: new file
|
||||
browser UI/component. It includes custom MIME type support, a file editor
|
||||
with syntax highlight, file download and file upload.
|
||||
|
||||
- [[#341](https://git.platypush.tech/platypush/platypush/issues/341)]:
|
||||
procedures are now native entities that can be managed from the entities panel.
|
||||
A new versatile procedure editor has also been added, with support for nested
|
||||
blocks, conditions, loops, variables, context autocomplete, and more.
|
||||
|
||||
- [`procedure`]: Added the following features to YAML/structured procedures:
|
||||
|
||||
- `set`: to set variables whose scope is limited to the procedure / code
|
||||
block where they are created. `variable.set` is useful to permanently
|
||||
store variables on the db, `variable.mset` is useful to set temporary
|
||||
global variables in memory through Redis, but sometimes you may just want
|
||||
to assign a value to a variable that only needs to live within a procedure,
|
||||
event hook or cron.
|
||||
|
||||
```yaml
|
||||
- set:
|
||||
foo: bar
|
||||
temperature: ${output.get('temperature')}
|
||||
```
|
||||
|
||||
- `return` can now return values too when invoked within a procedure:
|
||||
|
||||
```yaml
|
||||
- return: something
|
||||
# Or
|
||||
- return: "Result: ${output.get('response')}"
|
||||
```
|
||||
|
||||
- The default logging format is now much more compact. The full body of events
|
||||
and requests is no longer included by default in `info` mode - instead, a
|
||||
summary with the message type, ID and response time is logged. The full
|
||||
payloads can still be logged by enabling `debug` logs through e.g. `-v`.
|
||||
|
||||
## [1.2.3]
|
||||
|
||||
- [[#422](https://git.platypush.tech/platypush/platypush/issues/422)]: adapted
|
||||
media plugins to support streaming from the yt-dlp process. This allows
|
||||
videos to have merged audio+video even if they had separate tracks upstream.
|
||||
|
||||
- [`media.*`] Many improvements on the media UI.
|
||||
|
||||
- [`zigbee.mqtt`] Removed synchronous logic from `zigbee.mqtt.device_set`. It
|
||||
was prone to timeouts as well as pointless - the updated device state will
|
||||
anyway be received as an event.
|
||||
|
||||
## [1.2.2]
|
||||
|
||||
- Fixed regression on older version of Python that don't fully support
|
||||
`pyproject.toml` and can't install data files the new way.
|
||||
|
||||
## [1.2.1]
|
||||
|
||||
- Added static `/login` and `/register` Flask fallback routes to prevent 404 if
|
||||
the client doesn't have JavaScript enabled.
|
||||
|
||||
- Fixed `apt` packages for Debian oldstable after the `setup.py` to
|
||||
`pyproject.toml` migration.
|
||||
|
||||
- Fixed license string in the `pyproject.toml`.
|
||||
|
||||
## [1.2.0]
|
||||
|
||||
- [#419](https://git.platypush.tech/platypush/platypush/issues/419): added
|
||||
support for randomly generated API tokens alongside JWT.
|
||||
|
||||
- [#339](https://git.platypush.tech/platypush/platypush/issues/339): added
|
||||
support for 2FA with OTP codes.
|
||||
|
||||
- [#393](https://git.platypush.tech/platypush/platypush/issues/393): added
|
||||
`bind_socket` parameter to `backend.http`, so Platypush can listen on (or
|
||||
exclusively if `listen_port` is null) on a local UNIX socket as well.
|
||||
|
||||
- [#401](https://git.platypush.tech/platypush/platypush/issues/401): added
|
||||
`--redis-bin` option / `PLATYPUSH_REDIS_BIN` environment variable to support
|
||||
custom Redis (or drop-in replacements) executables when `--start-redis` is
|
||||
specified.
|
||||
|
||||
- [#413](https://git.platypush.tech/platypush/platypush/issues/401): added
|
||||
support for page-specific PWAs. If you navigate to `/plugin/<plugin-name>`,
|
||||
and you install it as a PWA, you'll install a PWA only for that plugin - not
|
||||
for the whole Platypush UI.
|
||||
|
||||
- Migrated project setup from `setup.py` to `pyproject.toml`.
|
||||
|
||||
- [`70db33b4e`](https://git.platypush.tech/platypush/platypush/commit/70db33b4e):
|
||||
more application resilience in case Redis goes down.
|
||||
|
||||
- [`ee27b2c4`](https://git.platypush.tech/platypush/platypush/commit/ee27b2c4):
|
||||
Refactor of all the authentication endpoints into a single `/auth` endpoint:
|
||||
|
||||
- `POST /register` → `POST /auth?type=register`
|
||||
- `POST /login` → `POST /auth?type=login`
|
||||
- `POST /auth` → `POST /auth?type=token`
|
||||
- `POST /auth` → `POST /auth?type=jwt`
|
||||
|
||||
- [`2ccf0050`](https://git.platypush.tech/platypush/platypush/commit/2ccf0050):
|
||||
Added support for binary content to `qrcode.generate`.
|
||||
|
||||
- [`b69e9500`](https://git.platypush.tech/platypush/platypush/commit/b69e9500):
|
||||
Support for fullscreen mode on the `camera` plugins UI.
|
||||
|
||||
## [1.1.3] - 2024-07-16
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017, 2020 Fabio Manganiello
|
||||
Copyright (c) 2017, 2024 Fabio Manganiello
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
11
README.md
11
README.md
|
@ -67,6 +67,7 @@
|
|||
* [Other Web panels](#other-web-panels)
|
||||
* [Dashboards](#dashboards)
|
||||
* [PWA support](#pwa-support)
|
||||
- [Two-factor authentication](#two-factor-authentication)
|
||||
- [Mobile app](#mobile-app)
|
||||
- [Browser extension](#browser-extension)
|
||||
- [Tests](#tests)
|
||||
|
@ -1247,6 +1248,16 @@ PWA (progressive web app) to work. The Platypush PWA allows you to install a
|
|||
Platypush native-like client on your mobile devices if you don't want to use the
|
||||
full Android app.
|
||||
|
||||
## Two-factor authentication
|
||||
|
||||
Support for 2FA over OTP codes requires to enable the
|
||||
[`otp`](https://docs.platypush.tech/platypush/plugins/otp.html) and
|
||||
[`qrcode`](https://docs.platypush.tech/platypush/plugins/qrcode.html) plugins.
|
||||
|
||||
After installing the dependencies, you can enable it by navigating to
|
||||
_Settings_ -> _Users_ from the Web panel. Then select your user, choose _Set up
|
||||
2FA_ and proceed with the steps on screen to set up your authenticator.
|
||||
|
||||
## Mobile app
|
||||
|
||||
An [official Android
|
||||
|
|
|
@ -1,24 +1,9 @@
|
|||
services:
|
||||
platypush:
|
||||
restart: "always"
|
||||
command:
|
||||
- platypush
|
||||
# Comment --start-redis if you want to run an external Redis service
|
||||
# In such case you'll also have to ensure that the appropriate Redis
|
||||
# variables are set in the .env file, or the Redis configuration is
|
||||
# passed in the config.yaml, or use the --redis-host and --redis-port
|
||||
# command-line options
|
||||
- --start-redis
|
||||
|
||||
# Custom list of host devices that should be accessible to the container -
|
||||
# e.g. an Arduino, an ESP-compatible microcontroller, a joystick etc.
|
||||
# devices:
|
||||
# - /dev/ttyUSB0
|
||||
|
||||
# Uncomment if you need plugins that require access to low-level hardware
|
||||
# (e.g. Bluetooth BLE or GPIO/SPI/I2C) if access to individual devices is
|
||||
# not enough or isn't practical
|
||||
# privileged: true
|
||||
# Replace the build section with the next line if instead of building the
|
||||
# image from a local checkout you want to pull the latest base
|
||||
# (Alpine-based) image from the remote registry
|
||||
# image: "registry.platypush.tech/platypush:latest"
|
||||
|
||||
build:
|
||||
context: .
|
||||
|
@ -31,6 +16,25 @@ services:
|
|||
# Fedora base image
|
||||
# dockerfile: ./platypush/install/docker/fedora.Dockerfile
|
||||
|
||||
restart: "always"
|
||||
command:
|
||||
- platypush
|
||||
- --redis-host
|
||||
- redis
|
||||
# Or, if you want to run Redis from the same container as Platypush,
|
||||
# replace --redis-host redis with the line below
|
||||
# - --start-redis
|
||||
|
||||
# Custom list of host devices that should be accessible to the container -
|
||||
# e.g. an Arduino, an ESP-compatible microcontroller, a joystick etc.
|
||||
# devices:
|
||||
# - /dev/ttyUSB0
|
||||
|
||||
# Uncomment if you need plugins that require access to low-level hardware
|
||||
# (e.g. Bluetooth BLE or GPIO/SPI/I2C) if access to individual devices is
|
||||
# not enough or isn't practical
|
||||
# privileged: true
|
||||
|
||||
# Copy .env.example to .env and modify as needed
|
||||
# env_file:
|
||||
# - .env
|
||||
|
@ -40,7 +44,13 @@ services:
|
|||
# expose it
|
||||
- "8008:8008"
|
||||
|
||||
volumes:
|
||||
- /path/to/your/config.yaml:/etc/platypush
|
||||
- /path/to/a/workdir:/var/lib/platypush
|
||||
# volumes:
|
||||
# Replace with a path that contains/will contain your config.yaml file
|
||||
# - /path/to/your/config:/etc/platypush
|
||||
# Replace with a path that contains/will contain your working directory
|
||||
# - /path/to/a/workdir:/var/lib/platypush
|
||||
# Optionally, use an external volume for the cache
|
||||
# - /path/to/a/cachedir:/var/cache/platypush
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
``media.omxplayer``
|
||||
=====================================
|
||||
|
||||
.. automodule:: platypush.plugins.media.omxplayer
|
||||
:members:
|
||||
|
5
docs/source/platypush/plugins/procedures.rst
Normal file
5
docs/source/platypush/plugins/procedures.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
``procedures``
|
||||
==============
|
||||
|
||||
.. automodule:: platypush.plugins.procedures
|
||||
:members:
|
|
@ -77,7 +77,6 @@ Plugins
|
|||
platypush/plugins/media.kodi.rst
|
||||
platypush/plugins/media.mplayer.rst
|
||||
platypush/plugins/media.mpv.rst
|
||||
platypush/plugins/media.omxplayer.rst
|
||||
platypush/plugins/media.plex.rst
|
||||
platypush/plugins/media.subtitles.rst
|
||||
platypush/plugins/media.vlc.rst
|
||||
|
@ -100,6 +99,7 @@ Plugins
|
|||
platypush/plugins/otp.rst
|
||||
platypush/plugins/pihole.rst
|
||||
platypush/plugins/ping.rst
|
||||
platypush/plugins/procedures.rst
|
||||
platypush/plugins/pushbullet.rst
|
||||
platypush/plugins/pwm.pca9685.rst
|
||||
platypush/plugins/qrcode.rst
|
||||
|
|
|
@ -21,9 +21,8 @@ from .utils import run
|
|||
# see https://git.platypush.tech/platypush/platypush/issues/399
|
||||
when = hook
|
||||
|
||||
|
||||
__version__ = '1.3.1'
|
||||
__author__ = 'Fabio Manganiello <fabio@manganiello.tech>'
|
||||
__version__ = '1.1.3'
|
||||
__all__ = [
|
||||
'Application',
|
||||
'Variable',
|
||||
|
@ -41,6 +40,7 @@ __all__ = [
|
|||
'procedure',
|
||||
'run',
|
||||
'when',
|
||||
'__version__',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -365,7 +365,13 @@ class Application:
|
|||
elif isinstance(msg, Response):
|
||||
msg.log()
|
||||
elif isinstance(msg, Event):
|
||||
msg.log()
|
||||
log.info(
|
||||
'Received event: %s.%s[id=%s]',
|
||||
msg.__class__.__module__,
|
||||
msg.__class__.__name__,
|
||||
msg.id,
|
||||
)
|
||||
msg.log(level=logging.DEBUG)
|
||||
self.event_processor.process_event(msg)
|
||||
|
||||
return _f
|
||||
|
|
|
@ -10,8 +10,6 @@ from multiprocessing import Process
|
|||
from time import time
|
||||
from typing import Mapping, Optional
|
||||
|
||||
import psutil
|
||||
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.netutil import bind_sockets, bind_unix_socket
|
||||
from tornado.process import cpu_count, fork_processes
|
||||
|
@ -421,6 +419,14 @@ class HttpBackend(Backend):
|
|||
workers when the server terminates:
|
||||
https://github.com/tornadoweb/tornado/issues/1912.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
self.logger.warning(
|
||||
'Could not import psutil, hanging worker processes might remain active'
|
||||
)
|
||||
return
|
||||
|
||||
parent_pid = (
|
||||
self._server_proc.pid
|
||||
if self._server_proc and self._server_proc.pid
|
||||
|
|
|
@ -4,8 +4,15 @@ import logging
|
|||
|
||||
from flask import Blueprint, request, abort, jsonify
|
||||
|
||||
from platypush.backend.http.app.utils import authenticate
|
||||
from platypush.backend.http.app.utils.auth import (
|
||||
UserAuthStatus,
|
||||
current_user,
|
||||
get_current_user_or_auth_status,
|
||||
)
|
||||
from platypush.exceptions.user import UserException
|
||||
from platypush.user import UserManager
|
||||
from platypush.user import User, UserManager
|
||||
from platypush.utils import utcnow
|
||||
|
||||
auth = Blueprint('auth', __name__)
|
||||
log = logging.getLogger(__name__)
|
||||
|
@ -16,39 +23,24 @@ __routes__ = [
|
|||
]
|
||||
|
||||
|
||||
@auth.route('/auth', methods=['POST'])
|
||||
def auth_endpoint():
|
||||
"""
|
||||
Authentication endpoint. It validates the user credentials provided over a JSON payload with the following
|
||||
structure:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
def _dump_session(session, redirect_page='/'):
|
||||
return jsonify(
|
||||
{
|
||||
"username": "USERNAME",
|
||||
"password": "PASSWORD",
|
||||
"expiry_days": "The generated token should be valid for these many days"
|
||||
'status': 'ok',
|
||||
'user_id': session.user_id,
|
||||
'session_token': session.session_token,
|
||||
'expires_at': session.expires_at,
|
||||
'redirect': redirect_page,
|
||||
}
|
||||
)
|
||||
|
||||
``expiry_days`` is optional, and if omitted or set to zero the token will be valid indefinitely.
|
||||
|
||||
Upon successful validation, a new JWT token will be generated using the service's self-generated RSA key-pair and it
|
||||
will be returned to the user. The token can then be used to authenticate API calls to ``/execute`` by setting the
|
||||
``Authorization: Bearer <TOKEN_HERE>`` header upon HTTP calls.
|
||||
|
||||
:return: Return structure:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"token": "<generated token here>"
|
||||
}
|
||||
"""
|
||||
def _jwt_auth():
|
||||
try:
|
||||
payload = json.loads(request.get_data(as_text=True))
|
||||
username, password = payload['username'], payload['password']
|
||||
except Exception as e:
|
||||
log.warning('Invalid payload passed to the auth endpoint: ' + str(e))
|
||||
except Exception:
|
||||
log.warning('Invalid payload passed to the auth endpoint')
|
||||
abort(400)
|
||||
|
||||
expiry_days = payload.get('expiry_days')
|
||||
|
@ -59,8 +51,366 @@ def auth_endpoint():
|
|||
user_manager = UserManager()
|
||||
|
||||
try:
|
||||
return jsonify({
|
||||
'token': user_manager.generate_jwt_token(username=username, password=password, expires_at=expires_at),
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
'token': user_manager.generate_jwt_token(
|
||||
username=username, password=password, expires_at=expires_at
|
||||
),
|
||||
}
|
||||
)
|
||||
except UserException as e:
|
||||
abort(401, str(e))
|
||||
|
||||
|
||||
def _session_auth():
|
||||
user_manager = UserManager()
|
||||
session_token = request.cookies.get('session_token')
|
||||
redirect_page = request.args.get('redirect') or '/'
|
||||
|
||||
if session_token:
|
||||
user, session = user_manager.authenticate_user_session(session_token)[:2]
|
||||
if user and session:
|
||||
return _dump_session(session, redirect_page)
|
||||
|
||||
if request.form:
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
code = request.form.get('code')
|
||||
remember = request.form.get('remember')
|
||||
expires = utcnow() + datetime.timedelta(days=365) if remember else None
|
||||
session, status = user_manager.create_user_session( # type: ignore
|
||||
username=username,
|
||||
password=password,
|
||||
code=code,
|
||||
expires_at=expires,
|
||||
with_status=True,
|
||||
)
|
||||
|
||||
if session:
|
||||
return _dump_session(session, redirect_page)
|
||||
|
||||
if status:
|
||||
auth_status = UserAuthStatus.by_status(status)
|
||||
assert auth_status
|
||||
return auth_status.to_response()
|
||||
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
|
||||
|
||||
def _create_token():
|
||||
payload = {}
|
||||
try:
|
||||
payload = json.loads(request.get_data(as_text=True))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
user = None
|
||||
username = payload.get('username')
|
||||
password = payload.get('password')
|
||||
code = payload.get('code')
|
||||
name = payload.get('name')
|
||||
expiry_days = payload.get('expiry_days')
|
||||
user_manager = UserManager()
|
||||
response = get_current_user_or_auth_status(request)
|
||||
|
||||
# Try and authenticate with the credentials passed in the JSON payload
|
||||
if username and password:
|
||||
user = user_manager.authenticate_user(username, password, code=code)
|
||||
if not isinstance(user, User):
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
|
||||
if not user:
|
||||
if not (response and isinstance(response, User)):
|
||||
return response.to_response()
|
||||
|
||||
user = response
|
||||
|
||||
expires_at = None
|
||||
if expiry_days:
|
||||
expires_at = datetime.datetime.now() + datetime.timedelta(days=expiry_days)
|
||||
|
||||
try:
|
||||
token = UserManager().generate_api_token(
|
||||
username=str(user.username), name=name, expires_at=expires_at
|
||||
)
|
||||
return jsonify({'token': token})
|
||||
except UserException:
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
|
||||
|
||||
def _delete_token():
|
||||
try:
|
||||
payload = json.loads(request.get_data(as_text=True))
|
||||
token = payload.get('token')
|
||||
assert token
|
||||
except (AssertionError, json.JSONDecodeError):
|
||||
return UserAuthStatus.INVALID_TOKEN.to_response()
|
||||
|
||||
user_manager = UserManager()
|
||||
|
||||
try:
|
||||
token = payload.get('token')
|
||||
if not token:
|
||||
return UserAuthStatus.INVALID_TOKEN.to_response()
|
||||
|
||||
ret = user_manager.delete_api_token(token)
|
||||
if not ret:
|
||||
return UserAuthStatus.INVALID_TOKEN.to_response()
|
||||
|
||||
return jsonify({'status': 'ok'})
|
||||
except UserException:
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
|
||||
|
||||
def _register_route():
|
||||
"""Registration endpoint"""
|
||||
user_manager = UserManager()
|
||||
session_token = request.cookies.get('session_token')
|
||||
redirect_page = request.args.get('redirect') or '/'
|
||||
|
||||
if session_token:
|
||||
user, session = user_manager.authenticate_user_session(session_token)[:2]
|
||||
if user and session:
|
||||
return _dump_session(session, redirect_page)
|
||||
|
||||
if user_manager.get_user_count() > 0:
|
||||
return UserAuthStatus.REGISTRATION_DISABLED.to_response()
|
||||
|
||||
if not request.form:
|
||||
return UserAuthStatus.MISSING_USERNAME.to_response()
|
||||
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
remember = request.form.get('remember')
|
||||
|
||||
if not username:
|
||||
return UserAuthStatus.MISSING_USERNAME.to_response()
|
||||
if not password:
|
||||
return UserAuthStatus.MISSING_PASSWORD.to_response()
|
||||
if password != confirm_password:
|
||||
return UserAuthStatus.PASSWORD_MISMATCH.to_response()
|
||||
|
||||
user_manager.create_user(username=username, password=password)
|
||||
session, status = user_manager.create_user_session( # type: ignore
|
||||
username=username,
|
||||
password=password,
|
||||
expires_at=(utcnow() + datetime.timedelta(days=365) if remember else None),
|
||||
with_status=True,
|
||||
)
|
||||
|
||||
if session:
|
||||
return _dump_session(session, redirect_page)
|
||||
|
||||
if status:
|
||||
return status.to_response() # type: ignore
|
||||
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
|
||||
|
||||
def _auth_get():
|
||||
"""
|
||||
Get the current authentication status of the user session.
|
||||
"""
|
||||
user_manager = UserManager()
|
||||
session_token = request.cookies.get('session_token')
|
||||
redirect_page = request.args.get('redirect') or '/'
|
||||
user, session, status = user_manager.authenticate_user_session( # type: ignore
|
||||
session_token, with_status=True
|
||||
)
|
||||
|
||||
if user and session:
|
||||
return _dump_session(session, redirect_page)
|
||||
|
||||
response = get_current_user_or_auth_status(request)
|
||||
if isinstance(response, User):
|
||||
user = response
|
||||
return jsonify(
|
||||
{'status': 'ok', 'user_id': user.user_id, 'username': user.username}
|
||||
)
|
||||
|
||||
if response:
|
||||
status = response
|
||||
|
||||
if status:
|
||||
if not isinstance(status, UserAuthStatus):
|
||||
status = UserAuthStatus.by_status(status)
|
||||
if not status:
|
||||
status = UserAuthStatus.INVALID_CREDENTIALS
|
||||
return status.to_response()
|
||||
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
|
||||
|
||||
def _auth_post():
|
||||
"""
|
||||
Authenticate the user session.
|
||||
"""
|
||||
auth_type = request.args.get('type') or 'token'
|
||||
|
||||
if auth_type == 'token':
|
||||
return _create_token()
|
||||
|
||||
if auth_type == 'jwt':
|
||||
return _jwt_auth()
|
||||
|
||||
if auth_type == 'register':
|
||||
return _register_route()
|
||||
|
||||
if auth_type == 'login':
|
||||
return _session_auth()
|
||||
|
||||
return UserAuthStatus.INVALID_AUTH_TYPE.to_response()
|
||||
|
||||
|
||||
def _auth_delete():
|
||||
"""
|
||||
Logout/invalidate a token or the current user session.
|
||||
"""
|
||||
# Delete the specified API token if it's passed on the JSON payload
|
||||
token = None
|
||||
try:
|
||||
payload = json.loads(request.get_data(as_text=True))
|
||||
token = payload.get('token')
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if token:
|
||||
return _delete_token()
|
||||
|
||||
user_manager = UserManager()
|
||||
session_token = request.cookies.get('session_token')
|
||||
redirect_page = request.args.get('redirect') or '/'
|
||||
|
||||
if session_token:
|
||||
user, session = user_manager.authenticate_user_session(session_token)[:2]
|
||||
if user and session:
|
||||
user_manager.delete_user_session(session_token)
|
||||
return jsonify({'status': 'ok', 'redirect': redirect_page})
|
||||
|
||||
return UserAuthStatus.INVALID_SESSION.to_response()
|
||||
|
||||
|
||||
def _tokens_get():
|
||||
user = current_user()
|
||||
if not user:
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
|
||||
tokens = UserManager().get_api_tokens(username=str(user.username))
|
||||
return jsonify(
|
||||
{
|
||||
'tokens': [
|
||||
{
|
||||
'id': t.id,
|
||||
'name': t.name,
|
||||
'created_at': t.created_at,
|
||||
'expires_at': t.expires_at,
|
||||
}
|
||||
for t in tokens
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _tokens_delete():
|
||||
args = {}
|
||||
|
||||
try:
|
||||
payload = json.loads(request.get_data(as_text=True))
|
||||
token = payload.get('token')
|
||||
if token:
|
||||
args['token'] = token
|
||||
else:
|
||||
token_id = payload.get('token_id')
|
||||
if token_id:
|
||||
args['token_id'] = token_id
|
||||
|
||||
assert args, 'No token or token_id specified'
|
||||
except (AssertionError, json.JSONDecodeError):
|
||||
return UserAuthStatus.INVALID_TOKEN.to_response()
|
||||
|
||||
user_manager = UserManager()
|
||||
user = current_user()
|
||||
if not user:
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
|
||||
args['username'] = str(user.username)
|
||||
|
||||
try:
|
||||
user_manager.delete_api_token(**args)
|
||||
return jsonify({'status': 'ok'})
|
||||
except AssertionError as e:
|
||||
return (
|
||||
jsonify({'status': 'error', 'error': 'bad_request', 'message': str(e)}),
|
||||
400,
|
||||
)
|
||||
except UserException:
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
except Exception as e:
|
||||
log.error('Token deletion error', exc_info=e)
|
||||
|
||||
return UserAuthStatus.UNKNOWN_ERROR.to_response()
|
||||
|
||||
|
||||
@auth.route('/auth', methods=['GET', 'POST', 'DELETE'])
|
||||
def auth_endpoint():
|
||||
"""
|
||||
Authentication endpoint. It validates the user credentials provided over a
|
||||
JSON payload with the following structure:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"username": "USERNAME",
|
||||
"password": "PASSWORD",
|
||||
"code": "2FA_CODE",
|
||||
"expiry_days": "The generated token should be valid for these many days"
|
||||
}
|
||||
|
||||
``expiry_days`` is optional, and if omitted or set to zero the token will
|
||||
be valid indefinitely.
|
||||
|
||||
Upon successful validation, a new JWT token will be generated using the
|
||||
service's self-generated RSA key-pair and it will be returned to the user.
|
||||
The token can then be used to authenticate API calls to ``/execute`` by
|
||||
setting the ``Authorization: Bearer <TOKEN_HERE>`` header upon HTTP calls.
|
||||
|
||||
:return: Return structure:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"token": "<generated token here>"
|
||||
}
|
||||
"""
|
||||
if request.method == 'GET':
|
||||
return _auth_get()
|
||||
|
||||
if request.method == 'POST':
|
||||
return _auth_post()
|
||||
|
||||
if request.method == 'DELETE':
|
||||
return _auth_delete()
|
||||
|
||||
return UserAuthStatus.INVALID_METHOD.to_response()
|
||||
|
||||
|
||||
@auth.route('/tokens', methods=['GET', 'DELETE'])
|
||||
@authenticate()
|
||||
def tokens_route():
|
||||
"""
|
||||
:return: The list of API tokens created by the logged in user.
|
||||
Note that this endpoint is only accessible by authenticated users
|
||||
and it won't return the clear-text token values, as those aren't
|
||||
stored in the database anyway.
|
||||
"""
|
||||
if request.method == 'GET':
|
||||
return _tokens_get()
|
||||
|
||||
if request.method == 'DELETE':
|
||||
return _tokens_delete()
|
||||
|
||||
return UserAuthStatus.INVALID_METHOD.to_response()
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -14,9 +14,27 @@ __routes__ = [
|
|||
|
||||
@index.route('/')
|
||||
@authenticate()
|
||||
def index():
|
||||
def index_route():
|
||||
"""Route to the main web panel"""
|
||||
return render_template('index.html', utils=HttpUtils)
|
||||
|
||||
|
||||
@index.route('/login', methods=['GET'])
|
||||
def login_route():
|
||||
"""
|
||||
Login GET route. It simply renders the index template, which will
|
||||
redirect to the login page if the user is not authenticated.
|
||||
"""
|
||||
return render_template('index.html', utils=HttpUtils)
|
||||
|
||||
|
||||
@index.route('/register', methods=['GET'])
|
||||
def register_route():
|
||||
"""
|
||||
Register GET route. It simply renders the index template, which will
|
||||
redirect to the registration page if no users are present.
|
||||
"""
|
||||
return render_template('index.html', utils=HttpUtils)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
import datetime
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, redirect, render_template, make_response
|
||||
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.utils import HttpUtils
|
||||
from platypush.user import UserManager
|
||||
from platypush.utils import utcnow
|
||||
|
||||
login = Blueprint('login', __name__, template_folder=template_folder)
|
||||
|
||||
# Declare routes list
|
||||
__routes__ = [
|
||||
login,
|
||||
]
|
||||
|
||||
|
||||
@login.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""Login page"""
|
||||
user_manager = UserManager()
|
||||
session_token = request.cookies.get('session_token')
|
||||
|
||||
redirect_page = request.args.get('redirect')
|
||||
if not redirect_page:
|
||||
redirect_page = request.headers.get('Referer', '/')
|
||||
if re.search('(^https?://[^/]+)?/login[^?#]?', redirect_page):
|
||||
# Prevent redirect loop
|
||||
redirect_page = '/'
|
||||
|
||||
if session_token:
|
||||
user, session = user_manager.authenticate_user_session(session_token)
|
||||
if user:
|
||||
return redirect(redirect_page, 302) # lgtm [py/url-redirection]
|
||||
|
||||
if request.form:
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
remember = request.form.get('remember')
|
||||
expires = utcnow() + datetime.timedelta(days=365) if remember else None
|
||||
|
||||
session = user_manager.create_user_session(
|
||||
username=username, password=password, expires_at=expires
|
||||
)
|
||||
|
||||
if session:
|
||||
redirect_target = redirect(redirect_page, 302) # lgtm [py/url-redirection]
|
||||
response = make_response(redirect_target)
|
||||
response.set_cookie('session_token', session.session_token, expires=expires)
|
||||
return response
|
||||
|
||||
return render_template('index.html', utils=HttpUtils)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -12,7 +12,7 @@ __routes__ = [
|
|||
|
||||
|
||||
@logout.route('/logout', methods=['GET', 'POST'])
|
||||
def logout():
|
||||
def logout_route():
|
||||
"""Logout page"""
|
||||
user_manager = UserManager()
|
||||
redirect_page = request.args.get(
|
||||
|
@ -23,7 +23,7 @@ def logout():
|
|||
if not session_token:
|
||||
abort(417, 'Not logged in')
|
||||
|
||||
user, _ = user_manager.authenticate_user_session(session_token)
|
||||
user, _ = user_manager.authenticate_user_session(session_token)[:2]
|
||||
if not user:
|
||||
abort(403, 'Invalid session token')
|
||||
|
||||
|
|
220
platypush/backend/http/app/routes/otp.py
Normal file
220
platypush/backend/http/app/routes/otp.py
Normal file
|
@ -0,0 +1,220 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.app.utils import UserAuthStatus, authenticate
|
||||
from platypush.backend.http.utils import HttpUtils
|
||||
from platypush.exceptions.user import (
|
||||
InvalidCredentialsException,
|
||||
InvalidOtpCodeException,
|
||||
UserException,
|
||||
)
|
||||
from platypush.config import Config
|
||||
from platypush.context import get_plugin
|
||||
from platypush.user import UserManager
|
||||
|
||||
otp = Blueprint('otp', __name__, template_folder=template_folder)
|
||||
|
||||
# Declare routes list
|
||||
__routes__ = [
|
||||
otp,
|
||||
]
|
||||
|
||||
|
||||
def _get_otp_and_qrcode():
|
||||
otp = get_plugin('otp') # pylint: disable=redefined-outer-name
|
||||
qrcode = get_plugin('qrcode')
|
||||
assert (
|
||||
otp and qrcode
|
||||
), 'The otp and/or qrcode plugins are not available in your installation'
|
||||
|
||||
return otp, qrcode
|
||||
|
||||
|
||||
def _get_username():
|
||||
user = HttpUtils.current_user()
|
||||
if not user:
|
||||
raise InvalidCredentialsException('Invalid user session')
|
||||
|
||||
return str(user.username)
|
||||
|
||||
|
||||
def _get_otp_uri_and_qrcode(username: str, otp_secret: Optional[str] = None):
|
||||
if not otp_secret:
|
||||
return None, None
|
||||
|
||||
otp, qrcode = _get_otp_and_qrcode() # pylint: disable=redefined-outer-name
|
||||
otp_uri = (
|
||||
otp.provision_time_otp(
|
||||
name=username,
|
||||
secret=otp_secret,
|
||||
issuer=f'platypush@{Config.get_device_id()}',
|
||||
).output
|
||||
if otp_secret
|
||||
else None
|
||||
)
|
||||
|
||||
otp_qrcode = (
|
||||
qrcode.generate(content=otp_uri, format='png').output.get('data')
|
||||
if otp_uri
|
||||
else None
|
||||
)
|
||||
|
||||
return otp_uri, otp_qrcode
|
||||
|
||||
|
||||
def _verify_code(code: str, otp_secret: str) -> bool:
|
||||
otp, _ = _get_otp_and_qrcode() # pylint: disable=redefined-outer-name
|
||||
return otp.verify_time_otp(otp=code, secret=otp_secret).output
|
||||
|
||||
|
||||
def _dump_response(
|
||||
username: str,
|
||||
otp_secret: Optional[str] = None,
|
||||
backup_codes: Optional[List[str]] = None,
|
||||
):
|
||||
otp_uri, otp_qrcode = _get_otp_uri_and_qrcode(username, otp_secret)
|
||||
return jsonify(
|
||||
{
|
||||
'username': username,
|
||||
'otp_secret': otp_secret,
|
||||
'otp_uri': otp_uri,
|
||||
'qrcode': otp_qrcode,
|
||||
'backup_codes': backup_codes or [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_otp():
|
||||
username = _get_username()
|
||||
user_manager = UserManager()
|
||||
otp_secret = user_manager.get_otp_secret(username)
|
||||
return _dump_response(
|
||||
username=username,
|
||||
otp_secret=otp_secret,
|
||||
)
|
||||
|
||||
|
||||
def _authenticate_user(username: str, password: Optional[str]):
|
||||
assert password, 'The password field is required when setting up OTP'
|
||||
user, auth_status = UserManager().authenticate_user( # type: ignore
|
||||
username, password, skip_2fa=True, with_status=True
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise InvalidCredentialsException(auth_status.value[2])
|
||||
|
||||
|
||||
def _post_otp():
|
||||
body = request.json
|
||||
assert body, 'Invalid request body'
|
||||
|
||||
username = _get_username()
|
||||
dry_run = body.get('dry_run', False)
|
||||
otp_secret = body.get('otp_secret')
|
||||
|
||||
if not dry_run:
|
||||
_authenticate_user(username, body.get('password'))
|
||||
|
||||
if otp_secret:
|
||||
code = body.get('code')
|
||||
assert code, 'The code field is required when setting up OTP'
|
||||
|
||||
if not _verify_code(code, otp_secret):
|
||||
raise InvalidOtpCodeException()
|
||||
|
||||
user_manager = UserManager()
|
||||
user_otp, backup_codes = user_manager.enable_otp(
|
||||
username=username,
|
||||
otp_secret=otp_secret,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
return _dump_response(
|
||||
username=username,
|
||||
otp_secret=str(user_otp.otp_secret),
|
||||
backup_codes=backup_codes,
|
||||
)
|
||||
|
||||
|
||||
def _delete_otp():
|
||||
body = request.json
|
||||
assert body, 'Invalid request body'
|
||||
|
||||
username = _get_username()
|
||||
_authenticate_user(username, body.get('password'))
|
||||
|
||||
user_manager = UserManager()
|
||||
user_manager.disable_otp(username)
|
||||
return jsonify({'status': 'ok'})
|
||||
|
||||
|
||||
@otp.route('/otp/config', methods=['GET', 'POST', 'DELETE'])
|
||||
@authenticate()
|
||||
def otp_route():
|
||||
"""
|
||||
:return: The user's current MFA/OTP configuration:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"username": "testuser",
|
||||
"otp_secret": "JBSA6ZUZ5DPEK7YV",
|
||||
"otp_uri": "otpauth://totp/testuser?secret=JBSA6ZUZ5DPEK7YV&issuer=platypush@localhost",
|
||||
"qrcode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAABwklEQVR4nO3dMW7CQBAF0",
|
||||
"backup_codes": [
|
||||
"1A2B3C4D5E",
|
||||
"6F7G8H9I0J",
|
||||
"KLMNOPQRST",
|
||||
"UVWXYZ1234",
|
||||
"567890ABCD",
|
||||
"EFGHIJKLMN",
|
||||
"OPQRSTUVWX",
|
||||
"YZ12345678",
|
||||
"90ABCDEF12",
|
||||
"34567890AB"
|
||||
]
|
||||
}
|
||||
|
||||
"""
|
||||
try:
|
||||
if request.method.lower() == 'get':
|
||||
return _get_otp()
|
||||
|
||||
if request.method.lower() == 'post':
|
||||
return _post_otp()
|
||||
|
||||
if request.method.lower() == 'delete':
|
||||
return _delete_otp()
|
||||
|
||||
return jsonify({'error': 'Method not allowed'}), 405
|
||||
except AssertionError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except InvalidCredentialsException:
|
||||
return UserAuthStatus.INVALID_CREDENTIALS.to_response()
|
||||
except InvalidOtpCodeException:
|
||||
return UserAuthStatus.INVALID_OTP_CODE.to_response()
|
||||
except UserException as e:
|
||||
return jsonify({'error': e.__class__.__name__, 'message': str(e)}), 401
|
||||
except Exception as e:
|
||||
HttpUtils.log.error(f'Error while processing OTP request: {e}', exc_info=True)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@otp.route('/otp/refresh-codes', methods=['POST'])
|
||||
def refresh_codes():
|
||||
"""
|
||||
:return: A new set of backup codes for the user.
|
||||
"""
|
||||
username = _get_username()
|
||||
user_manager = UserManager()
|
||||
otp_secret = user_manager.get_otp_secret(username)
|
||||
if not otp_secret:
|
||||
return jsonify({'error': 'OTP not configured for the user'}), 400
|
||||
|
||||
backup_codes = user_manager.refresh_user_backup_codes(username)
|
||||
return jsonify({'backup_codes': backup_codes})
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -1,71 +0,0 @@
|
|||
import datetime
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, redirect, render_template, make_response, abort
|
||||
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.utils import HttpUtils
|
||||
from platypush.user import UserManager
|
||||
from platypush.utils import utcnow
|
||||
|
||||
register = Blueprint('register', __name__, template_folder=template_folder)
|
||||
|
||||
# Declare routes list
|
||||
__routes__ = [
|
||||
register,
|
||||
]
|
||||
|
||||
|
||||
@register.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
"""Registration page"""
|
||||
user_manager = UserManager()
|
||||
redirect_page = request.args.get('redirect')
|
||||
if not redirect_page:
|
||||
redirect_page = request.headers.get('Referer', '/')
|
||||
if re.search('(^https?://[^/]+)?/register[^?#]?', redirect_page):
|
||||
# Prevent redirect loop
|
||||
redirect_page = '/'
|
||||
|
||||
session_token = request.cookies.get('session_token')
|
||||
|
||||
if session_token:
|
||||
user, session = user_manager.authenticate_user_session(session_token)
|
||||
if user:
|
||||
return redirect(redirect_page, 302) # lgtm [py/url-redirection]
|
||||
|
||||
if user_manager.get_user_count() > 0:
|
||||
return redirect(
|
||||
'/login?redirect=' + redirect_page, 302
|
||||
) # lgtm [py/url-redirection]
|
||||
|
||||
if request.form:
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
remember = request.form.get('remember')
|
||||
|
||||
if password == confirm_password:
|
||||
user_manager.create_user(username=username, password=password)
|
||||
session = user_manager.create_user_session(
|
||||
username=username,
|
||||
password=password,
|
||||
expires_at=(
|
||||
utcnow() + datetime.timedelta(days=1) if not remember else None
|
||||
),
|
||||
)
|
||||
|
||||
if session:
|
||||
redirect_target = redirect(
|
||||
redirect_page, 302
|
||||
) # lgtm [py/url-redirection]
|
||||
response = make_response(redirect_target)
|
||||
response.set_cookie('session_token', session.session_token)
|
||||
return response
|
||||
else:
|
||||
abort(400, 'Password mismatch')
|
||||
|
||||
return render_template('index.html', utils=HttpUtils)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -7,7 +7,7 @@ from typing import Optional
|
|||
from tornado.web import RequestHandler, stream_request_body
|
||||
|
||||
from platypush.backend.http.app.utils import logger
|
||||
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status
|
||||
from platypush.backend.http.app.utils.auth import UserAuthStatus, get_auth_status
|
||||
|
||||
from ..mixins import PubSubMixin
|
||||
|
||||
|
@ -29,7 +29,7 @@ class StreamingRoute(RequestHandler, PubSubMixin, ABC):
|
|||
"""
|
||||
if self.auth_required:
|
||||
auth_status = get_auth_status(self.request)
|
||||
if auth_status != AuthStatus.OK:
|
||||
if auth_status != UserAuthStatus.OK:
|
||||
self.send_error(auth_status.value.code, error=auth_status.value.message)
|
||||
return
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import os
|
||||
import pathlib
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime as dt
|
||||
from typing import Optional, Tuple
|
||||
from typing import IO, Optional, Tuple
|
||||
|
||||
from tornado.web import stream_request_body
|
||||
|
||||
|
@ -17,6 +18,8 @@ class FileRoute(StreamingRoute):
|
|||
"""
|
||||
|
||||
BUFSIZE = 1024
|
||||
_bytes_written = 0
|
||||
_out_f: Optional[IO[bytes]] = None
|
||||
|
||||
@classmethod
|
||||
def path(cls) -> str:
|
||||
|
@ -39,6 +42,10 @@ class FileRoute(StreamingRoute):
|
|||
def file_size(self) -> int:
|
||||
return os.path.getsize(self.file_path)
|
||||
|
||||
@property
|
||||
def _content_length(self) -> int:
|
||||
return int(self.request.headers.get('Content-Length', 0))
|
||||
|
||||
@property
|
||||
def range(self) -> Tuple[Optional[int], Optional[int]]:
|
||||
range_hdr = self.request.headers.get('Range')
|
||||
|
@ -105,6 +112,77 @@ class FileRoute(StreamingRoute):
|
|||
|
||||
self.finish()
|
||||
|
||||
def on_finish(self) -> None:
|
||||
if self._out_f:
|
||||
try:
|
||||
if not (self._out_f and self._out_f.closed):
|
||||
self._out_f.close()
|
||||
except Exception as e:
|
||||
self.logger.warning('Error while closing the output file: %s', e)
|
||||
|
||||
self._out_f = None
|
||||
|
||||
return super().on_finish()
|
||||
|
||||
def _validate_upload(self, force: bool = False) -> bool:
|
||||
if not self.file_path:
|
||||
self.write_error(400, 'Missing path argument')
|
||||
return False
|
||||
|
||||
if not self._out_f:
|
||||
if not force and os.path.exists(self.file_path):
|
||||
self.write_error(409, f'{self.file_path} already exists')
|
||||
return False
|
||||
|
||||
self._bytes_written = 0
|
||||
dir_path = os.path.dirname(self.file_path)
|
||||
|
||||
try:
|
||||
pathlib.Path(dir_path).mkdir(parents=True, exist_ok=True)
|
||||
self._out_f = open( # pylint: disable=consider-using-with
|
||||
self.file_path, 'wb'
|
||||
)
|
||||
except PermissionError:
|
||||
self.write_error(403, 'Permission denied')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def finish(self, *args, **kwargs): # type: ignore
|
||||
try:
|
||||
return super().finish(*args, **kwargs)
|
||||
except Exception as e:
|
||||
self.logger.warning('Error while finishing the request: %s', e)
|
||||
|
||||
def data_received(self, chunk: bytes):
|
||||
# Ignore unless we're in POST/PUT mode
|
||||
if self.request.method not in ('POST', 'PUT'):
|
||||
return
|
||||
|
||||
force = self.request.method == 'PUT'
|
||||
if not self._validate_upload(force=force):
|
||||
self.finish()
|
||||
return
|
||||
|
||||
if not chunk:
|
||||
self.logger.debug('Received EOF from client')
|
||||
self.finish()
|
||||
return
|
||||
|
||||
assert self._out_f
|
||||
self._out_f.write(chunk)
|
||||
self._out_f.flush()
|
||||
self._bytes_written += len(chunk)
|
||||
self.logger.debug(
|
||||
'Written chunk of size %d to %s, progress: %d/%d',
|
||||
len(chunk),
|
||||
self.file_path,
|
||||
self._bytes_written,
|
||||
self._content_length,
|
||||
)
|
||||
|
||||
self.flush()
|
||||
|
||||
def get(self) -> None:
|
||||
with self._serve() as f:
|
||||
if f:
|
||||
|
@ -119,3 +197,9 @@ class FileRoute(StreamingRoute):
|
|||
def head(self) -> None:
|
||||
with self._serve():
|
||||
pass
|
||||
|
||||
def post(self) -> None:
|
||||
self.logger.info('Receiving file POST upload request for %r', self.file_path)
|
||||
|
||||
def put(self) -> None:
|
||||
self.logger.info('Receiving file PUT upload request for %r', self.file_path)
|
||||
|
|
|
@ -3,7 +3,9 @@ from typing import Optional
|
|||
from platypush.backend.http.app.utils import logger, send_request
|
||||
from platypush.backend.http.media.handlers import MediaHandler
|
||||
|
||||
from ._registry import load_media_map, save_media_map
|
||||
from ._registry import clear_media_map, load_media_map, save_media_map
|
||||
|
||||
_init = False
|
||||
|
||||
|
||||
def get_media_url(media_id: str) -> str:
|
||||
|
@ -17,6 +19,12 @@ def register_media(source: str, subtitles: Optional[str] = None) -> MediaHandler
|
|||
"""
|
||||
Registers a media file and returns its associated media handler.
|
||||
"""
|
||||
global _init
|
||||
|
||||
if not _init:
|
||||
clear_media_map()
|
||||
_init = True
|
||||
|
||||
media_id = MediaHandler.get_media_id(source)
|
||||
media_url = get_media_url(media_id)
|
||||
media_map = load_media_map()
|
||||
|
|
|
@ -25,10 +25,15 @@ def load_media_map() -> MediaMap:
|
|||
logger().warning('Could not load media map: %s', e)
|
||||
return {}
|
||||
|
||||
return {
|
||||
media_id: MediaHandler.build(**media_info)
|
||||
for media_id, media_info in media_map.items()
|
||||
}
|
||||
parsed_map = {}
|
||||
for media_id, media_info in media_map.items():
|
||||
try:
|
||||
parsed_map[media_id] = MediaHandler.build(**media_info)
|
||||
except Exception as e:
|
||||
logger().debug('Could not load media %s: %s', media_id, e)
|
||||
continue
|
||||
|
||||
return parsed_map
|
||||
|
||||
|
||||
def save_media_map(new_map: MediaMap):
|
||||
|
@ -38,3 +43,12 @@ def save_media_map(new_map: MediaMap):
|
|||
with media_map_lock:
|
||||
redis = get_redis()
|
||||
redis.mset({MEDIA_MAP_VAR: json.dumps(new_map, cls=Message.Encoder)})
|
||||
|
||||
|
||||
def clear_media_map():
|
||||
"""
|
||||
Clears the media map from the server.
|
||||
"""
|
||||
with media_map_lock:
|
||||
redis = get_redis()
|
||||
redis.delete(MEDIA_MAP_VAR)
|
||||
|
|
|
@ -17,7 +17,7 @@ class MediaStreamRoute(StreamingRoute):
|
|||
Route for media streams.
|
||||
"""
|
||||
|
||||
SUPPORTED_METHODS = ['GET', 'PUT', 'DELETE']
|
||||
SUPPORTED_METHODS = ['GET', 'HEAD', 'PUT', 'DELETE']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -50,6 +50,23 @@ class MediaStreamRoute(StreamingRoute):
|
|||
except Exception as e:
|
||||
self._on_error(e)
|
||||
|
||||
def head(self, media_id: Optional[str] = None):
|
||||
"""
|
||||
Streams a media resource by ID.
|
||||
"""
|
||||
|
||||
if not media_id:
|
||||
self.finish()
|
||||
return
|
||||
|
||||
# Strip the extension
|
||||
media_id = '.'.join(media_id.split('.')[:-1])
|
||||
|
||||
try:
|
||||
self.stream_media(media_id, head=True)
|
||||
except Exception as e:
|
||||
self._on_error(e)
|
||||
|
||||
def put(self, *_, **__):
|
||||
"""
|
||||
The `PUT` route is used to prepare a new media resource for streaming.
|
||||
|
@ -93,10 +110,10 @@ class MediaStreamRoute(StreamingRoute):
|
|||
"""
|
||||
Returns the list of registered media resources.
|
||||
"""
|
||||
self.add_header('Content-Type', 'application/json')
|
||||
self.set_header('Content-Type', 'application/json')
|
||||
self.finish(json.dumps([dict(media) for media in load_media_map().values()]))
|
||||
|
||||
def stream_media(self, media_id: str):
|
||||
def stream_media(self, media_id: str, head: bool = False):
|
||||
"""
|
||||
Route to stream a media file given its ID.
|
||||
"""
|
||||
|
@ -107,11 +124,11 @@ class MediaStreamRoute(StreamingRoute):
|
|||
range_hdr = self.request.headers.get('Range')
|
||||
content_length = media_hndl.content_length
|
||||
|
||||
self.add_header('Accept-Ranges', 'bytes')
|
||||
self.add_header('Content-Type', media_hndl.mime_type)
|
||||
self.set_header('Accept-Ranges', 'bytes')
|
||||
self.set_header('Content-Type', media_hndl.mime_type)
|
||||
|
||||
if 'download' in self.request.arguments:
|
||||
self.add_header(
|
||||
self.set_header(
|
||||
'Content-Disposition',
|
||||
'attachment'
|
||||
+ ('; filename="{media_hndl.filename}"' if media_hndl.filename else ''),
|
||||
|
@ -129,7 +146,7 @@ class MediaStreamRoute(StreamingRoute):
|
|||
content_length = to_bytes - from_bytes
|
||||
|
||||
self.set_status(206)
|
||||
self.add_header(
|
||||
self.set_header(
|
||||
'Content-Range',
|
||||
f'bytes {from_bytes}-{to_bytes}/{media_hndl.content_length}',
|
||||
)
|
||||
|
@ -137,7 +154,13 @@ class MediaStreamRoute(StreamingRoute):
|
|||
from_bytes = 0
|
||||
to_bytes = STREAMING_BLOCK_SIZE
|
||||
|
||||
self.add_header('Content-Length', str(content_length))
|
||||
self.set_header('Content-Length', str(content_length))
|
||||
|
||||
if head:
|
||||
self.flush()
|
||||
self.finish()
|
||||
return
|
||||
|
||||
for chunk in media_hndl.get_data(
|
||||
from_bytes=from_bytes,
|
||||
to_bytes=to_bytes,
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
from .auth import (
|
||||
UserAuthStatus,
|
||||
authenticate,
|
||||
authenticate_token,
|
||||
authenticate_user_pass,
|
||||
current_user,
|
||||
get_auth_status,
|
||||
)
|
||||
from .bus import bus, send_message, send_request
|
||||
|
@ -17,10 +19,12 @@ from .streaming import get_streaming_routes
|
|||
from .ws import get_ws_routes
|
||||
|
||||
__all__ = [
|
||||
'UserAuthStatus',
|
||||
'authenticate',
|
||||
'authenticate_token',
|
||||
'authenticate_user_pass',
|
||||
'bus',
|
||||
'current_user',
|
||||
'get_auth_status',
|
||||
'get_http_port',
|
||||
'get_ip_or_hostname',
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import base64
|
||||
from functools import wraps
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
from flask import request, redirect, jsonify
|
||||
from flask import request, redirect
|
||||
from flask.wrappers import Response
|
||||
|
||||
from platypush.config import Config
|
||||
from platypush.user import UserManager
|
||||
from platypush.user import User, UserManager
|
||||
|
||||
from ..logger import logger
|
||||
from .status import AuthStatus
|
||||
from .status import UserAuthStatus
|
||||
|
||||
user_manager = UserManager()
|
||||
|
||||
|
@ -41,8 +41,8 @@ def get_cookie(req, name: str) -> Optional[str]:
|
|||
return cookie.value
|
||||
|
||||
|
||||
def authenticate_token(req):
|
||||
token = Config.get('token')
|
||||
def authenticate_token(req) -> Optional[User]:
|
||||
global_token = Config.get('user.global_token')
|
||||
user_token = None
|
||||
|
||||
if 'X-Token' in req.headers:
|
||||
|
@ -55,14 +55,27 @@ def authenticate_token(req):
|
|||
user_token = get_arg(req, 'token')
|
||||
|
||||
if not user_token:
|
||||
return False
|
||||
return None
|
||||
|
||||
try:
|
||||
user_manager.validate_jwt_token(user_token)
|
||||
return True
|
||||
# Stantard API token authentication
|
||||
return user_manager.validate_api_token(user_token)
|
||||
except Exception as e:
|
||||
logger().debug(str(e))
|
||||
return bool(token and user_token == token)
|
||||
try:
|
||||
# Legacy JWT token authentication
|
||||
return user_manager.validate_jwt_token(user_token)
|
||||
except Exception as ee:
|
||||
logger().debug(
|
||||
'Invalid token. API token error: %s, JWT token error: %s', e, ee
|
||||
)
|
||||
|
||||
# Legacy global token authentication.
|
||||
# The global token should be specified in the configuration file,
|
||||
# as a root parameter named `token`.
|
||||
if bool(global_token and user_token == global_token):
|
||||
return User(username='__token__', user_id=1)
|
||||
|
||||
logger().info(e)
|
||||
|
||||
|
||||
def authenticate_user_pass(req):
|
||||
|
@ -91,7 +104,7 @@ def authenticate_user_pass(req):
|
|||
return user_manager.authenticate_user(username, password)
|
||||
|
||||
|
||||
def authenticate_session(req):
|
||||
def authenticate_session(req) -> Optional[User]:
|
||||
user = None
|
||||
|
||||
# Check the X-Session-Token header
|
||||
|
@ -106,9 +119,9 @@ def authenticate_session(req):
|
|||
user_session_token = get_cookie(req, 'session_token')
|
||||
|
||||
if user_session_token:
|
||||
user, _ = user_manager.authenticate_user_session(user_session_token)
|
||||
user, _ = user_manager.authenticate_user_session(user_session_token)[:2]
|
||||
|
||||
return user is not None
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(
|
||||
|
@ -128,18 +141,18 @@ def authenticate(
|
|||
skip_auth_methods=skip_auth_methods,
|
||||
)
|
||||
|
||||
if auth_status == AuthStatus.OK:
|
||||
if auth_status == UserAuthStatus.OK:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
if json:
|
||||
return jsonify(auth_status.to_dict()), auth_status.value.code
|
||||
return auth_status.to_response()
|
||||
|
||||
if auth_status == AuthStatus.NO_USERS:
|
||||
if auth_status == UserAuthStatus.REGISTRATION_REQUIRED:
|
||||
return redirect(
|
||||
f'/register?redirect={redirect_page or request.url}', 307
|
||||
)
|
||||
|
||||
if auth_status == AuthStatus.UNAUTHORIZED:
|
||||
if auth_status == UserAuthStatus.INVALID_CREDENTIALS:
|
||||
return redirect(f'/login?redirect={redirect_page or request.url}', 307)
|
||||
|
||||
return Response(
|
||||
|
@ -154,43 +167,67 @@ def authenticate(
|
|||
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
def get_auth_status(req, skip_auth_methods=None) -> AuthStatus:
|
||||
def get_current_user_or_auth_status(
|
||||
req, skip_auth_methods=None
|
||||
) -> Union[User, UserAuthStatus]:
|
||||
"""
|
||||
Check against the available authentication methods (except those listed in
|
||||
``skip_auth_methods``) if the user is properly authenticated.
|
||||
Returns the current user if authenticated, and the authentication status if
|
||||
``with_status`` is True.
|
||||
"""
|
||||
|
||||
n_users = user_manager.get_user_count()
|
||||
skip_methods = skip_auth_methods or []
|
||||
|
||||
# User/pass HTTP authentication
|
||||
http_auth_ok = True
|
||||
if n_users > 0 and 'http' not in skip_methods:
|
||||
http_auth_ok = authenticate_user_pass(req)
|
||||
if http_auth_ok:
|
||||
return AuthStatus.OK
|
||||
response = authenticate_user_pass(req)
|
||||
if response:
|
||||
user = response[0] if isinstance(response, tuple) else response
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Token-based authentication
|
||||
token_auth_ok = True
|
||||
if 'token' not in skip_methods:
|
||||
token_auth_ok = authenticate_token(req)
|
||||
if token_auth_ok:
|
||||
return AuthStatus.OK
|
||||
user = authenticate_token(req)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Session token based authentication
|
||||
session_auth_ok = True
|
||||
if n_users > 0 and 'session' not in skip_methods:
|
||||
return AuthStatus.OK if authenticate_session(req) else AuthStatus.UNAUTHORIZED
|
||||
user = authenticate_session(req)
|
||||
if user:
|
||||
return user
|
||||
|
||||
return UserAuthStatus.INVALID_CREDENTIALS
|
||||
|
||||
# At least a user should be created before accessing an authenticated resource
|
||||
if n_users == 0 and 'session' not in skip_methods:
|
||||
return AuthStatus.NO_USERS
|
||||
return UserAuthStatus.REGISTRATION_REQUIRED
|
||||
|
||||
if ( # pylint: disable=too-many-boolean-expressions
|
||||
('http' not in skip_methods and http_auth_ok)
|
||||
or ('token' not in skip_methods and token_auth_ok)
|
||||
or ('session' not in skip_methods and session_auth_ok)
|
||||
):
|
||||
return AuthStatus.OK
|
||||
return UserAuthStatus.OK
|
||||
|
||||
return AuthStatus.UNAUTHORIZED
|
||||
return UserAuthStatus.INVALID_CREDENTIALS
|
||||
|
||||
|
||||
def get_auth_status(req, skip_auth_methods=None) -> UserAuthStatus:
|
||||
"""
|
||||
Check against the available authentication methods (except those listed in
|
||||
``skip_auth_methods``) if the user is properly authenticated.
|
||||
"""
|
||||
ret = get_current_user_or_auth_status(req, skip_auth_methods=skip_auth_methods)
|
||||
return UserAuthStatus.OK if isinstance(ret, User) else ret
|
||||
|
||||
|
||||
def current_user() -> Optional[User]:
|
||||
"""
|
||||
Returns the current user if authenticated.
|
||||
"""
|
||||
ret = get_current_user_or_auth_status(request)
|
||||
return ret if isinstance(ret, User) else None
|
||||
|
|
|
@ -1,21 +1,76 @@
|
|||
from collections import namedtuple
|
||||
from enum import Enum
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
StatusValue = namedtuple('StatusValue', ['code', 'message'])
|
||||
from platypush.user import AuthenticationStatus
|
||||
|
||||
StatusValue = namedtuple('StatusValue', ['code', 'error', 'message'])
|
||||
|
||||
|
||||
class AuthStatus(Enum):
|
||||
class UserAuthStatus(Enum):
|
||||
"""
|
||||
Models the status of the authentication.
|
||||
"""
|
||||
|
||||
OK = StatusValue(200, 'OK')
|
||||
UNAUTHORIZED = StatusValue(401, 'Unauthorized')
|
||||
NO_USERS = StatusValue(412, 'Please create a user first')
|
||||
OK = StatusValue(200, AuthenticationStatus.OK, 'OK')
|
||||
INVALID_AUTH_TYPE = StatusValue(
|
||||
400, AuthenticationStatus.INVALID_AUTH_TYPE, 'Invalid authentication type'
|
||||
)
|
||||
INVALID_CREDENTIALS = StatusValue(
|
||||
401, AuthenticationStatus.INVALID_CREDENTIALS, 'Invalid credentials'
|
||||
)
|
||||
INVALID_JWT_TOKEN = StatusValue(
|
||||
401, AuthenticationStatus.INVALID_JWT_TOKEN, 'Invalid JWT token'
|
||||
)
|
||||
INVALID_OTP_CODE = StatusValue(
|
||||
401, AuthenticationStatus.INVALID_OTP_CODE, 'Invalid OTP code'
|
||||
)
|
||||
INVALID_METHOD = StatusValue(
|
||||
405, AuthenticationStatus.INVALID_METHOD, 'Invalid method'
|
||||
)
|
||||
MISSING_OTP_CODE = StatusValue(
|
||||
401, AuthenticationStatus.MISSING_OTP_CODE, 'Missing OTP code'
|
||||
)
|
||||
MISSING_PASSWORD = StatusValue(
|
||||
400, AuthenticationStatus.MISSING_PASSWORD, 'Missing password'
|
||||
)
|
||||
INVALID_SESSION = StatusValue(
|
||||
401, AuthenticationStatus.INVALID_CREDENTIALS, 'Invalid session'
|
||||
)
|
||||
INVALID_TOKEN = StatusValue(
|
||||
400, AuthenticationStatus.INVALID_JWT_TOKEN, 'Invalid token'
|
||||
)
|
||||
MISSING_USERNAME = StatusValue(
|
||||
400, AuthenticationStatus.MISSING_USERNAME, 'Missing username'
|
||||
)
|
||||
PASSWORD_MISMATCH = StatusValue(
|
||||
400, AuthenticationStatus.PASSWORD_MISMATCH, 'Password mismatch'
|
||||
)
|
||||
REGISTRATION_DISABLED = StatusValue(
|
||||
401, AuthenticationStatus.REGISTRATION_DISABLED, 'Registrations are disabled'
|
||||
)
|
||||
REGISTRATION_REQUIRED = StatusValue(
|
||||
412, AuthenticationStatus.REGISTRATION_REQUIRED, 'Please create a user first'
|
||||
)
|
||||
UNKNOWN_ERROR = StatusValue(
|
||||
500, AuthenticationStatus.UNKNOWN_ERROR, 'Unknown error'
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'code': self.value[0],
|
||||
'message': self.value[1],
|
||||
'error': self.value[1].name,
|
||||
'message': self.value[2],
|
||||
}
|
||||
|
||||
def to_response(self):
|
||||
return jsonify(self.to_dict()), self.value[0]
|
||||
|
||||
@staticmethod
|
||||
def by_status(status: AuthenticationStatus):
|
||||
for auth_status in UserAuthStatus:
|
||||
if auth_status.value[1] == status:
|
||||
return auth_status
|
||||
|
||||
return None
|
||||
|
|
|
@ -5,7 +5,7 @@ from threading import Thread
|
|||
from tornado.ioloop import IOLoop
|
||||
from tornado.websocket import WebSocketHandler
|
||||
|
||||
from platypush.backend.http.app.utils.auth import AuthStatus, get_auth_status
|
||||
from platypush.backend.http.app.utils.auth import UserAuthStatus, get_auth_status
|
||||
|
||||
from ..mixins import MessageType, PubSubMixin
|
||||
|
||||
|
@ -25,7 +25,7 @@ class WSRoute(WebSocketHandler, Thread, PubSubMixin, ABC):
|
|||
|
||||
def open(self, *_, **__):
|
||||
auth_status = get_auth_status(self.request)
|
||||
if auth_status != AuthStatus.OK:
|
||||
if auth_status != UserAuthStatus.OK:
|
||||
self.close(code=1008, reason=auth_status.value.message) # Policy Violation
|
||||
return
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from abc import ABC, abstractmethod
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Generator, Optional
|
||||
|
||||
from platypush.message import JSONAble
|
||||
|
@ -57,9 +56,6 @@ class MediaHandler(JSONAble, ABC):
|
|||
logging.exception(e)
|
||||
errors[hndl_class.__name__] = str(e)
|
||||
|
||||
if os.path.exists(source):
|
||||
source = f'file://{source}'
|
||||
|
||||
raise AttributeError(
|
||||
f'The source {source} has no handlers associated. Errors: {errors}'
|
||||
)
|
||||
|
|
|
@ -15,6 +15,9 @@ class FileHandler(MediaHandler):
|
|||
prefix_handlers = ['file://']
|
||||
|
||||
def __init__(self, source, *args, **kwargs):
|
||||
if isinstance(source, str) and os.path.exists(source):
|
||||
source = f'file://{source}'
|
||||
|
||||
super().__init__(source, *args, **kwargs)
|
||||
|
||||
self.path = os.path.abspath(
|
||||
|
@ -33,7 +36,7 @@ class FileHandler(MediaHandler):
|
|||
), f'{source} is not a valid media file (detected format: {self.mime_type})'
|
||||
|
||||
self.extension = mimetypes.guess_extension(self.mime_type)
|
||||
if self.url and self.extension:
|
||||
if self.url and self.extension and not self.url.endswith(self.extension):
|
||||
self.url += self.extension
|
||||
self.content_length = os.path.getsize(self.path)
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import re
|
|||
|
||||
from platypush.config import Config
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.app.utils import current_user
|
||||
|
||||
|
||||
class HttpUtils:
|
||||
|
@ -130,5 +131,9 @@ class HttpUtils:
|
|||
path = path[0] if len(path) == 1 else os.path.join(*path)
|
||||
return os.path.isfile(path)
|
||||
|
||||
@staticmethod
|
||||
def current_user():
|
||||
return current_user()
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -1 +1 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" src="/static/js/chunk-vendors.05911ac4.js"></script><script defer="defer" src="/static/js/app.a9d9b93f.js"></script><link href="/static/css/chunk-vendors.d510eff2.css" rel="stylesheet"><link href="/static/css/app.34a0a3ba.css" rel="stylesheet"><link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#ffffff"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="Platypush"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#ffffff"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" src="/static/js/chunk-vendors.5645dfdc.js"></script><script defer="defer" src="/static/js/app.d90fb573.js"></script><link href="/static/css/chunk-vendors.d510eff2.css" rel="stylesheet"><link href="/static/css/app.70fb1f4a.css" rel="stylesheet"><link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#ffffff"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="Platypush"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#ffffff"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/1019.af89a8dd.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/1019.af89a8dd.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/1054.1651fcc4.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/1054.1651fcc4.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/1421.1a42ddca.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/1421.1a42ddca.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/1449.9ddbde9a.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/1449.9ddbde9a.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/2029.66acebb6.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/2029.66acebb6.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/2140.57230853.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/2140.57230853.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/215.91074688.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/215.91074688.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/2694.515bb415.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/2694.515bb415.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/2718.5a080a62.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/2718.5a080a62.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/2764.7b323478.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/2764.7b323478.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/293.521a4f1c.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/293.521a4f1c.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/3248.a0e1e73b.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/3248.a0e1e73b.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/3426.50cde06e.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/3426.50cde06e.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/38.b93403c3.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/38.b93403c3.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/3865.8b16d712.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/3865.8b16d712.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/4015.b27ff6b3.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/4015.b27ff6b3.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/4106.2a5e087e.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/4106.2a5e087e.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/4339.10e2638e.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/4339.10e2638e.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/4364.460ea7ea.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/4364.460ea7ea.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/4470.aa130b90.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/4470.aa130b90.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/4795.708edd2b.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/4795.708edd2b.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
platypush/backend/http/webapp/dist/static/css/5053.af8a2a60.css
vendored
Normal file
1
platypush/backend/http/webapp/dist/static/css/5053.af8a2a60.css
vendored
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue