Gemini-Gopher bi-hosting tool - run at gemini://vger.cloud:1965 & JetForce at gemini://gemini.vger.cloud:1965
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.

gegobi.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. #!/usr/bin/env python3
  2. import argparse
  3. import time
  4. import mimetypes
  5. import os
  6. import shlex
  7. import subprocess
  8. import socket
  9. import socketserver
  10. import ssl
  11. import stat
  12. import sys
  13. import tempfile
  14. import urllib.parse
  15. HOST = "localhost"
  16. def _format_filesize(size):
  17. if size < 1024:
  18. return "{:5.1f} B".format(size)
  19. elif size < 1024**2:
  20. return "{:5.1f} KiB".format(size / 1024.0)
  21. elif size < 1024**3:
  22. return "{:5.1f} MiB".format(size / 1024.0**2)
  23. class GegobiHandler(socketserver.BaseRequestHandler):
  24. def __init__(self, *args, **kwargs):
  25. socketserver.BaseRequestHandler.__init__(self, *args, **kwargs)
  26. def setup(self):
  27. """
  28. Wrap socket in SSL session.
  29. """
  30. self.request = context.wrap_socket(self.request, server_side=True)
  31. def handle(self):
  32. # Parse request URL, make sure it's for a Gemini resource
  33. self.parse_request()
  34. if self.request_scheme != "gemini" or self.request_host not in ("localhost", self.server.args.host):
  35. self.send_gemini_header(50, "This server does not proxy requests.")
  36. return
  37. # Perform redirects
  38. if self.request_path in self.server.redirects:
  39. self.send_gemini_header(31, self.server.redirects[self.request_path])
  40. self.request.close()
  41. return
  42. # Resolve path to filesystem
  43. ## First, transform request_path to a relative path, so os.join doesn't
  44. ## break out of the base directory
  45. while self.request_path.startswith(os.sep):
  46. self.request_path = self.request_path[1:]
  47. ## Handle tilde paths
  48. if self.server.args.tilde and self.request_path.startswith("~"):
  49. bits = self.request_path.split(os.sep)
  50. # Remove ~ to get username
  51. bits[0] = bits[0][1:]
  52. bits.insert(1, self.server.args.tilde)
  53. bits.insert(0, os.sep + "home")
  54. local_path = os.path.join(*bits)
  55. ## Standard path
  56. else:
  57. local_path = os.path.join(self.server.args.base, self.request_path)
  58. ## Make absolutely sure we're not anywhere we shouldn't be
  59. if not local_path.startswith(self.server.args.base):
  60. self.send_gemini_header(51, "Not found.")
  61. return
  62. # Handle not founds
  63. if not os.path.exists(local_path):
  64. self.send_gemini_header(51, "Not found.")
  65. return
  66. if ".." in local_path or local_path.endswith(".pem"):
  67. self.send_gemini_header(51, "Not found.")
  68. return
  69. # Check for .nogegobi files
  70. dir_ = os.path.dirname(local_path)
  71. while True:
  72. if os.path.exists(os.path.join(dir_,".nogegobi")):
  73. self.send_gemini_header(51, "Not found.")
  74. return
  75. if dir_ == "/":
  76. break
  77. dir_ = os.path.split(dir_)[0]
  78. # Check for world readability
  79. st = os.stat(local_path)
  80. if not st.st_mode & stat.S_IROTH:
  81. self.send_gemini_header(51, "Not found.")
  82. return
  83. # Handle directories
  84. if os.path.isdir(local_path):
  85. # Redirect to add trailing slash so relative URLs work
  86. if self.request_path and not self.request_path.endswith("/"):
  87. self.send_gemini_header(31, self.request_url+"/")
  88. return
  89. # Check for gemini or gopher menus
  90. geminimap = os.path.join(local_path, "index.gmi")
  91. geminimap2 = os.path.join(local_path, "index.gemini")
  92. gophermap = os.path.join(local_path, "gophermap")
  93. indexgph = os.path.join(local_path, "index.gph")
  94. if os.path.exists(geminimap):
  95. self.handle_geminimap(geminimap)
  96. elif os.path.exists(geminimap2):
  97. self.handle_geminimap(geminimap2)
  98. elif os.path.exists(gophermap):
  99. self.handle_gophermap(gophermap)
  100. elif os.path.exists(indexgph):
  101. self.handle_gph(indexgph)
  102. else:
  103. self.generate_directory_listing(local_path)
  104. # Handle files
  105. else:
  106. self.handle_file(local_path)
  107. # Clean up
  108. self.request.close()
  109. def _send(self, string):
  110. self.request.send(string.encode("UTF-8"))
  111. def send_gemini_header(self, status, meta):
  112. """
  113. Send a Gemini header, and close the connection if the status code does
  114. not indicate success.
  115. """
  116. self._send("{} {}\r\n".format(status, meta))
  117. if status / 10 != 2:
  118. self.request.close()
  119. def parse_request(self):
  120. """
  121. Read a URL from the Gemini client and parse it up into parts,
  122. including separating out the Gopher item type.
  123. """
  124. requested_url = self.request.recv(1024).decode("UTF-8").strip()
  125. if "://" not in requested_url:
  126. requested_url = "gemini://" + requested_url
  127. self.request_url = requested_url
  128. parsed = urllib.parse.urlparse(requested_url)
  129. self.request_scheme = parsed.scheme
  130. self.request_host = parsed.hostname
  131. self.request_port = parsed.port or 1965
  132. self.request_path = parsed.path[1:] if parsed.path.startswith("/") else parsed.path
  133. self.request_path = urllib.parse.unquote(self.request_path)
  134. self.request_query = parsed.query
  135. def handle_geminimap(self, filename):
  136. self.send_gemini_header(20, "text/gemini")
  137. with open(filename,"r") as fp:
  138. self._send(fp.read())
  139. def handle_gophermap(self, filename):
  140. self.send_gemini_header(20, "text/gemini")
  141. with open(filename, "r") as fp:
  142. for line in fp:
  143. if "\t" in line:
  144. itemtype = line[0]
  145. parts = line[1:].strip().split("\t")
  146. if itemtype == "i":
  147. self._send(parts[0])
  148. else:
  149. if len(parts) == 2:
  150. # Relative link to same server
  151. name, link = parts
  152. if itemtype == "h" and link.startswith("URL:"):
  153. link = link[4:]
  154. elif len(parts) == 4:
  155. # External gopher link
  156. name, path, host, port = parts
  157. link = self._gopher_url(host, port, itemtype, path)
  158. self._send("=> %s %s\r\n" % (link, name))
  159. else:
  160. self._send(line)
  161. def handle_gph(self, filename):
  162. self.send_gemini_header(20, "text/gemini")
  163. with open(filename, "r") as fp:
  164. for line in fp:
  165. if line.startswith("[") and "]" in line and "|" in line:
  166. line = line.strip()
  167. # Menu item
  168. # Ugly way to handle escaped pipes...
  169. line = line.replace("\|","___GEGOBI_PIPE___")
  170. itemtype, name, link, host, port = line[1:-1].split("|")
  171. name = name.replace("___GEGOBI_PIPE___","|")
  172. if not itemtype:
  173. continue
  174. elif itemtype == "i":
  175. self._send(name)
  176. continue
  177. if host == "server":
  178. # Link to same server
  179. if itemtype == "h" and link.startswith("URL:"):
  180. link = link[4:]
  181. else:
  182. # External gopher link
  183. link = self._gopher_url(host, port, itemtype, path)
  184. self._send("=> %s %s\r\n" % (link, name))
  185. elif line.startswith("t"):
  186. # Trim t and send as info line
  187. self._send(line[1:])
  188. else:
  189. self._send(line)
  190. def _gopher_url(self, host, port, itemtype, path):
  191. path = itemtype + path
  192. if port == "70":
  193. netloc = host
  194. else:
  195. netloc = host + ":" + port
  196. return urllib.parse.urlunparse(("gopher", netloc, path, "", "", ""))
  197. def generate_directory_listing(self, directory):
  198. self.send_gemini_header(20, "text/gemini")
  199. self._send("[/{}]\r\n".format(self.request_path))
  200. self._send("\r\n")
  201. if directory != self.server.args.base:
  202. up = self._get_up_url()
  203. self._send("=> %s %s\r\n" % (urllib.parse.quote(up), ".."))
  204. for f in os.listdir(directory):
  205. # Only list world readable files
  206. st = os.stat(os.path.join(directory,f))
  207. if not st.st_mode & stat.S_IROTH:
  208. continue
  209. label = f.ljust(32)
  210. label += time.ctime(st.st_mtime).ljust(30)
  211. label += _format_filesize(st.st_size)
  212. self._send("=> %s %s\r\n" % (urllib.parse.quote(f), label))
  213. def _get_up_url(self):
  214. # You'd think this would be simple...
  215. path_to_split = "/" + self.request_path
  216. if path_to_split.endswith("/"):
  217. path_to_split = path_to_split[0:-1]
  218. up, _ = os.path.split(path_to_split)
  219. return up
  220. path_bits = list(os.path.split(self.request_path))
  221. while not path_bits[-1]:
  222. path_bits.pop()
  223. path_bits.pop()
  224. if not path_bits:
  225. return "/"
  226. else:
  227. return os.path.join(*path_bits)
  228. def handle_file(self, filename):
  229. """
  230. """
  231. # Guess/detect MIME type
  232. mimetype, encoding = mimetypes.guess_type(filename)
  233. if not mimetype:
  234. out = subprocess.check_output(
  235. shlex.split("file --brief --mime-type %s" % filename)
  236. )
  237. mimetype = out.decode("UTF-8").strip()
  238. self.send_gemini_header(20, mimetype)
  239. with open(filename,"rb") as fp:
  240. self.request.send(fp.read())
  241. if __name__ == "__main__":
  242. parser = argparse.ArgumentParser(description=
  243. """GeGoBi is a tool to easily serve existing Gopher content via the
  244. Gemini protocol as well, resulting in "Gemini-Gopher bi-hosting"
  245. (or "GeGoBi").""")
  246. parser.add_argument('--base', type=str, nargs="?", default="/var/gopher",
  247. help='Gopherhole base directory.')
  248. parser.add_argument('--cert', type=str, nargs="?", default="cert.pem",
  249. help='TLS certificate file.')
  250. parser.add_argument('--host', type=str, required=True,
  251. help='Hostname of Gemini server.')
  252. parser.add_argument('--key', type=str, nargs="?", default="key.pem",
  253. help='TLS private key file.')
  254. parser.add_argument('--local', action="store_true",
  255. help='Serve only on 127.0.0.1.')
  256. parser.add_argument('--port', type=int, nargs="?", default=1965,
  257. help='TCP port to serve on.')
  258. parser.add_argument('--redirects', type=str, nargs="?", default="",
  259. help='File to read redirect definitions from.')
  260. parser.add_argument('--tilde', type=str, nargs="?", default="",
  261. help='Home subdirectory to map tilde URLs to.')
  262. args = parser.parse_args()
  263. # Absolutise base directory and make sure it exists
  264. args.base = os.path.abspath(args.base)
  265. if not os.path.exists(args.base):
  266. print("Could not find base directory {}.".format(
  267. args.base))
  268. sys.exit(1)
  269. if not (os.path.exists(args.cert) and os.path.exists(args.key)):
  270. print("Could not find certificate file {} and/or key file {}.".format(
  271. args.cert, args.key))
  272. sys.exit(1)
  273. context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
  274. context.load_cert_chain(certfile=args.cert, keyfile=args.key)
  275. socketserver.ThreadingTCPServer.allow_reuse_address = 1
  276. gegobi = socketserver.ThreadingTCPServer(("localhost" if args.local else "",
  277. args.port), GegobiHandler)
  278. gegobi.args = args
  279. gegobi.redirects = {}
  280. if args.redirects:
  281. with open(args.redirects, "r") as fp:
  282. for line in fp:
  283. try:
  284. old, new = line.strip().split()
  285. except ValueError:
  286. continue
  287. gegobi.redirects[old] = new
  288. try:
  289. gegobi.serve_forever()
  290. except KeyboardInterrupt:
  291. gegobi.shutdown()
  292. gegobi.server_close()