First commit
This commit is contained in:
commit
8c45ad2bda
|
|
@ -0,0 +1,7 @@
|
||||||
|
FLASK_APP=project/__init__.py
|
||||||
|
FLASK_DEBUG=1
|
||||||
|
DATABASE_URL=postgresql://hello_flask:hello_flask@db:5432/hello_flask_dev
|
||||||
|
SQL_HOST=db
|
||||||
|
SQL_PORT=5432
|
||||||
|
DATABASE=postgres
|
||||||
|
APP_FOLDER=/usr/src/app
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
FLASK_APP=project/__init__.py
|
||||||
|
FLASK_DEBUG=0
|
||||||
|
DATABASE_URL=postgresql://hello_flask:hello_flask@db:5432/hello_flask_prod
|
||||||
|
SQL_HOST=db
|
||||||
|
SQL_PORT=5432
|
||||||
|
DATABASE=postgres
|
||||||
|
APP_FOLDER=/home/app/web
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
POSTGRES_USER=hello_flask
|
||||||
|
POSTGRES_PASSWORD=hello_flask
|
||||||
|
POSTGRES_DB=hello_flask_prod
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
*.pyc
|
||||||
|
__pycache
|
||||||
|
.DS_Store
|
||||||
|
.env.dev
|
||||||
|
.env.prod
|
||||||
|
.env.prod.db
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="db" uuid="38a4b686-379e-4677-ac2e-0c6e327e4377">
|
||||||
|
<driver-ref>sqlite.xerial</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:sqlite:F:\Dev\Template\Docker\Flask\flask-on-docker-main\flask-site-docker-bulma-pycharm\services\web\project\instance\db.sqlite</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
<libraries>
|
||||||
|
<library>
|
||||||
|
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.43.0/org/xerial/sqlite-jdbc/3.43.0.0/sqlite-jdbc-3.43.0.0.jar</url>
|
||||||
|
</library>
|
||||||
|
</libraries>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module version="4">
|
||||||
|
<component name="PyDocumentationSettings">
|
||||||
|
<option name="format" value="PLAIN" />
|
||||||
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
</component>
|
||||||
|
<component name="TemplatesService">
|
||||||
|
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
|
||||||
|
<option name="TEMPLATE_FOLDERS">
|
||||||
|
<list>
|
||||||
|
<option value="$MODULE_DIR$/services/web/project/templates" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredPackages">
|
||||||
|
<value>
|
||||||
|
<list size="1">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="werkzeug" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.11 (flask-site-docker-bulma-pycharm)" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (flask-site-docker-bulma-pycharm)" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 TestDriven.io
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Dockerizing Flask with Postgres, Gunicorn, and Nginx
|
||||||
|
|
||||||
|
## Want to learn how to build this?
|
||||||
|
|
||||||
|
Check out the [tutorial](https://testdriven.io/blog/dockerizing-flask-with-postgres-gunicorn-and-nginx).
|
||||||
|
|
||||||
|
## Want to use this project?
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Uses the default Flask development server.
|
||||||
|
|
||||||
|
1. Rename *.env.dev-sample* to *.env.dev*.
|
||||||
|
1. Update the environment variables in the *docker-compose.yml* and *.env.dev* files.
|
||||||
|
- (M1 chip only) Remove `-slim-buster` from the Python dependency in `services/web/Dockerfile` to suppress an issue with installing psycopg2
|
||||||
|
1. Build the images and run the containers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Test it out at [http://localhost:5000](http://localhost:5000). The "web" folder is mounted into the container and your code changes apply automatically.
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
Uses gunicorn + nginx.
|
||||||
|
|
||||||
|
1. Rename *.env.prod-sample* to *.env.prod* and *.env.prod.db-sample* to *.env.prod.db*. Update the environment variables.
|
||||||
|
1. Build the images and run the containers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Test it out at [http://localhost:1337](http://localhost:1337). No mounted folders. To apply changes, the image must be re-built.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./services/web
|
||||||
|
dockerfile: Dockerfile.prod
|
||||||
|
command: gunicorn --bind 0.0.0.0:5000 manage:app
|
||||||
|
volumes:
|
||||||
|
- static_volume:/home/app/web/project/static
|
||||||
|
- media_volume:/home/app/web/project/media
|
||||||
|
expose:
|
||||||
|
- 5000
|
||||||
|
env_file:
|
||||||
|
- ./.env.prod-sample
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
db:
|
||||||
|
image: postgres:13
|
||||||
|
volumes:
|
||||||
|
- postgres_data_prod:/var/lib/postgresql/data/
|
||||||
|
env_file:
|
||||||
|
- ./.env.prod.db-sample
|
||||||
|
nginx:
|
||||||
|
build: ./services/nginx
|
||||||
|
volumes:
|
||||||
|
- static_volume:/home/app/web/project/static
|
||||||
|
- media_volume:/home/app/web/project/media
|
||||||
|
ports:
|
||||||
|
- 1337:80
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data_prod:
|
||||||
|
static_volume:
|
||||||
|
media_volume:
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: ./services/web
|
||||||
|
command: python manage.py run -h 0.0.0.0
|
||||||
|
volumes:
|
||||||
|
- ./services/web/:/usr/src/app/
|
||||||
|
ports:
|
||||||
|
- 5001:5000
|
||||||
|
env_file:
|
||||||
|
- ./.env.dev-sample
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
db:
|
||||||
|
image: postgres:13
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data/
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=hello_flask
|
||||||
|
- POSTGRES_PASSWORD=hello_flask
|
||||||
|
- POSTGRES_DB=hello_flask_dev
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
FROM nginx:1.25
|
||||||
|
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
upstream hello_flask {
|
||||||
|
server web:5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://hello_flask;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /home/app/web/project/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media/ {
|
||||||
|
alias /home/app/web/project/media/;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# pull official base image
|
||||||
|
FROM python:3.11.3-slim-buster
|
||||||
|
|
||||||
|
# set work directory
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
|
||||||
|
# install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y netcat
|
||||||
|
|
||||||
|
# install dependencies
|
||||||
|
RUN pip install --upgrade pip
|
||||||
|
COPY ./requirements.txt /usr/src/app/requirements.txt
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
# copy project
|
||||||
|
COPY . /usr/src/app/
|
||||||
|
|
||||||
|
# run entrypoint.sh
|
||||||
|
ENTRYPOINT ["/usr/src/app/entrypoint.sh"]
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
###########
|
||||||
|
# BUILDER #
|
||||||
|
###########
|
||||||
|
|
||||||
|
# pull official base image
|
||||||
|
FROM python:3.11.3-slim-buster as builder
|
||||||
|
|
||||||
|
# set work directory
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
|
||||||
|
# install system dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends gcc
|
||||||
|
|
||||||
|
# lint
|
||||||
|
RUN pip install --upgrade pip
|
||||||
|
RUN pip install flake8==6.0.0
|
||||||
|
COPY . /usr/src/app/
|
||||||
|
# RUN flake8 --ignore=E501,F401 .
|
||||||
|
|
||||||
|
# install python dependencies
|
||||||
|
COPY ./requirements.txt .
|
||||||
|
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
#########
|
||||||
|
# FINAL #
|
||||||
|
#########
|
||||||
|
|
||||||
|
# pull official base image
|
||||||
|
FROM python:3.11.3-slim-buster
|
||||||
|
|
||||||
|
# create directory for the app user
|
||||||
|
RUN mkdir -p /home/app
|
||||||
|
|
||||||
|
# create the app user
|
||||||
|
RUN addgroup --system app && adduser --system --group app
|
||||||
|
|
||||||
|
# create the appropriate directories
|
||||||
|
ENV HOME=/home/app
|
||||||
|
ENV APP_HOME=/home/app/web
|
||||||
|
RUN mkdir $APP_HOME
|
||||||
|
WORKDIR $APP_HOME
|
||||||
|
|
||||||
|
# install dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends netcat
|
||||||
|
COPY --from=builder /usr/src/app/wheels /wheels
|
||||||
|
COPY --from=builder /usr/src/app/requirements.txt .
|
||||||
|
RUN pip install --upgrade pip
|
||||||
|
RUN pip install --no-cache /wheels/*
|
||||||
|
|
||||||
|
# copy entrypoint-prod.sh
|
||||||
|
COPY ./entrypoint.prod.sh $APP_HOME
|
||||||
|
|
||||||
|
# copy project
|
||||||
|
COPY . $APP_HOME
|
||||||
|
|
||||||
|
# chown all the files to the app user
|
||||||
|
RUN chown -R app:app $APP_HOME
|
||||||
|
|
||||||
|
# change to the app user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# run entrypoint.prod.sh
|
||||||
|
ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"]
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ "$DATABASE" = "postgres" ]
|
||||||
|
then
|
||||||
|
echo "Waiting for postgres..."
|
||||||
|
|
||||||
|
while ! nc -z $SQL_HOST $SQL_PORT; do
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "PostgreSQL started"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ "$DATABASE" = "postgres" ]
|
||||||
|
then
|
||||||
|
echo "Waiting for postgres..."
|
||||||
|
|
||||||
|
while ! nc -z $SQL_HOST $SQL_PORT; do
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "PostgreSQL started"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python manage.py create_db
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
from flask.cli import FlaskGroup
|
||||||
|
|
||||||
|
from project.app import app, db
|
||||||
|
from project.models.users import User
|
||||||
|
|
||||||
|
|
||||||
|
cli = FlaskGroup(app)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("create_db")
|
||||||
|
def create_db():
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@cli.command("seed_db")
|
||||||
|
def seed_db():
|
||||||
|
db.session.add(User(email="michael@mherman.org"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
from flask import Flask
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .extensions import db
|
||||||
|
from .libraries.flask_login_pum import LoginManager
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_class=Config):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# login_manager = LoginManager()
|
||||||
|
# login_manager.login_view = 'auth.login'
|
||||||
|
# login_manager.roles_view = 'main.profile'
|
||||||
|
# login_manager.init_app(app)
|
||||||
|
#
|
||||||
|
# from .models.users import User
|
||||||
|
# @login_manager.user_loader
|
||||||
|
# def load_user(user_id): #reload user object from the user ID stored in the session
|
||||||
|
# # since the user_id is just the primary key of our user table, use it in the query for the user
|
||||||
|
# return User.query.get(int(user_id))
|
||||||
|
|
||||||
|
# Register blueprints here
|
||||||
|
from .routes.main import bp as main_bp
|
||||||
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
|
@app.route('/test/')
|
||||||
|
def test_page():
|
||||||
|
return '<h1>PUM application is running, at least for this page !</h1>'
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from . import create_app, db
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=os.environ.get("FLASK_DEBUG"), host='0.0.0.0',port=os.environ.get("FLASK_SERVER_PORT"))
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
class Config(object):
|
||||||
|
basedir_db = basedir + '/instance'
|
||||||
|
# SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite://")
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URI') or 'sqlite:///' + os.path.join(basedir_db, 'db.sqlite')
|
||||||
|
print(SQLALCHEMY_DATABASE_URI)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
STATIC_FOLDER = f"{os.getenv('APP_FOLDER')}/project/static"
|
||||||
|
MEDIA_FOLDER = f"{os.getenv('APP_FOLDER')}/project/media"
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
db = SQLAlchemy()
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,10 @@
|
||||||
|
__title__ = "Flask-Login"
|
||||||
|
__description__ = "User session management for Flask"
|
||||||
|
__url__ = "https://github.com/maxcountryman/flask-login"
|
||||||
|
__version_info__ = ("0", "6", "3")
|
||||||
|
__version__ = ".".join(__version_info__)
|
||||||
|
__author__ = "Matthew Frazier"
|
||||||
|
__author_email__ = "leafstormrush@gmail.com"
|
||||||
|
__maintainer__ = "Max Countryman"
|
||||||
|
__license__ = "MIT"
|
||||||
|
__copyright__ = "(c) 2011 by Matthew Frazier"
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
from .__about__ import __version__
|
||||||
|
from .config import AUTH_HEADER_NAME
|
||||||
|
from .config import COOKIE_DURATION
|
||||||
|
from .config import COOKIE_HTTPONLY
|
||||||
|
from .config import COOKIE_NAME
|
||||||
|
from .config import COOKIE_SECURE
|
||||||
|
from .config import ID_ATTRIBUTE
|
||||||
|
from .config import LOGIN_MESSAGE
|
||||||
|
from .config import LOGIN_MESSAGE_CATEGORY
|
||||||
|
from .config import REFRESH_MESSAGE
|
||||||
|
from .config import REFRESH_MESSAGE_CATEGORY
|
||||||
|
from .login_manager import LoginManager
|
||||||
|
from .mixins import AnonymousUserMixin
|
||||||
|
from .mixins import UserMixin
|
||||||
|
from .signals import session_protected
|
||||||
|
from .signals import user_accessed
|
||||||
|
from .signals import user_loaded_from_cookie
|
||||||
|
from .signals import user_loaded_from_request
|
||||||
|
from .signals import user_logged_in
|
||||||
|
from .signals import user_logged_out
|
||||||
|
from .signals import user_login_confirmed
|
||||||
|
from .signals import user_needs_refresh
|
||||||
|
from .signals import user_unauthorized
|
||||||
|
from .test_client import FlaskLoginClient
|
||||||
|
from .utils import confirm_login
|
||||||
|
from .utils import current_user
|
||||||
|
from .utils import decode_cookie
|
||||||
|
from .utils import encode_cookie
|
||||||
|
from .utils import fresh_login_required
|
||||||
|
from .utils import login_fresh
|
||||||
|
from .utils import login_remembered
|
||||||
|
from .utils import login_required
|
||||||
|
from .utils import role_required
|
||||||
|
from .utils import login_url
|
||||||
|
from .utils import login_user
|
||||||
|
from .utils import logout_user
|
||||||
|
from .utils import make_next_param
|
||||||
|
from .utils import set_login_view
|
||||||
|
from .utils import set_roles_view
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
"AUTH_HEADER_NAME",
|
||||||
|
"COOKIE_DURATION",
|
||||||
|
"COOKIE_HTTPONLY",
|
||||||
|
"COOKIE_NAME",
|
||||||
|
"COOKIE_SECURE",
|
||||||
|
"ID_ATTRIBUTE",
|
||||||
|
"LOGIN_MESSAGE",
|
||||||
|
"LOGIN_MESSAGE_CATEGORY",
|
||||||
|
"REFRESH_MESSAGE",
|
||||||
|
"REFRESH_MESSAGE_CATEGORY",
|
||||||
|
"LoginManager",
|
||||||
|
"AnonymousUserMixin",
|
||||||
|
"UserMixin",
|
||||||
|
"session_protected",
|
||||||
|
"user_accessed",
|
||||||
|
"user_loaded_from_cookie",
|
||||||
|
"user_loaded_from_request",
|
||||||
|
"user_logged_in",
|
||||||
|
"user_logged_out",
|
||||||
|
"user_login_confirmed",
|
||||||
|
"user_needs_refresh",
|
||||||
|
"user_unauthorized",
|
||||||
|
"FlaskLoginClient",
|
||||||
|
"confirm_login",
|
||||||
|
"current_user",
|
||||||
|
"decode_cookie",
|
||||||
|
"encode_cookie",
|
||||||
|
"fresh_login_required",
|
||||||
|
"login_fresh",
|
||||||
|
"login_remembered",
|
||||||
|
"login_required",
|
||||||
|
"role_required",
|
||||||
|
"login_url",
|
||||||
|
"login_user",
|
||||||
|
"logout_user",
|
||||||
|
"make_next_param",
|
||||||
|
"set_login_view",
|
||||||
|
"set_roles_view",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name == "user_loaded_from_header":
|
||||||
|
import warnings
|
||||||
|
from .signals import _user_loaded_from_header
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'user_loaded_from_header' is deprecated and will be"
|
||||||
|
" removed in Flask-Login 0.7. Use"
|
||||||
|
" 'user_loaded_from_request' instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return _user_loaded_from_header
|
||||||
|
|
||||||
|
raise AttributeError(name)
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
#: The default name of the "remember me" cookie (``remember_token``)
|
||||||
|
COOKIE_NAME = "remember_token"
|
||||||
|
|
||||||
|
#: The default time before the "remember me" cookie expires (365 days).
|
||||||
|
COOKIE_DURATION = timedelta(days=365)
|
||||||
|
|
||||||
|
#: Whether the "remember me" cookie requires Secure; defaults to ``False``
|
||||||
|
COOKIE_SECURE = False
|
||||||
|
|
||||||
|
#: Whether the "remember me" cookie uses HttpOnly or not; defaults to ``True``
|
||||||
|
COOKIE_HTTPONLY = True
|
||||||
|
|
||||||
|
#: Whether the "remember me" cookie requires same origin; defaults to ``None``
|
||||||
|
COOKIE_SAMESITE = None
|
||||||
|
|
||||||
|
#: The default flash message to display when users need to log in.
|
||||||
|
LOGIN_MESSAGE = "Please log in to access this page."
|
||||||
|
|
||||||
|
ROLES_MESSAGE = "Your are not authorized to access this page."
|
||||||
|
|
||||||
|
#: The default flash message category to display when users need to log in.
|
||||||
|
LOGIN_MESSAGE_CATEGORY = "message"
|
||||||
|
|
||||||
|
ROLES_MESSAGE_CATEGORY = "message"
|
||||||
|
|
||||||
|
#: The default flash message to display when users need to reauthenticate.
|
||||||
|
REFRESH_MESSAGE = "Please reauthenticate to access this page."
|
||||||
|
|
||||||
|
#: The default flash message category to display when users need to
|
||||||
|
#: reauthenticate.
|
||||||
|
REFRESH_MESSAGE_CATEGORY = "message"
|
||||||
|
|
||||||
|
#: The default attribute to retreive the str id of the user
|
||||||
|
ID_ATTRIBUTE = "get_id"
|
||||||
|
|
||||||
|
#: Default name of the auth header (``Authorization``)
|
||||||
|
AUTH_HEADER_NAME = "Authorization"
|
||||||
|
|
||||||
|
#: A set of session keys that are populated by Flask-Login. Use this set to
|
||||||
|
#: purge keys safely and accurately.
|
||||||
|
SESSION_KEYS = {
|
||||||
|
"_user_id",
|
||||||
|
"_remember",
|
||||||
|
"_remember_seconds",
|
||||||
|
"_id",
|
||||||
|
"_fresh",
|
||||||
|
"next",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: A set of HTTP methods which are exempt from `login_required` and
|
||||||
|
#: `fresh_login_required`. By default, this is just ``OPTIONS``.
|
||||||
|
EXEMPT_METHODS = {"OPTIONS"}
|
||||||
|
|
||||||
|
#: If true, the page the user is attempting to access is stored in the session
|
||||||
|
#: rather than a url parameter when redirecting to the login view; defaults to
|
||||||
|
#: ``False``.
|
||||||
|
USE_SESSION_FOR_NEXT = False
|
||||||
|
|
@ -0,0 +1,578 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from flask import abort
|
||||||
|
from flask import current_app
|
||||||
|
from flask import flash
|
||||||
|
from flask import g
|
||||||
|
from flask import has_app_context
|
||||||
|
from flask import redirect
|
||||||
|
from flask import request
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
from .config import AUTH_HEADER_NAME
|
||||||
|
from .config import COOKIE_DURATION
|
||||||
|
from .config import COOKIE_HTTPONLY
|
||||||
|
from .config import COOKIE_NAME
|
||||||
|
from .config import COOKIE_SAMESITE
|
||||||
|
from .config import COOKIE_SECURE
|
||||||
|
from .config import ID_ATTRIBUTE
|
||||||
|
from .config import ROLES_MESSAGE
|
||||||
|
from .config import LOGIN_MESSAGE
|
||||||
|
from .config import ROLES_MESSAGE_CATEGORY
|
||||||
|
from .config import LOGIN_MESSAGE_CATEGORY
|
||||||
|
from .config import REFRESH_MESSAGE
|
||||||
|
from .config import REFRESH_MESSAGE_CATEGORY
|
||||||
|
from .config import SESSION_KEYS
|
||||||
|
from .config import USE_SESSION_FOR_NEXT
|
||||||
|
from .mixins import AnonymousUserMixin
|
||||||
|
from .signals import session_protected
|
||||||
|
from .signals import user_accessed
|
||||||
|
from .signals import user_loaded_from_cookie
|
||||||
|
from .signals import user_loaded_from_request
|
||||||
|
from .signals import user_needs_refresh
|
||||||
|
from .signals import user_unauthorized
|
||||||
|
from .utils import _create_identifier
|
||||||
|
from .utils import _user_context_processor
|
||||||
|
from .utils import decode_cookie
|
||||||
|
from .utils import encode_cookie
|
||||||
|
from .utils import expand_login_view
|
||||||
|
from .utils import login_url as make_login_url
|
||||||
|
from .utils import make_next_param
|
||||||
|
|
||||||
|
|
||||||
|
class LoginManager:
|
||||||
|
"""This object is used to hold the settings used for logging in. Instances
|
||||||
|
of :class:`LoginManager` are *not* bound to specific apps, so you can
|
||||||
|
create one in the main body of your code and then bind it to your
|
||||||
|
app in a factory function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app=None, add_context_processor=True):
|
||||||
|
#: A class or factory function that produces an anonymous user, which
|
||||||
|
#: is used when no one is logged in.
|
||||||
|
self.anonymous_user = AnonymousUserMixin
|
||||||
|
|
||||||
|
#: The name of the view to redirect to when the user needs to log in.
|
||||||
|
#: (This can be an absolute URL as well, if your authentication
|
||||||
|
#: machinery is external to your application.)
|
||||||
|
self.login_view = None
|
||||||
|
|
||||||
|
self.roles_view = None
|
||||||
|
|
||||||
|
#: Names of views to redirect to when the user needs to log in,
|
||||||
|
#: per blueprint. If the key value is set to None the value of
|
||||||
|
#: :attr:`login_view` will be used instead.
|
||||||
|
self.blueprint_login_views = {}
|
||||||
|
|
||||||
|
self.blueprint_roles_views = {}
|
||||||
|
|
||||||
|
#: The message to flash when a user is redirected to the login page.
|
||||||
|
self.login_message = LOGIN_MESSAGE
|
||||||
|
self.roles_message = ROLES_MESSAGE
|
||||||
|
|
||||||
|
#: The message category to flash when a user is redirected to the login
|
||||||
|
#: page.
|
||||||
|
self.login_message_category = LOGIN_MESSAGE_CATEGORY
|
||||||
|
self.roles_message_category = ROLES_MESSAGE_CATEGORY
|
||||||
|
|
||||||
|
#: The name of the view to redirect to when the user needs to
|
||||||
|
#: reauthenticate.
|
||||||
|
self.refresh_view = None
|
||||||
|
|
||||||
|
#: The message to flash when a user is redirected to the 'needs
|
||||||
|
#: refresh' page.
|
||||||
|
self.needs_refresh_message = REFRESH_MESSAGE
|
||||||
|
|
||||||
|
#: The message category to flash when a user is redirected to the
|
||||||
|
#: 'needs refresh' page.
|
||||||
|
self.needs_refresh_message_category = REFRESH_MESSAGE_CATEGORY
|
||||||
|
|
||||||
|
#: The mode to use session protection in. This can be either
|
||||||
|
#: ``'basic'`` (the default) or ``'strong'``, or ``None`` to disable
|
||||||
|
#: it.
|
||||||
|
self.session_protection = "basic"
|
||||||
|
|
||||||
|
#: If present, used to translate flash messages ``self.login_message``
|
||||||
|
#: and ``self.needs_refresh_message``
|
||||||
|
self.localize_callback = None
|
||||||
|
|
||||||
|
self.unauthorized_callback = None
|
||||||
|
|
||||||
|
self.needs_refresh_callback = None
|
||||||
|
|
||||||
|
self.id_attribute = ID_ATTRIBUTE
|
||||||
|
|
||||||
|
self._user_callback = None
|
||||||
|
|
||||||
|
self._header_callback = None
|
||||||
|
|
||||||
|
self._request_callback = None
|
||||||
|
|
||||||
|
self._session_identifier_generator = _create_identifier
|
||||||
|
|
||||||
|
if app is not None:
|
||||||
|
self.init_app(app, add_context_processor)
|
||||||
|
|
||||||
|
def setup_app(self, app, add_context_processor=True): # pragma: no cover
|
||||||
|
"""
|
||||||
|
This method has been deprecated. Please use
|
||||||
|
:meth:`LoginManager.init_app` instead.
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'setup_app' is deprecated and will be removed in"
|
||||||
|
" Flask-Login 0.7. Use 'init_app' instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
self.init_app(app, add_context_processor)
|
||||||
|
|
||||||
|
def init_app(self, app, add_context_processor=True):
|
||||||
|
"""
|
||||||
|
Configures an application. This registers an `after_request` call, and
|
||||||
|
attaches this `LoginManager` to it as `app.login_manager`.
|
||||||
|
|
||||||
|
:param app: The :class:`flask.Flask` object to configure.
|
||||||
|
:type app: :class:`flask.Flask`
|
||||||
|
:param add_context_processor: Whether to add a context processor to
|
||||||
|
the app that adds a `current_user` variable to the template.
|
||||||
|
Defaults to ``True``.
|
||||||
|
:type add_context_processor: bool
|
||||||
|
"""
|
||||||
|
app.login_manager = self
|
||||||
|
app.after_request(self._update_remember_cookie)
|
||||||
|
|
||||||
|
if add_context_processor:
|
||||||
|
app.context_processor(_user_context_processor)
|
||||||
|
|
||||||
|
def unauthorized(self):
|
||||||
|
"""
|
||||||
|
This is called when the user is required to log in. If you register a
|
||||||
|
callback with :meth:`LoginManager.unauthorized_handler`, then it will
|
||||||
|
be called. Otherwise, it will take the following actions:
|
||||||
|
|
||||||
|
- Flash :attr:`LoginManager.login_message` to the user.
|
||||||
|
|
||||||
|
- If the app is using blueprints find the login view for
|
||||||
|
the current blueprint using `blueprint_login_views`. If the app
|
||||||
|
is not using blueprints or the login view for the current
|
||||||
|
blueprint is not specified use the value of `login_view`.
|
||||||
|
|
||||||
|
- Redirect the user to the login view. (The page they were
|
||||||
|
attempting to access will be passed in the ``next`` query
|
||||||
|
string variable, so you can redirect there if present instead
|
||||||
|
of the homepage. Alternatively, it will be added to the session
|
||||||
|
as ``next`` if USE_SESSION_FOR_NEXT is set.)
|
||||||
|
|
||||||
|
If :attr:`LoginManager.login_view` is not defined, then it will simply
|
||||||
|
raise a HTTP 401 (Unauthorized) error instead.
|
||||||
|
|
||||||
|
This should be returned from a view or before/after_request function,
|
||||||
|
otherwise the redirect will have no effect.
|
||||||
|
"""
|
||||||
|
user_unauthorized.send(current_app._get_current_object())
|
||||||
|
|
||||||
|
if self.unauthorized_callback:
|
||||||
|
return self.unauthorized_callback()
|
||||||
|
|
||||||
|
if request.blueprint in self.blueprint_login_views:
|
||||||
|
login_view = self.blueprint_login_views[request.blueprint]
|
||||||
|
else:
|
||||||
|
login_view = self.login_view
|
||||||
|
|
||||||
|
if not login_view:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
if self.login_message:
|
||||||
|
if self.localize_callback is not None:
|
||||||
|
flash(
|
||||||
|
self.localize_callback(self.login_message),
|
||||||
|
category=self.login_message_category,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(self.login_message, category=self.login_message_category)
|
||||||
|
|
||||||
|
config = current_app.config
|
||||||
|
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
|
||||||
|
login_url = expand_login_view(login_view)
|
||||||
|
session["_id"] = self._session_identifier_generator()
|
||||||
|
session["next"] = make_next_param(login_url, request.url)
|
||||||
|
redirect_url = make_login_url(login_view)
|
||||||
|
else:
|
||||||
|
redirect_url = make_login_url(login_view, next_url=request.url)
|
||||||
|
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
def roles_unauthorized(self):
|
||||||
|
user_unauthorized.send(current_app._get_current_object())
|
||||||
|
|
||||||
|
if self.unauthorized_callback:
|
||||||
|
return self.unauthorized_callback()
|
||||||
|
|
||||||
|
if request.blueprint in self.blueprint_roles_views:
|
||||||
|
roles_view = self.blueprint_roles_views[request.blueprint]
|
||||||
|
else:
|
||||||
|
roles_view = self.roles_view
|
||||||
|
|
||||||
|
if not roles_view:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
if self.login_message:
|
||||||
|
if self.localize_callback is not None:
|
||||||
|
flash(
|
||||||
|
self.localize_callback(self.roles_message),
|
||||||
|
category=self.roles_message_category,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(self.roles_message, category=self.roles_message_category)
|
||||||
|
|
||||||
|
redirect_url = make_login_url(roles_view, next_url=request.url)
|
||||||
|
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
def user_loader(self, callback):
|
||||||
|
"""
|
||||||
|
This sets the callback for reloading a user from the session. The
|
||||||
|
function you set should take a user ID (a ``str``) and return a
|
||||||
|
user object, or ``None`` if the user does not exist.
|
||||||
|
|
||||||
|
:param callback: The callback for retrieving a user object.
|
||||||
|
:type callback: callable
|
||||||
|
"""
|
||||||
|
self._user_callback = callback
|
||||||
|
return self.user_callback
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_callback(self):
|
||||||
|
"""Gets the user_loader callback set by user_loader decorator."""
|
||||||
|
return self._user_callback
|
||||||
|
|
||||||
|
def request_loader(self, callback):
|
||||||
|
"""
|
||||||
|
This sets the callback for loading a user from a Flask request.
|
||||||
|
The function you set should take Flask request object and
|
||||||
|
return a user object, or `None` if the user does not exist.
|
||||||
|
|
||||||
|
:param callback: The callback for retrieving a user object.
|
||||||
|
:type callback: callable
|
||||||
|
"""
|
||||||
|
self._request_callback = callback
|
||||||
|
return self.request_callback
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_callback(self):
|
||||||
|
"""Gets the request_loader callback set by request_loader decorator."""
|
||||||
|
return self._request_callback
|
||||||
|
|
||||||
|
def unauthorized_handler(self, callback):
|
||||||
|
"""
|
||||||
|
This will set the callback for the `unauthorized` method, which among
|
||||||
|
other things is used by `login_required`. It takes no arguments, and
|
||||||
|
should return a response to be sent to the user instead of their
|
||||||
|
normal view.
|
||||||
|
|
||||||
|
:param callback: The callback for unauthorized users.
|
||||||
|
:type callback: callable
|
||||||
|
"""
|
||||||
|
self.unauthorized_callback = callback
|
||||||
|
return callback
|
||||||
|
|
||||||
|
def needs_refresh_handler(self, callback):
|
||||||
|
"""
|
||||||
|
This will set the callback for the `needs_refresh` method, which among
|
||||||
|
other things is used by `fresh_login_required`. It takes no arguments,
|
||||||
|
and should return a response to be sent to the user instead of their
|
||||||
|
normal view.
|
||||||
|
|
||||||
|
:param callback: The callback for unauthorized users.
|
||||||
|
:type callback: callable
|
||||||
|
"""
|
||||||
|
self.needs_refresh_callback = callback
|
||||||
|
return callback
|
||||||
|
|
||||||
|
def needs_refresh(self):
|
||||||
|
"""
|
||||||
|
This is called when the user is logged in, but they need to be
|
||||||
|
reauthenticated because their session is stale. If you register a
|
||||||
|
callback with `needs_refresh_handler`, then it will be called.
|
||||||
|
Otherwise, it will take the following actions:
|
||||||
|
|
||||||
|
- Flash :attr:`LoginManager.needs_refresh_message` to the user.
|
||||||
|
|
||||||
|
- Redirect the user to :attr:`LoginManager.refresh_view`. (The page
|
||||||
|
they were attempting to access will be passed in the ``next``
|
||||||
|
query string variable, so you can redirect there if present
|
||||||
|
instead of the homepage.)
|
||||||
|
|
||||||
|
If :attr:`LoginManager.refresh_view` is not defined, then it will
|
||||||
|
simply raise a HTTP 401 (Unauthorized) error instead.
|
||||||
|
|
||||||
|
This should be returned from a view or before/after_request function,
|
||||||
|
otherwise the redirect will have no effect.
|
||||||
|
"""
|
||||||
|
user_needs_refresh.send(current_app._get_current_object())
|
||||||
|
|
||||||
|
if self.needs_refresh_callback:
|
||||||
|
return self.needs_refresh_callback()
|
||||||
|
|
||||||
|
if not self.refresh_view:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
if self.needs_refresh_message:
|
||||||
|
if self.localize_callback is not None:
|
||||||
|
flash(
|
||||||
|
self.localize_callback(self.needs_refresh_message),
|
||||||
|
category=self.needs_refresh_message_category,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
flash(
|
||||||
|
self.needs_refresh_message,
|
||||||
|
category=self.needs_refresh_message_category,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = current_app.config
|
||||||
|
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
|
||||||
|
login_url = expand_login_view(self.refresh_view)
|
||||||
|
session["_id"] = self._session_identifier_generator()
|
||||||
|
session["next"] = make_next_param(login_url, request.url)
|
||||||
|
redirect_url = make_login_url(self.refresh_view)
|
||||||
|
else:
|
||||||
|
login_url = self.refresh_view
|
||||||
|
redirect_url = make_login_url(login_url, next_url=request.url)
|
||||||
|
|
||||||
|
return redirect(redirect_url)
|
||||||
|
|
||||||
|
def header_loader(self, callback):
|
||||||
|
"""
|
||||||
|
This function has been deprecated. Please use
|
||||||
|
:meth:`LoginManager.request_loader` instead.
|
||||||
|
|
||||||
|
This sets the callback for loading a user from a header value.
|
||||||
|
The function you set should take an authentication token and
|
||||||
|
return a user object, or `None` if the user does not exist.
|
||||||
|
|
||||||
|
:param callback: The callback for retrieving a user object.
|
||||||
|
:type callback: callable
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'header_loader' is deprecated and will be removed in"
|
||||||
|
" Flask-Login 0.7. Use 'request_loader' instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
self._header_callback = callback
|
||||||
|
return callback
|
||||||
|
|
||||||
|
def _update_request_context_with_user(self, user=None):
|
||||||
|
"""Store the given user as ctx.user."""
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
user = self.anonymous_user()
|
||||||
|
|
||||||
|
g._login_user = user
|
||||||
|
|
||||||
|
def _load_user(self):
|
||||||
|
"""Loads user from session or remember_me cookie as applicable"""
|
||||||
|
|
||||||
|
if self._user_callback is None and self._request_callback is None:
|
||||||
|
raise Exception(
|
||||||
|
"Missing user_loader or request_loader. Refer to "
|
||||||
|
"http://flask-login.readthedocs.io/#how-it-works "
|
||||||
|
"for more info."
|
||||||
|
)
|
||||||
|
|
||||||
|
user_accessed.send(current_app._get_current_object())
|
||||||
|
|
||||||
|
# Check SESSION_PROTECTION
|
||||||
|
if self._session_protection_failed():
|
||||||
|
return self._update_request_context_with_user()
|
||||||
|
|
||||||
|
user = None
|
||||||
|
|
||||||
|
# Load user from Flask Session
|
||||||
|
user_id = session.get("_user_id")
|
||||||
|
if user_id is not None and self._user_callback is not None:
|
||||||
|
user = self._user_callback(user_id)
|
||||||
|
|
||||||
|
# Load user from Remember Me Cookie or Request Loader
|
||||||
|
if user is None:
|
||||||
|
config = current_app.config
|
||||||
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
header_name = config.get("AUTH_HEADER_NAME", AUTH_HEADER_NAME)
|
||||||
|
has_cookie = (
|
||||||
|
cookie_name in request.cookies and session.get("_remember") != "clear"
|
||||||
|
)
|
||||||
|
if has_cookie:
|
||||||
|
cookie = request.cookies[cookie_name]
|
||||||
|
user = self._load_user_from_remember_cookie(cookie)
|
||||||
|
elif self._request_callback:
|
||||||
|
user = self._load_user_from_request(request)
|
||||||
|
elif header_name in request.headers:
|
||||||
|
header = request.headers[header_name]
|
||||||
|
user = self._load_user_from_header(header)
|
||||||
|
|
||||||
|
return self._update_request_context_with_user(user)
|
||||||
|
|
||||||
|
def _session_protection_failed(self):
|
||||||
|
sess = session._get_current_object()
|
||||||
|
ident = self._session_identifier_generator()
|
||||||
|
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
mode = app.config.get("SESSION_PROTECTION", self.session_protection)
|
||||||
|
|
||||||
|
if not mode or mode not in ["basic", "strong"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# if the sess is empty, it's an anonymous user or just logged out
|
||||||
|
# so we can skip this
|
||||||
|
if sess and ident != sess.get("_id", None):
|
||||||
|
if mode == "basic" or sess.permanent:
|
||||||
|
if sess.get("_fresh") is not False:
|
||||||
|
sess["_fresh"] = False
|
||||||
|
session_protected.send(app)
|
||||||
|
return False
|
||||||
|
elif mode == "strong":
|
||||||
|
for k in SESSION_KEYS:
|
||||||
|
sess.pop(k, None)
|
||||||
|
|
||||||
|
sess["_remember"] = "clear"
|
||||||
|
session_protected.send(app)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _load_user_from_remember_cookie(self, cookie):
|
||||||
|
user_id = decode_cookie(cookie)
|
||||||
|
if user_id is not None:
|
||||||
|
session["_user_id"] = user_id
|
||||||
|
session["_fresh"] = False
|
||||||
|
user = None
|
||||||
|
if self._user_callback:
|
||||||
|
user = self._user_callback(user_id)
|
||||||
|
if user is not None:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
user_loaded_from_cookie.send(app, user=user)
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_user_from_header(self, header):
|
||||||
|
if self._header_callback:
|
||||||
|
user = self._header_callback(header)
|
||||||
|
if user is not None:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
from .signals import _user_loaded_from_header
|
||||||
|
|
||||||
|
_user_loaded_from_header.send(app, user=user)
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_user_from_request(self, request):
|
||||||
|
if self._request_callback:
|
||||||
|
user = self._request_callback(request)
|
||||||
|
if user is not None:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
user_loaded_from_request.send(app, user=user)
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_remember_cookie(self, response):
|
||||||
|
# Don't modify the session unless there's something to do.
|
||||||
|
if "_remember" not in session and current_app.config.get(
|
||||||
|
"REMEMBER_COOKIE_REFRESH_EACH_REQUEST"
|
||||||
|
):
|
||||||
|
session["_remember"] = "set"
|
||||||
|
|
||||||
|
if "_remember" in session:
|
||||||
|
operation = session.pop("_remember", None)
|
||||||
|
|
||||||
|
if operation == "set" and "_user_id" in session:
|
||||||
|
self._set_cookie(response)
|
||||||
|
elif operation == "clear":
|
||||||
|
self._clear_cookie(response)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _set_cookie(self, response):
|
||||||
|
# cookie settings
|
||||||
|
config = current_app.config
|
||||||
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
||||||
|
path = config.get("REMEMBER_COOKIE_PATH", "/")
|
||||||
|
|
||||||
|
secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE)
|
||||||
|
httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY)
|
||||||
|
samesite = config.get("REMEMBER_COOKIE_SAMESITE", COOKIE_SAMESITE)
|
||||||
|
|
||||||
|
if "_remember_seconds" in session:
|
||||||
|
duration = timedelta(seconds=session["_remember_seconds"])
|
||||||
|
else:
|
||||||
|
duration = config.get("REMEMBER_COOKIE_DURATION", COOKIE_DURATION)
|
||||||
|
|
||||||
|
# prepare data
|
||||||
|
data = encode_cookie(str(session["_user_id"]))
|
||||||
|
|
||||||
|
if isinstance(duration, int):
|
||||||
|
duration = timedelta(seconds=duration)
|
||||||
|
|
||||||
|
try:
|
||||||
|
expires = datetime.utcnow() + duration
|
||||||
|
except TypeError as e:
|
||||||
|
raise Exception(
|
||||||
|
"REMEMBER_COOKIE_DURATION must be a datetime.timedelta,"
|
||||||
|
f" instead got: {duration}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
# actually set it
|
||||||
|
response.set_cookie(
|
||||||
|
cookie_name,
|
||||||
|
value=data,
|
||||||
|
expires=expires,
|
||||||
|
domain=domain,
|
||||||
|
path=path,
|
||||||
|
secure=secure,
|
||||||
|
httponly=httponly,
|
||||||
|
samesite=samesite,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clear_cookie(self, response):
|
||||||
|
config = current_app.config
|
||||||
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
||||||
|
path = config.get("REMEMBER_COOKIE_PATH", "/")
|
||||||
|
response.delete_cookie(cookie_name, domain=domain, path=path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _login_disabled(self):
|
||||||
|
"""Legacy property, use app.config['LOGIN_DISABLED'] instead."""
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'_login_disabled' is deprecated and will be removed in"
|
||||||
|
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
|
||||||
|
" instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_app_context():
|
||||||
|
return current_app.config.get("LOGIN_DISABLED", False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@_login_disabled.setter
|
||||||
|
def _login_disabled(self, newvalue):
|
||||||
|
"""Legacy property setter, use app.config['LOGIN_DISABLED'] instead."""
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'_login_disabled' is deprecated and will be removed in"
|
||||||
|
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
|
||||||
|
" instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
current_app.config["LOGIN_DISABLED"] = newvalue
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
class UserMixin:
|
||||||
|
"""
|
||||||
|
This provides default implementations for the methods that Flask-Login
|
||||||
|
expects user objects to have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Python 3 implicitly set __hash__ to None if we override __eq__
|
||||||
|
# We set it back to its default implementation
|
||||||
|
__hash__ = object.__hash__
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return self.is_active
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
try:
|
||||||
|
return str(self.id)
|
||||||
|
except AttributeError:
|
||||||
|
raise NotImplementedError("No `id` attribute - override `get_id`") from None
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
"""
|
||||||
|
Checks the equality of two `UserMixin` objects using `get_id`.
|
||||||
|
"""
|
||||||
|
if isinstance(other, UserMixin):
|
||||||
|
return self.get_id() == other.get_id()
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
"""
|
||||||
|
Checks the inequality of two `UserMixin` objects using `get_id`.
|
||||||
|
"""
|
||||||
|
equal = self.__eq__(other)
|
||||||
|
if equal is NotImplemented:
|
||||||
|
return NotImplemented
|
||||||
|
return not equal
|
||||||
|
|
||||||
|
|
||||||
|
class AnonymousUserMixin:
|
||||||
|
"""
|
||||||
|
This is the default object for representing an anonymous user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
from flask.signals import Namespace
|
||||||
|
|
||||||
|
_signals = Namespace()
|
||||||
|
|
||||||
|
#: Sent when a user is logged in. In addition to the app (which is the
|
||||||
|
#: sender), it is passed `user`, which is the user being logged in.
|
||||||
|
user_logged_in = _signals.signal("logged-in")
|
||||||
|
|
||||||
|
#: Sent when a user is logged out. In addition to the app (which is the
|
||||||
|
#: sender), it is passed `user`, which is the user being logged out.
|
||||||
|
user_logged_out = _signals.signal("logged-out")
|
||||||
|
|
||||||
|
#: Sent when the user is loaded from the cookie. In addition to the app (which
|
||||||
|
#: is the sender), it is passed `user`, which is the user being reloaded.
|
||||||
|
user_loaded_from_cookie = _signals.signal("loaded-from-cookie")
|
||||||
|
|
||||||
|
#: Sent when the user is loaded from the header. In addition to the app (which
|
||||||
|
#: is the #: sender), it is passed `user`, which is the user being reloaded.
|
||||||
|
_user_loaded_from_header = _signals.signal("loaded-from-header")
|
||||||
|
|
||||||
|
#: Sent when the user is loaded from the request. In addition to the app (which
|
||||||
|
#: is the #: sender), it is passed `user`, which is the user being reloaded.
|
||||||
|
user_loaded_from_request = _signals.signal("loaded-from-request")
|
||||||
|
|
||||||
|
#: Sent when a user's login is confirmed, marking it as fresh. (It is not
|
||||||
|
#: called for a normal login.)
|
||||||
|
#: It receives no additional arguments besides the app.
|
||||||
|
user_login_confirmed = _signals.signal("login-confirmed")
|
||||||
|
|
||||||
|
#: Sent when the `unauthorized` method is called on a `LoginManager`. It
|
||||||
|
#: receives no additional arguments besides the app.
|
||||||
|
user_unauthorized = _signals.signal("unauthorized")
|
||||||
|
|
||||||
|
#: Sent when the `needs_refresh` method is called on a `LoginManager`. It
|
||||||
|
#: receives no additional arguments besides the app.
|
||||||
|
user_needs_refresh = _signals.signal("needs-refresh")
|
||||||
|
|
||||||
|
#: Sent whenever the user is accessed/loaded
|
||||||
|
#: receives no additional arguments besides the app.
|
||||||
|
user_accessed = _signals.signal("accessed")
|
||||||
|
|
||||||
|
#: Sent whenever session protection takes effect, and a session is either
|
||||||
|
#: marked non-fresh or deleted. It receives no additional arguments besides
|
||||||
|
#: the app.
|
||||||
|
session_protected = _signals.signal("session-protected")
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name == "user_loaded_from_header":
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"'user_loaded_from_header' is deprecated and will be"
|
||||||
|
" removed in Flask-Login 0.7. Use"
|
||||||
|
" 'user_loaded_from_request' instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return _user_loaded_from_header
|
||||||
|
|
||||||
|
raise AttributeError(name)
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
|
||||||
|
class FlaskLoginClient(FlaskClient):
|
||||||
|
"""
|
||||||
|
A Flask test client that knows how to log in users
|
||||||
|
using the Flask-Login extension.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop("user", None)
|
||||||
|
fresh = kwargs.pop("fresh_login", True)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
with self.session_transaction() as sess:
|
||||||
|
sess["_user_id"] = user.get_id()
|
||||||
|
sess["_fresh"] = fresh
|
||||||
|
|
@ -0,0 +1,470 @@
|
||||||
|
import hmac
|
||||||
|
from functools import wraps
|
||||||
|
from hashlib import sha512
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from urllib.parse import urlunsplit
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from flask import g
|
||||||
|
from flask import has_request_context
|
||||||
|
from flask import request
|
||||||
|
from flask import session
|
||||||
|
from flask import url_for
|
||||||
|
from werkzeug.local import LocalProxy
|
||||||
|
|
||||||
|
from .config import COOKIE_NAME
|
||||||
|
from .config import EXEMPT_METHODS
|
||||||
|
from .signals import user_logged_in
|
||||||
|
from .signals import user_logged_out
|
||||||
|
from .signals import user_login_confirmed
|
||||||
|
|
||||||
|
#: A proxy for the current user. If no user is logged in, this will be an
|
||||||
|
#: anonymous user
|
||||||
|
current_user = LocalProxy(lambda: _get_user())
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cookie(payload, key=None):
|
||||||
|
"""
|
||||||
|
This will encode a ``str`` value into a cookie, and sign that cookie
|
||||||
|
with the app's secret key.
|
||||||
|
|
||||||
|
:param payload: The value to encode, as `str`.
|
||||||
|
:type payload: str
|
||||||
|
|
||||||
|
:param key: The key to use when creating the cookie digest. If not
|
||||||
|
specified, the SECRET_KEY value from app config will be used.
|
||||||
|
:type key: str
|
||||||
|
"""
|
||||||
|
return f"{payload}|{_cookie_digest(payload, key=key)}"
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cookie(cookie, key=None):
|
||||||
|
"""
|
||||||
|
This decodes a cookie given by `encode_cookie`. If verification of the
|
||||||
|
cookie fails, ``None`` will be implicitly returned.
|
||||||
|
|
||||||
|
:param cookie: An encoded cookie.
|
||||||
|
:type cookie: str
|
||||||
|
|
||||||
|
:param key: The key to use when creating the cookie digest. If not
|
||||||
|
specified, the SECRET_KEY value from app config will be used.
|
||||||
|
:type key: str
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload, digest = cookie.rsplit("|", 1)
|
||||||
|
if hasattr(digest, "decode"):
|
||||||
|
digest = digest.decode("ascii") # pragma: no cover
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if hmac.compare_digest(_cookie_digest(payload, key=key), digest):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def make_next_param(login_url, current_url):
|
||||||
|
"""
|
||||||
|
Reduces the scheme and host from a given URL so it can be passed to
|
||||||
|
the given `login` URL more efficiently.
|
||||||
|
|
||||||
|
:param login_url: The login URL being redirected to.
|
||||||
|
:type login_url: str
|
||||||
|
:param current_url: The URL to reduce.
|
||||||
|
:type current_url: str
|
||||||
|
"""
|
||||||
|
l_url = urlsplit(login_url)
|
||||||
|
c_url = urlsplit(current_url)
|
||||||
|
|
||||||
|
if (not l_url.scheme or l_url.scheme == c_url.scheme) and (
|
||||||
|
not l_url.netloc or l_url.netloc == c_url.netloc
|
||||||
|
):
|
||||||
|
return urlunsplit(("", "", c_url.path, c_url.query, ""))
|
||||||
|
return current_url
|
||||||
|
|
||||||
|
|
||||||
|
def expand_login_view(login_view):
|
||||||
|
"""
|
||||||
|
Returns the url for the login view, expanding the view name to a url if
|
||||||
|
needed.
|
||||||
|
|
||||||
|
:param login_view: The name of the login view or a URL for the login view.
|
||||||
|
:type login_view: str
|
||||||
|
"""
|
||||||
|
if login_view.startswith(("https://", "http://", "/")):
|
||||||
|
return login_view
|
||||||
|
|
||||||
|
return url_for(login_view)
|
||||||
|
|
||||||
|
|
||||||
|
def login_url(login_view, next_url=None, next_field="next"):
|
||||||
|
"""
|
||||||
|
Creates a URL for redirecting to a login page. If only `login_view` is
|
||||||
|
provided, this will just return the URL for it. If `next_url` is provided,
|
||||||
|
however, this will append a ``next=URL`` parameter to the query string
|
||||||
|
so that the login view can redirect back to that URL. Flask-Login's default
|
||||||
|
unauthorized handler uses this function when redirecting to your login url.
|
||||||
|
To force the host name used, set `FORCE_HOST_FOR_REDIRECTS` to a host. This
|
||||||
|
prevents from redirecting to external sites if request headers Host or
|
||||||
|
X-Forwarded-For are present.
|
||||||
|
|
||||||
|
:param login_view: The name of the login view. (Alternately, the actual
|
||||||
|
URL to the login view.)
|
||||||
|
:type login_view: str
|
||||||
|
:param next_url: The URL to give the login view for redirection.
|
||||||
|
:type next_url: str
|
||||||
|
:param next_field: What field to store the next URL in. (It defaults to
|
||||||
|
``next``.)
|
||||||
|
:type next_field: str
|
||||||
|
"""
|
||||||
|
base = expand_login_view(login_view)
|
||||||
|
|
||||||
|
if next_url is None:
|
||||||
|
return base
|
||||||
|
|
||||||
|
parsed_result = urlsplit(base)
|
||||||
|
md = parse_qs(parsed_result.query, keep_blank_values=True)
|
||||||
|
md[next_field] = make_next_param(base, next_url)
|
||||||
|
netloc = current_app.config.get("FORCE_HOST_FOR_REDIRECTS") or parsed_result.netloc
|
||||||
|
parsed_result = parsed_result._replace(
|
||||||
|
netloc=netloc, query=urlencode(md, doseq=True)
|
||||||
|
)
|
||||||
|
return urlunsplit(parsed_result)
|
||||||
|
|
||||||
|
|
||||||
|
def login_fresh():
|
||||||
|
"""
|
||||||
|
This returns ``True`` if the current login is fresh.
|
||||||
|
"""
|
||||||
|
return session.get("_fresh", False)
|
||||||
|
|
||||||
|
|
||||||
|
def login_remembered():
|
||||||
|
"""
|
||||||
|
This returns ``True`` if the current login is remembered across sessions.
|
||||||
|
"""
|
||||||
|
config = current_app.config
|
||||||
|
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
has_cookie = cookie_name in request.cookies and session.get("_remember") != "clear"
|
||||||
|
if has_cookie:
|
||||||
|
cookie = request.cookies[cookie_name]
|
||||||
|
user_id = decode_cookie(cookie)
|
||||||
|
return user_id is not None
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def login_user(user, remember=False, duration=None, force=False, fresh=True):
|
||||||
|
"""
|
||||||
|
Logs a user in. You should pass the actual user object to this. If the
|
||||||
|
user's `is_active` property is ``False``, they will not be logged in
|
||||||
|
unless `force` is ``True``.
|
||||||
|
|
||||||
|
This will return ``True`` if the log in attempt succeeds, and ``False`` if
|
||||||
|
it fails (i.e. because the user is inactive).
|
||||||
|
|
||||||
|
:param user: The user object to log in.
|
||||||
|
:type user: object
|
||||||
|
:param remember: Whether to remember the user after their session expires.
|
||||||
|
Defaults to ``False``.
|
||||||
|
:type remember: bool
|
||||||
|
:param duration: The amount of time before the remember cookie expires. If
|
||||||
|
``None`` the value set in the settings is used. Defaults to ``None``.
|
||||||
|
:type duration: :class:`datetime.timedelta`
|
||||||
|
:param force: If the user is inactive, setting this to ``True`` will log
|
||||||
|
them in regardless. Defaults to ``False``.
|
||||||
|
:type force: bool
|
||||||
|
:param fresh: setting this to ``False`` will log in the user with a session
|
||||||
|
marked as not "fresh". Defaults to ``True``.
|
||||||
|
:type fresh: bool
|
||||||
|
"""
|
||||||
|
if not force and not user.is_active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_id = getattr(user, current_app.login_manager.id_attribute)()
|
||||||
|
session["_user_id"] = user_id
|
||||||
|
session["_fresh"] = fresh
|
||||||
|
session["_id"] = current_app.login_manager._session_identifier_generator()
|
||||||
|
|
||||||
|
if remember:
|
||||||
|
session["_remember"] = "set"
|
||||||
|
if duration is not None:
|
||||||
|
try:
|
||||||
|
# equal to timedelta.total_seconds() but works with Python 2.6
|
||||||
|
session["_remember_seconds"] = (
|
||||||
|
duration.microseconds
|
||||||
|
+ (duration.seconds + duration.days * 24 * 3600) * 10**6
|
||||||
|
) / 10.0**6
|
||||||
|
except AttributeError as e:
|
||||||
|
raise Exception(
|
||||||
|
f"duration must be a datetime.timedelta, instead got: {duration}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
current_app.login_manager._update_request_context_with_user(user)
|
||||||
|
user_logged_in.send(current_app._get_current_object(), user=_get_user())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def logout_user():
|
||||||
|
"""
|
||||||
|
Logs a user out. (You do not need to pass the actual user.) This will
|
||||||
|
also clean up the remember me cookie if it exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = _get_user()
|
||||||
|
|
||||||
|
if "_user_id" in session:
|
||||||
|
session.pop("_user_id")
|
||||||
|
|
||||||
|
if "_fresh" in session:
|
||||||
|
session.pop("_fresh")
|
||||||
|
|
||||||
|
if "_id" in session:
|
||||||
|
session.pop("_id")
|
||||||
|
|
||||||
|
cookie_name = current_app.config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||||
|
if cookie_name in request.cookies:
|
||||||
|
session["_remember"] = "clear"
|
||||||
|
if "_remember_seconds" in session:
|
||||||
|
session.pop("_remember_seconds")
|
||||||
|
|
||||||
|
user_logged_out.send(current_app._get_current_object(), user=user)
|
||||||
|
|
||||||
|
current_app.login_manager._update_request_context_with_user()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_login():
|
||||||
|
"""
|
||||||
|
This sets the current session as fresh. Sessions become stale when they
|
||||||
|
are reloaded from a cookie.
|
||||||
|
"""
|
||||||
|
session["_fresh"] = True
|
||||||
|
session["_id"] = current_app.login_manager._session_identifier_generator()
|
||||||
|
user_login_confirmed.send(current_app._get_current_object())
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(func):
|
||||||
|
"""
|
||||||
|
If you decorate a view with this, it will ensure that the current user is
|
||||||
|
logged in and authenticated before calling the actual view. (If they are
|
||||||
|
not, it calls the :attr:`LoginManager.unauthorized` callback.) For
|
||||||
|
example::
|
||||||
|
|
||||||
|
@app.route('/post')
|
||||||
|
@login_required
|
||||||
|
def post():
|
||||||
|
pass
|
||||||
|
|
||||||
|
If there are only certain times you need to require that your user is
|
||||||
|
logged in, you can do so with::
|
||||||
|
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
return current_app.login_manager.unauthorized()
|
||||||
|
|
||||||
|
...which is essentially the code that this function adds to your views.
|
||||||
|
|
||||||
|
It can be convenient to globally turn off authentication when unit testing.
|
||||||
|
To enable this, if the application configuration variable `LOGIN_DISABLED`
|
||||||
|
is set to `True`, this decorator will be ignored.
|
||||||
|
|
||||||
|
.. Note ::
|
||||||
|
|
||||||
|
Per `W3 guidelines for CORS preflight requests
|
||||||
|
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
|
||||||
|
HTTP ``OPTIONS`` requests are exempt from login checks.
|
||||||
|
|
||||||
|
:param func: The view function to decorate.
|
||||||
|
:type func: function
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def decorated_view(*args, **kwargs):
|
||||||
|
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
|
||||||
|
pass
|
||||||
|
elif not current_user.is_authenticated:
|
||||||
|
return current_app.login_manager.unauthorized()
|
||||||
|
|
||||||
|
# flask 1.x compatibility
|
||||||
|
# current_app.ensure_sync is only available in Flask >= 2.0
|
||||||
|
if callable(getattr(current_app, "ensure_sync", None)):
|
||||||
|
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_view
|
||||||
|
|
||||||
|
def role_required(admin=False,role_names=''):
|
||||||
|
# def role_required(*role_names):
|
||||||
|
def wrapper(view_function):
|
||||||
|
|
||||||
|
@wraps(view_function)
|
||||||
|
def decorator(*args, **kwargs):
|
||||||
|
|
||||||
|
if(admin == True and current_user.admin!=1):
|
||||||
|
print('Authorization level not valid for the user {}'.format(current_user.name))
|
||||||
|
return current_app.login_manager.roles_unauthorized()
|
||||||
|
else:
|
||||||
|
if(current_user.roles == role_names):
|
||||||
|
print('Current user role {} is valid for {}'.format(current_user.roles, role_names))
|
||||||
|
else:
|
||||||
|
if(current_user.admin == 1):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
print('Current user role {} is not valid for {}'.format(current_user.roles, role_names))
|
||||||
|
return current_app.login_manager.roles_unauthorized()
|
||||||
|
|
||||||
|
if callable(getattr(current_app, "ensure_sync", None)):
|
||||||
|
return current_app.ensure_sync(view_function)(*args, **kwargs)
|
||||||
|
|
||||||
|
return view_function(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def fresh_login_required(func):
|
||||||
|
"""
|
||||||
|
If you decorate a view with this, it will ensure that the current user's
|
||||||
|
login is fresh - i.e. their session was not restored from a 'remember me'
|
||||||
|
cookie. Sensitive operations, like changing a password or e-mail, should
|
||||||
|
be protected with this, to impede the efforts of cookie thieves.
|
||||||
|
|
||||||
|
If the user is not authenticated, :meth:`LoginManager.unauthorized` is
|
||||||
|
called as normal. If they are authenticated, but their session is not
|
||||||
|
fresh, it will call :meth:`LoginManager.needs_refresh` instead. (In that
|
||||||
|
case, you will need to provide a :attr:`LoginManager.refresh_view`.)
|
||||||
|
|
||||||
|
Behaves identically to the :func:`login_required` decorator with respect
|
||||||
|
to configuration variables.
|
||||||
|
|
||||||
|
.. Note ::
|
||||||
|
|
||||||
|
Per `W3 guidelines for CORS preflight requests
|
||||||
|
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
|
||||||
|
HTTP ``OPTIONS`` requests are exempt from login checks.
|
||||||
|
|
||||||
|
:param func: The view function to decorate.
|
||||||
|
:type func: function
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def decorated_view(*args, **kwargs):
|
||||||
|
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
|
||||||
|
pass
|
||||||
|
elif not current_user.is_authenticated:
|
||||||
|
return current_app.login_manager.unauthorized()
|
||||||
|
elif not login_fresh():
|
||||||
|
return current_app.login_manager.needs_refresh()
|
||||||
|
try:
|
||||||
|
# current_app.ensure_sync available in Flask >= 2.0
|
||||||
|
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||||
|
except AttributeError: # pragma: no cover
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_view
|
||||||
|
|
||||||
|
|
||||||
|
def set_login_view(login_view, blueprint=None):
|
||||||
|
"""
|
||||||
|
Sets the login view for the app or blueprint. If a blueprint is passed,
|
||||||
|
the login view is set for this blueprint on ``blueprint_login_views``.
|
||||||
|
|
||||||
|
:param login_view: The user object to log in.
|
||||||
|
:type login_view: str
|
||||||
|
:param blueprint: The blueprint which this login view should be set on.
|
||||||
|
Defaults to ``None``.
|
||||||
|
:type blueprint: object
|
||||||
|
"""
|
||||||
|
|
||||||
|
num_login_views = len(current_app.login_manager.blueprint_login_views)
|
||||||
|
if blueprint is not None or num_login_views != 0:
|
||||||
|
(current_app.login_manager.blueprint_login_views[blueprint.name]) = login_view
|
||||||
|
|
||||||
|
if (
|
||||||
|
current_app.login_manager.login_view is not None
|
||||||
|
and None not in current_app.login_manager.blueprint_login_views
|
||||||
|
):
|
||||||
|
(
|
||||||
|
current_app.login_manager.blueprint_login_views[None]
|
||||||
|
) = current_app.login_manager.login_view
|
||||||
|
|
||||||
|
current_app.login_manager.login_view = None
|
||||||
|
else:
|
||||||
|
current_app.login_manager.login_view = login_view
|
||||||
|
|
||||||
|
def set_roles_view(roles_view, blueprint=None):
|
||||||
|
"""
|
||||||
|
Sets the login view for the app or blueprint. If a blueprint is passed,
|
||||||
|
the login view is set for this blueprint on ``blueprint_login_views``.
|
||||||
|
|
||||||
|
:param login_view: The user object to log in.
|
||||||
|
:type login_view: str
|
||||||
|
:param blueprint: The blueprint which this login view should be set on.
|
||||||
|
Defaults to ``None``.
|
||||||
|
:type blueprint: object
|
||||||
|
"""
|
||||||
|
|
||||||
|
num_roles_views = len(current_app.login_manager.blueprint_roles_views)
|
||||||
|
if blueprint is not None or num_roles_views != 0:
|
||||||
|
(current_app.login_manager.blueprint_roles_views[blueprint.name]) = roles_view
|
||||||
|
|
||||||
|
if (
|
||||||
|
current_app.login_manager.roles_view is not None
|
||||||
|
and None not in current_app.login_manager.blueprint_roles_views
|
||||||
|
):
|
||||||
|
(
|
||||||
|
current_app.login_manager.blueprint_roles_views[None]
|
||||||
|
) = current_app.login_manager.roles_view
|
||||||
|
|
||||||
|
current_app.login_manager.roles_view = None
|
||||||
|
else:
|
||||||
|
current_app.login_manager.roles_view = roles_view
|
||||||
|
|
||||||
|
def _get_user():
|
||||||
|
if has_request_context():
|
||||||
|
if "_login_user" not in g:
|
||||||
|
current_app.login_manager._load_user()
|
||||||
|
|
||||||
|
return g._login_user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_digest(payload, key=None):
|
||||||
|
key = _secret_key(key)
|
||||||
|
|
||||||
|
return hmac.new(key, payload.encode("utf-8"), sha512).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_remote_addr():
|
||||||
|
address = request.headers.get("X-Forwarded-For", request.remote_addr)
|
||||||
|
if address is not None:
|
||||||
|
# An 'X-Forwarded-For' header includes a comma separated list of the
|
||||||
|
# addresses, the first address being the actual remote address.
|
||||||
|
address = address.encode("utf-8").split(b",")[0].strip()
|
||||||
|
return address
|
||||||
|
|
||||||
|
|
||||||
|
def _create_identifier():
|
||||||
|
user_agent = request.headers.get("User-Agent")
|
||||||
|
if user_agent is not None:
|
||||||
|
user_agent = user_agent.encode("utf-8")
|
||||||
|
base = f"{_get_remote_addr()}|{user_agent}"
|
||||||
|
if str is bytes:
|
||||||
|
base = str(base, "utf-8", errors="replace") # pragma: no cover
|
||||||
|
h = sha512()
|
||||||
|
h.update(base.encode("utf8"))
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_context_processor():
|
||||||
|
return dict(current_user=_get_user())
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_key(key=None):
|
||||||
|
if key is None:
|
||||||
|
key = current_app.config["SECRET_KEY"]
|
||||||
|
|
||||||
|
if isinstance(key, str): # pragma: no cover
|
||||||
|
key = key.encode("latin1") # ensure bytes
|
||||||
|
|
||||||
|
return key
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# from services.web.project.libraries.flask_login_pum import UserMixin
|
||||||
|
# from services.web.project import db
|
||||||
|
|
||||||
|
from ..libraries.flask_login_pum import UserMixin
|
||||||
|
from .. import db
|
||||||
|
|
||||||
|
class User(UserMixin, db.Model):
|
||||||
|
_table_args__ = {'extend_existing': True}
|
||||||
|
id = db.Column(db.Integer, primary_key=True) # primary keys are required by SQLAlchemy
|
||||||
|
email = db.Column(db.String(100), unique=True)
|
||||||
|
password = db.Column(db.String(100))
|
||||||
|
name = db.Column(db.String(1000))
|
||||||
|
roles = db.Column(db.String(100), nullable=False, server_default='user')
|
||||||
|
admin = db.Column(db.Integer, nullable=False, server_default='0')
|
||||||
|
log_type = db.Column(db.String(10), nullable=False, server_default='none')
|
||||||
|
plex_id = db.Column(db.Integer, nullable=False, server_default='0')
|
||||||
|
blocked = db.Column(db.Integer, nullable=False, server_default='0')
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
from . import routes
|
||||||
|
# from services.web.project.routes.main import routes
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
from flask import render_template, redirect, url_for, request, flash
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from . import bp
|
||||||
|
from ...libraries.flask_login_pum import login_required, current_user
|
||||||
|
|
||||||
|
# from services.web.project.routes.main import bp
|
||||||
|
# from services.web.project.libraries.flask_login_pum import login_required, current_user
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('main/index.html')
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
hi!
|
||||||
|
rryt
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,109 @@
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Functions to open and close a modal
|
||||||
|
function openModal($el) {
|
||||||
|
$el.classList.add('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal($el) {
|
||||||
|
$el.classList.remove('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllModals() {
|
||||||
|
(document.querySelectorAll('.modal') || []).forEach(($modal) => {
|
||||||
|
closeModal($modal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a click event on buttons to open a specific modal
|
||||||
|
(document.querySelectorAll('.js-modal-trigger') || []).forEach(($trigger) => {
|
||||||
|
const modal = $trigger.dataset.target;
|
||||||
|
const $target = document.getElementById(modal);
|
||||||
|
|
||||||
|
$trigger.addEventListener('click', () => {
|
||||||
|
openModal($target);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a click event on various child elements to close the parent modal
|
||||||
|
(document.querySelectorAll('.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach(($close) => {
|
||||||
|
const $target = $close.closest('.modal');
|
||||||
|
|
||||||
|
$close.addEventListener('click', () => {
|
||||||
|
closeModal($target);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a keyboard event to close all modals
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
const e = event || window.event;
|
||||||
|
|
||||||
|
if (e.keyCode === 27) { // Escape key
|
||||||
|
closeAllModals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
(document.querySelectorAll('.notification .delete') || []).forEach(($delete) => {
|
||||||
|
const $notification = $delete.parentNode;
|
||||||
|
|
||||||
|
$delete.addEventListener('click', () => {
|
||||||
|
$notification.parentNode.removeChild($notification);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
|
// Get all "navbar-burger" elements
|
||||||
|
var $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
|
||||||
|
|
||||||
|
// Check if there are any navbar burgers
|
||||||
|
if ($navbarBurgers.length > 0) {
|
||||||
|
|
||||||
|
// Add a click event on each of them
|
||||||
|
$navbarBurgers.forEach(function ($el) {
|
||||||
|
$el.addEventListener('click', function () {
|
||||||
|
|
||||||
|
// Get the target from the "data-target" attribute
|
||||||
|
var target = $el.dataset.target;
|
||||||
|
var $target = document.getElementById(target);
|
||||||
|
|
||||||
|
// console.log("el : " + $el)
|
||||||
|
// console.log($el)
|
||||||
|
// console.log("target : " + target)
|
||||||
|
// console.log(target)
|
||||||
|
// console.log("$target : " + $target)
|
||||||
|
// console.log($target)
|
||||||
|
|
||||||
|
// Toggle the class on both the "navbar-burger" and the "navbar-menu"
|
||||||
|
$el.classList.toggle('is-active');
|
||||||
|
$target.classList.toggle('is-active');
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// const bulmaCollapsibleInstances = bulmaCollapsible.attach('.is-collapsible');
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const bulmaCollapsibleInstances = bulmaCollapsible.attach('.is-collapsible');
|
||||||
|
let cardToggles2 = document.getElementsByClassName('card-header-icon');
|
||||||
|
//console.log(cardToggles2)
|
||||||
|
for (let i = 0; i < cardToggles2.length; i++) {
|
||||||
|
cardToggles2[i].addEventListener('click', e => {
|
||||||
|
|
||||||
|
var element = e.currentTarget.parentElement.childNodes[3].childNodes[1].childNodes[1]
|
||||||
|
|
||||||
|
if(bulmaCollapsibleInstances[i].collapsed() == true){
|
||||||
|
element.classList.replace('fa-angle-up','fa-angle-down')
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
element.classList.replace('fa-angle-down','fa-angle-up')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,931 @@
|
||||||
|
var p = {
|
||||||
|
name: 'Unknown',
|
||||||
|
version: 'Unknown',
|
||||||
|
os: 'Unknown'
|
||||||
|
};
|
||||||
|
if (typeof platform !== 'undefined') {
|
||||||
|
p.name = platform.name;
|
||||||
|
p.version = platform.version;
|
||||||
|
p.os = platform.os.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['IE', 'Microsoft Edge', 'IE Mobile'].indexOf(p.name) > -1) {
|
||||||
|
if (!getCookie('browserDismiss')) {
|
||||||
|
var $browser_warning = $('<div id="browser-warning">' +
|
||||||
|
'<i class="fa fa-exclamation-circle"></i> ' +
|
||||||
|
'Tautulli does not support Internet Explorer or Microsoft Edge! ' +
|
||||||
|
'Please use a different browser such as Chrome or Firefox.' +
|
||||||
|
'<button type="button" class="close"><i class="fa fa-remove"></i></button>' +
|
||||||
|
'</div>');
|
||||||
|
$('body').prepend($browser_warning);
|
||||||
|
var offset = $browser_warning.height();
|
||||||
|
warningOffset(offset);
|
||||||
|
|
||||||
|
$browser_warning.one('click', 'button.close', function () {
|
||||||
|
$browser_warning.remove();
|
||||||
|
warningOffset(-offset);
|
||||||
|
setCookie('browserDismiss', 'true', 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
function warningOffset(offset) {
|
||||||
|
var navbar = $('.navbar-fixed-top');
|
||||||
|
if (navbar.length) {
|
||||||
|
navbar.offset({top: navbar.offset().top + offset});
|
||||||
|
}
|
||||||
|
var container = $('.body-container');
|
||||||
|
if (container.length) {
|
||||||
|
container.offset({top: container.offset().top + offset});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initConfigCheckbox(elem, toggleElem, reverse) {
|
||||||
|
toggleElem = (toggleElem === undefined) ? null : toggleElem;
|
||||||
|
reverse = (reverse === undefined) ? false : reverse;
|
||||||
|
var config = toggleElem ? $(toggleElem) : $(elem).closest('div').next();
|
||||||
|
config.addClass('hidden-settings');
|
||||||
|
if ($(elem).is(":checked")) {
|
||||||
|
config.toggle(!reverse);
|
||||||
|
} else {
|
||||||
|
config.toggle(reverse);
|
||||||
|
}
|
||||||
|
$(elem).click(function () {
|
||||||
|
var config = toggleElem ? $(toggleElem) : $(this).closest('div').next();
|
||||||
|
if ($(this).is(":checked")) {
|
||||||
|
config.slideToggleBool(!reverse);
|
||||||
|
} else {
|
||||||
|
config.slideToggleBool(reverse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshTab() {
|
||||||
|
var url = $(location).attr('href');
|
||||||
|
var tabId = $('.ui-tabs-panel:visible').attr("id");
|
||||||
|
$('.ui-tabs-panel:visible').load(url + " #" + tabId, function () {
|
||||||
|
initThisPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMsg(msg, loader, timeout, ms, error) {
|
||||||
|
var feedback = $("#ajaxMsg");
|
||||||
|
var update = $("#updatebar");
|
||||||
|
var token_error = $("#token_error_bar");
|
||||||
|
if (update.is(":visible") || token_error.is(":visible")) {
|
||||||
|
var height = (update.is(":visible") ? update.height() : 0) + (token_error.is(":visible") ? token_error.height() : 0) + 35;
|
||||||
|
feedback.css("bottom", height + "px");
|
||||||
|
} else {
|
||||||
|
feedback.removeAttr("style");
|
||||||
|
}
|
||||||
|
var message = $("<div class='msg'>" + msg + "</div>");
|
||||||
|
if (loader) {
|
||||||
|
message = $("<div class='msg'><i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
|
||||||
|
feedback.css("padding", "14px 10px");
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
feedback.css("background-color", "rgba(255,0,0,0.5)");
|
||||||
|
}
|
||||||
|
$(feedback).html(message);
|
||||||
|
feedback.fadeIn();
|
||||||
|
if (timeout) {
|
||||||
|
setTimeout(function () {
|
||||||
|
message.fadeOut(function () {
|
||||||
|
$(this).remove();
|
||||||
|
feedback.fadeOut();
|
||||||
|
feedback.css("background-color", "");
|
||||||
|
});
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmAjaxCall(url, msg, data, loader_msg, callback) {
|
||||||
|
$("#confirm-message").html(msg);
|
||||||
|
$('#confirm-modal').modal();
|
||||||
|
$('#confirm-modal').off('click', '#confirm-button').one('click', '#confirm-button', function () {
|
||||||
|
if (loader_msg) {
|
||||||
|
showMsg(loader_msg, true, false);
|
||||||
|
}
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
type: 'POST',
|
||||||
|
cache: false,
|
||||||
|
async: true,
|
||||||
|
data: data,
|
||||||
|
complete: function (xhr, status) {
|
||||||
|
var result = $.parseJSON(xhr.responseText);
|
||||||
|
var msg = result.message;
|
||||||
|
if (result.result == 'success') {
|
||||||
|
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
|
||||||
|
} else {
|
||||||
|
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true);
|
||||||
|
}
|
||||||
|
if (typeof callback === "function") {
|
||||||
|
callback(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function doAjaxCall(url, elem, reload, form, showMsg, callback) {
|
||||||
|
// Set Message
|
||||||
|
var feedback = (showMsg) ? $("#ajaxMsg") : $();
|
||||||
|
var update = $("#updatebar");
|
||||||
|
var token_error = $("#token_error_bar");
|
||||||
|
if (update.is(":visible") || token_error.is(":visible")) {
|
||||||
|
var height = (update.is(":visible") ? update.height() : 0) + (token_error.is(":visible") ? token_error.height() : 0) + 35;
|
||||||
|
feedback.css("bottom", height + "px");
|
||||||
|
} else {
|
||||||
|
feedback.removeAttr("style");
|
||||||
|
}
|
||||||
|
feedback.fadeIn();
|
||||||
|
// Get Form data
|
||||||
|
var formID = "#" + url;
|
||||||
|
var dataString;
|
||||||
|
if (form === true) {
|
||||||
|
dataString = $(formID).serialize();
|
||||||
|
}
|
||||||
|
// Loader Image
|
||||||
|
var loader = $("<div class='msg ajaxLoader-" + url +"'><i class='fa fa-refresh fa-spin'></i> Saving...</div>");
|
||||||
|
// Data Success Message
|
||||||
|
var dataSucces = $(elem).data('success');
|
||||||
|
if (typeof dataSucces === "undefined") {
|
||||||
|
// Standard Message when variable is not set
|
||||||
|
dataSucces = "Success!";
|
||||||
|
}
|
||||||
|
// Data Error Message
|
||||||
|
var dataError = $(elem).data('error');
|
||||||
|
if (typeof dataError === "undefined") {
|
||||||
|
// Standard Message when variable is not set
|
||||||
|
dataError = "There was an error";
|
||||||
|
}
|
||||||
|
// Get Success & Error message from inline data, else use standard message
|
||||||
|
var succesMsg = $("<div class='msg'><i class='fa fa-check'></i> " + dataSucces + "</div>");
|
||||||
|
var errorMsg = $("<div class='msg'><i class='fa fa-exclamation-triangle'></i> " + dataError + "</div>");
|
||||||
|
// Check if checkbox is selected
|
||||||
|
if (form) {
|
||||||
|
if ($('td#select input[type=checkbox]').length > 0 && !$('td#select input[type=checkbox]').is(':checked') ||
|
||||||
|
$('#importLastFM #username:visible').length > 0 && $("#importLastFM #username").val().length === 0) {
|
||||||
|
feedback.addClass('error');
|
||||||
|
$(feedback).prepend(errorMsg);
|
||||||
|
setTimeout(function () {
|
||||||
|
errorMsg.fadeOut(function () {
|
||||||
|
$(this).remove();
|
||||||
|
feedback.fadeOut(function () {
|
||||||
|
feedback.removeClass('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$(formID + " select").children('option[disabled=disabled]').attr('selected', 'selected');
|
||||||
|
}, 2000);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ajax Call
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
data: dataString,
|
||||||
|
type: 'POST',
|
||||||
|
beforeSend: function (jqXHR, settings) {
|
||||||
|
// Start loader etc.
|
||||||
|
feedback.prepend(loader);
|
||||||
|
},
|
||||||
|
error: function (jqXHR, textStatus, errorThrown) {
|
||||||
|
feedback.addClass('error');
|
||||||
|
feedback.prepend(errorMsg);
|
||||||
|
setTimeout(function () {
|
||||||
|
errorMsg.fadeOut(function () {
|
||||||
|
$(this).remove();
|
||||||
|
feedback.fadeOut(function () {
|
||||||
|
feedback.removeClass('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
success: function (data, jqXHR) {
|
||||||
|
feedback.prepend(succesMsg);
|
||||||
|
feedback.addClass('success');
|
||||||
|
setTimeout(function (e) {
|
||||||
|
succesMsg.fadeOut(function () {
|
||||||
|
$(this).remove();
|
||||||
|
feedback.fadeOut(function () {
|
||||||
|
feedback.removeClass('success');
|
||||||
|
});
|
||||||
|
if (reload === true) refreshSubmenu();
|
||||||
|
if (reload === "table") {
|
||||||
|
refreshTable();
|
||||||
|
}
|
||||||
|
if (reload === "tabs") refreshTab();
|
||||||
|
if (reload === "page") location.reload();
|
||||||
|
if (reload === "submenu&table") {
|
||||||
|
refreshSubmenu();
|
||||||
|
refreshTable();
|
||||||
|
}
|
||||||
|
if (form) {
|
||||||
|
// Change the option to 'choose...'
|
||||||
|
$(formID + " select").children('option[disabled=disabled]').attr(
|
||||||
|
'selected', 'selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
complete: function (jqXHR, textStatus) {
|
||||||
|
// Remove loaders and stuff, ajax request is complete!
|
||||||
|
$('.ajaxLoader-' + url).remove();
|
||||||
|
if (typeof callback === "function") {
|
||||||
|
callback(jqXHR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getBrowsePath = function (key, path, filter_ext) {
|
||||||
|
var deferred = $.Deferred();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: 'browse_path',
|
||||||
|
type: 'GET',
|
||||||
|
data: {
|
||||||
|
key: key,
|
||||||
|
path: path,
|
||||||
|
filter_ext: filter_ext
|
||||||
|
},
|
||||||
|
success: function(data) {
|
||||||
|
deferred.resolve(data);
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
deferred.reject();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return deferred;
|
||||||
|
};
|
||||||
|
|
||||||
|
function doSimpleAjaxCall(url) {
|
||||||
|
$.ajax(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters(text) {
|
||||||
|
if ($(".dataTables_filter").length > 0) {
|
||||||
|
$(".dataTables_filter input").attr("placeholder", "filter " + text + "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIP(ip_address) {
|
||||||
|
var defer = $.Deferred();
|
||||||
|
|
||||||
|
if (ipaddr.isValid(ip_address)) {
|
||||||
|
var addr = ipaddr.process(ip_address);
|
||||||
|
if (addr.range() === 'loopback' || addr.range() === 'private' || addr.range() === 'linkLocal') {
|
||||||
|
defer.resolve();
|
||||||
|
} else {
|
||||||
|
defer.reject();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
defer.resolve('n/a');
|
||||||
|
}
|
||||||
|
|
||||||
|
return defer.promise();
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanTime(seconds) {
|
||||||
|
var d = Math.floor(moment.duration(seconds, 'seconds').asDays());
|
||||||
|
var h = Math.floor(moment.duration((seconds % 86400), 'seconds').asHours());
|
||||||
|
var m = Math.round(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes());
|
||||||
|
|
||||||
|
var text = '';
|
||||||
|
if (d > 0) {
|
||||||
|
text = '<h3>' + d + '</h3><p> day' + ((d > 1) ? 's' : '') + '</p>'
|
||||||
|
+ '<h3>' + h + '</h3><p> hr' + ((h > 1) ? 's' : '') + '</p>'
|
||||||
|
+ '<h3>' + m + '</h3><p> min' + ((m > 1) ? 's' : '') + '</p>';
|
||||||
|
} else if (h > 0) {
|
||||||
|
text = '<h3>' + h + '</h3><p> hr' + ((h > 1) ? 's' : '') + '</p>'
|
||||||
|
+ '<h3>' + m + '</h3><p> min' + ((m > 1) ? 's' : '') + '</p>';
|
||||||
|
} else {
|
||||||
|
text = '<h3>' + m + '</h3><p> min' + ((m > 1) ? 's' : '') + '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
String.prototype.toProperCase = function () {
|
||||||
|
return this.replace(/\w\S*/g, function (txt) {
|
||||||
|
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPercent(value1, value2) {
|
||||||
|
value1 = parseFloat(value1) | 0
|
||||||
|
value2 = parseFloat(value2) | 0
|
||||||
|
|
||||||
|
var percent = 0;
|
||||||
|
if (value1 !== 0 && value2 !== 0) {
|
||||||
|
percent = (value1 / value2) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function millisecondsToMinutes(ms, roundToMinute) {
|
||||||
|
if (ms > 0) {
|
||||||
|
var minutes = Math.floor(ms / 60000);
|
||||||
|
var seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||||
|
if (roundToMinute) {
|
||||||
|
return (seconds >= 30 ? (minutes + 1) : minutes);
|
||||||
|
} else {
|
||||||
|
return (seconds == 60 ? (minutes + 1) + ":00" : minutes + ":" + (seconds < 10 ? "0" : "") + seconds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (roundToMinute) {
|
||||||
|
return '0';
|
||||||
|
} else {
|
||||||
|
return '0:00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanDuration(ms, sig='dhm', units='ms', return_seconds=300000) {
|
||||||
|
var factors = {
|
||||||
|
d: 86400000,
|
||||||
|
h: 3600000,
|
||||||
|
m: 60000,
|
||||||
|
s: 1000,
|
||||||
|
ms: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ms = parseInt(ms);
|
||||||
|
var d, h, m, s;
|
||||||
|
|
||||||
|
if (ms > 0) {
|
||||||
|
if (return_seconds && ms < return_seconds) {
|
||||||
|
sig = 'dhms'
|
||||||
|
}
|
||||||
|
|
||||||
|
ms = ms * factors[units];
|
||||||
|
|
||||||
|
h = ms % factors['d'];
|
||||||
|
d = Math.trunc(ms / factors['d']);
|
||||||
|
|
||||||
|
m = h % factors['h'];
|
||||||
|
h = Math.trunc(h / factors['h']);
|
||||||
|
|
||||||
|
s = m % factors['m'];
|
||||||
|
m = Math.trunc(m / factors['m']);
|
||||||
|
|
||||||
|
ms = s % factors['s'];
|
||||||
|
s = Math.trunc(s / factors['s']);
|
||||||
|
|
||||||
|
var hd_list = [];
|
||||||
|
if (sig >= 'd' && d > 0) {
|
||||||
|
d = (sig === 'd' && h >= 12) ? d + 1 : d;
|
||||||
|
hd_list.push(d.toString() + ' day' + ((d > 1) ? 's' : ''));
|
||||||
|
}
|
||||||
|
if (sig >= 'dh' && h > 0) {
|
||||||
|
h = (sig === 'dh' && m >= 30) ? h + 1 : h;
|
||||||
|
hd_list.push(h.toString() + ' hr' + ((h > 1) ? 's' : ''));
|
||||||
|
}
|
||||||
|
if (sig >= 'dhm' && m > 0) {
|
||||||
|
m = (sig === 'dhm' && s >= 30) ? m + 1 : m;
|
||||||
|
hd_list.push(m.toString() + ' min' + ((m > 1) ? 's' : ''));
|
||||||
|
}
|
||||||
|
if (sig >= 'dhms' && s > 0) {
|
||||||
|
hd_list.push(s.toString() + ' sec' + ((s > 1) ? 's' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return hd_list.join(' ')
|
||||||
|
} else {
|
||||||
|
return '0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our countdown plugin takes a callback, a duration, and an optional message
|
||||||
|
$.fn.countdown = function (callback, duration, message) {
|
||||||
|
// If no message is provided, we use an empty string
|
||||||
|
message = message || "";
|
||||||
|
// Get reference to container, and set initial content
|
||||||
|
var container = $(this[0]).html(duration + message);
|
||||||
|
// Get reference to the interval doing the countdown
|
||||||
|
var countdown = setInterval(function () {
|
||||||
|
// If seconds remain
|
||||||
|
if (--duration) {
|
||||||
|
// Update our container's message
|
||||||
|
container.html(duration + message);
|
||||||
|
// Otherwise
|
||||||
|
} else {
|
||||||
|
// Clear the countdown interval
|
||||||
|
clearInterval(countdown);
|
||||||
|
// And fire the callback passing our container as `this`
|
||||||
|
callback.call(container);
|
||||||
|
}
|
||||||
|
// Run interval every 1000ms (1 second)
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
function setCookie(cname, cvalue, exdays) {
|
||||||
|
var d = new Date();
|
||||||
|
d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
|
||||||
|
var expires = "expires=" + d.toUTCString();
|
||||||
|
document.cookie = cname + "=" + cvalue + "; " + expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(cname) {
|
||||||
|
var name = cname + "=";
|
||||||
|
var ca = document.cookie.split(';');
|
||||||
|
for (var i = 0; i < ca.length; i++) {
|
||||||
|
var c = ca[i];
|
||||||
|
while (c.charAt(0) == ' ') c = c.substring(1);
|
||||||
|
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
var Accordion = function (el, multiple, close) {
|
||||||
|
this.el = el || {};
|
||||||
|
this.multiple = multiple || false;
|
||||||
|
this.close = (close === undefined) ? true : close;
|
||||||
|
// Variables privadas
|
||||||
|
var links = this.el.find('.link');
|
||||||
|
// Evento
|
||||||
|
links.on('click', {
|
||||||
|
el: this.el,
|
||||||
|
multiple: this.multiple,
|
||||||
|
close: this.close
|
||||||
|
}, this.dropdown);
|
||||||
|
};
|
||||||
|
Accordion.prototype.dropdown = function (e) {
|
||||||
|
var $el = e.data.el;
|
||||||
|
$this = $(this);
|
||||||
|
$next = $this.next();
|
||||||
|
if (!e.data.close && $this.parent().hasClass('open')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$next.slideToggle();
|
||||||
|
$this.parent().toggleClass('open');
|
||||||
|
if (!e.data.multiple) {
|
||||||
|
$el.find('.submenu').not($next).slideUp().parent().removeClass('open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function clearSearchButton(tableName, table) {
|
||||||
|
$('#' + tableName + '_filter').find('input[type=search]').wrap(
|
||||||
|
'<div class="input-group" role="group" aria-label="Search"></div>').after(
|
||||||
|
'<span class="input-group-btn"><button class="btn btn-form" data-toggle="button" aria-pressed="false" autocomplete="off" id="clear-search-' +
|
||||||
|
tableName + '"><i class="fa fa-remove"></i></button></span>');
|
||||||
|
$('#clear-search-' + tableName).click(function () {
|
||||||
|
table.search('').draw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Taken from https://github.com/Hellowlol/HTPC-Manager
|
||||||
|
window.onerror = function (message, file, line) {
|
||||||
|
var e = {
|
||||||
|
'page': window.location.href,
|
||||||
|
'message': message,
|
||||||
|
'file': file,
|
||||||
|
'line': line
|
||||||
|
};
|
||||||
|
$.post("log_js_errors", e, function (data) { });
|
||||||
|
};
|
||||||
|
|
||||||
|
$('*').on('click', '.refresh_pms_image', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
var background_div = $(this).parent().siblings(['style*=pms_image_proxy']).first();
|
||||||
|
var pms_proxy_url = background_div.css('background-image');
|
||||||
|
pms_proxy_url = /^url\((['"]?)(.*)\1\)$/.exec(pms_proxy_url);
|
||||||
|
pms_proxy_url = pms_proxy_url ? pms_proxy_url[2] : ""; // If matched, retrieve url, otherwise ""
|
||||||
|
|
||||||
|
if (pms_proxy_url.indexOf('pms_image_proxy') === -1) {
|
||||||
|
console.log('PMS image proxy url not found.');
|
||||||
|
} else {
|
||||||
|
background_div.css('background-image', 'none')
|
||||||
|
$.ajax({
|
||||||
|
url: pms_proxy_url,
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
},
|
||||||
|
success: function () {
|
||||||
|
background_div.css('background-image', 'url(' + pms_proxy_url + ')');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Taken from http://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable#answer-14919494
|
||||||
|
function humanFileSize(bytes, si = true) {
|
||||||
|
//var thresh = si ? 1000 : 1024;
|
||||||
|
var thresh = 1024; // Always divide by 2^10 but display SI units
|
||||||
|
if (Math.abs(bytes) < thresh) {
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
var units = si ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||||
|
var u = -1;
|
||||||
|
do {
|
||||||
|
bytes /= thresh;
|
||||||
|
++u;
|
||||||
|
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
|
||||||
|
return bytes.toFixed(1) + ' ' + units[u];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force max/min in number inputs
|
||||||
|
function forceMinMax(elem) {
|
||||||
|
var min = parseInt(elem.attr('min'));
|
||||||
|
var max = parseInt(elem.attr('max'));
|
||||||
|
var val = parseInt(elem.val());
|
||||||
|
var default_val = parseInt(elem.data('default'));
|
||||||
|
if (isNaN(val)) {
|
||||||
|
elem.val(default_val);
|
||||||
|
}
|
||||||
|
else if (min !== undefined && val < min) {
|
||||||
|
elem.val(min);
|
||||||
|
}
|
||||||
|
else if (max !== undefined && val > max) {
|
||||||
|
elem.val(max);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
elem.val(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalizeFirstLetter(string) {
|
||||||
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$.fn.slideToggleBool = function(bool, options) {
|
||||||
|
return bool ? $(this).slideDown(options) : $(this).slideUp(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
function openPlexXML(endpoint, plextv, params) {
|
||||||
|
var data = $.extend({endpoint: endpoint, plextv: plextv || false}, params);
|
||||||
|
var query = new URLSearchParams(data)
|
||||||
|
window.open('open_plex_xml?' + query.toString(), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopupCenter(url, title, w, h) {
|
||||||
|
// Fixes dual-screen position Most browsers Firefox
|
||||||
|
var dualScreenLeft = window.screenLeft != undefined ? window.screenLeft : window.screenX;
|
||||||
|
var dualScreenTop = window.screenTop != undefined ? window.screenTop : window.screenY;
|
||||||
|
|
||||||
|
var width = window.innerWidth ? window.innerWidth : document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width;
|
||||||
|
var height = window.innerHeight ? window.innerHeight : document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height;
|
||||||
|
|
||||||
|
var left = ((width / 2) - (w / 2)) + dualScreenLeft;
|
||||||
|
var top = ((height / 2) - (h / 2)) + dualScreenTop;
|
||||||
|
var newWindow = window.open(url, title, 'scrollbars=yes, width=' + w + ', height=' + h + ', top=' + top + ', left=' + left);
|
||||||
|
|
||||||
|
// Puts focus on the newWindow
|
||||||
|
if (window.focus) {
|
||||||
|
newWindow.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return newWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function setLocalStorage(key, value, path) {
|
||||||
|
var key_path = key;
|
||||||
|
if (path !== false) {
|
||||||
|
key_path = key_path + '_' + window.location.pathname;
|
||||||
|
}
|
||||||
|
localStorage.setItem(key_path, value);
|
||||||
|
}
|
||||||
|
function getLocalStorage(key, default_value, path) {
|
||||||
|
var key_path = key;
|
||||||
|
if (path !== false) {
|
||||||
|
key_path = key_path + '_' + window.location.pathname;
|
||||||
|
}
|
||||||
|
var value = localStorage.getItem(key_path);
|
||||||
|
if (value !== null) {
|
||||||
|
return value
|
||||||
|
} else if (default_value !== undefined) {
|
||||||
|
setLocalStorage(key, default_value, path);
|
||||||
|
return default_value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidv4() {
|
||||||
|
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, function(c) {
|
||||||
|
var cryptoObj = window.crypto || window.msCrypto; // for IE 11
|
||||||
|
return (c ^ cryptoObj.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlexHeaders(clientID) {
|
||||||
|
return {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Plex-Product': 'PUM',
|
||||||
|
'X-Plex-Version': 'Plex OAuth',
|
||||||
|
'X-Plex-Client-Identifier': clientID ? clientID : getLocalStorage('Tautulli_ClientID', uuidv4(), false),
|
||||||
|
'X-Plex-Platform': p.name,
|
||||||
|
'X-Plex-Platform-Version': p.version,
|
||||||
|
'X-Plex-Model': 'Plex OAuth',
|
||||||
|
'X-Plex-Device': p.os,
|
||||||
|
'X-Plex-Device-Name': p.name + ' (PUM)',
|
||||||
|
'X-Plex-Device-Screen-Resolution': window.screen.width + 'x' + window.screen.height,
|
||||||
|
'X-Plex-Language': 'fr'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var plex_oauth_window = null;
|
||||||
|
const plex_oauth_loader = '<style>' +
|
||||||
|
'.login-loader-container {' +
|
||||||
|
'font-family: "Open Sans", Arial, sans-serif;' +
|
||||||
|
'position: absolute;' +
|
||||||
|
'top: 0;' +
|
||||||
|
'right: 0;' +
|
||||||
|
'bottom: 0;' +
|
||||||
|
'left: 0;' +
|
||||||
|
'}' +
|
||||||
|
'.login-loader-message {' +
|
||||||
|
'color: #282A2D;' +
|
||||||
|
'text-align: center;' +
|
||||||
|
'position: absolute;' +
|
||||||
|
'left: 50%;' +
|
||||||
|
'top: 25%;' +
|
||||||
|
'transform: translate(-50%, -50%);' +
|
||||||
|
'}' +
|
||||||
|
'.login-loader {' +
|
||||||
|
'border: 5px solid #ccc;' +
|
||||||
|
'-webkit-animation: spin 1s linear infinite;' +
|
||||||
|
'animation: spin 1s linear infinite;' +
|
||||||
|
'border-top: 5px solid #282A2D;' +
|
||||||
|
'border-radius: 50%;' +
|
||||||
|
'width: 50px;' +
|
||||||
|
'height: 50px;' +
|
||||||
|
'position: relative;' +
|
||||||
|
'left: calc(50% - 25px);' +
|
||||||
|
'}' +
|
||||||
|
'@keyframes spin {' +
|
||||||
|
'0% { transform: rotate(0deg); }' +
|
||||||
|
'100% { transform: rotate(360deg); }' +
|
||||||
|
'}' +
|
||||||
|
'</style>' +
|
||||||
|
'<div class="login-loader-container">' +
|
||||||
|
'<div class="login-loader-message">' +
|
||||||
|
'<div class="login-loader"></div>' +
|
||||||
|
'<br>' +
|
||||||
|
'Redirecting to the Plex login page...' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
function closePlexOAuthWindow() {
|
||||||
|
if (plex_oauth_window) {
|
||||||
|
plex_oauth_window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlexOAuthPin = function (clientID) {
|
||||||
|
var x_plex_headers = getPlexHeaders(clientID);
|
||||||
|
var deferred = $.Deferred();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: 'https://plex.tv/api/v2/pins?strong=true',
|
||||||
|
type: 'POST',
|
||||||
|
headers: x_plex_headers,
|
||||||
|
success: function(data) {
|
||||||
|
deferred.resolve({pin: data.id, code: data.code});
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
deferred.reject();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return deferred;
|
||||||
|
};
|
||||||
|
|
||||||
|
var polling = null;
|
||||||
|
|
||||||
|
function PlexOAuth(success, error, pre, clientID) {
|
||||||
|
if (typeof pre === "function") {
|
||||||
|
pre()
|
||||||
|
}
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
plex_oauth_window = PopupCenter('', 'Plex-OAuth', 600, 700);
|
||||||
|
$(plex_oauth_window.document.body).html(plex_oauth_loader);
|
||||||
|
|
||||||
|
getPlexOAuthPin(clientID).then(function (data) {
|
||||||
|
var x_plex_headers = getPlexHeaders(clientID);
|
||||||
|
const pin = data.pin;
|
||||||
|
const code = data.code;
|
||||||
|
|
||||||
|
var oauth_params = {
|
||||||
|
'clientID': x_plex_headers['X-Plex-Client-Identifier'],
|
||||||
|
'context[device][product]': x_plex_headers['X-Plex-Product'],
|
||||||
|
'context[device][version]': x_plex_headers['X-Plex-Version'],
|
||||||
|
'context[device][platform]': x_plex_headers['X-Plex-Platform'],
|
||||||
|
'context[device][platformVersion]': x_plex_headers['X-Plex-Platform-Version'],
|
||||||
|
'context[device][device]': x_plex_headers['X-Plex-Device'],
|
||||||
|
'context[device][deviceName]': x_plex_headers['X-Plex-Device-Name'],
|
||||||
|
'context[device][model]': x_plex_headers['X-Plex-Model'],
|
||||||
|
'context[device][screenResolution]': x_plex_headers['X-Plex-Device-Screen-Resolution'],
|
||||||
|
'context[device][layout]': 'desktop',
|
||||||
|
'code': code
|
||||||
|
}
|
||||||
|
|
||||||
|
plex_oauth_window.location = 'https://app.plex.tv/auth/#!?' + encodeData(oauth_params);
|
||||||
|
polling = pin;
|
||||||
|
|
||||||
|
(function poll() {
|
||||||
|
$.ajax({
|
||||||
|
url: 'https://plex.tv/api/v2/pins/' + pin,
|
||||||
|
type: 'GET',
|
||||||
|
headers: x_plex_headers,
|
||||||
|
success: function (data) {
|
||||||
|
if (data.authToken){
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
if (typeof success === "function") {
|
||||||
|
success(data.authToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function (jqXHR, textStatus, errorThrown) {
|
||||||
|
if (textStatus !== "timeout") {
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
if (typeof error === "function") {
|
||||||
|
error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: function () {
|
||||||
|
if (!plex_oauth_window.closed && polling === pin){
|
||||||
|
setTimeout(function() {poll()}, 1000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, function () {
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
if (typeof error === "function") {
|
||||||
|
error()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeData(data) {
|
||||||
|
return Object.keys(data).map(function(key) {
|
||||||
|
return [key, data[key]].map(encodeURIComponent).join("=");
|
||||||
|
}).join("&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(endpoint, ...args) {
|
||||||
|
let endpoints = {
|
||||||
|
'pms_image_proxy': pms_image_proxy,
|
||||||
|
'info': info_page,
|
||||||
|
'library': library_page,
|
||||||
|
'user': user_page
|
||||||
|
};
|
||||||
|
|
||||||
|
var params = {};
|
||||||
|
|
||||||
|
if (endpoint in endpoints) {
|
||||||
|
params = endpoints[endpoint](...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint + '?' + $.param(params).replace(/'/g, '%27');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pms_image_proxy(img, rating_key, width, height, opacity, background, blur, fallback, refresh, clip, img_format) {
|
||||||
|
var params = {};
|
||||||
|
|
||||||
|
if (img != null) { params.img = img; }
|
||||||
|
if (rating_key != null) { params.rating_key = rating_key; }
|
||||||
|
if (width != null) { params.width = width; }
|
||||||
|
if (height != null) { params.height = height; }
|
||||||
|
if (opacity != null) { params.opacity = opacity; }
|
||||||
|
if (background != null) { params.background = background; }
|
||||||
|
if (blur != null) { params.blur = blur; }
|
||||||
|
if (fallback != null) { params.fallback = fallback; }
|
||||||
|
if (refresh != null) { params.refresh = true; }
|
||||||
|
if (clip != null) { params.clip = true; }
|
||||||
|
if (img_format != null) { params.img_format = img_format; }
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function info_page(rating_key, guid, history, live) {
|
||||||
|
var params = {};
|
||||||
|
|
||||||
|
if (live && history) {
|
||||||
|
params.guid = guid;
|
||||||
|
} else {
|
||||||
|
params.rating_key = rating_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (history) { params.source = 'history'; }
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function library_page(section_id) {
|
||||||
|
var params = {};
|
||||||
|
|
||||||
|
if (section_id != null) { params.section_id = section_id; }
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function user_page(user_id, user) {
|
||||||
|
var params = {};
|
||||||
|
|
||||||
|
if (user_id != null) { params.user_id = user_id; }
|
||||||
|
if (user != null) { params.user = user; }
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
MEDIA_TYPE_HEADERS = {
|
||||||
|
'movie': 'Movies',
|
||||||
|
'show': 'TV Shows',
|
||||||
|
'season': 'Seasons',
|
||||||
|
'episode': 'Episodes',
|
||||||
|
'artist': 'Artists',
|
||||||
|
'album': 'Albums',
|
||||||
|
'track': 'Tracks',
|
||||||
|
'video': 'Videos',
|
||||||
|
'audio': 'Tracks',
|
||||||
|
'photo': 'Photos'
|
||||||
|
}
|
||||||
|
|
||||||
|
function short_season(title) {
|
||||||
|
if (title.startsWith('Season ') && /^\d+$/.test(title.substring(7))) {
|
||||||
|
return 'S' + title.substring(7)
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAllBlurHash() {
|
||||||
|
$('[data-blurhash]').each(function() {
|
||||||
|
const elem = $(this);
|
||||||
|
const src = elem.data('blurhash');
|
||||||
|
loadBlurHash(elem, src);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBlurHash(elem, src) {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = src;
|
||||||
|
img.onload = () => {
|
||||||
|
const imgData = blurhash.getImageData(img);
|
||||||
|
|
||||||
|
blurhash
|
||||||
|
.encodePromise(imgData, img.width, img.height, 4, 4)
|
||||||
|
.then(hash => {
|
||||||
|
return blurhash.decodePromise(
|
||||||
|
hash,
|
||||||
|
img.width,
|
||||||
|
img.height
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(blurhashImgData => {
|
||||||
|
const imgObject = blurhash.getImageDataAsImage(
|
||||||
|
blurhashImgData,
|
||||||
|
img.width,
|
||||||
|
img.height,
|
||||||
|
(event, imgObject) => {
|
||||||
|
elem.css('background-image', 'url(' + imgObject.src + ')')
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toggleRevealToken(elem, click) {
|
||||||
|
var input = elem.parent().siblings('input');
|
||||||
|
if ((input.prop('type') === 'password' && click) || !input.val()) {
|
||||||
|
input.prop('type', 'text');
|
||||||
|
elem.children('.fa').removeClass('fa-eye-slash').addClass('fa-eye');
|
||||||
|
} else {
|
||||||
|
input.prop('type', 'password');
|
||||||
|
elem.children('.fa').removeClass('fa-eye').addClass('fa-eye-slash');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleRevealTokens() {
|
||||||
|
$('.reveal-token').each(function () {
|
||||||
|
_toggleRevealToken($(this));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('body').on('click', '.reveal-token', function() {
|
||||||
|
_toggleRevealToken($(this), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/57414592
|
||||||
|
// prevent modal close when click starts in modal and ends on backdrop
|
||||||
|
$(document).on('mousedown', '.modal', function(e){
|
||||||
|
window.clickStartedInModal = $(e.target).is('.modal-dialog *');
|
||||||
|
});
|
||||||
|
$(document).on('mouseup', '.modal', function(e){
|
||||||
|
if(!$(e.target).is('.modal-dialog *') && window.clickStartedInModal) {
|
||||||
|
window.preventModalClose = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('.modal').on('hide.bs.modal', function (e) {
|
||||||
|
if(window.preventModalClose){
|
||||||
|
window.preventModalClose = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$.fn.hasScrollBar = function() {
|
||||||
|
return this.get(0).scrollHeight > this.get(0).clientHeight;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
@-webkit-keyframes spinAround{from{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes spinAround{from{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.is-collapsible{height:0;overflow-y:hidden;transition:height .2s ease}.is-collapsible.is-active{transition:height .2s ease}.is-collapsible.message-body{padding:0!important}.is-collapsible.message-body .message-body-content{padding:1.25em 1.5em}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
||||||
|
dzqdfs
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Bulma Template</title>
|
||||||
|
<link rel="stylesheet" type= "text/css" href="{{ url_for('static',filename='styles/style.css') }}" />
|
||||||
|
<link rel="stylesheet" type= "text/css" href="{{ url_for('static',filename='styles/bulma-collapsible.min.css') }}" />
|
||||||
|
<script src="{{url_for('static', filename='js/modal.js')}}"></script>
|
||||||
|
<script src="{{url_for('static', filename='js/bulma-collapsible.min.js')}}"></script>
|
||||||
|
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js"></script>
|
||||||
|
<script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<section class="hero" style="background-color:#DE926F;">
|
||||||
|
|
||||||
|
<div class="hero-head">
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a class="navbar-item">
|
||||||
|
</a>
|
||||||
|
<div class="navbar-burger burger" data-target="navbarMenuHeroA">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="navbarMenuHeroA" class="navbar-menu" >
|
||||||
|
<div class="navbar-end">
|
||||||
|
<a href="{{ url_for('main.index') }}" class="navbar-item">
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="hero is-fullheight" style="background-color:#DAD7D7;">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<div class="column is-10 is-offset-1">
|
||||||
|
<div class="box">
|
||||||
|
<h1 class="title">
|
||||||
|
Bulma Template 02
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
Flask==2.3.2
|
||||||
|
Flask-SQLAlchemy==3.0.3
|
||||||
|
gunicorn==20.1.0
|
||||||
|
psycopg2-binary==2.9.6
|
||||||
|
Flask-Login
|
||||||
Loading…
Reference in New Issue