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.

189 lines
8.1 KiB

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