2024-07-20 15:48:46 +03:30
|
|
|
#!/usr/bin/python
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
# webutil.py: low level page request related methods, decorators, Flask app
|
|
|
|
# MikroWizard.com , Mikrotik router management solution
|
|
|
|
# Author: Tomi.Mickelsson@iki.fi , Modified by sepehr.ha@gmail.com
|
|
|
|
|
|
|
|
import time
|
|
|
|
import peewee
|
|
|
|
import functools
|
|
|
|
from flask import Flask, request, session, g, jsonify
|
|
|
|
from flask_session import Session
|
|
|
|
from flask.json.provider import DefaultJSONProvider
|
|
|
|
|
|
|
|
from libs.db import db
|
|
|
|
import config
|
|
|
|
import datetime
|
|
|
|
|
|
|
|
import logging
|
|
|
|
log = logging.getLogger("webutil")
|
|
|
|
|
|
|
|
|
|
|
|
# create and configure the Flask app
|
|
|
|
app = Flask(__name__, static_folder=None, template_folder="../templates")
|
|
|
|
app.config.update(config.flask_config)
|
|
|
|
Session(app)
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
# API decorator
|
|
|
|
|
|
|
|
def login_required(func=None, role=None , perm={}):
|
|
|
|
"""Decorator: must be logged on, and optionally must have the given role.
|
|
|
|
Insert after app.route like this:
|
|
|
|
@app.route('/api/users')
|
|
|
|
@login_required(role='superuser')"""
|
|
|
|
|
|
|
|
# yes, this is python magic, see https://blogs.it.ox.ac.uk/inapickle/2012/01/05/python-decorators-with-optional-arguments/
|
|
|
|
if not func:
|
|
|
|
return functools.partial(login_required, role=role, perm=perm)
|
|
|
|
@functools.wraps(func)
|
|
|
|
def inner(*args, **kwargs):
|
|
|
|
return _check_user_role(role, perm=perm) or func(*args, **kwargs )
|
|
|
|
return inner
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
# get data about me, return error replys
|
|
|
|
|
|
|
|
def get_myself():
|
|
|
|
"""Return the user object of the caller or None if he is a visitor.
|
|
|
|
Loads the user from the database, then caches it during request."""
|
|
|
|
|
|
|
|
if not "userid" in session:
|
|
|
|
return None
|
|
|
|
|
|
|
|
if hasattr(g, "MYSELF"):
|
|
|
|
return g.MYSELF # use cache
|
|
|
|
else:
|
|
|
|
g.MYSELF = db.get_user(session["userid"])
|
|
|
|
return g.MYSELF
|
|
|
|
|
|
|
|
def error_reply(errmsg, httpcode=200):
|
|
|
|
"""Logs an error and returns error code to the caller."""
|
|
|
|
log.error(errmsg)
|
|
|
|
return buildResponse({"status":"failed","err":"{}: {}".format(httpcode, errmsg)}, httpcode)
|
|
|
|
|
|
|
|
def warn_reply(errmsg, httpcode=200):
|
|
|
|
"""Logs a warning and returns error code to the caller."""
|
|
|
|
log.warning(errmsg)
|
|
|
|
return buildResponse({"status":"failed","error":"{}: {}".format(httpcode, errmsg)}, httpcode)
|
|
|
|
|
|
|
|
def get_agent():
|
|
|
|
"""Returns browser of caller."""
|
|
|
|
return request.headers.get('User-Agent', '')
|
|
|
|
|
|
|
|
def get_ip():
|
|
|
|
"""Returns IP address of caller."""
|
|
|
|
return request.headers.get('X-Real-IP') or request.remote_addr
|
|
|
|
|
|
|
|
|
|
|
|
def buildResponse(data,httpcode=200 ,error=False):
|
|
|
|
"""Builds a JSON response from data."""
|
|
|
|
res={}
|
|
|
|
res["result"]=data
|
2024-08-18 20:59:23 +03:30
|
|
|
if error:
|
|
|
|
res["result"]["err"]=error
|
2024-07-20 15:48:46 +03:30
|
|
|
|
|
|
|
return jsonify(res), httpcode
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
# before/after/error request handlers
|
|
|
|
|
|
|
|
@app.before_request
|
|
|
|
def before_request():
|
|
|
|
"""Executed always before a request. Connects to db, logs the request,
|
|
|
|
prepares global data, loads current user."""
|
|
|
|
|
|
|
|
# log request path+input, but not secrets
|
|
|
|
try:
|
|
|
|
params = request.json or request.args or request.form
|
|
|
|
except:
|
|
|
|
params = None
|
|
|
|
if params:
|
|
|
|
cloned = None
|
|
|
|
secret_keys = ["password", "passwd", "pwd"]
|
|
|
|
for k in secret_keys:
|
|
|
|
if k in params:
|
|
|
|
if not cloned:
|
|
|
|
cloned = params.copy()
|
|
|
|
cloned[k] = 'X'
|
|
|
|
if cloned:
|
|
|
|
params = cloned
|
|
|
|
|
|
|
|
params = str(params or '')[:1000]
|
|
|
|
method = request.method[:2]
|
|
|
|
# log.info("{} {} {}".format(method, request.path, params))
|
|
|
|
|
|
|
|
# connect to db
|
|
|
|
g.db = db.database
|
|
|
|
g.db.connection()
|
|
|
|
|
|
|
|
# have common data available in global g
|
|
|
|
# but do not pollute g, store only the most relevant data
|
|
|
|
g.HOST = request.headers.get('X-Real-Host', '')
|
|
|
|
g.ISLOGGED = "userid" in session
|
|
|
|
myrole = session.get("role") or ""
|
|
|
|
g.IS_SUPER_USER = myrole == "superuser"
|
|
|
|
|
|
|
|
if myrole == "disabled":
|
|
|
|
err = "account disabled"
|
|
|
|
log.warn(err)
|
|
|
|
return jsonify({"err":err}), 400
|
|
|
|
|
|
|
|
# time the request
|
|
|
|
g.t1 = time.time()
|
|
|
|
|
|
|
|
# where did we link from? (but filter our internal links)
|
|
|
|
# if request.referrer:
|
|
|
|
# log.info("linked from "+request.referrer)
|
|
|
|
|
|
|
|
|
|
|
|
@app.after_request
|
|
|
|
def after_request(response):
|
|
|
|
"""Executed after a request, unless a request occurred."""
|
|
|
|
|
|
|
|
# log about error
|
|
|
|
logmethod = None
|
|
|
|
if 400 <= response.status_code <= 599:
|
|
|
|
logmethod = log.error
|
|
|
|
elif not 200 <= response.status_code < 399:
|
|
|
|
logmethod = log.warn
|
|
|
|
if logmethod:
|
|
|
|
logmethod(" {} {} {}".format(response.status_code,
|
|
|
|
request.method, request.url))
|
|
|
|
|
|
|
|
# set CORS headers
|
|
|
|
response.headers['Access-Control-Allow-Origin'] = config.CORS_ALLOW_ORIGIN
|
|
|
|
response.headers['Access-Control-Allow-Methods'] = 'GET,POST,PUT,DELETE,OPTIONS'
|
|
|
|
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
|
|
|
response.headers['Access-Control-Allow-Credentials'] = 'true'
|
|
|
|
# response.headers['Access-Control-Expose-Headers'] = 'Access-Control-Allow-Origin'
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
@app.teardown_request
|
|
|
|
def teardown(error):
|
|
|
|
"""Always executed after a request."""
|
|
|
|
|
|
|
|
if hasattr(g, "db"):
|
|
|
|
g.db.close()
|
|
|
|
|
|
|
|
# log warning when a request takes >1.0sec
|
|
|
|
# (put long-running tasks into background)
|
|
|
|
if hasattr(g, "t1"):
|
|
|
|
delta = time.time()-g.t1
|
|
|
|
if delta > 1.0:
|
|
|
|
log.warn("SLOW! {} time={}".format(request.path, delta))
|
|
|
|
|
|
|
|
|
|
|
|
@app.errorhandler(404)
|
|
|
|
def page_not_found(error):
|
|
|
|
err = "404: " + request.path
|
|
|
|
return jsonify({"err":err}), 404
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
# logging (is in this module because binds to session)
|
|
|
|
|
|
|
|
class ColorFormatter(logging.Formatter):
|
|
|
|
"""Colorize warnings and errors"""
|
|
|
|
|
|
|
|
def format(self, rec):
|
|
|
|
if rec.levelno == logging.WARNING:
|
|
|
|
rec.msg = "\033[93m{}\033[0m".format(rec.msg)
|
|
|
|
elif rec.levelno in (logging.ERROR, logging.CRITICAL):
|
|
|
|
rec.msg = "\033[91m{}\033[0m".format(rec.msg)
|
|
|
|
return logging.Formatter.format(self, rec)
|
|
|
|
|
|
|
|
|
|
|
|
class MyLogContextFilter(logging.Filter):
|
|
|
|
"""Injects contextual info, ip+userid, into the log."""
|
|
|
|
|
|
|
|
def filter(self, record):
|
|
|
|
if request:
|
|
|
|
# take ip from a header or actual
|
|
|
|
ip = get_ip()
|
|
|
|
# take userid from the session
|
|
|
|
uid = session.get("userid", "anon")
|
|
|
|
else:
|
|
|
|
ip = ""
|
|
|
|
uid = " -WORKER" # background worker
|
|
|
|
|
|
|
|
record.ip = "local" if config.IS_LOCAL_DEV else ip
|
|
|
|
record.uid = uid
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def init_logging():
|
|
|
|
"""Initialize logging system."""
|
|
|
|
|
|
|
|
prefix = "PROD " if config.IS_PRODUCTION else ""
|
|
|
|
format = prefix+"%(levelname)3.3s %(uid)s@%(ip)s %(asctime)s %(filename)s %(message)s"
|
|
|
|
dfmt = "%d%m%y-%H:%M:%S"
|
|
|
|
logging.basicConfig(level=logging.INFO, format=format, datefmt=dfmt)
|
|
|
|
|
|
|
|
formatter = ColorFormatter(format, datefmt=dfmt)
|
|
|
|
|
|
|
|
# custom log data: userid + ip addr
|
|
|
|
f = MyLogContextFilter()
|
|
|
|
for handler in logging.root.handlers:
|
|
|
|
handler.addFilter(f)
|
|
|
|
handler.setFormatter(formatter) # remove if coloring not wanted
|
|
|
|
|
|
|
|
if config.PYSRV_LOG_SQL:
|
|
|
|
logging.getLogger('peewee').setLevel(logging.DEBUG)
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
# internal methods, serializing models
|
|
|
|
|
|
|
|
def _check_user_role(rolebase , perm={}):
|
|
|
|
"""Check that my role is atleast the given role. If not, log and return
|
|
|
|
an error."""
|
|
|
|
|
|
|
|
myrole = session.get("role") or ""
|
|
|
|
|
|
|
|
|
|
|
|
if not _is_role_atleast(myrole, rolebase,perm):
|
|
|
|
uid = session.get("userid") or ""
|
|
|
|
err = "Unauthorized! {} {} user={}".format(
|
|
|
|
request.method, request.path, uid)
|
|
|
|
return warn_reply(err, 200)
|
|
|
|
|
|
|
|
def _is_role_atleast(myrole, rolebase, perm):
|
|
|
|
"""Checks that myrole is same or above rolebase. Assumes a
|
|
|
|
simple role model where roles can be arranged from lowest
|
|
|
|
access to highest access level."""
|
|
|
|
|
|
|
|
if not rolebase:
|
|
|
|
# no role required, but I need to be logged-on
|
|
|
|
return "userid" in session
|
|
|
|
userperms=session.get("perms") or {}
|
|
|
|
perms = { "None":1,"read":2, "write":3, "full":4}
|
2025-01-02 20:12:00 +03:00
|
|
|
res=True
|
2024-07-20 15:48:46 +03:30
|
|
|
if len(perm)>0:
|
|
|
|
for key, value in perm.items():
|
|
|
|
if key in userperms:
|
2025-01-02 20:12:00 +03:00
|
|
|
if userperms[key]=='none':
|
|
|
|
res=False
|
2024-07-20 15:48:46 +03:30
|
|
|
res=res and perms[userperms[key]]>=perms[value]
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
|
|
|
|
levels = {"readonly":1, "editor":2, "user":3, "admin":4, "superuser":5}
|
|
|
|
try:
|
|
|
|
return res and levels[myrole] >= levels[rolebase]
|
|
|
|
except Exception as e:
|
|
|
|
log.error(e)
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
class MyJSONEncoder(DefaultJSONProvider):
|
|
|
|
def default(self, obj):
|
|
|
|
if isinstance(obj, peewee.SelectBase):
|
|
|
|
return list(obj)
|
|
|
|
elif isinstance(obj, db.BaseModel):
|
|
|
|
return obj.serialize()
|
|
|
|
elif isinstance(obj, datetime.datetime):
|
|
|
|
return obj.isoformat() if obj else None
|
|
|
|
#elif isinstance(obj, sqlite3.Cursor):
|
|
|
|
#return list(obj)
|
|
|
|
#if isinstance(obj, psycopg2.extensions.cursor):
|
|
|
|
#return list(obj)
|
|
|
|
return DefaultJSONProvider.default(obj)
|
|
|
|
|
|
|
|
app.json = MyJSONEncoder(app)
|
|
|
|
|
|
|
|
init_logging()
|