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()