The tiniest file-sharing server. Roughly equivalent to 1e-15 ordinary file-sharing servers.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

182 lines
7.8 KiB

7 years ago
  1. #!/usr/bin/python3
  2. """
  3. Femtoshare
  4. ==========
  5. Ultra simple self-hosted file sharing. All files can be accessed/modified by all users. Don't upload anything secret!
  6. Quickstart: run `./femtoshare.py`, then visit `http://localhost:8000/` in your web browser.
  7. See `./femtoshare.py --help` for usage information. See `README.md` for more documentation.
  8. """
  9. __author__ = "Anthony Zhang (Uberi)"
  10. __version__ = "1.0.4"
  11. __license__ = "MIT"
  12. from http.server import BaseHTTPRequestHandler, HTTPServer
  13. import cgi
  14. import os
  15. import re
  16. import html
  17. import urllib.parse
  18. import argparse
  19. from datetime import datetime
  20. from shutil import copyfileobj
  21. parser = argparse.ArgumentParser()
  22. parser.add_argument("--port", help="local network port to listen on", type=int, default=8000)
  23. 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")
  24. 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")
  25. args = parser.parse_args()
  26. if args.public:
  27. SERVER_ADDRESS = ("0.0.0.0", args.port)
  28. else:
  29. SERVER_ADDRESS = ("127.0.0.1", args.port)
  30. if args.files_dir.startswith("@"):
  31. FILES_DIRECTORY = os.path.join(os.path.dirname(os.path.realpath(__file__)), args.files_dir[1:])
  32. else:
  33. FILES_DIRECTORY = args.files_dir
  34. class FemtoshareRequestHandler(BaseHTTPRequestHandler):
  35. server_version = "Femtoshare/{}".format(__version__)
  36. def do_GET(self):
  37. if self.path == "/":
  38. self.send_directory_listing()
  39. return
  40. # check requested filename
  41. filename = urllib.parse.unquote(self.path[1:])
  42. if not self.path.startswith("/") or not self.is_valid_filename(filename):
  43. self.send_error(400, "Invalid file request")
  44. return
  45. self.send_file(filename)
  46. def do_POST(self):
  47. # check uploaded file (note that files within cgi.FieldStorage don't need to be explicitly closed)
  48. try:
  49. form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={"REQUEST_METHOD": "POST", "CONTENT_TYPE": self.headers["Content-Type"]})
  50. except:
  51. self.send_error(400, "Invalid file upload parameters")
  52. return
  53. if "delete_name" in form: # file delete
  54. filename = form["delete_name"].value
  55. if not self.is_valid_filename(filename):
  56. self.send_error(400, "Invalid filename for file deletion")
  57. return
  58. # delete uploaded file from disk
  59. local_path = os.path.join(FILES_DIRECTORY, filename)
  60. os.remove(local_path)
  61. elif "upload_file" in form: # file upload
  62. filename = form["upload_file"].filename
  63. if not self.is_valid_filename(filename):
  64. self.send_error(400, "Invalid filename for file upload")
  65. return
  66. # store uploaded file to disk
  67. local_path = os.path.join(FILES_DIRECTORY, filename)
  68. with open(local_path, "wb") as f:
  69. copyfileobj(form["upload_file"].file, f)
  70. else:
  71. self.send_error(400, "Invalid file upload parameters")
  72. return
  73. self.send_directory_listing()
  74. def send_file(self, file_path, headers_only=False):
  75. try:
  76. f = open(file_path, "rb")
  77. except OSError:
  78. self.send_error(404, "File not found")
  79. return
  80. try:
  81. file_info = os.stat(f.fileno())
  82. self.send_response(200)
  83. self.send_header("Content-type", "application/octet-stream")
  84. self.send_header("Content-Length", str(file_info.st_size))
  85. self.send_header("Last-Modified", self.date_time_string(file_info.st_mtime))
  86. self.end_headers()
  87. copyfileobj(f, self.wfile)
  88. finally:
  89. f.close()
  90. def send_directory_listing(self):
  91. table_entries = []
  92. try:
  93. for dir_entry in os.scandir(FILES_DIRECTORY):
  94. if not dir_entry.is_file(follow_symlinks=False): continue
  95. file_info = dir_entry.stat()
  96. table_entries.append((dir_entry.name, file_info.st_size, datetime.fromtimestamp(file_info.st_mtime)))
  97. except OSError:
  98. self.send_error(500, "Error listing directory contents")
  99. return
  100. response = (
  101. "<!doctype html>\n" +
  102. "<html>\n" +
  103. " <head>\n" +
  104. " <meta charset=\"utf-8\">\n" +
  105. " <title>femtoshare</title>\n" +
  106. " <style type=\"text/css\">\n" +
  107. " body { font-family: monospace; width: 80%; margin: 5em auto; text-align: center; }\n" +
  108. " h1 { font-size: 4em; margin: 0; }\n" +
  109. " a { color: inherit; }\n" +
  110. " table { border-collapse: collapse; width: 100%; }\n" +
  111. " table th, table td { border-top: 1px solid #f0f0f0; text-align: left; padding: 0.2em 0.5em; }\n" +
  112. " table th { border-top: none; }\n" +
  113. " body > form { margin: 1em auto; padding: 1em; display: inline-block; border-top: 0.2em solid black; }\n" +
  114. " body > form input { font-family: inherit; margin: 0 1em; }\n" +
  115. " table input { font-family: inherit; margin: 0; font-size: 0.8em; }\n" +
  116. " p { font-weight: bold; }\n" +
  117. " </style>\n" +
  118. " </head>\n" +
  119. " <body>\n" +
  120. " <h1>femtoshare</h1>\n" +
  121. " <form enctype=\"multipart/form-data\" method=\"post\"><input name=\"upload_file\" type=\"file\" /><input type=\"submit\" value=\"upload\" /></form>\n" +
  122. (
  123. " <table>\n" +
  124. " <thead><tr><th>name</th><th>size</th><th>last modified</th><th>actions</th></tr></thead>\n" +
  125. " <tbody>\n" +
  126. "".join(
  127. " <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(
  128. url=urllib.parse.quote(name), name=html.escape(name), size=size, last_modified=last_modified.isoformat()
  129. )
  130. for name, size, last_modified in table_entries
  131. ) +
  132. " </tbody>\n" +
  133. " </table>\n"
  134. if table_entries else
  135. " <p>(uploaded files will be visible here)</p>\n"
  136. ) +
  137. " </body>\n" +
  138. "</html>\n"
  139. )
  140. response_bytes = response.encode("utf8")
  141. self.send_response(200)
  142. self.send_header("Content-Type", "text/html")
  143. self.send_header("Content-Length", str(len(response_bytes)))
  144. self.end_headers()
  145. self.wfile.write(response_bytes)
  146. def is_valid_filename(self, filename):
  147. 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
  148. return False
  149. if filename in (os.pardir, os.curdir): # check for reserved filenames
  150. return False
  151. if re.match(r"^[\w `!@\$\^\(\)\-=_\[\];',\.]*$", filename) is None: # check for invalid characters
  152. return False
  153. return True
  154. if __name__ == '__main__':
  155. os.makedirs(FILES_DIRECTORY, exist_ok=True) # ensure files directory exists
  156. server = HTTPServer(SERVER_ADDRESS, FemtoshareRequestHandler)
  157. try:
  158. server.serve_forever()
  159. except KeyboardInterrupt:
  160. pass