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