server.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import cherrypy # <3
  2. from cherrypy.process import plugins
  3. import chevron # its fate is the same as cherrypy
  4. import os
  5. import hashlib
  6. import mimetypes
  7. import urllib
  8. import re
  9. import json
  10. import random
  11. import threading
  12. from storage import Storage, InvalidPath
  13. @cherrypy.tools.register("before_handler")
  14. def auth():
  15. if cherrypy.session.get("login"):
  16. return
  17. else:
  18. raise cherrypy.HTTPRedirect("/login")
  19. class IconHandler:
  20. def __init__(self):
  21. self.mimeDic = mimetypes.types_map
  22. self.custom = {}
  23. with open(os.path.abspath('./icons-config.json')) as ic:
  24. conf = json.load(ic)
  25. self.custom = conf['custom']
  26. self.mime_filter = conf['mime-filter']
  27. self.default = conf['default']
  28. def get_icon_and_class(self, ext):
  29. for exts, p in self.custom.items():
  30. if ext in exts.split(', '):
  31. return p['icon'], p['class']
  32. if '.' + ext in self.mimeDic:
  33. for pat, p in self.mime_filter.items():
  34. type_ = self.mimeDic['.' + ext]
  35. if re.match(pat, type_):
  36. return p['icon'], p['class']
  37. else:
  38. return self.default['icon'], self.default['class']
  39. class TimerKiller(plugins.SimplePlugin):
  40. def __init__(self, bus, app):
  41. plugins.SimplePlugin.__init__(self, bus)
  42. self.app = app
  43. def stop(self):
  44. self.app._timer.cancel()
  45. class FarCloud:
  46. def __init__(self, admin, templates, storage):
  47. # from 23 Oct 2020 admin MUST be
  48. # {"username": ..., "setpass": func with 1 arg, "getpass": func}
  49. self.admin = admin
  50. self.iconhandler = IconHandler()
  51. self.storage = storage
  52. self.templates = templates
  53. mimetypes.init()
  54. self.ip_request_count = {} # ip: requests number
  55. # ^ this will be cleared every self.COUNTER_RESET_INTERVAL
  56. # seconds with threading.Timer() and the limit below is used
  57. # to block IPs which reach this limit. If an IP is not in
  58. # this dictionary, it will be initialized to 0.
  59. #
  60. # Request count of an IP >= self.REQUEST_LIMIT
  61. # => the counter won't be increased anymore
  62. #
  63. # TODO: Should we decrease the requests number each time
  64. # instead of resetting it?
  65. # Advantage: Some IP may not send 2 * REQUEST_LIMIT at once
  66. # let's say in just one second.
  67. # Disadvantage: We have to store numbers higher than
  68. # REQUEST_LIMIT and some IP may force high
  69. # memory usage for our App since integers
  70. # in Python are limited by memory size
  71. # not number of bits like in C/C++
  72. #
  73. # >>>> This limit is used ONLY for login page <<<<
  74. self.REQUEST_LIMIT = 6
  75. self.COUNTER_RESET_INTERVAL = 30 # in seconds
  76. # ^ meaning 6 login validation request every 30 seconds
  77. self.reset_req_count()
  78. def reset_req_count(self):
  79. self.ip_request_count.clear()
  80. self._timer = threading.Timer(self.COUNTER_RESET_INTERVAL, self.reset_req_count)
  81. self._timer.start()
  82. def getmime(self, ext):
  83. if not ext.startswith("."):
  84. ext = "." + ext
  85. unknown = "application/octet-stream"
  86. return mimetypes.types_map.get(ext) or unknown
  87. def get_pass_hash(self, pass_):
  88. the_hash = hashlib.sha512(pass_.encode()).hexdigest()
  89. return the_hash
  90. def get_file_pass_hash(self, pass_):
  91. the_hash = hashlib.sha256(pass_.encode()).hexdigest()
  92. return the_hash
  93. def humanreadablesize(self, size):
  94. units = ("B", "KiB", "MiB", "GiB", "TiB")
  95. unit = 0
  96. while size >= 1024 and unit < 5:
  97. size /= 1024
  98. unit += 1
  99. return f"{size:.2f} " + units[unit]
  100. @cherrypy.tools.auth()
  101. @cherrypy.expose
  102. def modify(self, command, name, new):
  103. commands = dict()
  104. commands["name"] = self.storage.move
  105. commands["desc"] = lambda a,b: self.storage.set_property(a,"description",b)
  106. commands["pass"] = lambda a,b: self.storage.set_property(a,"password",b)
  107. if command not in commands:
  108. return "Invalid command"
  109. try:
  110. commands[command](name, new)
  111. except InvalidPath:
  112. raise cherrypy.HTTPError(400, "Invalid path")
  113. return "Well done :)"
  114. @cherrypy.tools.auth()
  115. @cherrypy.expose
  116. def change_password(self, old, new):
  117. old_hash = self.get_pass_hash(old)
  118. if old_hash == self.admin["getpass"]():
  119. new_hash = self.get_pass_hash(new)
  120. self.admin["setpass"](new_hash)
  121. cherrypy.session["login"] = False
  122. return "Successfully changed the password! Now logging out..."
  123. else:
  124. return "Sorry! Incorrect old password"
  125. @cherrypy.tools.auth()
  126. @cherrypy.expose
  127. def upload_url(self, url, name=""):
  128. try:
  129. _ = not name or self.storage._safe(name)
  130. req = urllib.request.Request(url)
  131. resp = urllib.request.urlopen(req)
  132. except ValueError as e:
  133. return cherrypy.HTTPError(400, str(e))
  134. except urllib.error.HTTPError as e:
  135. code = e.code
  136. return f"Cannot fetch: {code}"
  137. except InvalidPath:
  138. raise cherrypy.HTTPError(400, "Invalid path")
  139. except urllib.error.URLError:
  140. return "Cannot open URL"
  141. if not name:
  142. pat = "filename(.+)"
  143. hdr = resp.headers.get("Content-Disposition") or ' '
  144. fname = re.findall(pat, hdr)
  145. aname = url.split("/")[-1]
  146. if fname:
  147. name = fname[0]
  148. elif aname:
  149. name = aname
  150. else:
  151. name = str(random.randint(1_000, 10_000_000))
  152. self.storage.add(name, resp)
  153. return f"Downloaded the file and saved it with name {name}"
  154. @cherrypy.expose
  155. def hashsum(self, name):
  156. try:
  157. if not self.storage.exists(name):
  158. return "File does not exist"
  159. if self.storage.isdir(name):
  160. return "It is a directory"
  161. return self.storage.hashsum(name)
  162. except InvalidPath:
  163. return "The path you have sent is not valid(or in my scope)"
  164. except:
  165. return "Cannot open file."
  166. @cherrypy.tools.auth()
  167. @cherrypy.expose
  168. def logout(self):
  169. cherrypy.session["login"] = False
  170. raise cherrypy.HTTPRedirect("/")
  171. @cherrypy.expose
  172. def login(self, username="", password=""):
  173. ip = cherrypy.request.remote.ip
  174. if self.ip_request_count.setdefault(ip,0) >= self.REQUEST_LIMIT:
  175. return "Sorry! Your machine is sending too many validation requests. Please wait a few minutes and retry"
  176. if len(username) > 16:
  177. return "Too long username"
  178. if len(password) > 256:
  179. return "Too long password"
  180. if not username or not password:
  181. template_data = dict() # Let it be as is, for future needs
  182. return chevron.render(get_template("login"), template_data)
  183. if self.ip_request_count[ip] < self.REQUEST_LIMIT:
  184. self.ip_request_count[ip] += 1
  185. pwd_h = self.get_pass_hash(password)
  186. if username == self.admin["username"] and pwd_h == self.admin["getpass"]():
  187. cherrypy.session["login"] = True
  188. raise cherrypy.HTTPRedirect("/")
  189. return "Invalid username and/or password"
  190. @cherrypy.tools.auth()
  191. @cherrypy.expose
  192. def delete(self, name):
  193. try:
  194. if not self.storage.exists(name):
  195. return "File does not exist"
  196. self.storage.delete(name)
  197. return f"Deleted {name}"
  198. except InvalidPath:
  199. raise cherrypy.HTTPError(400, "Invalid path")
  200. except:
  201. raise cherrypy.HTTPError(500, "Cannot delete")
  202. @cherrypy.expose
  203. def download(self, name, password=""):
  204. hidden = os.path.basename(name).startswith(".")
  205. is_user = not cherrypy.session.get("login")
  206. if hidden and is_user:
  207. raise cherrypy.HTTPError(404, "No such file")
  208. if not name:
  209. raise cherrypy.HTTPError(418, "Hello! I am Teapotware :)")
  210. try:
  211. if not self.storage.exists(name):
  212. raise cherrypy.HTTPError(404, f"No file named {name}")
  213. except InvalidPath:
  214. raise cherrypy.HTTPError(400, "Invalid path was sent")
  215. file_password = storage.get_property(name, "password")
  216. if file_password and file_password != password:
  217. raise cherrypy.HTTPError(403, "Password mismatch")
  218. else:
  219. count = self.storage.get_property(name, "dcount") or 0
  220. self.storage.set_property(name, "dcount", count + 1)
  221. bname = os.path.basename(name)
  222. mime = self.getmime(bname.split(".")[-1])
  223. cherrypy.response.headers["Content-type"] = mime
  224. name_ = f"attachment; filename=\"{bname}\""
  225. cherrypy.response.headers["content-disposition"] = name_
  226. return cherrypy.lib.file_generator(self.storage.get(name))
  227. @cherrypy.tools.auth()
  228. @cherrypy.expose
  229. def upload(self, pwd="", desc="", name=""):
  230. if not name:
  231. name = str(random.randint(10000, 100000))
  232. try:
  233. p = self.storage._safe(name)
  234. except InvalidPath:
  235. raise cherrypy.HTTPError(400, "Invalid path")
  236. props = dict()
  237. props["password"] = pwd
  238. props["description"] = desc
  239. self.storage.add(name, cherrypy.request.body.fp, props)
  240. return f"Uploaded {name}"
  241. @cherrypy.tools.auth()
  242. @cherrypy.tools.json_out()
  243. @cherrypy.expose
  244. def counter(self):
  245. result = {}
  246. for file in self.storage.ls():
  247. print(file)
  248. count = self.storage.get_property(file, "dcount")
  249. if count:
  250. result[file] = count
  251. return result
  252. @cherrypy.tools.json_out()
  253. @cherrypy.expose
  254. def ls(self):
  255. files = dict()
  256. admin = cherrypy.session.get("login")
  257. for file in self.storage.ls():
  258. if not admin and file.startswith("."):
  259. continue
  260. props = dict()
  261. if not self.storage.isdir(file):
  262. props["size"] = self.storage.size(file)
  263. props["desc"] = self.storage.get_property(file, "description")
  264. if admin:
  265. props["pass"] = self.storage.get_property(file, "password")
  266. files[file] = props
  267. return files
  268. @cherrypy.expose
  269. def index(self):
  270. admin = cherrypy.session.get("login")
  271. basename = os.path.basename
  272. predicate = lambda f: admin or not basename(f).startswith(".")
  273. files = filter(predicate, self.storage.ls())
  274. table = []
  275. for file in files:
  276. if self.storage.isdir(file):
  277. continue
  278. pwd = self.storage.get_property(file, "password") or ""
  279. desc = self.storage.get_property(file, "description") or ""
  280. size = self.storage.size(file)
  281. file_ = file.split(".")
  282. ext = "" if len(file_) == 1 else file_[-1]
  283. icon, class_ = self.iconhandler.get_icon_and_class(ext)
  284. table.append({"name": file,
  285. "type_icon": icon,
  286. "type_class": class_,
  287. "desc": desc,
  288. "size": self.humanreadablesize(size),
  289. "admin": admin,
  290. "pass": pwd,
  291. "has_pass": bool(pwd)
  292. })
  293. totalsize = self.humanreadablesize(self.storage.total_size)
  294. return chevron.render(get_template("index"),
  295. {
  296. "files": table,
  297. "show_table": bool(table),
  298. "admin": admin,
  299. "totalsize": totalsize
  300. })
  301. if __name__ == "__main__":
  302. import configparser
  303. import sys
  304. def conv_SI_bytes(s):
  305. if s.isdigit():
  306. return int(s)
  307. s = s.upper()
  308. units = ("B", "K", "M", "G", "T")
  309. unit = s[-1]
  310. if unit not in units:
  311. raise ValueError("Unit MUST be either B,K,M,G,T")
  312. result = int(s[:-1])
  313. i = units.index(unit)
  314. return result * 1024 ** i
  315. admin = dict()
  316. config = configparser.ConfigParser()
  317. config_file = "server_config.ini"
  318. argv = sys.argv
  319. if "-h" in argv or (len(argv) > 1 and "-c" not in argv):
  320. print("Usage:", argv[0], "[-c <config_file>]", file=sys.stderr)
  321. sys.exit(0)
  322. if "-c" in argv:
  323. config_file = argv[argv.index("-c") + 1]
  324. config_file = os.path.abspath(config_file)
  325. if not os.path.exists(config_file):
  326. print("File does not exist:", config_file, file=sys.stderr)
  327. sys.exit(0)
  328. config.read(config_file)
  329. def_config = config["DEFAULT"]
  330. port = def_config.getint("port", 8085)
  331. host = def_config.get("host", "0.0.0.0")
  332. max_req_size = def_config.get("upload_max", "64G")
  333. unix_socket = def_config.get("unix_socket_file", None)
  334. posixtmp = "/tmp/"
  335. nttmp = os.path.expandvars("%systemdrive%/Windows/Temp/")
  336. def_base_dir = nttmp if os.name == "nt" else posixtmp
  337. base_dir = def_config.get("base_dir", def_base_dir)
  338. admin["username"] = def_config.get("admin_username", "admin")
  339. def_admin_pass_hash = hashlib.sha512(b"admin").hexdigest()
  340. def getpass_func():
  341. return def_config.get("admin_pass", def_admin_pass_hash)
  342. admin["getpass"] = getpass_func
  343. def setpass_func(new):
  344. def_config.update({"admin_pass": new})
  345. print(f"let's write to {config_file}")
  346. with open(config_file, "w") as fp:
  347. config.write(fp)
  348. admin["setpass"] = setpass_func
  349. debug = def_config.getboolean("debug", False)
  350. secure = def_config.getboolean("secure", False)
  351. db_dir = def_config.get("db_dir", os.path.join(base_dir, ".db"))
  352. max_req_size = conv_SI_bytes(max_req_size)
  353. conf = {"global":
  354. {
  355. "server.max_request_body_size": max_req_size,
  356. "server.socket_host": host,
  357. "server.socket_port": port,
  358. "server.socket_file": unix_socket,
  359. "tools.sessions.on": True,
  360. "tools.sessions.secure": secure,
  361. "tools.sessions.httponly": secure,
  362. "tools.staticdir.on": True,
  363. "tools.staticdir.dir": os.path.abspath("./webInterface/files/"),
  364. "tools.staticdir.root": "/",
  365. "environment": None if debug else "production"
  366. }
  367. }
  368. templates = dict()
  369. templates["index"] = os.path.realpath("./webInterface/index.mustache")
  370. templates["login"] = os.path.realpath("./webInterface/login.mustache")
  371. if not debug:
  372. for template, dir_ in templates.items():
  373. with open(dir_) as f:
  374. templates[template] = f.read()
  375. def get_template(template):
  376. if not debug:
  377. return templates[template]
  378. else:
  379. with open(templates[template]) as f:
  380. return f.read()
  381. valid_props = ("password", "description", "dcount")
  382. storage = Storage(base_dir, db_dir, valid_props)
  383. farcloud = FarCloud(admin, templates, storage)
  384. TimerKiller(cherrypy.engine, farcloud).subscribe()
  385. cherrypy.quickstart(farcloud, "/", conf)