diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 0000000..82678eb --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,25 @@ +name: tox + +on: + pull_request: + workflow_dispatch: # you can trigger this workflow manually + +jobs: + tox_on_ubuntu: + + runs-on: ubuntu-20.04 + strategy: + matrix: + python: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + - name: install tox + run: pip install tox + - name: run tox + # Run tox using the version of Python in `PATH` + run: tox -e py diff --git a/.gitignore b/.gitignore index 48c3721..1973a9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,10 @@ # to avoid leaking credentials to github: -sqldatabasename -sqlpassword -sqlserver -sqlusername -secrets/ -docker-compose.yml hash_password.py # to avoid leaking references to github: references references/ -# because this relates to my personal config: -config/www.webref.conf - # general things to ignore: build/ dist/ @@ -27,6 +18,7 @@ __pycache__/ *.tar.gz # due to using tox, pytest, vscode: +.coverage .tox .cache .eggs/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 5b32b4d..0000000 --- a/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM httpd:2.4 - -COPY static/ /webapp/static -COPY templates/ /webapp/templates -COPY webref.py /webapp -COPY config/webref.wsgi /webapp -COPY config/httpd.conf /usr/local/apache2/conf -COPY requirements.txt /webapp -RUN mkdir /var/run/apache2 # needed to store files for WSGI -RUN mkdir /var/www # home of www-data user -WORKDIR /webapp - -RUN apt update -RUN apt install -y python3 -RUN apt install -y python3-pip -RUN apt install -y libapache2-mod-wsgi-py3 -RUN pip3 install -r requirements.txt --break-system-packages - -CMD ["/usr/local/apache2/bin/httpd", "-D", "FOREGROUND"] diff --git a/LICENSE b/LICENSE index 6e966b8..30bcce9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 Adrian Schlatter +Copyright (c) 2021-2023 Adrian Schlatter Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..36a9fcd --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,11 @@ +exclude .gitignore +exclude MANIFEST.in +exclude tox.ini + +recursive-exclude .github * +recursive-exclude tests * + +recursive-include src *.css +recursive-include src *.js +recursive-include src *.php +recursive-include src *.tmpl diff --git a/config/httpd.conf b/config/httpd.conf deleted file mode 100644 index e095a03..0000000 --- a/config/httpd.conf +++ /dev/null @@ -1,50 +0,0 @@ -ServerName localhost -ServerAdmin root@localhost -ServerRoot /usr/local/apache2 -User www-data -Group www-data -PidFile logs/httpd.pid - -ServerTokens Prod -UseCanonicalName On -TraceEnable Off - -Timeout 10 -MaxRequestWorkers 100 - -Listen 0.0.0.0:80 - -LoadModule mpm_event_module modules/mod_mpm_event.so -LoadModule unixd_module modules/mod_unixd.so - -LoadModule log_config_module modules/mod_log_config.so - -LoadModule authn_core_module modules/mod_authn_core.so -LoadModule authz_core_module modules/mod_authz_core.so - -# apt installs mod_wsgi.so in a different folder: -LoadModule wsgi_module /usr/lib/apache2/modules/mod_wsgi.so - -ErrorLogFormat "[%{cu}t] [%-m:%-l] %-a %-L %M" -LogFormat "%h %l %u [%{%Y-%m-%d %H:%M:%S}t.%{usec_frac}t] \"%r\" %>s %b \ -\"%{Referer}i\" \"%{User-Agent}i\"" combined - -LogLevel debug -ErrorLog logs/error.log -CustomLog logs/access.log combined - - - Require all denied - Options SymLinksIfOwnerMatch - - - - WSGIDaemonProcess webref user=www-data group=www-data threads=5 - WSGIScriptAlias / /webapp/webref.wsgi - - - WSGIProcessGroup webref - WSGIApplicationGroup %{GLOBAL} - Require all granted - - diff --git a/config/webref.wsgi b/config/webref.wsgi deleted file mode 100644 index 32ffd43..0000000 --- a/config/webref.wsgi +++ /dev/null @@ -1,4 +0,0 @@ -import sys -sys.path.insert(0, '/webapp') - -from webref import app as application diff --git a/docker-compose_templ.yml b/docker-compose_templ.yml deleted file mode 100644 index 664a695..0000000 --- a/docker-compose_templ.yml +++ /dev/null @@ -1,22 +0,0 @@ -services: - webref: - image: webref - build: . - ports: - - "7000:80" - volumes: - - :/webapp/references - secrets: - - sqlusername - - sqlpassword - - sqlserver - - sqldatabasename -secrets: - sqlusername: - file: ./secrets/sqlusername - sqlpassword: - file: ./secrets/sqlpassword - sqlserver: - file: ./secrets/sqlserver - sqldatabasename: - file: ./secrets/sqldatabasename diff --git a/docs/DevNotes.md b/docs/DevNotes.md index bed3bff..e3e7978 100644 --- a/docs/DevNotes.md +++ b/docs/DevNotes.md @@ -1,91 +1,42 @@ # Development Notes -## Docker Lessons -* Web server must listen on 0.0.0.0:80: Docker port mapping does not work - otherwise (127.0.0.1:80 is not enough). -* Running apache in the foreground (as it's usually done inside a docker - container) has unexpected consequences: Apache reacts to SIGWINCH - (WINdow CHange SIGnal) by restarting. Therefore, resizing the terminal - stops the container... - - -## Deployment to a Synology NAS - -Build docker image: - -``` -docker build -t webref:0.x . -``` - -Then, save this image into a tar-ball: - -``` -docker save webref:0.x | gzip > webref-0.x.tar.gz -``` - -Copy this to your NAS where you run: - -``` -docker load < webref-0.x.tar.gz -``` - -Stop your existing webref container and delete it using the commands: - -``` -docker container ls -docker container stop -docker container rm -``` - -Go into your directory with your docker-compose.yml and run (maybe after -changing the version number inside docker-compose.yml): - -``` -docker-compose up --detach -``` - -We want https://webref.ourdomain.com to be handled by the webref -docker container => need reverse proxy. Also, we want Synology to handle -https certificates. I.e., we want the traffic decrypted before it reaches -our docker container. Synology's web interface is not -flexible enough to do this properly. It is still possible, however, -but we have to use a terminal: -[This article](https://primalcortex.wordpress.com/2018/05/07/synology-reverse-proxy-revisited-again/?unapproved=18819&moderation-hash=e368f1dda03465bca9880d8de938786a#comment-18819) -is useful. The config we put in '/etc/nginx/conf.d/server.webref.conf' is: - -``` -server { - listen 80; - server_name webref.ourdomain.com; - - return 301 https://$host$request_uri; -} - -server { - listen 443 ssl; - server_name webref.ourdomain.com; - - add_header Strict-Transport-Security "max-age=15768000; includeSubdomains; preload" always; - - location / { - proxy_pass http://localhost:7000; - } -} -``` - -This assumes your webref container has mapped internal port 80 to host port -7000. Also, it assumes that the NAS's https certificate is valid for -webref.ourdomain.com (listed as "Subject alternative name:"). - -Make sure to run - -```nginx -s reload``` - -to activate your new configuration - - -## Ugly - -* Docker image httpd:2.4 has apache in /usr/local/apache2. But apt installs - additional apache modules (mod_wsgi) in /usr/lib/apache2. +## Flask + +* Project layout follows [Flask's + Tutorial](https://flask.palletsprojects.com/en/3.0.x/tutorial/layout/) but + uses a namespace package-layout +* AJAX: ppf.webref main page sends a document and javascript. When doing + things, events are trigged that + - request new data from the backend + - modify the document based on new data + + +## Security + +* CSP (Content Security Policy): + - based on flask_talisman + - Follow hints by [Mozilla Observatory](https://observatory.mozilla.org) + and make sure we get an A+ +* CSRF (Cross-Site Request Forgery): + - based on flask_wtf + - read + [CSRF Protection](https://flask-wtf.readthedocs.io/en/0.15.x/csrf/#javascript-requests) + - If you run into the "Bad Request - The CSRF session token is missing." + problem, make sure to read [Fix Missing CSRF Token Issues with + Flask](https://nickjanetakis.com/blog/fix-missing-csrf-token-issues-with-flask) + - And if you start losing your mind while trying to fix CSRF problems: Try + running it in your production environment. I was unable to make it work + locally, I was unable to make it work on a test host, but it works on my + production server. Maybe this is related to the cookie problem related to + FQDNs mentioned in the article above: Neither my local computer nor my + test host have a fully qualified domain name but my production server + has. +* login: JabRef library is only available to logged-in users + + +## Tests + +* pytest +* read [Testing Flask + Applications](https://flask.palletsprojects.com/en/2.2.x/testing/) diff --git a/docs/README.md b/docs/README.md index af2755c..729cce2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,16 +1,15 @@ -# WebRef +# ppf.webref -WebRef is a web interface to a [JabRef SQL database](https://docs.jabref.org/collaborative-work/sqldatabase). -It allows you to access your references from anywhere in the world and from -any device with a web browser. You do not need to install Java, you -do not need to install an app. Any non-archaic phone, tablet, PC, Mac, or -Raspberry Pi will do. +ppf.webref is a web app providing an interface to a [JabRef SQL +database](https://docs.jabref.org/collaborative-work/sqldatabase). +Access your references from anywhere in the world and from any device with a +web browser. You do not need to install Java, you do not need to install an +app. Any non-archaic phone, tablet, PC, Mac, or Raspberry Pi will do. -Create a JabRef database (using your normal JabRef) and configure WebRef -to point to this database. Voila: Your references just became accessible -worldwide. +Create a JabRef database (using your normal JabRef) and point ppf.webref to +this database. Voila: Your references just became accessible worldwide. -Note: WebRef provides *read-only* access to your library. To add, edit, or +Note: ppf.webref provides *read-only* access to your library. To add, edit, or delete entries from your library, you still need a standard JabRef installation somewhere. @@ -21,76 +20,67 @@ somewhere. ## Installation -You need: +Prerequisite: You need JabRef to create, edit, and extend your library. -* JabRef: To create, edit, and extend your library -* Docker: To create and run docker images +Install ppf.webref: +```shell +pip install ppf.webref +``` -Steps: - -* Clone this repo -* Create a suitable docker-compose.yml (use - [docker-compose_templ.yml](../docker-compose_templ.yml) as a starting point) -* Create the following text files (assuming you did not change the paths - from the template docker-compose.yml): - - ./secrets/sqlusername: Username used to access your JabRef database - - ./secrets/sqlpassword: Password for that username - - ./secrets/sqlserver: The sql server holding your JabRef database - - ./secrets/sqldatabasename: The name of your JabRef database -* Run ```docker-compose up``` -* Point your webbrowser to localhost:7000 (or where you configured your - WebRef to be) - -This will start WebRef on your local machine which is nice for testing. -To get the most out of WebRef, you will probably want to -run this docker image on a web server. - -As we have not created any users yet, we can't login. To create -users, open your JabRef database (the one named in ./secrets/sqldatabasename) -and run this sql-code (make sure you don't have a table with this name -already): +Then, tell ppf.webref about your database by adding a section as follows to +`~/.config/ppf.webref/ppf.webref.conf` (create it if it does not exist): ``` -create table user ( - id INT auto_increment, - username varchar(20) character set utf8 not null, - password char(80) character set ascii not null, - primary key (id), - unique(username) -) +[flask] +secret_key = + +[database] +server = : +databasename = +username = +password = ``` -Now we have a user table but no users in it, yet. Let's find a password and hash -it with the following python code (of course, we replace the dummy password -with your own password beforehand): +`secret_key` is needed to encrypt cookies. Set it to a random string, e.g. by +running this snippet: +```shell +python -c 'import secrets; print(secrets.token_hex())' ``` -import bcrypt -password = 'This is my password' +Finally, run -bytes = password.encode('utf-8') -salt = bcrypt.gensalt() -print(bcrypt.hashpw(bytes, salt)) +```shell +flask --app ppf.webref run ``` -The output looks something like this: +and point your webbrowser to http://localhost:5000. -``` -b'$2b$12$1royHRBq6o/mbDdO7LjR8eaThWYErI6HLLdn7MBfajtpRLlwWSJ8m' -``` +[This will start ppf.webref on your local machine which is nice for testing. +To get the most out of ppf.webref, you will probably want to run ppf.webref on +a web server.] -Now add your user to the user table in you JabRef database using this sql-code -(again, replace "webref" with your username and the password hash with the -hash you generated above): +ppf.webref will present a login form. However, as we have not created any users +yet, we can't login. To create a user, run: +```shell +flask --app ppf.webref useradd ``` -insert into user (username, password) -values ( - "webref", - "$2b$12$1royHRBq6o/mbDdO7LjR8eaThWYErI6HLLdn7MBfajtpRLlwWSJ8m" -); + +This will: + +* create a table 'user' in your db if it does not exist, yet +* register user in user table + +To set a password for this new user or to change the password of an existing +user, do + +```shell +flask --app ppf.webref passwd ``` -Now we are ready to go. +which will ask for and store (a salted hash of) the password in the +user table. + +Now we are able to login. diff --git a/docs/README_pypi.md b/docs/README_pypi.md new file mode 100644 index 0000000..be8e2cc --- /dev/null +++ b/docs/README_pypi.md @@ -0,0 +1,99 @@ +# ppf.webref + +ppf.webref is a web app providing an interface to a [JabRef SQL +database](https://docs.jabref.org/collaborative-work/sqldatabase). +It allows you to access your references from anywhere in the world and from +any device with a web browser. You do not need to install Java, you +do not need to install an app. Any non-archaic phone, tablet, PC, Mac, or +Raspberry Pi will do. + +Create a JabRef database (using your normal JabRef) and configure ppf.webref +to point to this database. Voila: Your references just became accessible +worldwide. + +Note: ppf.webref provides *read-only* access to your library. To add, edit, or +delete entries from your library, you still need a standard JabRef installation +somewhere. + + +## Installation + +Prerequisite: You need JabRef to create, edit, and extend your library. + +Install ppf.webref: + +```shell +pip install ppf.webref +``` + +Then, tell ppf.webref about your database by adding a section as follows to +`~/.config/ppf.webref/ppf.webref.conf` (create it if it does not exist): + +``` +[database] +server = : +databasename = +username = +password = +``` + +Finally, run + +```shell +flask --app ppf.webref run +``` + +and point your webbrowser to http://localhost:5000. + +This will start ppf.webref on your local machine which is nice for testing. +To get the most out of ppf.webref, you will probably want to run ppf.webref on +a web server. + +As we have not created any users yet, we can't login. To create +users, open your JabRef database (the one named in the config file above) +and run this sql-code (make sure you don't have a table with this name +already): + +``` +create table user ( + id INT auto_increment, + username varchar(20) character set utf8 not null, + password char(80) character set ascii not null, + primary key (id), + unique(username) +) +``` + +Now we have a user table but no users in it, yet. Let's find a password and +hash it with the following python code (of course, we replace the dummy +password with your own password beforehand): + +``` +import bcrypt + +password = 'This is my password' + +bytes = password.encode('utf-8') +salt = bcrypt.gensalt() +print(bcrypt.hashpw(bytes, salt)) +``` + +The output looks something like this: + +``` +b'$2b$12$1royHRBq6o/mbDdO7LjR8eaThWYErI6HLLdn7MBfajtpRLlwWSJ8m' +``` + +Now add your user to the user table in you JabRef database using this sql-code +(again, replace "webref" with your username and the password hash with the +hash you generated above): + +``` +insert into user (username, password) +values ( + "webref", + "$2b$12$1royHRBq6o/mbDdO7LjR8eaThWYErI6HLLdn7MBfajtpRLlwWSJ8m" +); +``` + +Now we are ready to go. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..53f56ac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[build-system] +requires = ["setuptools>=40.5.0", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a51cd93..0000000 --- a/requirements.txt +++ /dev/null @@ -1,21 +0,0 @@ -bcrypt==4.0.1 -blinker==1.7.0 -click==8.1.3 -Flask==2.3.3 -Flask-Bcrypt==1.0.1 -Flask-Login==0.6.2 -Flask-SQLAlchemy==3.0.3 -flask-talisman==1.0.0 -Flask-WTF==1.1.1 -greenlet==2.0.2 -importlib-metadata==6.1.0 -itsdangerous==2.1.2 -Jinja2==3.1.2 -MarkupSafe==2.1.2 -ppf-jabref==0.1.0 -PyMySQL==1.0.3 -SQLAlchemy==2.0.7 -typing-extensions==4.5.0 -Werkzeug==2.3.8 -WTForms==3.0.1 -zipp==3.15.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3709b54 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,95 @@ +[metadata] +name = ppf.webref +description = Flask app providing a web-interface to a JabRef database +long_description = file: docs/README_pypi.md +long_description_content_type = text/markdown +url = https://github.com/adrianschlatter/ppf.webref/tree/master +project_urls = + Bug Reports = https://github.com/adrianschlatter/ppf.webref/issues + Source = https://github.com/adrianschlatter/ppf.webref +author = Adrian Schlatter + +license = MIT +license_files = LICENSE + +# See https://pypi.python.org/pypi?%3Aaction=list_classifiers +classifiers = + Development Status :: 3 - Alpha + Intended Audience :: Developers + Intended Audience :: Information Technology + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3 + Operating System :: OS Independent +keywords = jabref, references, web, server, app + +[options] +package_dir = + = src +packages = find_namespace: +include_package_data = True +# need f-strings: +python_requires = >=3.6, <4 +install_requires = + flask + flask-sqlalchemy + flask_talisman + flask_login + flask_wtf + flask_bcrypt + pymysql + ppf.jabref + plumbum + importlib_metadata;python_version<'3.8' + +[options.packages.find] +where = src + +[options.extras_require] +# List additional groups of dependencies here. You can install these using: +# pip install -e .[dev,test] +test = + check-manifest + setuptools>=40.5.0 + flake8 + pytest + pytest-cov + coverage +dev = + build + tox + twine + +[tool:pytest] +testpaths = + tests +addopts = --cov + +[flake8] +per-file-ignores = + # imported but unused, import *, undefined name: + __init__.py: F401, F403, F821 +filename = + */src/*.py + */docs/*.py + */tests/*.py + setup.py + +[check-manifest] +ignore = + tox.ini + tests + tests/** + docs/** + +[coverage:run] +command_line = -m pytest +branch = True + +[coverage:report] +include = src/* +omit = + tests/* + setup.py +exclude_also: + if not test: + if __name__ == '__main__': diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7c7f94d --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# required for backwards compatibility + +from setuptools import setup + +setup() diff --git a/src/ppf/webref/__init__.py b/src/ppf/webref/__init__.py new file mode 100644 index 0000000..f1b8123 --- /dev/null +++ b/src/ppf/webref/__init__.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 +""" +Web-Interface for JabRef library. + +It lists the entries in a given JabRef library. It provides a simple +search bar to filter for those entries your looking for. Currently, it +provides read-only access to the library without any possibility to modify +existing entries or to add new ones. +""" + +from flask import Flask, render_template, send_from_directory +from flask import url_for, redirect +from flask_login import login_user, LoginManager +from flask_login import login_required, logout_user +from flask_bcrypt import Bcrypt +from flask_talisman import Talisman +from flask_wtf import FlaskForm, CSRFProtect +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import InputRequired, Length +from ppf.jabref import Entry, Field, split_by_unescaped_sep +from pathlib import Path +from ppf.webref.secrets import get_secrets +from ppf.webref.model import db, User +from ppf.webref.cli import reg_cli_cmds + + +class LoginForm(FlaskForm): + username = StringField(validators=[InputRequired(), Length(min=4, max=20)], + render_kw={"placeholder": "Username"}) + password = PasswordField(validators=[InputRequired(), Length(min=4)], + render_kw={"placeholder": "Password"}) + submit = SubmitField("Login") + + +class SearchForm(FlaskForm): + searchexpr = StringField() + submit = SubmitField("Search") + + +def create_app(test=False): + # get secrets to access db: + (secret_key, + sqlusername, sqlpassword, sqlserver, sqldatabasename) = get_secrets() + # create and configure the app: + app = Flask(__name__, static_url_path='', static_folder='static') + # database configuration: + if not test: + app.config['SQLALCHEMY_DATABASE_URI'] = ( + f'mysql+pymysql://{sqlusername}:{sqlpassword}' + f'@{sqlserver}/{sqldatabasename}') + else: + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + + app.config['WTF_CSRF_ENABLED'] = not test + app.config['TESTING'] = test + app.config['SECRET_KEY'] = secret_key + db.init_app(app) + with app.app_context(): + db.create_all() + # register command-line tools: + reg_cli_cmds(app) + + # CSRF protection: + csrf = CSRFProtect() + csrf.init_app(app) + + # content security policy: + csp = {'default-src': "'none'", + 'script-src': + "'self' https://code.jquery.com https://cdnjs.cloudflare.com", + 'form-action': "'self'", + 'connect-src': "'self'", + 'style-src': "'self'", + 'base-uri': "'none'", + 'frame-ancestors': "'none'"} + Talisman(app, content_security_policy=csp, force_https=False) + bcrypt = Bcrypt(app) + + login_manager = LoginManager() + login_manager.init_app(app) + login_manager.login_view = 'login' + + @login_manager.user_loader + def load_user(user_id): + return db.session.get(User, int(user_id)) + + @app.route('/') + @login_required + def root(): + """Show WebApp.""" + return render_template('index.php', form=SearchForm()) + + @app.route('/login', methods=['GET', 'POST']) + def login(): + form = LoginForm() + + if form.validate_on_submit(): # POST request? And valid? + user = User.query.filter_by(username=form.username.data).first() + if user: + if bcrypt.check_password_hash( + user.password, form.password.data): + login_user(user) + return redirect(url_for('root')) + + return render_template('login.php', form=form) + + @app.route('/logout', methods=['GET', 'POST']) + @login_required + def logout(): + logout_user() + return redirect(url_for('login')) + + @app.route('/references/') + @login_required + def send_reference(path): + """Send reference.""" + return send_from_directory('references', path) + + @app.route('/loadEntries.php', methods=['POST']) + @login_required + def loadEntries(): + """Return entries from library matching search expression.""" + form = SearchForm() + searchexpr = form.searchexpr.data + + patternmatchingQ = (db.select(Field.entry_shared_id) + .where(Field.value.op('regexp')(searchexpr)) + .distinct()) + entryQ = (db.select(Entry) + .where(Entry.shared_id.in_(patternmatchingQ))) + + entries = [{f: entry[0].fields.get(f, None) + for f in ['author', 'title', 'year', 'file']} + for entry in db.session.execute(entryQ)] + + basepath = Path('references') + for entry in entries: + if entry['file'] is not None: + filepath = Path(split_by_unescaped_sep(entry['file'])[1]) + entry['file'] = basepath / filepath + if not entry['file'].exists() or filepath.is_absolute(): + entry['file'] = None + + return render_template('entry_table.tmpl', entries=entries) + + return app + + +if __name__ == '__main__': + app = create_app() + app.run(debug=True, use_debugger=False, use_reloader=False, host='0.0.0.0') diff --git a/src/ppf/webref/cli.py b/src/ppf/webref/cli.py new file mode 100644 index 0000000..35fd807 --- /dev/null +++ b/src/ppf/webref/cli.py @@ -0,0 +1,69 @@ +import click +from getpass import getpass +import bcrypt +import sys +from .model import db, User + + +@click.command('useradd') +@click.argument('username') +def useradd_command(username): + """Register a new user.""" + # Make sure relevant tables exist: + db.create_all() + # Create user without password: + user = User(username=username, password=None) + # Check whether this user already exists: + if User.query.filter_by(username=username).first(): + print(f'User {username} already exists.') + sys.exit(1) + # Otherwise, add user to database: + db.session.add(user) + db.session.commit() + print(f'Added user {username}') + + +@click.command('userdel') +@click.argument('username') +def userdel_command(username): + """Delete user username.""" + # Make sure relevant tables exist: + db.create_all() + # Check whether this user already exists: + user = User.query.filter_by(username=username).first() + if not user: + print(f'User {username} does not exist.') + sys.exit(1) + # Otherwise, delete user from database: + db.session.delete(user) + db.session.commit() + print(f'Deleted user {username}') + + +@click.command('passwd') +@click.argument('username') +def passwd_command(username): + """Change password of user 'username'.""" + # Make sure relevant tables exist: + db.create_all() + # Check whether this user already exists: + user = User.query.filter_by(username=username).first() + if not user: + print(f'User {username} does not exist.') + sys.exit(1) + print(f'Changing password for {username}.') + # If so, change password: + password = getpass() + # Salt it and hash it: + bytes = password.encode('utf-8') + salt = bcrypt.gensalt() + # Store it: + user.password = bcrypt.hashpw(bytes, salt) + db.session.commit() + print(f'Changed password for {username}.') + + +def reg_cli_cmds(app): + app.cli.add_command(useradd_command) + app.cli.add_command(passwd_command) + app.cli.add_command(userdel_command) diff --git a/src/ppf/webref/model.py b/src/ppf/webref/model.py new file mode 100644 index 0000000..ada4d31 --- /dev/null +++ b/src/ppf/webref/model.py @@ -0,0 +1,47 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from sqlalchemy.orm import relationship +from sqlalchemy.orm.collections import attribute_mapped_collection +from sqlalchemy.ext.associationproxy import association_proxy + + +db = SQLAlchemy() + + +class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(20), nullable=False, unique=True) + password = db.Column(db.String(80), nullable=True) + + +# Everything below: +# Copy of ppf.jabref as we have to redefine the models based on db.Model: + + +class Entry(db.Model): + """Represent a JabRef Entry.""" + + __tablename__ = 'ENTRY' + + shared_id = db.Column('SHARED_ID', db.Integer, primary_key=True) + type = db.Column(db.VARCHAR(255), nullable=False) + version = db.Column(db.Integer, nullable=True) + _fields = relationship( + 'Field', + collection_class=attribute_mapped_collection('name')) + + # Access like entry.fields['author'] returns 'A. Muller' + # which is nicer than using entry.fields['author'].value: + fields = association_proxy('_fields', 'value', + creator=lambda k, v: Field(name=k, value=v)) + + +class Field(db.Model): + """Represent a JabRef Field.""" + + __tablename__ = 'FIELD' + + entry_shared_id = db.Column(db.Integer, db.ForeignKey('ENTRY.SHARED_ID'), + primary_key=True) + name = db.Column(db.VARCHAR(255), primary_key=True) + value = db.Column(db.Text, nullable=True) diff --git a/src/ppf/webref/secrets.py b/src/ppf/webref/secrets.py new file mode 100644 index 0000000..48c0799 --- /dev/null +++ b/src/ppf/webref/secrets.py @@ -0,0 +1,47 @@ +""" +Credential management + +ppf.webref is meant to be run from the command line, often from inside a +docker container. Docker provides a mechanism to manage secrets. The user +creates a container, adds a (named) secret, and runs the container. +Inside the container, the named secret is available in the text file +/run/secrets/. + +Outside of a docker container, having a config file in + +~/.config/ppf.webref/ppf.webref.conf + +is recommended. +""" + +from plumbum import cli +from urllib.parse import quote_plus +from pathlib import Path + + +def get_secrets(): + config_path = Path('~/.config/ppf.webref/ppf.webref.conf').expanduser() + if config_path.exists(): + with cli.Config(config_path) as config: + secret_key = config.get('flask.secret_key', None) + sqlusername = config.get('database.username', None) + sqlpassword = config.get('database.password', None) + sqlserver = config.get('database.server', None) + sqldatabasename = config.get('database.databasename', None) + else: + secrets_path = Path('/run/secrets') + if not secrets_path.exists(): + secrets_path = Path('./secrets') + + if secrets_path.exists(): + secret_key = open(secrets_path / 'secret_key').readline().strip() + sqlusername = open(secrets_path / 'sqlusername').readline().strip() + sqlpassword = open(secrets_path / 'sqlpassword').readline().strip() + sqlserver = open(secrets_path / 'sqlserver').readline().strip() + sqldatabasename = (open(secrets_path / 'sqldatabasename') + .readline().strip()) + else: + raise RuntimeError('No config file found') + + sqlpassword = quote_plus(sqlpassword) + return secret_key, sqlusername, sqlpassword, sqlserver, sqldatabasename diff --git a/static/script.js b/src/ppf/webref/static/script.js similarity index 90% rename from static/script.js rename to src/ppf/webref/static/script.js index f5f8cd5..8992ffe 100644 --- a/static/script.js +++ b/src/ppf/webref/static/script.js @@ -1,4 +1,3 @@ - $(document).ready(function () { // this is the id of the form $("#search_form").submit(function (e) { @@ -11,6 +10,7 @@ $(document).ready(function () { type: "POST", url: url, data: form.serialize(), // serializes the form's elements. + headers: { 'X-CSRFToken': form.attr('csrf_token') }, success: function (data) { document.getElementById("entry_table").innerHTML = data; } diff --git a/static/style.css b/src/ppf/webref/static/style.css similarity index 100% rename from static/style.css rename to src/ppf/webref/static/style.css diff --git a/templates/entry_table.tmpl b/src/ppf/webref/templates/entry_table.tmpl similarity index 100% rename from templates/entry_table.tmpl rename to src/ppf/webref/templates/entry_table.tmpl diff --git a/templates/index.php b/src/ppf/webref/templates/index.php similarity index 73% rename from templates/index.php rename to src/ppf/webref/templates/index.php index 15d6aba..e5b99d4 100644 --- a/templates/index.php +++ b/src/ppf/webref/templates/index.php @@ -3,7 +3,7 @@ - WebRef: A Web Interface to JabRef + ppf.webref: A Web Interface to JabRef