diff --git a/.gitignore b/.gitignore index 8f600a8..55c2c31 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ uwsgi.ini .*.swp *~ MANIFEST +venv/ diff --git a/INSTALL.md b/INSTALL.md index 3814318..001df92 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -158,3 +158,125 @@ in the uwsgi.ini file instead of module, like this: uid = www-data gid = www-data logto = /var/log/dnote.log + + + +These are some notes I made for CentOS 7.9, it still needs cleaning, I just needed a place to dump it for now +--------------------------------------------------------------------------------------------------------------- + +```sh + +I still need to update the install notes properly explain exactly how it’s installed but here it is in a nutshell: + +Pre-requisites: + +Python 3.6.8 (this is the version I used because that’s what comes with CentOS) + +The following CentOS packages that are needed for Python3 and http mod_wsgi: + +mod_wsgi.x86_64 + +python3-mod_wsgi.x86_64 + +python3-libs.x86_64 + +python3-pip.noarch + +python3-setuptools.noarch + +Install it all in one command: + +yum install python3-mod_wsgi.x86_64 python3-libs.x86_64 python3-pip.noarch python3-setuptools.noarch + +Once the above is installed. + +Apache is used as the web server, and it uses qsgi to launch the python code. + +This is the Apache httpd config file location: + +/etc/httpd/conf.d/share-le-ssl.conf + +And this is the contents, what’s important is the line that saysWSGIScriptAlias and the few lines below that, the other stuff is unrelated, just putting it here for full context. + +# customise the links as per you own deployment + + + + ServerName dnote.domain.com + ServerAdmin dnote@domain.com + ServerAlias www.dnote.domain.com + DocumentRoot /var/www/html + ErrorLog /var/log/httpd/share-error.log + CustomLog /var/log/httpd/share-requests.log combined + + WSGIScriptAlias /dnote /var/www/dnote/wsgi.py + + Options -Indexes + Options FollowSymLinks + Order allow,deny + Allow from all + + Alias /d/static /var/www/dnote/static + + Order allow,deny + Allow from all + + +SSLCertificateFile /etc/letsencrypt/live/dnote.domain.com/cert.pem +SSLCertificateKeyFile /etc/letsencrypt/live/dnote.domain.com/privkey.pem +Include /etc/letsencrypt/options-ssl-apache.conf +SSLCertificateChainFile /etc/letsencrypt/live/dnote.domain.com/chain.pem + + + + +Steps to install it are: + +clone the git repo from: + +git clone git@github.com:Pyroseza/d-note.git + +checkout the py3 branch + +cd d-note + +git checkout py3 + +zip it up + +cd .. + +zip -r d-note.zip d-note + +upload it to /tmp on your server hosting this: dnote.domain.com + +scp d-note.zip dnote.domain.com:/tmp + +run these commands: + +cd /tmp + +rm -rf d-note + +unzip d-note.zip + +cd d-note/ + +python3 setup.py install + +systemctl restart httpd + +tail -f /etc/httpd/logs/share-requests.log /etc/httpd/logs/share-error.log + +The bulk of the code gets installed under Python3’s site-packages, here: + +/usr/local/lib/python3.6/site-packages/dnote-2.0.0-py3.6.egg/ + +The wsgi server file gets installed here: + +/var/www/dnote/wsgi.py + +and the config gets installed here: + +/etc/dnote/d-note.ini +``` diff --git a/README.md b/README.md index 411e94c..49336c8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ unless otherwise stated. See [the license](LICENSE.md) for the complete license. Demo ---- -If you would like to test drive d-note, I have it running at https://ae7.st/d/. +If you would like to test drive d-note, the original author (Aaron Toponce) has +an older version running at https://ae7.st/d/. Currently, it is hosted using a self-signed certificate. As such, the fingerprints of the certificate are: diff --git a/d-note.ini b/d-note.ini index 459408d..f0f7c58 100644 --- a/d-note.ini +++ b/d-note.ini @@ -7,15 +7,16 @@ app = dnote # search for config files here -config_path = ~/.dnote -data_dir = ~/.dnote/data +#config_path = /.dnote +#data_dir = ~/.dnote/data + +# size of bytes used unique url and duress key lengths, don't go lower than 4, 16 is default +byte_size = 16 # in production, use these values -#config_path = /etc/dnote -#data_dir = /var/lib/dnote/data +config_path = /etc/dnote +data_dir = /var/lib/dnote/data [dnote] # intentionally left blank. # Used to interpolate defaults above when they don't get used in another category below - - diff --git a/dnote/__init__.py b/dnote/__init__.py index b2b6758..f773c13 100644 --- a/dnote/__init__.py +++ b/dnote/__init__.py @@ -1,12 +1,15 @@ +#!/usr/bin/env python3 + """This module sets up the paths for the Flask web application.""" import os -import utils +from . import utils from flask import Flask, render_template, request, redirect, url_for -from note import Note +from .note import Note DNOTE = Flask(__name__) HERE = DNOTE.root_path + @DNOTE.route('/', methods=['GET']) def index(): """Return the index.html for the main application.""" @@ -14,21 +17,25 @@ def index(): note = Note() return render_template('index.html', random=note.url, error=error) + @DNOTE.route('/security/', methods=['GET']) def security(): """Return the index.html for the security page.""" return render_template('security.html') + @DNOTE.route('/faq/', methods=['GET']) def faq(): """Return the index.html for the faq page.""" return render_template('faq.html') + @DNOTE.route('/about/', methods=['GET']) def about(): """Return the index.html for the about page.""" return render_template('about.html') + @DNOTE.route('/post', methods=['POST']) def show_post(): """Return the random URL after posting the plaintext.""" @@ -52,6 +59,7 @@ def show_post(): note.encrypt() return render_template('post.html', random=note.url) + @DNOTE.route('/', methods=['POST', 'GET']) def fetch_url(random_url): """Return the decrypted note. Begin short destruction timer. @@ -77,6 +85,7 @@ def fetch_url(random_url): else: return render_template('keyerror.html', random=note.url) + if __name__ == '__main__': DNOTE.debug = True DNOTE.run() diff --git a/dnote/note.py b/dnote/note.py index 89a63a3..10124e6 100644 --- a/dnote/note.py +++ b/dnote/note.py @@ -1,52 +1,79 @@ """Encrypts and decrypts notes.""" import base64 +import codecs +import configparser import os import sys import zlib from Crypto.Cipher import AES from Crypto.Hash import HMAC, SHA512 -from Crypto.Protocol import KDF +from Crypto.Protocol.KDF import PBKDF2 from Crypto.Util import Counter - -import ConfigParser +from . import utils # copy the config file from conf dir to either /etc/dnote or ~/.dnote, # then run this script. -config = ConfigParser.SafeConfigParser() +try: + + config = configparser.ConfigParser() + + for path in ['/etc/dnote', '~/.dnote']: + expanded_path = os.path.join(os.path.expanduser(path), 'd-note.ini') + if os.path.exists(expanded_path): + try: + config.read(expanded_path) + print(f"Using config file: {expanded_path}") + break + except configparser.InterpolationSyntaxError as e: + raise EOFError(f"Unable to parse configuration file properly: {e}") + else: + raise ValueError("Config file not found") -for path in ['/etc/dnote', '~/.dnote']: - expanded_path = "{0}/{1}".format(os.path.expanduser(path), 'd-note.ini') - if os.path.exists(expanded_path): - try: - f = open(expanded_path) - config.readfp(f) - f.close() - except ConfigParser.InterpolationSyntaxError as e: - raise EOFError("Unable to parse configuration file properly: {0}".format(e)) + cfgs = config.defaults() -cfgs = {} + for section in config.sections(): + if section not in cfgs: + cfgs[section] = {} -for section in config.sections(): - if not cfgs.has_key(section): - cfgs[section] = {} + for k, v in config.items(section): + cfgs[section][k] = v - for k, v in config.items(section): - cfgs[section][k] = v + dconfig_path = os.path.expanduser(cfgs.get('dnote', {}).get('config_path')) + dconfig = os.path.join(dconfig_path, "dconfig.py") -dconfig_path = os.path.expanduser(cfgs['dnote']['config_path']) -dconfig = dconfig_path + "/dconfig.py" + # add dconfig.py to the sys.path + sys.path.append(dconfig_path) -# add dconfig.py to the sys.path -sys.path.append(dconfig_path) +except Exception as e: + raise Exception(f"unable to load config: {e}") try: import dconfig except ImportError: - print "You need to run 'generate_dnote_hashes' as part of the installation." + print("You need to run 'generate_dnote_hashes' as part of the installation.") os.sys.exit(1) -data_dir = os.path.expanduser(cfgs['dnote']['data_dir']) +data_dir = cfgs.get('dnote').get('data_dir') + + +def cleave_hash(hash: str, remove=True): + """ Generic function that can either strip off trailing equals signs from a base64 encoded + byte string or figures out how to put them back""" + if remove: + return hash.replace('=', '') + else: + # 3 goes to identify the right ending for the hash + for i in range(3): + padding = '=' * i + try: + tmp_hash = f"{hash}{padding}" + tmp_b64dec = base64.urlsafe_b64decode(utils.enc(tmp_hash, "utf-8")) + return tmp_hash + except Exception as e: + continue + raise ValueError("The partial hash you passed in is not compatible with this function") + class Note(object): """Note Model""" @@ -59,9 +86,13 @@ class Note(object): passphrase = None # User provided passphrase dkey = None # Duress passphrase plaintext = None # Plain text note - ciphertext = None # Encrypted text + ciphertext = None # encrypted text + byte_size = None # Used to calculate the size of the URL and duress key def __init__(self, url=None): + """initialise for Note object, url is optional""" + # load the byte_size from the config + self.byte_size = max(int(cfgs['dnote'].get('byte_size', 16)), 4) if url is None: self.create_url() else: @@ -73,10 +104,13 @@ def exists(self): def path(self, kind=None): """Return the file path to the note file""" + if isinstance(self.fname, bytes): + self.fname = utils.dec(self.fname, "utf-8") + file_path = os.path.join(os.path.expanduser(data_dir), self.fname) if kind is None: - return '%s/%s' % (data_dir, self.fname) + return file_path else: - return '%s/%s.%s' % (data_dir, self.fname, kind) + return f'{file_path}.{kind}' def create_url(self): """Create a cryptographic nonce for our URL, and use PBKDF2 with our @@ -86,16 +120,10 @@ def create_url(self): - 128-bits for file name - 256-bits for AES-256 key - 512-bits for HMAC-SHA512 key""" - - self.nonce = os.urandom(16) - self.f_key = KDF.PBKDF2( - self.nonce, dconfig.nonce_salt.decode("hex"), 16) - self.aes_key = KDF.PBKDF2( - self.nonce, dconfig.aes_salt.decode("hex"), 32) - self.mac_key = KDF.PBKDF2( - self.nonce, dconfig.mac_salt.decode("hex"), 64) - self.url = base64.urlsafe_b64encode(self.nonce)[:22] - self.fname = base64.urlsafe_b64encode(self.f_key)[:22] + self.nonce = utils.get_rand_bytes(self.byte_size) + self.fname_and_fkey() + self.aes_and_mac(self.nonce) + self.url = cleave_hash(utils.dec(base64.urlsafe_b64encode(self.nonce), "utf-8")) if self.exists(): return self.create_url() @@ -105,35 +133,33 @@ def decode_url(self, url): keyword arguments: url -- the url after the FQDN provided by the client""" - self.url = url - url = url + "==" # add the padding back - self.nonce = base64.urlsafe_b64decode(url.encode("utf-8")) - self.f_key = KDF.PBKDF2( - self.nonce, dconfig.nonce_salt.decode("hex"), 16) - self.aes_key = KDF.PBKDF2( - self.nonce, dconfig.aes_salt.decode("hex"), 32) - self.mac_key = KDF.PBKDF2( - self.nonce, dconfig.mac_salt.decode("hex"), 64) - duress = KDF.PBKDF2( - self.nonce, dconfig.duress_salt.decode("hex"), 16) - self.dkey = base64.urlsafe_b64encode(duress)[:22] - self.fname = base64.urlsafe_b64encode(self.f_key)[:22] + obj = cleave_hash(url, remove=False) + self.nonce = base64.urlsafe_b64decode(utils.enc(obj, "utf-8")) + self.fname_and_fkey() + self.aes_and_mac(self.nonce) + self.duress_key() + + def fname_and_fkey(self): + """Set filename and f_key""" + self.f_key = PBKDF2(self.nonce, utils.dec(dconfig.nonce_salt), 16) + self.fname = cleave_hash(utils.dec(base64.urlsafe_b64encode(self.f_key), "utf-8")) + + def aes_and_mac(self, obj): + """Set AES and HMAC keys""" + self.aes_key = PBKDF2(obj, utils.dec(dconfig.aes_salt), 32) + self.mac_key = PBKDF2(obj, utils.dec(dconfig.mac_salt), 64) def set_passphrase(self, passphrase): """Set a user defined passphrase to override the AES and HMAC keys""" self.passphrase = passphrase - self.aes_key = KDF.PBKDF2( - passphrase, dconfig.aes_salt.decode("hex"), 32) - self.mac_key = KDF.PBKDF2( - passphrase, dconfig.mac_salt.decode("hex"), 64) + self.aes_and_mac(passphrase) def duress_key(self): """Generates a duress key for Big Brother. It is stored on disk in plaintext.""" - duress_key = KDF.PBKDF2( - self.nonce, dconfig.duress_salt.decode('hex'), 16) - self.dkey = base64.urlsafe_b64encode(duress_key)[:22] + duress_key = PBKDF2(self.nonce, utils.dec(dconfig.duress_salt), self.byte_size) + self.dkey = cleave_hash(utils.dec(base64.urlsafe_b64encode(duress_key), "utf-8")) def secure_remove(self): """Securely overwrite any file, then remove the file. Do not make any @@ -143,51 +169,53 @@ def secure_remove(self): for kind in (None, 'key', 'dkey'): if not os.path.exists(self.path(kind)): continue - with open(self.path(kind), "r+") as note: - for char in xrange(os.stat(note.name).st_size): - note.seek(char) - note.write(str(os.urandom(1))) + with open(self.path(kind), "r+b") as note: + for byt_idx in range(os.stat(note.name).st_size): + note.seek(byt_idx) + note.write(utils.get_rand_bytes(1)) os.remove(self.path(kind)) def encrypt(self): - """Encrypt a plaintext to a URI file. + """encrypt a plaintext to a URI file. All files are encrypted with AES in CTR mode. HMAC-SHA512 is used to provide authenticated encryption ( encrypt then mac ). No private keys are stored on the server.""" - plain = zlib.compress(self.plaintext.encode('utf-8')) - with open(self.path(), 'w') as note: - init_value = os.urandom(12) - ctr = Counter.new(128, - initial_value=long(init_value.encode('hex'), 16)) + plain = zlib.compress(utils.enc(self.plaintext, 'utf-8')) + with open(self.path(), 'wb') as note: + init_value = utils.get_rand_bytes(12) + ctr = Counter.new(128, initial_value=int(utils.enc(init_value), 16)) aes = AES.new(self.aes_key, AES.MODE_CTR, counter=ctr) ciphertext = aes.encrypt(plain) - ciphertext = init_value + ciphertext - hmac = HMAC.new(self.mac_key, ciphertext, SHA512) - ciphertext = hmac.digest() + ciphertext - note.write(ciphertext) + ciphertext_with_init = init_value + ciphertext + hmac = HMAC.new(self.mac_key, ciphertext_with_init, SHA512) + # ciphertext = hmac.digest() + ciphertext + hmac_dig = hmac.digest() + ciphertext_with_init_and_hmac = hmac_dig + ciphertext_with_init + note.write(ciphertext_with_init_and_hmac) def decrypt(self): - """Decrypt the ciphertext from a given URI file.""" + """decrypt the ciphertext from a given URI file.""" - with open(self.path(), 'r') as note: + with open(self.path(), 'rb') as note: message = note.read() tag = message[:64] data = message[64:] init_value = data[:12] body = data[12:] - ctr = Counter.new(128, initial_value=long(init_value.encode('hex'), 16)) + ctr = Counter.new(128, initial_value=int(utils.enc(init_value), 16)) aes = AES.new(self.aes_key, AES.MODE_CTR, counter=ctr) plaintext = aes.decrypt(body) # check the message tags, return True if is good # constant time comparison tag2 = HMAC.new(self.mac_key, data, SHA512).digest() hmac_check = 0 - for char1, char2 in zip(tag, tag2): - hmac_check |= ord(char1) ^ ord(char2) + for byte1, byte2 in zip(tag, tag2): + # hmac_check |= ord(char1) ^ ord(char2) + hmac_check |= byte1 ^ byte2 if hmac_check == 0: - self.plaintext = zlib.decompress(plaintext).decode('utf-8') + self.plaintext = utils.dec(zlib.decompress(plaintext), 'utf-8') else: return False return True diff --git a/dnote/templates/keyerror.html b/dnote/templates/keyerror.html index 71c7ed1..80b6de0 100644 --- a/dnote/templates/keyerror.html +++ b/dnote/templates/keyerror.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block title %}Note Error{% endblock %} {% block content %} -

Error

-

Your password was entered in error. Please try again.

+

Error

+

Your password was entered in error. Please + try again.

{% endblock %} diff --git a/dnote/templates/note.html b/dnote/templates/note.html index 290c213..a3b01c9 100644 --- a/dnote/templates/note.html +++ b/dnote/templates/note.html @@ -1,15 +1,15 @@ {% extends "base.html" %} {% block title %}Private Note{% endblock %} {% block head %} - - + {% endblock %} {% block content %} -

Reading Secret Note

-

Here is your private note, decrypted only for your browser. This note has been securely destroyed on the server. In 3 minutes, you will be redirected back to the home page.

- +

Reading Secret Note

+

Here is your private note, decrypted only for your browser. This note has been securely destroyed on the server. In 3 + minutes, you will be redirected back to the home page.

+ {% endblock %} diff --git a/dnote/templates/post.html b/dnote/templates/post.html index c3c24dc..eb25512 100644 --- a/dnote/templates/post.html +++ b/dnote/templates/post.html @@ -1,63 +1,71 @@ {% extends "base.html" %} {% block title %}Note Created{% endblock %} {% block head %} - - + {% endblock %} {% block destroy %} - Destroy note +Destroy + note {% endblock%} {% block content %} -

Your Secret Note Details

-

If you submitted this note in error, you can destroy the note here.

-

Your private url: - {{ url_for('fetch_url', random_url=random, _external=True) }} -

-

Your private ID: - {{ random }} - - ? - For plausible deniability, provide just the private ID to the note, rather than the full URL. - -

- {% if passphrase %} -

Your passphrase: - {{ passphrase}} -

- {% endif %} - {% if duress %} -

Your duress key: - {{ duress }} - - ? - A key that you provide to someone that is demanding decryption of the note. Except, this key will immediately destroy the note, without decrypting it. - -

- {% endif %} -

Alternatively, you can scan this QR code, and "share" it using your mobile device via text message, email, etc. NOTE: ZXing Barcode users: disable "Retrieve more info" from the settings, otherwise your recipient will NOT be able to view the post.

- Show QR Code -
 
- + } + {% endblock %} diff --git a/dnote/utils.py b/dnote/utils.py index 7bdda52..7b456d4 100644 --- a/dnote/utils.py +++ b/dnote/utils.py @@ -1,19 +1,38 @@ """Utility functions for d-note.""" +import codecs +import os import random from Crypto.Hash import SHA -from note import data_dir +from .note import data_dir + + +def dec(obj, encoding: str = 'hex'): + return codecs.decode(obj, encoding) + + +def enc(obj, encoding: str = 'hex'): + return codecs.encode(obj, encoding) + + +def get_rand_bytes(length: int = 16) -> bytes: + return os.urandom(length) + + +def logit(name, var): + print(f"var: {name}, value: {var}, type: {type(var)}") + def duress_text(): """Return 5 random sentences of the Zen of Python.""" import subprocess text = '' - python = subprocess.Popen(('python', '-c', 'import this'), - stdout=subprocess.PIPE) - sentence = [x for x in python.communicate()[0].splitlines() if x != ''] - for _ in range(5): - text = text + random.choice(sentence) + ' ' + python = subprocess.Popen(('python3', '-c', 'import this'), stdout=subprocess.PIPE) + lines = dec(python.communicate()[0], "utf-8").splitlines() + sentence = [x for x in lines if x != ''] + text = ' '.join(random.choices(sentence, k=5)) return text + def verify_hashcash(token): """Return True or False based on the Hashcash token @@ -26,10 +45,10 @@ def verify_hashcash(token): Keyword arguments: token -- a proposed Hashcash token to validate.""" - digest = SHA.new(token) - with open('%s/hashcash.db' % data_dir, 'a+') as database: + digest = SHA.new(enc(token, "utf-8")) + with open(os.path.join(data_dir, 'hashcash.db'), 'a+') as database: if digest.hexdigest()[:4] == '0000' and token not in database.read(): - database.write(token+'\n') + database.write(f'{token}\n') return True else: return False diff --git a/scripts/generate_dnote_hashes b/scripts/generate_dnote_hashes index eb6f2aa..0aa9301 100755 --- a/scripts/generate_dnote_hashes +++ b/scripts/generate_dnote_hashes @@ -1,27 +1,29 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import os -import ConfigParser +import configparser +import codecs # copy the config file from conf dir to either /etc/dnote or ~/.dnote, # then run this script. -config = ConfigParser.SafeConfigParser() +config = configparser.ConfigParser() for path in ['/etc/dnote', '~/.dnote']: - expanded_path = "{0}/{1}".format(os.path.expanduser(path), 'd-note.ini') + expanded_path = os.path.join(os.path.expanduser(path), 'd-note.ini') if os.path.exists(expanded_path): - try: - f = open(expanded_path) - config.readfp(f) - f.close() - except ConfigParser.InterpolationSyntaxError as e: - raise EOFError("Unable to parse configuration file properly: {0}".format(e)) + print(f"Using config file: {expanded_path}") + try: + config.read(expanded_path) + print(f"Using config file: {expanded_path}") + break + except configparser.InterpolationSyntaxError as e: + raise EOFError(f"Unable to parse configuration file properly: {e}") -cfgs = {} +cfgs = config.defaults() for section in config.sections(): - if not cfgs.has_key(section): + if section not in cfgs: cfgs[section] = {} for k, v in config.items(section): @@ -31,18 +33,31 @@ for section in config.sections(): data_dir = os.path.expanduser(cfgs['dnote']['data_dir']) if not os.path.isdir(data_dir): - os.makedirs(data_dir, 0755) + os.makedirs(data_dir, mode=0o755) dconfig_path = os.path.expanduser(cfgs['dnote']['config_path']) -dconfig = dconfig_path + "/dconfig.py" +dconfig = os.path.join(dconfig_path, "dconfig.py") if not os.path.isdir(dconfig_path): - os.makedirs(dconfig_path, 0755) + os.makedirs(dconfig_path, mode=0o755) + + +def enc(obj, encoding: str = 'hex'): + return codecs.encode(obj, encoding) + + +def get_rand_bytes(length: int = 16) -> bytes: + return os.urandom(length) + + +def get_new_hash(length: int = 16): + return enc(get_rand_bytes(length)) + if not os.path.exists(dconfig): with open(dconfig, 'w') as f: - f.write('aes_salt = "%s"\n' % os.urandom(16).encode('hex')) - f.write('mac_salt = "%s"\n' % os.urandom(16).encode('hex')) - f.write('nonce_salt = "%s"\n' % os.urandom(16).encode('hex')) - f.write('duress_salt = "%s"\n' % os.urandom(16).encode('hex')) - os.chmod(dconfig, 0440) + f.write(f'aes_salt = "{get_new_hash()}"\n') + f.write(f'mac_salt = "{get_new_hash()}"\n') + f.write(f'nonce_salt = "{get_new_hash()}"\n') + f.write(f'duress_salt = "{get_new_hash()}"\n') + os.chmod(dconfig, mode=0o440) diff --git a/setup.py b/setup.py index eb689b8..b665db1 100644 --- a/setup.py +++ b/setup.py @@ -1,26 +1,29 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 from setuptools import setup, find_packages - import os import glob + def read(fname): return open(os.path.join(os.path.dirname(__file__), fname)).read() + setup( name='dnote', - version='1.0.1', + version='2.0.0', description='d-note is a self-destructing notes web application', packages=find_packages(), - install_requires=['Flask'], + install_requires=['Flask', 'pycrypto'], zip_safe=False, include_package_data=True, license='GPLv3', author_email='aaron.toponce@gmail.com', author='Aaron Toponce', - maintainer='Clint Savage', - url='http://github.com/atoponce/d-note', + maintainer='Jarrod Price', + url='http://github.com/Pyroseza/d-note', long_description=read('README'), scripts=['scripts/generate_dnote_hashes'], + data_files=[('/etc/dnote', ['d-note.ini']), ('/var/www/dnote', ['wsgi.py'])], + python_requires='==3.6.8', ) diff --git a/wsgi.py b/wsgi.py new file mode 100755 index 0000000..3f4ae87 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import sys +import logging +logging.basicConfig(stream=sys.stderr) +from dnote import DNOTE as application