summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore183
-rw-r--r--app.py172
-rw-r--r--config-default.yml11
-rw-r--r--config.py55
-rw-r--r--local.htpasswd0
-rw-r--r--reload.file0
-rw-r--r--requirements.txt7
-rw-r--r--static/Readme.md85
-rw-r--r--static/hilite.css69
-rw-r--r--static/joplinapi.uwsgi.ini20
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
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..decd9df
--- /dev/null
+++ b/app.py
@@ -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