#!/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, NoReturn 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: bool = True) -> None: super().__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) -> None: 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) -> bool: 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) -> None: while not self._stopped: if not self.filesOK(): os.execv(__file__, sys.argv) time.sleep(1) def stop(self) -> None: self._stopped = True class ArgumentParserError(Exception): pass class ThrowingArgumentParser(argparse.ArgumentParser): def error(self, message) -> NoReturn: raise ArgumentParserError(message) # Evil hack boxes.ArgumentParser = ThrowingArgumentParser # type: ignore class BServer: lang_re = re.compile(r"([a-z]{2,3}(-[-a-zA-Z0-9]*)?)\s*(;\s*q=(\d\.?\d*))?") def __init__(self, url_prefix="", static_url="static") -> 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] = {} self.url_prefix = url_prefix self.static_url = static_url 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""".format(name, name, name + "_id", name + "_description", options) else: input = """""" % \ (name, name, name + "_id", name + "_description", default or a.default) return row % input 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) langparam = "" if lang_name: langparam = "?language=" + lang_name result = [f"""{self.genHTMLStart(lang)} {_("%s - Boxes") % _(name)} {self.genHTMLMeta()} {self.genHTMLMetaLanguageLink()} {self.genHTMLCSS()} {self.genHTMLJS()}

{_("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="{self.static_url}/')) result.append(f'''
Picture of box.
{self.genPagePartFooter(lang)} ''') return (s.encode("utf-8") for s in result) def genPageMenu(self, lang): _ = lang.gettext lang_name = lang.info().get('language', None) langparam = "" if lang_name: langparam = "?language=" + lang_name result = [f"""{self.genHTMLStart(lang)} {_("Boxes.py")} {self.genHTMLMeta()} {self.genHTMLMetaLanguageLink()} {self.genHTMLCSS()} {self.genHTMLJS()}

{_("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.genPagePartFooter(lang)} """) return (s.encode("utf-8") for s in result) def genHTMLStart(self, lang) -> str: lang_attr = lang.info().get("language", "") if lang_attr != "": return f"""""" return "" def genHTMLMeta(self) -> str: return f''' ''' def genHTMLMetaLanguageLink(self) -> str: """Generates meta language list for search engines.""" languages = self.getLanguages() s = "" for language in languages: s += f'\n' return s def genHTMLCSS(self) -> str: return f'' def genHTMLJS(self) -> str: return f'' def genHTMLLanguageSelection(self, lang) -> str: """Generates a dropdown selection for the language change.""" current_language = lang.info().get('language', '') languages = self.getLanguages() if len(languages) < 2: return "" html_option = "" for language in languages: html_option += f"\n" return """
""" def genPagePartFooter(self, lang) -> str: _ = lang.gettext return """ """ def genPageError(self, name, e, lang) -> list[bytes]: """Generates a error page.""" _ = lang.gettext h = f"""{self.genHTMLStart(lang)} {_("Error generating %s") % _(name)} {self.genHTMLMeta()}

{_("An error occurred!")}

""" for s in str(e).split("\n"): h += f"

{html.escape(s)}

\n" h += "" return [h.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', f"{type_}; charset={encoding}")]) f = open(path, 'rb') return environ['wsgi.file_wrapper'](f, 512 * 1024) def getURL(self, environ) -> str: 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(self.url_prefix) url += quote(environ.get('SCRIPT_NAME', '')) url += quote(environ.get('PATH_INFO', '')) if environ.get('QUERY_STRING'): url += '?' + environ['QUERY_STRING'] return url def serveGallery(self, environ, start_response, lang): _ = lang.gettext lang_name = lang.info().get('language', None) start_response("200 OK", [('Content-type', "text/html; charset=utf-8")]) if ("Gallery", lang_name) in self._cache: return self._cache[("Gallery", lang_name)] langparam = "" if lang_name: langparam = "?language=" + lang_name result = [f""" {self.genHTMLStart(lang)} {_("Gallery")} - {_("Boxes.py")} {self.genHTMLMeta()} {self.genHTMLMetaLanguageLink()} {self.genHTMLCSS()} {self.genHTMLJS()}

{_("Boxes.py")}

{_("Gallery")}

self-Logo
"""] for nr, group in enumerate(self.groups): result.append(f"

{_(group.title)}

\n") for box in group.generators: name = box.__name__ fn = f"samples/{name}-thumb.jpg" thumbnail = f"{self.static_url}/{fn}" static_filename = os.path.join(self.staticdir, fn) alt = f"{_(name)}" href = f"{name}{langparam}" if not os.path.exists(static_filename): result.append(f""" {_(name)}

{_(box.__doc__)}
\n""") else: result.append(f""" {alt}\n""") result.append(f""" {self.genPagePartFooter(lang)} """ ) self._cache[("Gallery", lang_name)] = [s.encode("utf-8") for s in result] return self._cache[("Gallery", lang_name)] def serve(self, environ, start_response): # serve favicon from static for generated SVGs if environ["PATH_INFO"] == "favicon.ico": environ["PATH_INFO"] = "/static/favicon.ico" 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')] 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 if name == "Gallery": return self.serveGallery(environ, start_response, lang) 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.genPageMenu(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) 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.genPageError(name, e, lang) 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.genPageError(name, e, lang) 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.genPageError(name, e, lang) 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', f'attachment; filename="{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__": parser = argparse.ArgumentParser() parser.add_argument("--host", default="") parser.add_argument("--port", type=int, default=8000) parser.add_argument("--url_prefix", default="", help="URL path to Boxes.py instance") parser.add_argument("--static_url", default="static", help="URL of static content") args = parser.parse_args() boxserver = BServer(url_prefix=args.url_prefix, static_url=args.static_url) fc = FileChecker() fc.start() httpd = make_server(args.host, args.port, boxserver.serve) print(f"BoxesServer serving on {args.host}:{args.port}...") try: httpd.serve_forever() except KeyboardInterrupt: fc.stop() httpd.server_close() print("BoxesServer stops.") else: boxserver = BServer(url_prefix='/boxes.py', static_url="https://florianfesti.github.io/boxes/static") application = boxserver.serve