diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e61bca2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,116 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + diff --git a/README.org b/README.org new file mode 100644 index 0000000..283829e --- /dev/null +++ b/README.org @@ -0,0 +1,3 @@ +* raincloud + + /Self-hosted file sharing cloud for you and your firends./ diff --git a/images/logo.svg b/images/logo.svg new file mode 100644 index 0000000..8003b24 --- /dev/null +++ b/images/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/raincloud/__init__.py b/raincloud/__init__.py new file mode 100755 index 0000000..e58bd62 --- /dev/null +++ b/raincloud/__init__.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python + +from hmac import compare_digest as compare_hash +from flask import ( + Flask, + abort, + redirect, + render_template, + request, + send_from_directory, + session, + url_for, +) +from raincloud.directory_handler import DirectoryHandler, RaincloudIOException +from raincloud.session_handler import SessionHandler +from werkzeug.utils import secure_filename +import crypt +import werkzeug + + +def app(settings_file=None): + + # Create app + app = Flask(__name__) + + # Load config + app.config.from_object("raincloud.default_settings") + if settings_file: + app.config.from_pyfile(settings_file) + else: + app.config.from_envvar("RAINCLOUD_SETTINGS", silent=True) + + if not app.config["BASE_PATH"] or app.config["BASE_PATH"] == "": + print("No BASE_PATH defined") + exit(1) + + # Create handlers + dh = DirectoryHandler(app.config["BASE_PATH"]) + sh = SessionHandler() + + @app.route("/", methods=["GET", "POST"]) + @app.route("//", methods=["GET"]) + def directory(directory, filename=None): + + try: + + # Clean sessions + sh.clean_sessions() + + # Logout + if request.method == "POST" and "logout" in request.form: + sh.delete_session(session[directory]) + return redirect(url_for("directory", directory=directory)) + + config = dh.get_config(directory) + + if config["hashed_password"]: + authenticated = ( + True + if directory in session + and sh.validate_session(directory, session[directory]) + else False + ) + + if not authenticated: + if request.method == "POST": + if compare_hash( + config["hashed_password"], + crypt.crypt( + request.form["password"], config["hashed_password"] + ), + ): + id_ = sh.create_session_id() + session[directory] = id_ + sh.add_session(directory, id_) + return redirect(url_for("directory", directory=directory)) + else: + return render_template( + "authenticate.html", + cloud_name=app.config["CLOUD_NAME"], + config=config, + ) + + if request.method == "GET": + # List + if not filename: + files = dh.get_files(directory) + return render_template( + "directory.html", + cloud_name=app.config["CLOUD_NAME"], + config=config, + files=files, + ) + + # Download + else: + if config["download"] and filename != "rc.toml": + return send_from_directory( + dh.get_absolute_path(directory), filename + ) + else: + abort(404) + + # Upload + elif request.method == "POST": + if config["upload"]: + f = request.files["file"] + filename = secure_filename(f.filename) + if filename != "rc.toml": + dh.save_to_directory(f, directory, filename) + + # Reload + return redirect(url_for("directory", directory=directory)) + else: + abort(403) + + except RaincloudIOException as e: + print(e) + abort(404) + + return app diff --git a/raincloud/default_settings.py b/raincloud/default_settings.py new file mode 100644 index 0000000..a189dae --- /dev/null +++ b/raincloud/default_settings.py @@ -0,0 +1,3 @@ +CLOUD_NAME = "raincloud" +SECRET_KEY = "dev" +BASE_PATH = "public" diff --git a/raincloud/directory_handler.py b/raincloud/directory_handler.py new file mode 100644 index 0000000..ea23f11 --- /dev/null +++ b/raincloud/directory_handler.py @@ -0,0 +1,73 @@ +from datetime import datetime +from pathlib import Path +import toml + + +class RaincloudIOException(Exception): + pass + + +class DirectoryHandler: + def __init__(self, base_path): + self.base_path = Path(base_path) + if not self.base_path.exists(): + raise RaincloudIOException( + f"Base path '{self.base_path.resolve()}' not existent" + ) + + def get_config(self, directory): + """Load a 'rc.toml' file from given directory. + + Parameters: + directory - basepath of 'rc.toml' + + Returns: Dictionary of config parameters + """ + path = self.base_path / directory / "rc.toml" + + if path.exists(): + config = {} + config["directory"] = directory + + parsed_config = toml.load(path) + config["hashed_password"] = ( + parsed_config["hashed_password"] + if "hashed_password" in parsed_config + else None + ) + config["download"] = ( + parsed_config["download"] if "download" in parsed_config else False + ) + config["upload"] = ( + parsed_config["upload"] if "upload" in parsed_config else False + ) + return config + + else: + raise RaincloudIOException("No raincloud directory") + + def get_files(self, directory): + """Get files from directory.""" + path = self.base_path / directory + file_paths = [f for f in path.glob("*") if f.is_file()] + files = [] + for p in file_paths: + if p.name != "rc.toml": + files.append(p.name) + return files + + def get_absolute_path(self, directory): + """Get absolute path of 'directory'.""" + return (self.base_path / directory).resolve() + + def save_to_directory(self, file_, directory, filename): + """Save 'file_' to 'directory' with 'filename'.""" + filepath = self.base_path / directory / filename + if not filepath.exists(): + file_.save(filepath) + else: + file_.save( + filepath.with_suffix( + f".{datetime.now().strftime('%Y%m%d%H%M')}{filepath.suffix}" + ) + ) diff --git a/raincloud/session_handler.py b/raincloud/session_handler.py new file mode 100644 index 0000000..c0e11ba --- /dev/null +++ b/raincloud/session_handler.py @@ -0,0 +1,36 @@ +from datetime import datetime, timedelta +import uuid + + +class SessionHandler: + def __init__(self): + self.sessions = [] + + def create_session_id(self): + """Create a new session ID.""" + ids = [s[2] for s in self.sessions] + id_ = uuid.uuid4() + while id_ in ids: + id_ = uuid.uuid4() + return id_ + + def add_session(self, directory, id_): + """Add session with 'id_' allowing access to 'directory'.""" + self.sessions.append((datetime.now(), directory, id_)) + + def clean_sessions(self): + """Remove all sessions which are older than one day.""" + self.sessions = [ + s for s in self.sessions if s[0] > datetime.now() - timedelta(days=1) + ] + + def validate_session(self, directory, id_): + """check if session with 'id_' is allowed to access 'directory'.""" + valid_dates = [s[0] for s in self.sessions if s[1] == directory and s[2] == id_] + if len(valid_dates) > 0 and valid_dates[0] > datetime.now() - timedelta(days=1): + return True + return False + + def delete_session(self, id_): + """Delete session with 'id_'.""" + self.sessions = [s for s in self.sessions if s[2] != id_] diff --git a/raincloud/static/favicon.png b/raincloud/static/favicon.png new file mode 100644 index 0000000..8628c8c Binary files /dev/null and b/raincloud/static/favicon.png differ diff --git a/raincloud/static/logo.svg b/raincloud/static/logo.svg new file mode 100644 index 0000000..c06dcc2 --- /dev/null +++ b/raincloud/static/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/raincloud/static/style.css b/raincloud/static/style.css new file mode 100644 index 0000000..aa60628 --- /dev/null +++ b/raincloud/static/style.css @@ -0,0 +1,108 @@ +@keyframes fade-in { + 0% { opacity: 0; } + 100% { opacity: 1; } +} + +body { + font-family: sans-serif; + margin: 0; +} + +#container { + display: flex; + flex-direction: column; +} + +#header { + padding: 0px 30px; + border-bottom: 1px solid lightgray; + font-size: 20px; + display: flex; + justify-content: space-between; + align-items: center; + min-height: 60px; +} + +#cloud-name { + color: #28bcff; + margin-right: 30px; +} + +#directory-name { + font-weight: bold; +} + +#menu { + animation: fade-in ease-in-out .5s; +} + +#content { + margin: 50px 0px; + text-align: center; + animation: fade-in ease-in-out .5s; +} + +.file { + border-bottom: 1px solid lightgrey; + padding: 0px 30px; + min-height: 60px; + display: flex; + justify-content: space-between; + align-items: center; +} + +form { + display: inline-block; +} + +.button { + background-color: #28bcff; + border: none; + border-radius: 5px; + color: white; + padding: 12px 28px; + text-align: center; + text-decoration: none; + display: inline-block; + margin: 4px 2px; + font-size: 16px; + cursor: pointer; +} + +.button:hover { + background-color: #0cb3ff; +} + +.upload { + background-color: #ff814e; +} + +.upload:hover { + background-color: #ff7740; +} + +.logout { + background-color: #ff4e62; +} + +.logout:hover { + background-color: #ff3e54; +} + +input[type="file"] { + display: none; +} + +input[type="password"] { + border: 1px solid lightgrey; + padding: 11px 11px; + font-size: 16px; + border-radius: 5px; + width: 300px; +} + +input[type="password"]:focus { + outline: none; + border: 2px solid #ff814e; + padding: 10px 10px; +} diff --git a/raincloud/templates/authenticate.html b/raincloud/templates/authenticate.html new file mode 100644 index 0000000..bc89042 --- /dev/null +++ b/raincloud/templates/authenticate.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block content %} +
+ + +
+{% endblock %} diff --git a/raincloud/templates/base.html b/raincloud/templates/base.html new file mode 100644 index 0000000..b291d51 --- /dev/null +++ b/raincloud/templates/base.html @@ -0,0 +1,18 @@ + + + + +{{ config["directory"] }} | {{ cloud_name }} +
+ +
+ {% block content %}{% endblock %} +
+
diff --git a/raincloud/templates/directory.html b/raincloud/templates/directory.html new file mode 100644 index 0000000..7e2ea14 --- /dev/null +++ b/raincloud/templates/directory.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block menu %} + {% if config["upload"] %} +
+ +
+ {% endif %} + {% if config["hashed_password"] %} +
+ + +
+ {% endif %} +{% endblock %} +{% block content %} + {% for f in files %} +
+

{{ f }}

+ {% if config["download"] %} + Download + {% endif %} +
+ {% endfor %} +{% endblock %} diff --git a/run.py b/run.py new file mode 100755 index 0000000..26ca89f --- /dev/null +++ b/run.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python + +import raincloud + +if __name__ == "__main__": + app = raincloud.app() + app.run() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b1ae010 --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +from setuptools import find_packages, setup + +setup( + name="raincloud", + long_description=open("README.org").read(), + packages=find_packages(), + include_package_data=True, + zip_safe=False, + install_requires=["flask", "toml"], +)