Browse Source

Initial commit

master
Anthony Zhang 7 years ago
commit
cd068f7537
3 changed files with 308 additions and 0 deletions
  1. +22
    -0
      LICENSE.txt
  2. +104
    -0
      README.md
  3. +182
    -0
      femtoshare.py

+ 22
- 0
LICENSE.txt View File

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2018 Anthony Zhang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

+ 104
- 0
README.md View File

@ -0,0 +1,104 @@
Femtoshare
==========
Ultra simple self-hosted file sharing. All files can be accessed/modified by all users. Don't upload anything secret!
Quickstart: run `./femtoshare.py`, then visit `http://localhost:8000/` in your web browser.
Options:
$ ./femtoshare.py --help
usage: femtoshare.py [-h] [--port PORT] [--public]
optional arguments:
-h, --help show this help message and exit
--port PORT local network port to listen on
--public listen on remote network interfaces (allows other hosts to see
the website; otherwise only this host can see it)
Rationale
---------
I often need to send/receive files from untrusted computers. Google Drive can be used for receiving, but not sending - I don't care about people seeing the files, but I do care about not revealing my account credentials when logging in to upload files.
Services like WeTransfer work for sending, but they often have tiny file size limits and long, hard-to-type file URLs, if they work at all.
I made Femtoshare to fix these issues. It's basically an FTP server that only needs a web browser to browse/download/upload. No file size limits and easily-memorized links, plus no worries about revealing important account credentials! A site-wide password is easily added to keep out the most unsophisticated attackers.
Deployment
----------
Minimal public-facing deployment: `./femtoshare.py --port 1234 --public` is visible at `http://YOUR_HOST:1234`.
For improved security, authentication, and HTTPS support, we can run the script as a systemd daemon and put it behind an Nginx reverse proxy, configured to use HTTP Basic Authentication. Assuming a fresh Ubuntu 16.04 LTS machine:
1. SSH into the machine: `ssh ubuntu@ec2-35-166-68-253.us-west-2.compute.amazonaws.com`.
2. Run software updates: `sudo apt-get update && sudo apt-get upgrade`.
3. Harden SSH: in `/etc/ssh/sshd_config`, change `PasswordAuthentication` to `no` and `PermitRootLogin` to `no` and `AllowUsers` to `ubuntu`. Restart `sshd` using `sudo service sshd restart`.
4. Get requirements: `sudo apt-get nginx openssl`.
5. Get the code: `sudo mkdir -p /var/www/femtoshare && cd /var/www/femtoshare && sudo wget https://raw.githubusercontent.com/Uberi/femtoshare/master/femtoshare.py`.
6. Set up restricted user: `sudo adduser --system --no-create-home --disabled-login --group femtoshare` (there is a `nobody` user, but it's better to have our own user in case other daemons also use `nobody`).
7. Set up file storage directory: `sudo install -o femtoshare -g femtoshare -d /var/www/femtoshare/files`.
8. Set up systemd service to start Femtoshare on boot:
```bash
sudo tee /lib/systemd/system/femtoshare.service << EOF
[Unit]
Description=Femtoshare
[Service]
Type=simple
PrivateTmp=yes
User=femtoshare
Group=femtoshare
ExecStart=/var/www/femtoshare/femtoshare.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable femtoshare.service
```
9. Set up a password file for HTTP Basic Authentication: `echo "user:$(openssl passwd -apr1 -salt 8b80ef96d09ffd0be0daa1202f55bb09 'YOUR_PASSWORD_HERE')" | sudo tee /var/www/femtoshare/.htpasswd`
10. Set up Nginx as a reverse proxy with HTTP Basic Authentication:
```bash
sudo tee /etc/nginx/conf.d/femtoshare.conf << EOF
server {
listen 80;
server_name ~.;
# HTTP Basic Authentication
auth_basic "Log in with username 'user' to access files";
auth_basic_user_file /var/www/femtoshare/.htpasswd;
# serve directory listing
location = / {
proxy_pass http://127.0.0.1:8000;
client_max_body_size 1000M;
}
# serve uploaded file
location ~ /.+ {
root /var/www/femtoshare/files;
}
}
EOF
```
11. Set up HTTPS with Let's Encrypt: `export LC_ALL="en_US.UTF-8"; export LC_CTYPE="en_US.UTF-8"; sudo wget https://dl.eff.org/certbot-auto && sudo chmod a+x certbot-auto && sudo ./certbot-auto --nginx --debug`
12. Set up twice-daily certificate renewal cronjob with Let's Encrypt: add `23 0,12 * * * PATH=/home/ubuntu/bin:/home/ubuntu/.local/bin:/home/ubuntu/bin:/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin /var/www/femtoshare/certbot-auto renew >> /var/www/femtoshare/letsencrypt-renew-certificate.log 2>&1` in the root crontab with `sudo crontab -e` (setting `PATH` is necessary in order to get Nginx config updates to work).
13. Start Femtoshare and Nginx: `sudo systemctl start femtoshare.service` and `sudo service nginx restart`.
14. Allow incoming and outgoing HTTP and HTTPS traffic through the firewall.
License
-------
Copyright 2018-2018 [Anthony Zhang (Uberi)](http://anthonyz.ca).
The source code is available online at [GitHub](https://github.com/Uberi/femtoshare).
This program is made available under the MIT license. See ``LICENSE.txt`` in the project's root directory for more information.

+ 182
- 0
femtoshare.py View File

@ -0,0 +1,182 @@
#!/usr/bin/python3
"""
Femtoshare
==========
Ultra simple self-hosted file sharing. All files can be accessed/modified by all users. Don't upload anything secret!
Quickstart: run `./femtoshare.py`, then visit `http://localhost:8000/` in your web browser.
See `./femtoshare.py --help` for usage information. See `README.md` for more documentation.
"""
__author__ = "Anthony Zhang (Uberi)"
__version__ = "1.0.4"
__license__ = "MIT"
from http.server import BaseHTTPRequestHandler, HTTPServer
import cgi
import os
import re
import html
import urllib.parse
import argparse
from datetime import datetime
from shutil import copyfileobj
parser = argparse.ArgumentParser()
parser.add_argument("--port", help="local network port to listen on", type=int, default=8000)
parser.add_argument("--public", help="listen on remote network interfaces (allows other hosts to see the website; otherwise only this host can see it)", action="store_true")
parser.add_argument("--files-dir", help="directory to upload/download files from (prefix with # to specify that the path is relative to the Femtoshare executable)", default="#files")
args = parser.parse_args()
if args.public:
SERVER_ADDRESS = ("0.0.0.0", args.port)
else:
SERVER_ADDRESS = ("127.0.0.1", args.port)
if args.files_dir.startswith("@"):
FILES_DIRECTORY = os.path.join(os.path.dirname(os.path.realpath(__file__)), args.files_dir[1:])
else:
FILES_DIRECTORY = args.files_dir
class FemtoshareRequestHandler(BaseHTTPRequestHandler):
server_version = "Femtoshare/{}".format(__version__)
def do_GET(self):
if self.path == "/":
self.send_directory_listing()
return
# check requested filename
filename = urllib.parse.unquote(self.path[1:])
if not self.path.startswith("/") or not self.is_valid_filename(filename):
self.send_error(400, "Invalid file request")
return
self.send_file(filename)
def do_POST(self):
# check uploaded file (note that files within cgi.FieldStorage don't need to be explicitly closed)
try:
form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={"REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers["Content-Type"]})
except:
self.send_error(400, "Invalid file upload parameters")
return
if "delete_name" in form: # file delete
filename = form["delete_name"].value
if not self.is_valid_filename(filename):
self.send_error(400, "Invalid filename for file deletion")
return
# delete uploaded file from disk
local_path = os.path.join(FILES_DIRECTORY, filename)
os.remove(local_path)
elif "upload_file" in form: # file upload
filename = form["upload_file"].filename
if not self.is_valid_filename(filename):
self.send_error(400, "Invalid filename for file upload")
return
# store uploaded file to disk
local_path = os.path.join(FILES_DIRECTORY, filename)
with open(local_path, "wb") as f:
copyfileobj(form["upload_file"].file, f)
else:
self.send_error(400, "Invalid file upload parameters")
return
self.send_directory_listing()
def send_file(self, file_path, headers_only=False):
try:
f = open(file_path, "rb")
except OSError:
self.send_error(404, "File not found")
return
try:
file_info = os.stat(f.fileno())
self.send_response(200)
self.send_header("Content-type", "application/octet-stream")
self.send_header("Content-Length", str(file_info.st_size))
self.send_header("Last-Modified", self.date_time_string(file_info.st_mtime))
self.end_headers()
copyfileobj(f, self.wfile)
finally:
f.close()
def send_directory_listing(self):
table_entries = []
try:
for dir_entry in os.scandir(FILES_DIRECTORY):
if not dir_entry.is_file(follow_symlinks=False): continue
file_info = dir_entry.stat()
table_entries.append((dir_entry.name, file_info.st_size, datetime.fromtimestamp(file_info.st_mtime)))
except OSError:
self.send_error(500, "Error listing directory contents")
return
response = (
"<!doctype html>\n" +
"<html>\n" +
" <head>\n" +
" <meta charset=\"utf-8\">\n" +
" <title>femtoshare</title>\n" +
" <style type=\"text/css\">\n" +
" body { font-family: monospace; width: 80%; margin: 5em auto; text-align: center; }\n" +
" h1 { font-size: 4em; margin: 0; }\n" +
" a { color: inherit; }\n" +
" table { border-collapse: collapse; width: 100%; }\n" +
" table th, table td { border-top: 1px solid #f0f0f0; text-align: left; padding: 0.2em 0.5em; }\n" +
" table th { border-top: none; }\n" +
" body > form { margin: 1em auto; padding: 1em; display: inline-block; border-top: 0.2em solid black; }\n" +
" body > form input { font-family: inherit; margin: 0 1em; }\n" +
" table input { font-family: inherit; margin: 0; font-size: 0.8em; }\n" +
" p { font-weight: bold; }\n" +
" </style>\n" +
" </head>\n" +
" <body>\n" +
" <h1>femtoshare</h1>\n" +
" <form enctype=\"multipart/form-data\" method=\"post\"><input name=\"upload_file\" type=\"file\" /><input type=\"submit\" value=\"upload\" /></form>\n" +
(
" <table>\n" +
" <thead><tr><th>name</th><th>size</th><th>last modified</th><th>actions</th></tr></thead>\n" +
" <tbody>\n" +
"".join(
" <tr><td><a href=\"{url}\">{name}</a></td><td>{size:,}</td><td>{last_modified}</td><td><form method=\"post\"><input name=\"delete_name\" type=\"hidden\" value=\"{name}\" /><input type=\"submit\" value=\"delete\" /></form></td></tr>\n".format(
url=urllib.parse.quote(name), name=html.escape(name), size=size, last_modified=last_modified.isoformat()
)
for name, size, last_modified in table_entries
) +
" </tbody>\n" +
" </table>\n"
if table_entries else
" <p>(uploaded files will be visible here)</p>\n"
) +
" </body>\n" +
"</html>\n"
)
response_bytes = response.encode("utf8")
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(response_bytes)))
self.end_headers()
self.wfile.write(response_bytes)
def is_valid_filename(self, filename):
if (os.path.sep is not None and os.path.sep in filename) or (os.path.altsep is not None and os.path.altsep in filename): # check for filesystem separators
return False
if filename in (os.pardir, os.curdir): # check for reserved filenames
return False
if re.match(r"^[\w `!@\$\^\(\)\-=_\[\];',\.]*$", filename) is None: # check for invalid characters
return False
return True
if __name__ == '__main__':
os.makedirs(FILES_DIRECTORY, exist_ok=True) # ensure files directory exists
server = HTTPServer(SERVER_ADDRESS, FemtoshareRequestHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
pass

Loading…
Cancel
Save