diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..72e294c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +__pycache__ +*.pyc +*.pyo +*.pyd \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..5c8316e --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +# True for development, False for production +DEBUG=True + +# Flask ENV +FLASK_APP=run.py +FLASK_ENV=development + +# Used for CDN (in production) +# No Slash at the end +ASSETS_ROOT=/static/assets + +# LOCAL 5001 FLask +# GITHUB_ID = +# GITHUB_SECRET = diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65f07c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# tests and coverage +*.pytest_cache +.coverage + +# database & logs +*.db +*.sqlite3 +*.log + +# venv +env +venv + +# other +.DS_Store + +# sphinx docs +_build +_static +_templates + +# javascript +package-lock.json +.vscode/symbols.json + +apps/static/assets/node_modules +apps/static/assets/yarn.lock +apps/static/assets/.temp + +migrations +node_modules +yarn.lock diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77ce542 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.9 + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV FLASK_APP run.py +ENV DEBUG True + +COPY requirements.txt . + +# install python dependencies +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +COPY env.sample .env + +COPY . . + +RUN flask db init +RUN flask db migrate +RUN flask db upgrade + +# gunicorn +CMD ["gunicorn", "--config", "gunicorn-cfg.py", "run:app"] diff --git a/README.md b/README.md index e77a7b3..493f5f4 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,14 @@
-> Free product - **Flask Dashboard** starter project - Features: +> Features: -- Up-to-date [dependencies](./requirements.txt): **Flask 2.0.1** -- [SCSS compilation](#recompile-css) via **Gulp** -- UI Kit: **Black Dashboard** (Free Version) provided by **[Creative-Tim](https://www.creative-tim.com/)** -- Flask Codebase - provided by **[AppSeed](https://appseed.us/)** -- SQLite, PostgreSQL, SQLAlchemy ORM -- Alembic (DB schema migrations) -- Modular design with **Blueprints** -- Session-Based authentication (via **flask_login**) -- Forms validation -- Deployment scripts: Docker, Gunicorn / Nginx, Heroku +- ✅ `Up-to-date dependencies` +- ✅ Black Dashboard, BS4 Design +- ✅ `DB Tools`: SQLAlchemy ORM, `Flask-Migrate` (schema migrations) +- ✅ `Persistence`: SQLite (dev), MySql (prod) +- ✅ `Authentication`: Session Based, `OAuth` via Github +- ✅ `Deployment`: Docker, Page Compression (Flask-Minify)
@@ -57,66 +53,137 @@ $ cd black-dashboard-flask > Start the app in Docker ```bash -$ docker-compose pull # download dependencies -$ docker-compose build # local set up -$ docker-compose up -d # start the app +$ docker-compose up --build ``` -Visit `http://localhost:85` in your browser. The app should be up & running. +Visit `http://localhost:5085` in your browser. The app should be up & running.
-## Quick start +## Create/Edit `.env` file + +The meaning of each variable can be found below: + +- `DEBUG`: if `True` the app runs in develoment mode + - For production value `False` should be used +- `ASSETS_ROOT`: used in assets management + - default value: `/static/assets` +- `OAuth` via Github + - `GITHUB_ID`= + - `GITHUB_SECRET`= + +
+ +## Manual Build > UNZIP the sources or clone the private repository. After getting the code, open a terminal and navigate to the working directory, with product source code. +### 👉 Set Up for `Unix`, `MacOS` + +> Install modules via `VENV` + ```bash -$ # Get the code -$ git clone https://github.com/creativetimofficial/black-dashboard-flask.git -$ cd black-dashboard-flask -$ -$ # Virtualenv modules installation (Unix based systems) $ virtualenv env $ source env/bin/activate -$ -$ # Virtualenv modules installation (Windows based systems) -$ # virtualenv env -$ # .\env\Scripts\activate -$ -$ # Install modules - SQLite Database $ pip3 install -r requirements.txt -$ -$ # OR with PostgreSQL connector -$ # pip install -r requirements-pgsql.txt -$ -$ # Set the FLASK_APP environment variable -$ (Unix/Mac) export FLASK_APP=run.py -$ (Windows) set FLASK_APP=run.py -$ (Powershell) $env:FLASK_APP = ".\run.py" -$ -$ # Set up the DEBUG environment -$ # (Unix/Mac) export FLASK_ENV=development -$ # (Windows) set FLASK_ENV=development -$ # (Powershell) $env:FLASK_ENV = "development" -$ -$ # Start the application (development mode) -$ # --host=0.0.0.0 - expose the app on all network interfaces (default 127.0.0.1) -$ # --port=5000 - specify the app port (default 5000) -$ flask run --host=0.0.0.0 --port=5000 -$ -$ # Access the dashboard in browser: http://127.0.0.1:5000/ ``` -> Note: To use the app, please access the registration page and create a new user. After authentication, the app will unlock the private pages. +
+ +> Set Up Flask Environment + +```bash +$ export FLASK_APP=run.py +$ export FLASK_ENV=development +``` + +
+ +> Start the app + +```bash +$ flask run +// OR +$ flask run --cert=adhoc # For HTTPS server +``` + +At this point, the app runs at `http://127.0.0.1:5000/`. + +
+ +### 👉 Set Up for `Windows` + +> Install modules via `VENV` (windows) + +``` +$ virtualenv env +$ .\env\Scripts\activate +$ pip3 install -r requirements.txt +``` + +
+ +> Set Up Flask Environment + +```bash +$ # CMD +$ set FLASK_APP=run.py +$ set FLASK_ENV=development +$ +$ # Powershell +$ $env:FLASK_APP = ".\run.py" +$ $env:FLASK_ENV = "development" +``` + +
+ +> Start the app + +```bash +$ flask run +// OR +$ flask run --cert=adhoc # For HTTPS server +``` + +At this point, the app runs at `http://127.0.0.1:5000/`. + +
+ +## Recompile SCSS + +The SCSS/CSS files used to style the Ui are saved in the `apps/static/assets` directory. +In order to update the Ui colors (primary, secondary) this procedure needs to be followed. + +```bash +$ yarn # install modules +$ # # edit variables +$ vi apps/static/assets/scss/black-dashboard/custom/_variables.scss +$ gulp # SCSS to CSS translation +``` + +The `_variables.scss` content defines the `primary` and `secondary` colors: + +```scss +$default: #344675 !default; // EDIT for customization +$primary: #e14eca !default; // EDIT for customization +$secondary: #f4f5f7 !default; // EDIT for customization +$success: #00f2c3 !default; // EDIT for customization +$info: #1d8cf8 !default; // EDIT for customization +$warning: #ff8d72 !default; // EDIT for customization +$danger: #fd5d93 !default; // EDIT for customization +$black: #222a42 !default; // EDIT for customization +```
## Documentation + The documentation for the **Black Dashboard Flask** is hosted at our [website](https://demos.creative-tim.com/black-dashboard-flask/docs/1.0/getting-started/getting-started-flask.html).
## File Structure + Within the download you'll find the following directories and files: ```bash @@ -124,23 +191,23 @@ Within the download you'll find the following directories and files: | |-- apps/ | | - | |-- home/ # A simple app that serve HTML files - | | |-- routes.py # Define app routes + | |-- home/ # A simple app that serve HTML files + | | |-- routes.py # Define app routes | | - | |-- authentication/ # Handles auth routes (login and register) - | | |-- routes.py # Define authentication routes - | | |-- models.py # Defines models - | | |-- forms.py # Define auth forms (login and register) + | |-- authentication/ # Handles auth routes (login and register) + | | |-- routes.py # Define authentication routes + | | |-- models.py # Defines models + | | |-- forms.py # Define auth forms (login and register) | | | |-- static/ - | | |-- # CSS files, Javascripts files + | | |-- # CSS files, Javascripts files | | - | |-- templates/ # Templates used to render pages - | | |-- includes/ # HTML chunks and components - | | | |-- navigation.html # Top menu component - | | | |-- sidebar.html # Sidebar component - | | | |-- footer.html # App Footer - | | | |-- scripts.html # Scripts common to all pages + | |-- templates/ # Templates used to render pages + | | |-- includes/ # HTML chunks and components + | | | |-- navigation.html # Top menu component + | | | |-- sidebar.html # Sidebar component + | | | |-- footer.html # App Footer + | | | |-- scripts.html # Scripts common to all pages | | | | | |-- layouts/ # Master pages | | | |-- base-fullscreen.html # Used by Authentication pages @@ -158,15 +225,7 @@ Within the download you'll find the following directories and files: | config.py # Set up the app | __init__.py # Initialize the app | - |-- requirements.txt # Development modules - SQLite storage - |-- requirements-mysql.txt # Production modules - Mysql DMBS - |-- requirements-pqsql.txt # Production modules - PostgreSql DMBS - | - |-- Dockerfile # Deployment - |-- docker-compose.yml # Deployment - |-- gunicorn-cfg.py # Deployment - |-- nginx # Deployment - | |-- appseed-app.conf # Deployment + |-- requirements.txt # App Dependencies | |-- .env # Inject Configuration via Environment |-- run.py # Start the app - WSGI gateway @@ -176,61 +235,6 @@ Within the download you'll find the following directories and files:
-> The bootstrap flow - -- `run.py` loads the `.env` file -- Initialize the app using the specified profile: *Debug* or *Production* - - If env.DEBUG is set to *True* the SQLite storage is used - - If env.DEBUG is set to *False* the specified DB driver is used (MySql, PostgreSQL) -- Call the app factory method `create_app` defined in app/__init__.py -- Redirect the guest users to Login page -- Unlock the pages served by *home* blueprint for authenticated users - -
- -## Recompile CSS - -To recompile SCSS files, follow this setup: - -
- -**Step #1** - Install tools - -- [NodeJS](https://nodejs.org/en/) 12.x or higher -- [Gulp](https://gulpjs.com/) - globally - - `npm install -g gulp-cli` -- [Yarn](https://yarnpkg.com/) (optional) - -
- -**Step #2** - Change the working directory to `assets` folder - -```bash -$ cd apps/static/assets -``` - -
- -**Step #3** - Install modules (this will create a classic `node_modules` directory) - -```bash -$ npm install -// OR -$ yarn -``` - -
- -**Step #4** - Edit & Recompile SCSS files - -```bash -$ gulp scss -``` - -The generated file is saved in `static/assets/css` directory. - -
- ## Browser Support At present, we officially aim to support the last two versions of the following browsers: diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..a6cfa41 --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1,48 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask import Flask +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy +from importlib import import_module + + +db = SQLAlchemy() +login_manager = LoginManager() + + +def register_extensions(app): + db.init_app(app) + login_manager.init_app(app) + + +def register_blueprints(app): + for module_name in ('authentication', 'home'): + module = import_module('apps.{}.routes'.format(module_name)) + app.register_blueprint(module.blueprint) + + +def configure_database(app): + + @app.before_first_request + def initialize_database(): + db.create_all() + + @app.teardown_request + def shutdown_session(exception=None): + db.session.remove() + +from apps.authentication.oauth import github_blueprint + +def create_app(config): + app = Flask(__name__) + app.config.from_object(config) + register_extensions(app) + + app.register_blueprint(github_blueprint, url_prefix="/login") + + register_blueprints(app) + configure_database(app) + return app diff --git a/apps/authentication/__init__.py b/apps/authentication/__init__.py new file mode 100644 index 0000000..25243ec --- /dev/null +++ b/apps/authentication/__init__.py @@ -0,0 +1,12 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask import Blueprint + +blueprint = Blueprint( + 'authentication_blueprint', + __name__, + url_prefix='' +) diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py new file mode 100644 index 0000000..89f43da --- /dev/null +++ b/apps/authentication/forms.py @@ -0,0 +1,31 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField +from wtforms.validators import Email, DataRequired + +# login and registration + + +class LoginForm(FlaskForm): + username = StringField('Username', + id='username_login', + validators=[DataRequired()]) + password = PasswordField('Password', + id='pwd_login', + validators=[DataRequired()]) + + +class CreateAccountForm(FlaskForm): + username = StringField('Username', + id='username_create', + validators=[DataRequired()]) + email = StringField('Email', + id='email_create', + validators=[DataRequired(), Email()]) + password = PasswordField('Password', + id='pwd_create', + validators=[DataRequired()]) diff --git a/apps/authentication/models.py b/apps/authentication/models.py new file mode 100644 index 0000000..695a191 --- /dev/null +++ b/apps/authentication/models.py @@ -0,0 +1,57 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask_login import UserMixin + +from sqlalchemy.orm import relationship +from flask_dance.consumer.storage.sqla import OAuthConsumerMixin + +from apps import db, login_manager + +from apps.authentication.util import hash_pass + +class Users(db.Model, UserMixin): + + __tablename__ = 'Users' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True) + email = db.Column(db.String(64), unique=True) + password = db.Column(db.LargeBinary) + + oauth_github = db.Column(db.String(100), nullable=True) + + def __init__(self, **kwargs): + for property, value in kwargs.items(): + # depending on whether value is an iterable or not, we must + # unpack it's value (when **kwargs is request.form, some values + # will be a 1-element list) + if hasattr(value, '__iter__') and not isinstance(value, str): + # the ,= unpack of a singleton fails PEP8 (travis flake8 test) + value = value[0] + + if property == 'password': + value = hash_pass(value) # we need bytes here (not plain str) + + setattr(self, property, value) + + def __repr__(self): + return str(self.username) + + +@login_manager.user_loader +def user_loader(id): + return Users.query.filter_by(id=id).first() + + +@login_manager.request_loader +def request_loader(request): + username = request.form.get('username') + user = Users.query.filter_by(username=username).first() + return user if user else None + +class OAuth(OAuthConsumerMixin, db.Model): + user_id = db.Column(db.Integer, db.ForeignKey("Users.id", ondelete="cascade"), nullable=False) + user = db.relationship(Users) diff --git a/apps/authentication/oauth.py b/apps/authentication/oauth.py new file mode 100644 index 0000000..fa33165 --- /dev/null +++ b/apps/authentication/oauth.py @@ -0,0 +1,58 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +import os +from flask import current_app as app +from flask_login import current_user, login_user +from flask_dance.consumer import oauth_authorized +from flask_dance.contrib.github import github, make_github_blueprint +from flask_dance.consumer.storage.sqla import SQLAlchemyStorage +from flask_dance.contrib.twitter import twitter, make_twitter_blueprint +from sqlalchemy.orm.exc import NoResultFound +from apps.config import Config +from .models import Users, db, OAuth +from flask import redirect, url_for +from flask import flash + +github_blueprint = make_github_blueprint( + client_id=Config.GITHUB_ID, + client_secret=Config.GITHUB_SECRET, + scope = 'user', + storage=SQLAlchemyStorage( + OAuth, + db.session, + user=current_user, + user_required=False, + ), +) + +@oauth_authorized.connect_via(github_blueprint) +def github_logged_in(blueprint, token): + info = github.get("/user") + + if info.ok: + + account_info = info.json() + username = account_info["login"] + + query = Users.query.filter_by(oauth_github=username) + try: + + user = query.one() + login_user(user) + + except NoResultFound: + + # Save to db + user = Users() + user.username = '(gh)' + username + user.oauth_github = username + + # Save current user + db.session.add(user) + db.session.commit() + + login_user(user) + diff --git a/apps/authentication/routes.py b/apps/authentication/routes.py new file mode 100644 index 0000000..1b45390 --- /dev/null +++ b/apps/authentication/routes.py @@ -0,0 +1,133 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask import render_template, redirect, request, url_for +from flask_login import ( + current_user, + login_user, + logout_user +) + +from flask_dance.contrib.github import github + +from apps import db, login_manager +from apps.authentication import blueprint +from apps.authentication.forms import LoginForm, CreateAccountForm +from apps.authentication.models import Users + +from apps.authentication.util import verify_pass + + +@blueprint.route('/') +def route_default(): + return redirect(url_for('authentication_blueprint.login')) + +# Login & Registration + +@blueprint.route("/github") +def login_github(): + """ Github login """ + if not github.authorized: + return redirect(url_for("github.login")) + + res = github.get("/user") + return redirect(url_for('home_blueprint.index')) + +@blueprint.route('/login', methods=['GET', 'POST']) +def login(): + login_form = LoginForm(request.form) + if 'login' in request.form: + + # read form data + username = request.form['username'] + password = request.form['password'] + + # Locate user + user = Users.query.filter_by(username=username).first() + + # Check the password + if user and verify_pass(password, user.password): + + login_user(user) + return redirect(url_for('authentication_blueprint.route_default')) + + # Something (user or pass) is not ok + return render_template('accounts/login.html', + msg='Wrong user or password', + form=login_form) + + if not current_user.is_authenticated: + return render_template('accounts/login.html', + form=login_form) + return redirect(url_for('home_blueprint.index')) + + +@blueprint.route('/register', methods=['GET', 'POST']) +def register(): + create_account_form = CreateAccountForm(request.form) + if 'register' in request.form: + + username = request.form['username'] + email = request.form['email'] + + # Check usename exists + user = Users.query.filter_by(username=username).first() + if user: + return render_template('accounts/register.html', + msg='Username already registered', + success=False, + form=create_account_form) + + # Check email exists + user = Users.query.filter_by(email=email).first() + if user: + return render_template('accounts/register.html', + msg='Email already registered', + success=False, + form=create_account_form) + + # else we can create the user + user = Users(**request.form) + db.session.add(user) + db.session.commit() + + # Delete user from session + logout_user() + + return render_template('accounts/register.html', + msg='Account created successfully.', + success=True, + form=create_account_form) + + else: + return render_template('accounts/register.html', form=create_account_form) + + +@blueprint.route('/logout') +def logout(): + logout_user() + return redirect(url_for('authentication_blueprint.login')) + + +# Errors + +@login_manager.unauthorized_handler +def unauthorized_handler(): + return render_template('home/page-403.html'), 403 + + +@blueprint.errorhandler(403) +def access_forbidden(error): + return render_template('home/page-403.html'), 403 + + +@blueprint.errorhandler(404) +def not_found_error(error): + return render_template('home/page-404.html'), 404 + + +@blueprint.errorhandler(500) +def internal_error(error): + return render_template('home/page-500.html'), 500 diff --git a/apps/authentication/util.py b/apps/authentication/util.py new file mode 100644 index 0000000..9130da8 --- /dev/null +++ b/apps/authentication/util.py @@ -0,0 +1,34 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +import os +import hashlib +import binascii + +# Inspiration -> https://www.vitoshacademy.com/hashing-passwords-in-python/ + + +def hash_pass(password): + """Hash a password for storing.""" + + salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii') + pwdhash = hashlib.pbkdf2_hmac('sha512', password.encode('utf-8'), + salt, 100000) + pwdhash = binascii.hexlify(pwdhash) + return (salt + pwdhash) # return bytes + + +def verify_pass(provided_password, stored_password): + """Verify a stored password against one provided by user""" + + stored_password = stored_password.decode('ascii') + salt = stored_password[:64] + stored_password = stored_password[64:] + pwdhash = hashlib.pbkdf2_hmac('sha512', + provided_password.encode('utf-8'), + salt.encode('ascii'), + 100000) + pwdhash = binascii.hexlify(pwdhash).decode('ascii') + return pwdhash == stored_password diff --git a/apps/config.py b/apps/config.py new file mode 100644 index 0000000..46cd31b --- /dev/null +++ b/apps/config.py @@ -0,0 +1,59 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +import os + +class Config(object): + + basedir = os.path.abspath(os.path.dirname(__file__)) + + # Set up the App SECRET_KEY + # SECRET_KEY = config('SECRET_KEY' , default='S#perS3crEt_007') + SECRET_KEY = os.getenv('SECRET_KEY', 'S#perS3crEt_007') + + # This will create a file in FOLDER + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'db.sqlite3') + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # Assets Management + ASSETS_ROOT = os.getenv('ASSETS_ROOT', '/static/assets') + + SOCIAL_AUTH_GITHUB = False + + GITHUB_ID = os.getenv('GITHUB_ID') + GITHUB_SECRET = os.getenv('GITHUB_SECRET') + + # Enable/Disable Github Social Login + if GITHUB_ID and GITHUB_SECRET: + SOCIAL_AUTH_GITHUB = True + +class ProductionConfig(Config): + DEBUG = False + + # Security + SESSION_COOKIE_HTTPONLY = True + REMEMBER_COOKIE_HTTPONLY = True + REMEMBER_COOKIE_DURATION = 3600 + + # PostgreSQL database + SQLALCHEMY_DATABASE_URI = '{}://{}:{}@{}:{}/{}'.format( + os.getenv('DB_ENGINE' , 'mysql'), + os.getenv('DB_USERNAME' , 'appseed_db_usr'), + os.getenv('DB_PASS' , 'pass'), + os.getenv('DB_HOST' , 'localhost'), + os.getenv('DB_PORT' , 3306), + os.getenv('DB_NAME' , 'appseed_db') + ) + + +class DebugConfig(Config): + DEBUG = True + + +# Load all possible configurations +config_dict = { + 'Production': ProductionConfig, + 'Debug' : DebugConfig +} diff --git a/apps/home/__init__.py b/apps/home/__init__.py new file mode 100644 index 0000000..dec76db --- /dev/null +++ b/apps/home/__init__.py @@ -0,0 +1,12 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from flask import Blueprint + +blueprint = Blueprint( + 'home_blueprint', + __name__, + url_prefix='' +) diff --git a/apps/home/routes.py b/apps/home/routes.py new file mode 100644 index 0000000..49ca9ba --- /dev/null +++ b/apps/home/routes.py @@ -0,0 +1,54 @@ +# -*- encoding: utf-8 -*- +""" +Copyright (c) 2019 - present AppSeed.us +""" + +from apps.home import blueprint +from flask import render_template, request +from flask_login import login_required +from jinja2 import TemplateNotFound + + +@blueprint.route('/index') +@login_required +def index(): + + return render_template('home/index.html', segment='index') + + +@blueprint.route('/