#!/usr/bin/env python3 # Copyright (C) 2016-2017 Florian Festi # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . from __future__ import annotations import argparse import gettext import glob import html import mimetypes import os.path import re import sys import tempfile import threading import time import traceback from typing import Any from urllib.parse import parse_qs from urllib.parse import unquote_plus, quote from wsgiref.simple_server import make_server import markdown try: import boxes.generators except ImportError: sys.path.append(os.path.join(os.path.dirname(__file__), "..")) import boxes.generators class FileChecker(threading.Thread): def __init__(self, files=[], checkmodules=True) -> None: super(FileChecker, self).__init__() self.checkmodules = checkmodules self.timestamps = {} self._stopped = False for path in files: self.timestamps[path] = os.stat(path).st_mtime if checkmodules: self._addModules() def _addModules(self): for name, module in sys.modules.items(): path = getattr(module, "__file__", None) if not path: continue if path not in self.timestamps: self.timestamps[path] = os.stat(path).st_mtime def filesOK(self): if self.checkmodules: self._addModules() for path, timestamp in self.timestamps.items(): try: if os.stat(path).st_mtime != timestamp: return False except FileNotFoundError: return False return True def run(self): while not self._stopped: if not self.filesOK(): os.execv(__file__, sys.argv) time.sleep(1) def stop(self): self._stopped = True class ArgumentParserError(Exception): pass class ThrowingArgumentParser(argparse.ArgumentParser): def error(self, message): raise ArgumentParserError(message) # Evil hack boxes.ArgumentParser = ThrowingArgumentParser # type: ignore static_url = "static" class BServer: lang_re = re.compile(r"([a-z]{2,3}(-[-a-zA-Z0-9]*)?)\s*(;\s*q=(\d\.?\d*))?") def __init__(self) -> None: self.boxes = {b.__name__ : b for b in boxes.generators.getAllBoxGenerators().values() if b.webinterface} self.boxes['TrayLayout2'] = boxes.generators.traylayout.TrayLayout2 # type: ignore # no attribute "traylayout" self.groups = boxes.generators.ui_groups self.groups_by_name = boxes.generators.ui_groups_by_name for name, box in self.boxes.items(): self.groups_by_name.get(box.ui_group, self.groups_by_name["Misc"]).add(box) self.staticdir = os.path.join(os.path.dirname(__file__), '../static/') self._languages = None self._cache: dict[Any, Any] = {} def getLanguages(self, domain=None, localedir=None): if self._languages is not None: return self._languages self._languages = [] domain = "boxes.py" for localedir in ["locale", gettext._default_localedir]: files = glob.glob(os.path.join(localedir, '*', 'LC_MESSAGES', '%s.mo' % domain)) self._languages.extend([file.split(os.path.sep)[-3] for file in files]) self._languages.sort() return self._languages def getLanguage(self, args, accept_language): lang = None langs = [] for i, arg in enumerate(args): if arg.startswith("language="): lang = arg[len("language="):] del args[i] break if lang: try: return gettext.translation('boxes.py', localedir='locale', languages=[lang]) except OSError: pass try: return gettext.translation('boxes.py', languages=[lang]) except OSError: pass # selected language not found try browser default languages = accept_language.split(",") for l in languages: m = self.lang_re.match(l.strip()) if m: langs.append((float(m.group(4) or 1.0), m.group(1))) langs.sort(reverse=True) langs = [l[1].replace("-", "_") for l in langs] try: return gettext.translation('boxes.py', localedir='locale', languages=langs) except OSError: return gettext.translation('boxes.py', languages=langs, fallback=True) def arg2html(self, a, prefix, defaults={}, _=lambda s:s): name = a.option_strings[0].replace("-", "") if isinstance(a, argparse._HelpAction): return "" viewname = name if prefix and name.startswith(prefix + '_'): viewname = name[len(prefix)+1:] default = defaults.get(name, None) row = """%%s%s\n""" % \ (name+"_id", name, _(viewname), name+"_description","" if not a.help else markdown.markdown(_(a.help))) if (isinstance(a, argparse._StoreAction) and hasattr(a.type, "html")): input = a.type.html(name, default or a.default, _) elif a.dest == "layout": val = (default or a.default).split("\n") input = """""" % \ (name, name, name+"_id", name+"_description", max((len(l) for l in val))+10, len(val)+1, default or a.default) elif a.choices: options = "\n".join( ("""""" % (e, ' selected="selected"' if (e == (default or a.default)) or (str(e) == str(default or a.default)) else "", _(e)) for e in a.choices)) input = """\n""" % (name, name, name+"_id", name+"_description", options) else: input = """""" % \ (name, name, name+"_id", name+"_description", default or a.default) return row % input scripts = """ """ def args2html_cached(self, name, box, lang, action="", defaults={}): if defaults == {}: key = (name, lang.info().get('language', None), action) if key not in self._cache: self._cache[key] = list(self.args2html(name, box, lang, action, defaults)) return self._cache[key] return self.args2html(name, box, lang, action, defaults) def args2html(self, name, box, lang, action="", defaults={}): _ = lang.gettext lang_name = lang.info().get('language', None) if lang_name: langparam = "?language=" + lang_name else: langparam = "" result = [f""" {_("Boxes - %s") % _(name)} {self.scripts % (len(box.argparser._action_groups)-3)}

{_("Boxes.py")}

self-Logo

{_(name)}

{_(box.__doc__) if box.__doc__ else ""}

"""] groupid = 0 for group in box.argparser._action_groups[3:] + box.argparser._action_groups[:3]: if not group._group_actions: continue if len(group._group_actions) == 1 and isinstance(group._group_actions[0], argparse._HelpAction): continue prefix = getattr(group, "prefix", None) result.append(f'''

{_(group.title)}

\n\n''') for a in group._group_actions: if a.dest in ("input", "output"): continue result.append(self.arg2html(a, prefix, defaults, _)) result.append("") groupid += 1 result.append(f"""


""") no_img_msg = _('There is no image yet. Please donate an image of your project on GitHub!') if box.description: result.append( markdown.markdown(_(box.description), extensions=["extra"]) .replace('src="static/', f'src="{static_url}/')) result.append(f'''
{self.footer(lang)} ''' ) return (s.encode("utf-8") for s in result) def menu(self, lang): _ = lang.gettext lang_name = lang.info().get('language', None) if lang_name: langparam = "?language=" + lang_name else: langparam = "" result = [f""" {_("Boxes.py")} """, """ {self.scripts % len(self.groups)}

{_("Boxes.py")}

{_("Create boxes and more with a laser cutter!")}

{_(''' Boxes.py is an Open Source box generator written in Python. It features both finished parametrized generators as well as a Python API for writing your own. It features finger and (flat) dovetail joints, flex cuts, holes and slots for screws, hinges, gears, pulleys and much more.''')}

self-Logo


{self.footer(lang)} """) return (s.encode("utf-8") for s in result) def footer(self, lang): _ = lang.gettext language = lang.info().get('language', '') return """ """ def errorMessage(self, name, e, _): return [(f""" {_("Error generating %s") % _(name)}

{_("An error occurred!")}

""" + "".join(u"

%s

" % html.escape(s) for s in type(u"")(e).split(u"\n")) + """ """).encode("utf-8") ] def serveStatic(self, environ, start_response): filename = environ["PATH_INFO"][len("/static/"):] path = os.path.join(self.staticdir, filename) if (not re.match(r"[a-zA-Z0-9_/-]+\.[a-zA-Z0-9]+", filename) or not os.path.exists(path)): if re.match(r"samples/.*-thumb.jpg", filename): path = os.path.join(self.staticdir, "nothing.png") else: start_response("404 Not Found", [('Content-type', 'text/plain')]) return [b"Not found"] type_, encoding = mimetypes.guess_type(filename) if encoding is None: encoding = "utf-8" # Images do not have charset. Just bytes. Except text based svg. # Todo: fallback if type_ is None? if type_ is not None and "image" in type_ and type_ != "image/svg+xml": start_response("200 OK", [('Content-type', "%s" % type_)]) else: start_response("200 OK", [('Content-type', "%s; charset=%s" % (type_, encoding))]) f = open(path, 'rb') return environ['wsgi.file_wrapper'](f, 512*1024) def getURL(self, environ): url = environ['wsgi.url_scheme']+'://' if environ.get('HTTP_HOST'): url += environ['HTTP_HOST'] else: url += environ['SERVER_NAME'] if environ['wsgi.url_scheme'] == 'https': if environ['SERVER_PORT'] != '443': url += ':' + environ['SERVER_PORT'] else: if environ['SERVER_PORT'] != '80': url += ':' + environ['SERVER_PORT'] url += quote(environ.get('SCRIPT_NAME', '')) url += quote(environ.get('PATH_INFO', '')) if environ.get('QUERY_STRING'): url += '?' + environ['QUERY_STRING'] return url def serve(self, environ, start_response): if environ["PATH_INFO"].startswith("/static/"): return self.serveStatic(environ, start_response) status = '200 OK' headers = [('Content-type', 'text/html; charset=utf-8'), ('X-XSS-Protection', '1; mode=block'), ('X-Content-Type-Options', 'nosniff'), ('x-frame-options', 'SAMEORIGIN'), ('Referrer-Policy', 'no-referrer')] d = parse_qs(environ.get('QUERY_STRING', '')) name = environ["PATH_INFO"][1:] args = [unquote_plus(arg) for arg in environ.get('QUERY_STRING', '').split("&")] render = "0" for arg in args: if arg.startswith("render="): render = arg[len("render="):] lang = self.getLanguage(args, environ.get("HTTP_ACCEPT_LANGUAGE", "")) _ = lang.gettext box_cls = self.boxes.get(name, None) if not box_cls: start_response(status, headers) lang_name = lang.info().get('language', None) if lang_name not in self._cache: self._cache[lang_name] = list(self.menu(lang)) return self._cache[lang_name] if name == "TrayLayout2": box = box_cls(self, webargs=True) else: box = box_cls() if render == "0": defaults = { } for a in args: kv = a.split('=') if len(kv) == 2: k, v = kv defaults[k] = html.escape(v, True) start_response(status, headers) return self.args2html_cached(name, box, lang, "./" + name, defaults=defaults) else: args = ["--"+ arg for arg in args if not arg.startswith("render=")] try: box.parseArgs(args) except (ArgumentParserError) as e: start_response(status, headers) return self.errorMessage(name, e, _) if name == "TrayLayout": start_response(status, headers) box.fillDefault(box.x, box.y) layout2 = boxes.generators.traylayout.TrayLayout2(self, webargs=True) layout2.argparser.set_defaults(layout=str(box)) return self.args2html( name, layout2, lang, action="TrayLayout2") if name == "TrayLayout2": try: box.parse(box.layout.split("\n")) except Exception as e: start_response(status, headers) return self.errorMessage(name, e, _) try: fd, box.output = tempfile.mkstemp() box.metadata["url"] = self.getURL(environ) box.open() box.render() box.close() except Exception as e: if not isinstance(e, ValueError): print("Exception during rendering:") traceback.print_exc() start_response("500 Internal Server Error", headers) return self.errorMessage(name, e, _) http_headers = box.formats.http_headers.get( box.format, [('Content-type', 'application/unknown; charset=utf-8')])[:] # Prevent crawlers. http_headers.append(('X-Robots-Tag', 'noindex,nofollow')) if box.format != "svg" or render == "2": extension = box.format if extension == "svg_Ponoko": extension = "svg" http_headers.append(('Content-Disposition', 'attachment; filename="%s.%s"' % (box.__class__.__name__, extension))) start_response(status, http_headers) result = open(box.output, 'rb').readlines() os.close(fd) os.remove(box.output) return (l for l in result) if __name__=="__main__": host = '' port = 8000 if len(sys.argv) > 1: tmp = sys.argv[1].split(':') if len(tmp) == 2: host = tmp[0] port = int(tmp[1]) else: port = int(tmp[0]) fc = FileChecker() fc.start() boxserver = BServer() httpd = make_server(host, port, boxserver.serve) print("BoxesServer serving on host:port %s:%s..." % (host, port) ) try: httpd.serve_forever() except KeyboardInterrupt: fc.stop() httpd.server_close() print("BoxesServer stops.") else: application = BServer().serve static_url = "https://florianfesti.github.io/boxes/static"