diff --git a/photobackup_bottle/init.py b/photobackup_bottle/init.py
deleted file mode 100755
index a4dcdf2..0000000
--- a/photobackup_bottle/init.py
+++ /dev/null
@@ -1,103 +0,0 @@
-#!/usr/bin/env python3
-# Copyright (C) 2013-2016 Stéphane Péchard.
-#
-# This file is part of PhotoBackup.
-#
-# PhotoBackup is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# PhotoBackup is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-
-""" PhotoBackup Python server initialization module.
-
- It asks the user for configuration and writes it
- to a .ini file.
-"""
-
-# stlib
-import configparser
-import getpass
-import hashlib
-import os
-import pwd
-import stat
-# pipped
-import bcrypt
-
-
-def writable_by(dirname, name, user_or_group):
- """ Checks if the given directory is writable by the named user or group.
- user_or_group is a boolean with True for a user and False for a group. """
- try:
- pwnam = pwd.getpwnam(name)
- except KeyError:
- print('[ERROR] User or group {0} does not exist!'.format(name))
- return False
- ugid = pwnam.pw_uid if user_or_group else pwnam.pw_gid
-
- dir_stat = os.stat(dirname)
- ug_stat = dir_stat[stat.ST_UID] if user_or_group else dir_stat[stat.ST_GID]
- iw_stat = stat.S_IWUSR if user_or_group else stat.S_IWGRP
-
- if ((ug_stat == ugid) and (dir_stat[stat.ST_MODE] & iw_stat)):
- return True
-
- return False
-
-
-def init(username=None):
- """ Initializes the PhotoBackup configuration file. """
- print("""===============================
-PhotoBackup_bottle init process
-===============================""")
-
- # ask for the upload directory (should be writable by the server)
- media_root = input("The directory where to put the pictures" +
- " (should be writable by the server you use): ")
- try:
- os.mkdir(media_root)
- print("Directory {0} does not exist, creating it".format(media_root))
- except OSError:
- print("Directory already exists")
-
- # test for user writability of the directory
- server_user = input("Owner of the directory [www-data]: ")
- if not server_user:
- server_user = 'www-data'
- if not writable_by(media_root, server_user, True) and \
- not writable_by(media_root, server_user, False):
- print('[INFO] Directory {0} is not writable by {1}, check it!'
- .format(media_root, server_user))
-
- # ask a password for the server
- password = getpass.getpass(prompt='The server password: ')
- pass_sha = hashlib.sha512(
- password.encode('utf-8')).hexdigest().encode('utf-8')
- passhash = bcrypt.hashpw(pass_sha, bcrypt.gensalt())
-
- # save in config file
- config_file = os.path.expanduser("~/.photobackup")
- config = configparser.ConfigParser()
- config.optionxform = str # to keep case of keys
- config.read(config_file) # to keep existing data
- suffix = '-' + username if username else ''
- config_key = 'photobackup' + suffix
- config[config_key] = {'BindAddress': '127.0.0.1',
- 'MediaRoot': media_root,
- 'Password': pass_sha.decode(),
- 'PasswordBcrypt': passhash.decode(),
- 'Port': 8420}
- with open(config_file, 'w') as configfile:
- config.write(configfile)
-
-
-if __name__ == '__main__':
- init()
diff --git a/photobackup_bottle/photobackup.py b/photobackup_bottle/photobackup.py
index 5215f0a..3652321 100755
--- a/photobackup_bottle/photobackup.py
+++ b/photobackup_bottle/photobackup.py
@@ -31,89 +31,48 @@
"""
# stlib
-import configparser
+
import os
import sys
+from functools import wraps # better decorators
# pipped
import bcrypt
from bottle import abort, redirect, request, route, run
import bottle
from docopt import docopt
-from logbook import info, warn, error, Logger, StreamHandler
+from logbook import Logger, StreamHandler
# local
-from . import __version__, init
-
-
-def create_logger():
- """ Creates the logger fpr this module. """
- StreamHandler(sys.stdout).push_application()
- return Logger('PhotoBackup')
-
-
-def init_config(username=None):
- """ Launch init.py script to create configuration file on user's disk. """
- init.init(username)
- sys.exit("\nCreated, now launch PhotoBackup server with 'photobackup run'")
-
-
-def print_list():
- """ Print the existing PhotoBackup configurations. """
- sections = '\n'.join(get_config().sections())
- sections = sections.replace('photobackup-', '- ')
- sections = sections.replace('photobackup', '')
- print('Runnable PhotoBackup configurations are:')
- print(sections)
-
-
-def read_config(username=None):
- """ Set configuration file data into local dictionnary. """
- config_file = os.path.expanduser("~/.photobackup")
- config = configparser.RawConfigParser()
- config.optionxform = lambda option: option # to keep case of keys
- try:
- config.read_file(open(config_file))
- except EnvironmentError:
- log.error("can't read configuration file, running 'photobackup init'")
- init_config(username)
-
- suffix = '-' + username if username else ''
- config_key = 'photobackup' + suffix
-
- values = None
- try:
- values = config[config_key]
- except KeyError:
- values = None
- return values
+from . import __version__, serverconfig
+# Server functions
def end(code, message):
""" Aborts the request and returns the given error. """
log.error(message)
abort(code, message)
-def validate_password(request, isTest=False):
- """ Validates the password given in the request
- against the stored Bcrypted one. """
- password = None
- try:
- password = request.forms.get('password').encode('utf-8')
- except AttributeError:
- end(403, "No password in request")
-
- if 'PasswordBcrypt' in config:
+def validate_password(func):
+ """ Validates the password given in the request against the stored Bcrypted one. """
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ password = None
+ try:
+ password = request.forms.get('password').encode('utf-8')
+ except AttributeError:
+ end(403, 'No password in request')
+
passcrypt = config['PasswordBcrypt'].encode('utf-8')
- if bcrypt.hashpw(password, passcrypt) != passcrypt:
- end(403, "wrong password!")
- elif 'Password' in config and config['Password'] != password:
- end(403, "wrong password!")
- elif isTest:
- end(401, "There's no password in server configuration!")
+ if not bcrypt.checkpw(password, passcrypt):
+ end(403, 'wrong password!')
+
+ return func(*args, **kwargs)
+
+ return wrapper
def save_file(upfile, filesize):
- """ Saves the sent file locally. """
+ """ Saves the received file locally. """
path = os.path.join(config['MediaRoot'], os.path.basename(upfile.raw_filename))
if not os.path.exists(path):
@@ -148,10 +107,9 @@ def index():
@route('/', method='POST')
+@validate_password
def save_image():
- """ Saves the given image to the parameterized directory. """
- validate_password(request)
-
+ """ Saves the given image to the directory set in the configured. """
upfile = request.files.get('upfile')
if not upfile:
end(401, "no file in the request!")
@@ -166,10 +124,9 @@ def save_image():
@route('/test', method='POST')
+@validate_password
def test():
- """ Tests the server capabilities to handle POST requests. """
- validate_password(request, True)
-
+ """ Tests the server capabilities to handle a POST requests. """
if not os.path.exists(config['MediaRoot']):
end(500, "'MediaRoot' directory does not exist!")
@@ -184,22 +141,52 @@ def test():
log.info("Test succeeded \o/")
+# CLI handlers - they don't use the log, but print()
+def init_config(section=None):
+ """ Creates the configuration file.
+ param section: Optional argument of a custom section that'll be created in the config file.
+ """
+ serverconfig.init(section)
+ print("Created, now launch PhotoBackup server with 'photobackup run'")
+ sys.exit(0)
+
+
+def print_list():
+ """ Prints the existing PhotoBackup configuration sections. """
+ print(serverconfig.return_config_sections())
+ sys.exit(0)
+
+
+# internal helpers
+def _create_logger():
+ """ Creates the logger fpr this module. """
+ StreamHandler(sys.stdout).push_application()
+ return Logger('PhotoBackup')
+
# variables
arguments = docopt(__doc__, version='PhotoBackup ' + __version__)
-log = create_logger()
-config = read_config(arguments[''])
+
+# the server configuraiton dict; will be filled-in in main()
+config = None
+
+log = _create_logger()
def main():
""" Prepares and launches the bottle app. """
- if (arguments['init']):
+ if arguments['init']:
init_config(arguments[''])
- elif (arguments['run']):
+ sys.exit(0)
+
+ global config
+ config = serverconfig.read_config(arguments[''])
+
+ if arguments['run']:
app = bottle.default_app()
if 'HTTPPrefix' in config:
app.mount(config['HTTPPrefix'], app)
app.run(port=config['Port'], host=config['BindAddress'])
- elif (arguments['list']):
+ elif arguments['list']:
print_list()
diff --git a/photobackup_bottle/serverconfig.py b/photobackup_bottle/serverconfig.py
new file mode 100644
index 0000000..289ea82
--- /dev/null
+++ b/photobackup_bottle/serverconfig.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+# Copyright (C) 2013-2016 Stéphane Péchard.
+#
+# This file is part of PhotoBackup.
+#
+# PhotoBackup is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# PhotoBackup is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+""" PhotoBackup Python server initialization module.
+
+ It asks the user for configuration and writes it
+ to a .ini file.
+"""
+
+# stlib
+import configparser
+import getpass
+import hashlib
+import os, errno # for creating the directory
+import sys
+try:
+ import pwd # permission checks on *nix systems
+except ImportError:
+ pass # bypass the import error for Windows
+import stat
+# pipped
+import bcrypt
+
+# variables
+_config_file = os.path.expanduser("~/.photobackup")
+
+
+def writable_by(dirname, user):
+ """ Checks if the given directory is writable by the named user, or the group she belongs to."""
+
+ dir_stat = os.stat(dirname)
+
+ if not stat.S_ISDIR(dir_stat.st_mode):
+ print('The {} is not a directory, exiting.'.format(dirname))
+ sys.exit(1)
+
+ try:
+ pwnam = pwd.getpwnam(user)
+ except KeyError:
+ print('[ERROR] User or group {0} does not exist!'.format(user))
+ return False
+ except NameError:
+ print('[WARN] Writable verification cannot be performed on non-unix like systems, skipping.')
+ return True
+
+ user_id, group_id = pwnam.pw_uid, pwnam.pw_gid
+ directory_mode = dir_stat[stat.ST_MODE]
+ # The user has to have w, r and x on a directory - to list content, create/modify, and access inodes
+ # https://stackoverflow.com/a/46745175/3446126
+
+ if user_id == dir_stat[stat.ST_UID] and stat.S_IRWXU & directory_mode == stat.S_IRWXU: # owner and has RWX
+ return True
+ elif group_id == dir_stat[stat.ST_GID] and stat.S_IRWXG & directory_mode == stat.S_IRWXG: # in group & it has RWX
+ return True
+ elif stat.S_IRWXO & directory_mode == stat.S_IRWXO: # everyone has RWX
+ return True
+
+ # no permissions
+ return False
+
+
+def init(section=None):
+ """ Initializes the PhotoBackup configuration file.
+ :param section: Optional argument of a custom section to be created in the config file. The generated name
+ will be "[photobackup-section]".
+ The option is useful for having different configurations like staging, production, etc, in the
+ same file, or pseudo multi-tenant environment.
+ If not set, the default "[photobackup]" section is created.
+ """
+ print("""===============================
+PhotoBackup_bottle init process
+===============================""")
+
+ # ask for the upload directory (should be writable by the server)
+ media_root = input('The directory where to put the pictures (should be writable by the server you use): ')
+
+ if not media_root:
+ print('No directory given, stopping.')
+ sys.exit(1)
+
+ try:
+ os.makedirs(media_root)
+ print('Directory {0} did not exist, created it'.format(media_root))
+ except OSError as ex:
+ if ex.errno != errno.EEXIST:
+ print('The directory did not exist, and failed to create it - {} '.format(ex))
+ sys.exit(1)
+
+ server_user = input('Owner of the directory [www-data]: ')
+ if not server_user:
+ server_user = 'www-data'
+
+ check_writable_by = input('Verify the user {0} has write permissions on {1} [Yn]: '
+ .format(server_user, media_root))
+
+ # test for user writability of the directory
+ if not check_writable_by or check_writable_by.strip().lower()[0] == 'y':
+ if not writable_by(media_root, server_user):
+ print('[WARN] Directory {0} is not writable by {1}, check it!'.format(media_root, server_user))
+
+ # ask for the server password
+ plaintext_password = getpass.getpass(prompt='The server password: ')
+
+ # sha512 on the password - adds entropy, allows pass >72 chars, no issues with funky Unicode chars
+ pass_sha = hashlib.sha512(plaintext_password.encode('utf-8')).hexdigest().encode('utf-8')
+
+ pass_bcrypt = bcrypt.hashpw(pass_sha, bcrypt.gensalt())
+
+ # save the config file
+
+ config = configparser.ConfigParser()
+ config.optionxform = str # to keep case of keys
+ config.read(_config_file) # to keep existing data; doesn't fail if there's no file yet
+
+ suffix = '-' + section if section else ''
+ config_key = 'photobackup' + suffix
+
+ config[config_key] = {'BindAddress': '127.0.0.1',
+ 'MediaRoot': media_root,
+ 'Password': pass_sha.decode(),
+ 'PasswordBcrypt': pass_bcrypt.decode(),
+ 'Port': 8420}
+
+ with open(_config_file, 'w') as configfile:
+ config.write(configfile)
+
+
+def read_config(section=None):
+ """ Returns a dictionary with the configuration data, read from the config file.
+ :param section: Optional argument of which custom section to read.
+ If not set, the "[photobackup]" section is read."""
+
+ config = configparser.RawConfigParser()
+ config.optionxform = lambda option: option # to keep case of keys
+ try:
+ if not os.path.isfile(_config_file):
+ raise OSError('The configuration file "{}" does not exist'.format(_config_file))
+ config.read_file(open(_config_file))
+ except EnvironmentError as ex:
+ print("Can't read the configuration file - run 'photobackup init'\n{}".format(ex))
+ sys.exit(1)
+
+ suffix = '-' + section if section else ''
+ config_key = 'photobackup' + suffix
+
+ values = None
+ try:
+ values = config[config_key]
+ except KeyError:
+ print("The configuration file does not have {} section".format(config_key))
+ sys.exit(1)
+
+ # sanitize - all required settings are present
+ if 'PasswordBcrypt' not in values: # legacy config, no bcrypted password - generate it now
+ if 'Password' not in values:
+ print('ERROR - the config file does not have nor PasswordBcrypt, nor Password.')
+ sys.exit(1)
+ values['PasswordBcrypt'] = bcrypt.hashpw(values['Password'].encode('utf-8'), bcrypt.gensalt())
+
+ for key in ('BindAddress', 'MediaRoot', 'Port'):
+ if key not in values:
+ print('The requered setting {} is not in the config file; exiting.'.format(key))
+ sys.exit(1)
+
+ return values
+
+
+def return_config_sections():
+ """ Print the existing PhotoBackup configuration sections; the default is named "". """
+
+ config = configparser.RawConfigParser()
+ config.optionxform = lambda option: option # to keep case of keys
+
+ try:
+ config.read_file(open(_config_file))
+ except EnvironmentError as ex:
+ print("Cannot read the configuration file - {}".format(ex))
+ sys.exit(1)
+
+ sections = '\n'.join(config.sections())
+ sections = sections.replace('photobackup-', '')
+ sections = sections.replace('photobackup', '')
+
+ return 'Runnable PhotoBackup configurations are: \n{}'.format(sections)
+
+
+if __name__ == '__main__':
+ init()