diff options
-rw-r--r-- | .gitignore | 183 | ||||
-rw-r--r-- | app.py | 172 | ||||
-rw-r--r-- | config-default.yml | 11 | ||||
-rw-r--r-- | config.py | 55 | ||||
-rw-r--r-- | local.htpasswd | 0 | ||||
-rw-r--r-- | reload.file | 0 | ||||
-rw-r--r-- | requirements.txt | 7 | ||||
-rw-r--r-- | static/Readme.md | 85 | ||||
-rw-r--r-- | static/hilite.css | 69 | ||||
-rw-r--r-- | static/joplinapi.uwsgi.ini | 20 |
10 files changed, 602 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccbf61f --- /dev/null +++ b/.gitignore @@ -0,0 +1,183 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# project files +config.yml @@ -0,0 +1,172 @@ +import shutil +from pathlib import Path + +import flask +import markdown +from flask_caching import Cache +from flask_htpasswd import HtPasswdAuth +from flask_restful import reqparse, Api, Resource + +from config import Config + +app = flask.Flask(__name__) +api = Api(app) + +# app config +if app.config["ENV"] == "development": + app.config.from_object("config.DevelopConfig") +else: + app.config.from_object(Config) + +cache = Cache(app) +htpasswd = HtPasswdAuth(app) +htpasswd.users.autosave = True +parser = reqparse.RequestParser() + +messages = { + 'OK': { + 'status': 200, + 'message': 'OK' + }, + 'Created': { + 'status': 201, + 'message': "User creation succeeded" + }, + 'Unauthorized Request': { + 'status': 401, + 'message': 'Unauthorized Request', + }, + 'Unprocessable Entity':{ + 'status': 422, + 'message': 'Missing parameter' + }, + 'Conflict': { + 'status': 409, + 'message': 'Username conflict' + }, + 'InternalServerError': { + 'status': 500, + 'message': 'Something went wrong, please contact the administrator.' + } +} + + +@app.route('/joplin/') +@cache.cached(timeout=21600) +def index(): + # codehilite css + with open(Path(app.root_path).joinpath('./static/hilite.css'), 'r') as hilite_file: + css = '<style>{css}</style>'.format(css=hilite_file.read()) + + # Open the README file + with open(Path(app.root_path).joinpath('./static/Readme.md'), 'r') as markdown_file: + + # Read the content of the file + content = markdown_file.read() + + html = ''.join([css, content]) + + # render finished HTML + return flask.Response(markdown.markdown(html, extensions=["fenced_code", "codehilite"]), status=200) + + +@app.route('/joplin/auth-test') +@htpasswd.required +def auth_test(user): + return 'Hello {user}'.format(user=user) + + +class NewUser(Resource): + """ + class to create new htpasswd user entry + """ + def post(self, username): + parser.add_argument('password') + parser.add_argument('invite') + + args = parser.parse_args() + password = args['password'] + invitecode = args['invite'] + path = app.config['JOPLIN_DIR'] + + # break early + if invitecode != app.config['INVITE_CODE']: + return flask.jsonify(messages['Unauthorized Request']) + + if None in [password, invitecode]: + return flask.jsonify(messages['Unprocessable Entity']) + + if username not in htpasswd.users.users(): + # firstly try to create the folder to break if permissions aren't correct + try: + Path.mkdir(Path(path).joinpath('./%s' % username), mode=0o750, exist_ok=True) + + except OSError: + return flask.jsonify(messages['InternalServerError']) + # create user entry + htpasswd.users.set_password(username, password) + + return flask.jsonify(messages['Created']) + else: + return flask.jsonify(messages['Conflict']) + + +class ChangePW(Resource): + """ + class to update a users password + """ + def post(self, username): + parser.add_argument('password') + parser.add_argument('new_password') + + args = parser.parse_args() + password = args['password'] + new_password = args['new_password'] + + if None in [password, new_password]: + return flask.jsonify(messages['Unprocessable Entity']) + + # check_password return False if password mismatch and None if no user is found + if htpasswd.users.check_password(username, password): + htpasswd.users.update(username, new_password) + + return flask.jsonify(messages['OK']) + else: + return flask.jsonify(messages['Unauthorized Request']) + + +class DelUser(Resource): + """ + class to delete a user + """ + def delete(self, username): + parser.add_argument('password') + + args = parser.parse_args() + password = args['password'] + + if password is None: + return flask.jsonify(messages['Unprocessable Entity']) + + # check_password return False if password mismatch and None if no user is found + if htpasswd.users.check_password(username, password): + htpasswd.users.delete(username) + + try: + # remove users files recursively + shutil.rmtree(Path(app.config['JOPLIN_DIR']).joinpath('./%s' % username)) + except FileNotFoundError: + pass + + return flask.Response(flask.jsonify([]), status=204) + else: + return flask.jsonify(messages['Unauthorized Request']) + + +api.add_resource(NewUser, '/joplin/<string:username>/create') +api.add_resource(ChangePW, '/joplin/<string:username>/changepw') +api.add_resource(DelUser, '/joplin/<string:username>') + + +if __name__ == '__main__': + app.run() diff --git a/config-default.yml b/config-default.yml new file mode 100644 index 0000000..9467e64 --- /dev/null +++ b/config-default.yml @@ -0,0 +1,11 @@ +{ + "secret-key": "super-secret-password", + "flask-secret": "different-super-secret-password", + + "cookie_domain": ".example.de/joplin", + "cookie_path": "/joplin/", + + "htpasswd_file": "/path/to/joplin/htauth/file", + "joplin_webdav_dir": "/var/www/webdav/joplin", + "invite_code": "secret invite code" +}
\ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..f3b597b --- /dev/null +++ b/config.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +from ruamel.yaml import YAML + +with open("config.yml", "r", encoding="utf-8") as file: + config = YAML() + config = config.load(file) + + +class Config(object): + DEBUG = False + TESTING = False + SECRET_KEY = config['secret-key'] + CSRF_ENABLED = True + + # joplin + JOPLIN_DIR = config['joplin_webdav_dir'] + INVITE_CODE = config['invite_code'] + + # flask-htpasswd + FLASK_SECRET = config['flask-secret'] + FLASK_HTPASSWD_PATH = config['htpasswd_file'] + + # cache + CACHE_TYPE = config['cache_type'] + CACHE_KEY_PREFIX = config['cache_key_prefix'] + CACHE_REDIS_URL = config['cache_redis_url'] + + # session cookies + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_DOMAIN = config['cookie_domain'] + SESSION_COOKIE_SAMESITE='Lax' + SESSION_COOKIE_PATH = config['cookie_path'] + PERMANENT_SESSION_LIFETIME = 600 + + +class DevelopConfig(Config): + DEBUG = True + TESTING = True + CSRF_ENABLED = False + + # joplin + JOPLIN_DIR = "." + + # flask-htpasswd + FLASK_HTPASSWD_PATH = "./local.htpasswd" + + # cache + CACHE_TYPE = config['cache_type'] + CACHE_KEY_PREFIX = config['cache_key_prefix'] + CACHE_REDIS_URL = config['cache_redis_url'] + + # session cookies + SESSION_COOKIE_SECURE = False + SESSION_COOKIE_DOMAIN = None + SESSION_COOKIE_SAMESITE = None diff --git a/local.htpasswd b/local.htpasswd new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/local.htpasswd diff --git a/reload.file b/reload.file new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/reload.file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5cf7beb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask==1.1.2 +flask-htpasswd==0.4.0 +Flask-RESTful==0.3.8 +Flask-Caching==1.8.0 +Markdown==3.2.1 +ruamel.yaml==0.16.10 +ruamel.yaml.clib==0.2.0 diff --git a/static/Readme.md b/static/Readme.md new file mode 100644 index 0000000..3be39cb --- /dev/null +++ b/static/Readme.md @@ -0,0 +1,85 @@ +# Joplin UserManagement WebApi +## How To +The Joplin UserManagement WebApi utilizes [url encoded](https://en.wikipedia.org/wiki/Percent-encoding) parameter to manage the Joplin userbase. + +## create new user +**Definition** + +`POST /joplin/<string:username>/create` + +**Arguments** + +- `"password":string` user password +- `"invite-code":string` invite code to create an account +```json +{ + "password": "password", + "invite-code": "invite" +} +``` + +**Response** + +- `201 Created` user creation succeeded +- `409 Conflict` user creation failed due to a conflict +- `422 Unprocessable Entity` one or more parameter/s were not given +- `500 Internal Server Error` user directory creation failure + +**Example** +```bash +curl --data "password=super_secret&invite-code=nachos" https://domain.tld/joplin/jim/create +``` +--- + +## update user password +**Definition** + +`POST /joplin/<string:username>/changepw` + +**Arguments** + +- `"password": string` current user password +- `"new_password": string` new user password +```json +{ + "password": "password", + "new_password": "new_password" +} +``` + +**Response** + +- `200 OK` password change succeeded +- `401 Unauthorized` the request was not authorized +- `422 Unprocessable Entity` one or more parameter/s were not given + +**Example** +```bash +curl --data "password=super_secret&new_password=5up3r_53cr37" https://domain.tld/joplin/jim/changepw +``` +--- + +## delete user account +**Definition** + +`DELETE /joplin/<string:username>` + +**Arguments** + +- `"password": string` users password +```json +{ + "password": "password" +} +``` + +**Response** + +- `204 No Content` user deletion succeeded +- `401 Unauthorized` the request was not authorized +- `422 Unprocessable Entity` one or more parameter/s were not given + +**Example** +```bash +curl -X DELETE --data "password=super_secret" https://domain.tld/joplin/jim +``` diff --git a/static/hilite.css b/static/hilite.css new file mode 100644 index 0000000..5acc1f9 --- /dev/null +++ b/static/hilite.css @@ -0,0 +1,69 @@ +.codehilite .hll { background-color: #ffffcc } +.codehilite { background: #f8f8f8; } +.codehilite .c { color: #008800; font-style: italic } /* Comment */ +.codehilite .err { border: 1px solid #FF0000 } /* Error */ +.codehilite .k { color: #AA22FF; font-weight: bold } /* Keyword */ +.codehilite .o { color: #666666 } /* Operator */ +.codehilite .ch { color: #008800; font-style: italic } /* Comment.Hashbang */ +.codehilite .cm { color: #008800; font-style: italic } /* Comment.Multiline */ +.codehilite .cp { color: #008800 } /* Comment.Preproc */ +.codehilite .cpf { color: #008800; font-style: italic } /* Comment.PreprocFile */ +.codehilite .c1 { color: #008800; font-style: italic } /* Comment.Single */ +.codehilite .cs { color: #008800; font-weight: bold } /* Comment.Special */ +.codehilite .gd { color: #A00000 } /* Generic.Deleted */ +.codehilite .ge { font-style: italic } /* Generic.Emph */ +.codehilite .gr { color: #FF0000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #00A000 } /* Generic.Inserted */ +.codehilite .go { color: #888888 } /* Generic.Output */ +.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #0044DD } /* Generic.Traceback */ +.codehilite .kc { color: #AA22FF; font-weight: bold } /* Keyword.Constant */ +.codehilite .kd { color: #AA22FF; font-weight: bold } /* Keyword.Declaration */ +.codehilite .kn { color: #AA22FF; font-weight: bold } /* Keyword.Namespace */ +.codehilite .kp { color: #AA22FF } /* Keyword.Pseudo */ +.codehilite .kr { color: #AA22FF; font-weight: bold } /* Keyword.Reserved */ +.codehilite .kt { color: #00BB00; font-weight: bold } /* Keyword.Type */ +.codehilite .m { color: #666666 } /* Literal.Number */ +.codehilite .s { color: #BB4444 } /* Literal.String */ +.codehilite .na { color: #BB4444 } /* Name.Attribute */ +.codehilite .nb { color: #AA22FF } /* Name.Builtin */ +.codehilite .nc { color: #0000FF } /* Name.Class */ +.codehilite .no { color: #880000 } /* Name.Constant */ +.codehilite .nd { color: #AA22FF } /* Name.Decorator */ +.codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */ +.codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +.codehilite .nf { color: #00A000 } /* Name.Function */ +.codehilite .nl { color: #A0A000 } /* Name.Label */ +.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.codehilite .nv { color: #B8860B } /* Name.Variable */ +.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.codehilite .w { color: #bbbbbb } /* Text.Whitespace */ +.codehilite .mb { color: #666666 } /* Literal.Number.Bin */ +.codehilite .mf { color: #666666 } /* Literal.Number.Float */ +.codehilite .mh { color: #666666 } /* Literal.Number.Hex */ +.codehilite .mi { color: #666666 } /* Literal.Number.Integer */ +.codehilite .mo { color: #666666 } /* Literal.Number.Oct */ +.codehilite .sa { color: #BB4444 } /* Literal.String.Affix */ +.codehilite .sb { color: #BB4444 } /* Literal.String.Backtick */ +.codehilite .sc { color: #BB4444 } /* Literal.String.Char */ +.codehilite .dl { color: #BB4444 } /* Literal.String.Delimiter */ +.codehilite .sd { color: #BB4444; font-style: italic } /* Literal.String.Doc */ +.codehilite .s2 { color: #BB4444 } /* Literal.String.Double */ +.codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +.codehilite .sh { color: #BB4444 } /* Literal.String.Heredoc */ +.codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +.codehilite .sx { color: #008000 } /* Literal.String.Other */ +.codehilite .sr { color: #BB6688 } /* Literal.String.Regex */ +.codehilite .s1 { color: #BB4444 } /* Literal.String.Single */ +.codehilite .ss { color: #B8860B } /* Literal.String.Symbol */ +.codehilite .bp { color: #AA22FF } /* Name.Builtin.Pseudo */ +.codehilite .fm { color: #00A000 } /* Name.Function.Magic */ +.codehilite .vc { color: #B8860B } /* Name.Variable.Class */ +.codehilite .vg { color: #B8860B } /* Name.Variable.Global */ +.codehilite .vi { color: #B8860B } /* Name.Variable.Instance */ +.codehilite .vm { color: #B8860B } /* Name.Variable.Magic */ +.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */
\ No newline at end of file diff --git a/static/joplinapi.uwsgi.ini b/static/joplinapi.uwsgi.ini new file mode 100644 index 0000000..84f3128 --- /dev/null +++ b/static/joplinapi.uwsgi.ini @@ -0,0 +1,20 @@ +[uwsgi] +master = true +project = JoplinWebApi +base = /var/www + +chdir = %(base)/%(project) +home = %(base)/%(project)/venv +module = joplinapi:app +plugins = python3 +manage-script-name = true + +processes = 3 +uid = www-data +gid = www-data +logto = /var/log/uwsgi/app/%(project).log +logformat = '%(addr) - %(host) ["%(ltime)"] "%(method) %(uri)" "%(status)" "%(cl)" "%(referrer)" rt="%(time)" ut="%(secs)' + +vacuum = true +die-on-term = true +touch-reload = %(chdir)/reload.file |