Files
flit/flit.py

441 lines
11 KiB
Python
Executable File

#!/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()]
existing_numbers = [
tryint(num[0:3]) for num in existing_names if tryint(num[0:3]) != None
]
if len(existing_numbers) == 0:
index = 0
else:
index = max(existing_numbers) + 1
if index > 999:
for index in range(1000):
if not index in existing_numbers:
break
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)
def tryint(s):
"""Return integer, or None
Args:
s (str): Integer as string
Returns:
Int or None
"""
try:
return int(s, 10)
except Exception:
return None
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"""
name = None
try:
name = opts.name
except AttributeError:
pass
if name:
if os.path.isdir(os.path.join(cwd, name)):
name = os.path.basename(os.path.realpath(os.path.join(cwd, name)))
list_folders(opts.root, verbose=opts.verbose, filter_name=name)
if opts.command == "del":
"""Delete due shares"""
del_due_folders()