#!/usr/bin/env python3 from datetime import datetime, timedelta import argparse import os import random import shutil import string from urllib.parse import quote import json """FLIT: Fluttering folders for simple file sharing. The script maintains a set of folders, named so that they are hard to guess, and contents are deleted after expiration date. Attributes: CONFIG (str): Configuration file name stored in each shared folder. """ CONFIG = ".flit.json" def parse_opts(): """Options parser Args: None Returns: Namespace: Options from the command line """ class _HelpAction(argparse._HelpAction): def __call__(self, parser, namespace, values, option_string=None): parser.print_help() print("") # retrieve subparsers from parser subparsers_actions = [ action for action in parser._actions if isinstance(action, argparse._SubParsersAction) ] for subparsers_action in subparsers_actions: # get all subparsers and print help for choice, subparser in subparsers_action.choices.items(): print("Command: {}".format(choice)) print(subparser.format_help()) parser.exit() parser = argparse.ArgumentParser(add_help=False) parser.add_argument( "--help", "-h", action=_HelpAction, help="Show this help message and exit" ) parser.add_argument( "--verbose", "-v", action="store_true", dest="verbose", default=False, help="Increase verbosity", ) parser.add_argument( "--root", "-r", action="store", dest="root", type=str, default="", help="Root address for printing URLS", ) parser.add_argument( "--home", action="store", dest="home", type=str, default=None, help="Home folder for flit. This is where folders are created, and --root should point to in the filesystem. Defaults to script location", ) subparsers = parser.add_subparsers(dest="command", help="Command defaults to add") add_parser = subparsers.add_parser("add", add_help=False) add_parser.add_argument( "-d", action="store", type=int, help="Days to keep files", default=30, dest="days", ) add_parser.add_argument( "-m", action="store", type=str, help="Describe share", default="-", dest="description", ) add_parser.add_argument( "files", action="store", type=str, help="Copy files/folders under the new share", default=[], nargs="*", ) list_parser = subparsers.add_parser("list", add_help=False) list_parser.add_argument( "--verbose", "-v", action="store_true", dest="verbose", default=False, help="Print individual files too", ) list_parser.add_argument( "name", action="store", type=str, help="List only named share", default=None, nargs="?" ) del_parser = subparsers.add_parser("del", add_help=False) return parser.parse_args() def random_char(): """Random character generator Args: None Returns: str: A single uppercase letter or number """ return random.choice(string.ascii_uppercase + string.digits) def random_name(): """Random unique name for a new share. Share names follow the syntax [NUM-ABC-DEF], Where NUM is a 3 digit growing number (to help navigating new folders), but the rest are random, to ensure secrecy. Args: None Returns: str: New folder name """ while True: existing_names = [c["name"] for c in get_folders()] index = 0 for existing in existing_names: try: e_index = int(existing[0:3]) index = max(e_index + 1, index) except: pass name = "{:03d}-{}-{}".format( index, "".join([random_char() for x in range(3)]), "".join([random_char() for x in range(3)]), ) if not os.path.exists(name): break return name def create_new(p, days, description): """Creates a new folder, and stores configuration Args: p (str): Share name days (int): Days to store the folder description (str): Description of the fileshare Returns: None """ os.mkdir(p) now = datetime.now() del_delta = timedelta(days=days) del_time = now + del_delta config = {} config["description"] = description config["delete_time"] = del_time.isoformat() config["created"] = now.isoformat() write_config(p, config) def copy_files(cwd, new_name, files): """Copy files to a new share Args: c (str): Working directory when the script was started new_name (str): Name of the new share files (list): List of paths to copy Returns: None """ target = os.path.abspath(new_name) for f in opts.files: print(f"Copying: {f}") source = os.path.join(cwd, f) if source.endswith("/"): source = source[0:-1] base = os.path.basename(source) if not os.path.exists(source): print("Path does not exist") continue if os.path.isfile(source): shutil.copy2(source, target) else: shutil.copytree( source, os.path.join(target, base), symlinks=True, ) def get_stats(p): """Get share properties Args: p (str): Name of the share Returns: Dict: Share properties """ config = read_config(p) del_time = datetime.fromisoformat(config["delete_time"]) now = datetime.now() mtime = datetime.fromisoformat(config["created"]) to_del_time = del_time - now is_due = now > del_time config["delete_time"] = del_time config["created"] = mtime config["name"] = p config["to_deletion"] = to_del_time config["due"] = is_due return config def get_sub_files(p): """Get 1st level files in a share Args: p (str): Name of the share Returns: List: Folders and Files in the share """ file_list = sorted([x for x in os.listdir(p) if not x.startswith(".")]) dir_list = [x + "/" for x in file_list if os.path.isdir(os.path.join(p, x))] file_list = [x for x in file_list if os.path.isfile(os.path.join(p, x))] return dir_list + file_list def get_folders(): """Get share properties for all the shares Args: None: Returns: List: List that contain `get_stats(p)` for every share """ dir_list = [p for p in os.listdir(".") if os.path.exists(os.path.join(p, CONFIG))] configs = [get_stats(p) for p in dir_list] configs.sort(key=lambda c: c["created"], reverse=True) return configs def list_folders(root_url, verbose=False, filter_name=None): """Show folder list Args: root_url (str): URL to root of the share system verbose (bool): If set true, displays folder contents filter_name (str): If set, display only one folder matching the name Returns: None """ folders = get_folders() header_format = "{:" + str(12 + len(root_url)) + "} {} {} {} {}" print(header_format.format("URL","Created","ToDelete","InDays","Description")) for c in folders: if filter_name is not None: if filter_name != c["name"]: continue due = "*" if c["due"] else " " print( "{}/{}/ {} {} {: 4d}d{} {}".format( root_url, c["name"], c["created"].isoformat()[0:10], c["delete_time"].isoformat()[0:10], c["to_deletion"].days, due, c["description"], ) ) if verbose: sub_files = get_sub_files(c["name"]) for sp in sub_files: print(" {}/{}/{}".format(root_url, c["name"], quote(sp, "/"))) def del_due_folders(): """Delete folders where due date has passed Args: None Returns: None """ folders = get_folders() for c in folders: if c["due"]: print("Deleting {}".format(c["name"])) shutil.rmtree(c["name"]) def read_config(p): """Read the share configuration file Args: p (str): Share name Returns: Dict: Share config Example: { "created": "2020-12-28T23:04:07.275200", "delete_time": "2020-12-29T23:04:07.275200", "description": "Test share" } """ with open(os.path.join(p, CONFIG), "rt") as fp: return json.load(fp) def write_config(p, config): """Write the share configuration file Args: p (str): Share name config (Dict): Share configuration Returns: None """ with open(os.path.join(p, CONFIG), "wt") as fp: return json.dump(config, fp, indent=2, sort_keys=True) if __name__ == "__main__": opts = parse_opts() cwd = os.getcwd() if opts.home == None: os.chdir(os.path.dirname(__file__)) else: os.chdir(opts.home) if opts.command == "add": """ Add a new share """ new_name = random_name() create_new(new_name, opts.days, opts.description) print(os.path.abspath(new_name)) print("") copy_files(cwd, new_name, opts.files) list_folders(opts.root, verbose=True, filter_name=new_name) if opts.command == "list" or opts.command is None: """ List shares """ if opts.name: if os.path.isdir(os.path.join(cwd, opts.name)): opts.name = os.path.basename(os.path.realpath(os.path.join(cwd, opts.name))) list_folders(opts.root, verbose=opts.verbose, filter_name=opts.name) if opts.command == "del": """ Delete due shares """ del_due_folders()