A web framework for aspiring media terrorists – PRE-ALPHA – DO NOT DEPLOY!
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.

__init__.py 38KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182
  1. # -*- coding: utf-8 -*-
  2. """ A webframework for aspiring media terrorists. """
  3. import os
  4. import sys
  5. import types
  6. import collections
  7. import copy
  8. import functools
  9. import pathlib # only needed to pass a pathlib.Path to scss compiler
  10. import datetime
  11. import logging
  12. import json
  13. import pickle
  14. import OpenSSL as openssl
  15. import nacl.utils
  16. import nacl.secret
  17. import werkzeug
  18. import click
  19. import flask
  20. import jinja2
  21. from playhouse import db_url
  22. import peewee
  23. import scss # pyScss
  24. # imports needed for scss handle_import copy in SCSSCore
  25. from itertools import product
  26. from scss.source import SourceFile
  27. from pathlib import PurePosixPath
  28. # comfort imports to expose flask functionality directly through poobrains
  29. from flask import Response, request, session, redirect, flash, abort, url_for, g
  30. from flask.helpers import locked_cached_property
  31. from jinja2 import Markup
  32. # internal imports
  33. from . import helpers
  34. from . import errors
  35. from . import defaults
  36. db_url.schemes['sqlite'] = db_url.schemes['sqliteext'] # Make sure we get the extensible sqlite database, so we can make regular expressions case-sensitive. see https://github.com/coleifer/peewee/issues/1221
  37. import __main__ # to look up project name
  38. if hasattr(__main__, '__file__'):
  39. project_name = os.path.splitext(os.path.basename(__main__.__file__))[0] # basically filename of called file - extension
  40. else:
  41. project_name = "REPL" # We're probably in a REPL, right? <- I think this assumption is wrong.
  42. try:
  43. import config # imports config relative to main project
  44. except ImportError as e:
  45. config = types.ModuleType('config')
  46. def is_renderable(x):
  47. """ jinja test to check if a value can be rendered """
  48. return hasattr(x, 'render') and callable(x.render) # not checking for inheritance here so MarkdownString matches, too.
  49. class FormDataParser(werkzeug.formparser.FormDataParser):
  50. def parse(self, *args, **kwargs):
  51. stream, form_flat, files_flat = super(FormDataParser, self).parse(*args, **kwargs)
  52. flat_data = {
  53. 'form': form_flat,
  54. 'files': files_flat
  55. }
  56. processed_data = {
  57. 'form': werkzeug.datastructures.MultiDict(),
  58. 'files': werkzeug.datastructures.MultiDict()
  59. }
  60. for subject, data in flat_data.items():
  61. for key in data.keys():
  62. current = processed_data[subject]
  63. segments = key.split('.')
  64. for segment in segments[:-1]:
  65. if not segment in current:
  66. current[segment] = werkzeug.datastructures.MultiDict()
  67. current = current[segment]
  68. #current[segments[-1]] = values
  69. current.setlist(segments[-1], data.getlist(key))
  70. #if 'submit' in form_flat:
  71. if subject == 'form' and 'submit' in data:
  72. current = processed_data[subject]
  73. segments = form_flat['submit'].split('.')
  74. for segment in segments[:-1]:
  75. if not segment in current:
  76. current[segment] = werkzeug.datastructures.MultiDict()
  77. current = current[segment]
  78. current[segments[-1]] = True
  79. # TODO: Make form ImmutableDict again?
  80. return (stream, processed_data['form'], processed_data['files'])
  81. class Request(flask.Request):
  82. form_data_parser_class = FormDataParser
  83. def close(self):
  84. files = self.__dict__.get('files')
  85. if files:
  86. for f in helpers.flatten_nested_multidict(files):
  87. f.close()
  88. class DummySession(werkzeug.datastructures.CallbackDict, flask.sessions.SessionMixin):
  89. new = False
  90. modified = False
  91. accessed = False
  92. new_session_funcs = set()
  93. def new_session(f):
  94. new_session_funcs.add(f)
  95. return f
  96. class Session(werkzeug.datastructures.CallbackDict, flask.sessions.SessionMixin):
  97. permanent = True # doesn't really apply to server-side sessions since we don't get told when a client-side cookie is deleted
  98. def __init__(self, sid=None, key=None, initial=None):
  99. if not sid:
  100. self.sessiondata = storage.SessionData()
  101. self.sid = None
  102. self.key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
  103. self.crypto = nacl.secret.SecretBox(self.key)
  104. # call all @new_session decorated functions with this session object
  105. for func in new_session_funcs:
  106. func(self)
  107. else:
  108. if not key:
  109. raise TypeError("Session initialized with sid but no key!")
  110. try:
  111. self.sessiondata = storage.SessionData.load(sid)
  112. self.key = key
  113. self.crypto = nacl.secret.SecretBox(self.key)
  114. plaintext = self.crypto.decrypt(bytes(self.sessiondata.data))
  115. for k, v in pickle.loads(plaintext).items():
  116. self[k] = v
  117. self.sid = sid
  118. except storage.SessionData.DoesNotExist: # throw away unknown sids and create new sessiondata
  119. self.sessiondata = storage.SessionData()
  120. self.sid = None
  121. self.key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
  122. self.crypto = nacl.secret.SecretBox(self.key)
  123. # call all @new_session decorated functions with this session object
  124. for func in new_session_funcs:
  125. func(self)
  126. def on_update(self):
  127. self.modified = True
  128. self.accessed = True
  129. super(Session, self).__init__(initial, on_update)
  130. @property
  131. def expires(self):
  132. return self.sessiondata.expires
  133. def save(self):
  134. self.sessiondata.expires = datetime.datetime.utcnow() + app.permanent_session_lifetime
  135. self.sessiondata.data = self.crypto.encrypt(pickle.dumps(dict(self), pickle.HIGHEST_PROTOCOL))
  136. if not self.sid:
  137. self.sessiondata.save(force_insert=True)
  138. else:
  139. self.sessiondata.save()
  140. self.sid = self.sessiondata.sid
  141. def delete_instance(self):
  142. return self.sessiondata.delete_instance()
  143. class SessionInterface(flask.sessions.SessionInterface):
  144. def open_session(self, app, request):
  145. cookie = request.cookies.get(app.session_cookie_name)
  146. if not request.is_secure:
  147. return DummySession()
  148. if cookie:
  149. try:
  150. untrusted = json.loads(cookie)
  151. except json.decoder.JSONDecodeError:
  152. #data = {'sid': None, 'key': None}
  153. #raise errors.ExposedError("Fucky session. ( ͡° ͜ʖ ͡°)")
  154. return Session()
  155. #if not ('sid' in untrusted and 'key' in untrusted
  156. data = untrusted
  157. if 'sid' in untrusted\
  158. and 'key' in untrusted\
  159. and isinstance(untrusted['sid'], str)\
  160. and isinstance(untrusted['key'], str):
  161. return Session(data['sid'], key=bytes.fromhex(data['key']))
  162. return DummySession() # a bit evil
  163. return Session()
  164. def should_set_cookie(self, app, session):
  165. return super(SessionInterface, self).should_set_cookie(app, session) and flask.request.is_secure
  166. def save_session(self, app, session, response):
  167. domain = self.get_cookie_domain(app)
  168. path = self.get_cookie_path(app)
  169. if session.modified:
  170. session.save()
  171. if self.should_set_cookie(app, session):
  172. if not session.sid:
  173. sid = ''
  174. else:
  175. sid = session.sid.hex
  176. httponly = self.get_cookie_httponly(app)
  177. secure = True
  178. samesite = self.get_cookie_samesite(app)
  179. expires = session.expires
  180. response.set_cookie(
  181. app.session_cookie_name,
  182. json.dumps({'sid': sid, 'key': session.key.hex()}),
  183. expires=expires,
  184. httponly=httponly,
  185. domain=domain,
  186. path=path,
  187. secure=secure,
  188. samesite=samesite
  189. )
  190. # Enable URL parameters like regex("[a-z]+")
  191. class RegexConverter(werkzeug.routing.BaseConverter):
  192. def __init__(self, url_map, *items):
  193. super(RegexConverter, self).__init__(url_map)
  194. self.regex = items[0]
  195. class SCSSCore(scss.extension.core.CoreExtension):
  196. # this function is a copy of scss.extension.core.CoreExtension.handle_import
  197. # with adjusted search_path ordering, meaining this needs to be updated,
  198. # at least when there's been a security flaw in this fixed upstream
  199. # but how do I find out?
  200. def handle_import(self, name, compilation, rule):
  201. """Implementation of the core Sass import mechanism, which just looks
  202. for files on disk.
  203. """
  204. # TODO this is all not terribly well-specified by Sass. at worst,
  205. # it's unclear how far "upwards" we should be allowed to go. but i'm
  206. # also a little fuzzy on e.g. how relative imports work from within a
  207. # file that's not actually in the search path.
  208. # TODO i think with the new origin semantics, i've made it possible to
  209. # import relative to the current file even if the current file isn't
  210. # anywhere in the search path. is that right?
  211. path = PurePosixPath(name)
  212. search_exts = list(compilation.compiler.dynamic_extensions)
  213. if path.suffix and path.suffix in search_exts:
  214. basename = path.stem
  215. else:
  216. basename = path.name
  217. relative_to = path.parent
  218. search_path = [] # tuple of (origin, start_from)
  219. search_path.extend(
  220. (origin, relative_to)
  221. for origin in compilation.compiler.search_path
  222. )
  223. if relative_to.is_absolute():
  224. relative_to = PurePosixPath(*relative_to.parts[1:])
  225. elif rule.source_file.origin:
  226. # Search relative to the current file first, only if not doing an
  227. # absolute import
  228. search_path.append((
  229. rule.source_file.origin,
  230. rule.source_file.relpath.parent / relative_to,
  231. ))
  232. for prefix, suffix in product(('_', ''), search_exts):
  233. filename = prefix + basename + suffix
  234. for origin, relative_to in search_path:
  235. relpath = relative_to / filename
  236. # Lexically (ignoring symlinks!) eliminate .. from the part
  237. # of the path that exists within Sass-space. pathlib
  238. # deliberately doesn't do this, but os.path does.
  239. relpath = PurePosixPath(os.path.normpath(str(relpath)))
  240. if rule.source_file.key == (origin, relpath):
  241. # Avoid self-import
  242. # TODO is this what ruby does?
  243. continue
  244. path = origin / relpath
  245. if not path.exists():
  246. continue
  247. # All good!
  248. # TODO if this file has already been imported, we'll do the
  249. # source preparation twice. make it lazy.
  250. return SourceFile.read(origin, relpath)
  251. class Poobrain(flask.Flask):
  252. session_interface = SessionInterface()
  253. request_class = Request
  254. debugger = None
  255. site = None
  256. admin = None
  257. boxes = None
  258. resource_extension_whitelist = None
  259. error_codes = {
  260. peewee.OperationalError: 500,
  261. peewee.IntegrityError: 400,
  262. peewee.DoesNotExist: 404
  263. }
  264. cronjobs = None
  265. def __init__(self, *args, **kwargs):
  266. if not 'root_path' in kwargs:
  267. kwargs['root_path'] = str(pathlib.Path('.').absolute()) #TODO: pathlib probably isn't really needed here
  268. super(Poobrain, self).__init__(*args, **kwargs)
  269. self.cronjobs = []
  270. @click.group(cls=flask.cli.FlaskGroup, create_app=lambda x: self)
  271. @click.option('--database', default="sqlite:///%s.db" % project_name)
  272. def cli(database):
  273. self.db = db_url.connect(database)
  274. self.cli = cli
  275. if config:
  276. for name in dir(config):
  277. if name.isupper():
  278. self.config[name] = getattr(config, name)
  279. for name in dir(defaults):
  280. if name.isupper and not name in self.config:
  281. self.config[name] = getattr(defaults, name)
  282. try:
  283. if self.config['LOGFILE']: # log to file, if configured
  284. log_handler = logging.handlers.WatchedFileHandler(self.config['LOGFILE'])
  285. if self.debug:
  286. log_handler.setLevel(logging.DEBUG)
  287. else:
  288. log_handler.setLevel(logging.WARNING)
  289. self.logger.addHandler(log_handler)
  290. except IOError as e:
  291. import grp
  292. user = os.getlogin()
  293. group = grp.getgrgid(os.getgid()).gr_name
  294. sys.exit("Somethings' fucky with the log file: %s. Current user/group is %s/%s." % (e,user,group))
  295. if self.debug:
  296. # show SQL queries
  297. peeweelog = logging.getLogger('peewee')
  298. peeweelog.setLevel(logging.DEBUG)
  299. peeweelog.addHandler(logging.StreamHandler())
  300. try:
  301. import signal
  302. import pudb
  303. if hasattr(signal, 'SIGINFO'):
  304. pudb.set_interrupt_handler(signal.SIGINFO)
  305. print("%s: a graphical debugger can be invoked with SIGINFO (^T)" % (self.name.upper()))
  306. self.debugger = pudb
  307. except ImportError:
  308. print("pudb not installed, falling back to pdb!")
  309. import signal # shouldn't be needed but feels hacky to leave out
  310. import pdb
  311. self.boxes = {}
  312. self.poobrain_path = os.path.dirname(os.path.realpath(__file__))
  313. self.site_path = os.getcwd()
  314. self.resource_extension_whitelist = ['css', 'scss', 'png', 'svg', 'ttf', 'otf', 'woff', 'js', 'jpg']
  315. self.scss_compiler = scss.Compiler(extensions=(SCSSCore,), root=pathlib.Path('/'), search_path=self.theme_paths)
  316. if 'DATABASE' in self.config:
  317. self.db = db_url.connect(self.config['DATABASE'], autocommit=True, autorollback=True)
  318. else:
  319. import optparse # Pretty fucking ugly, but at least its in the stdlib. TODO: Can we *somehow* make this work with prompt in cli/__init__.py install command?
  320. parser = optparse.OptionParser()
  321. parser.add_option('--database', default="sqlite:///%s.db" % project_name, dest='database') # NOTE: If you change this, you'll also have to change the --database default in cli/__init__.py or else install will fuck up
  322. (options, _) = parser.parse_args()
  323. self.logger.warning("No DATABASE in config, using generated default or --database parameter '%s'. This should only happen before the install command is executed." % options.database)
  324. self.db = db_url.connect(options.database)
  325. self.add_url_rule('/theme/<path:resource>', 'serve_theme_resources', self.serve_theme_resources)
  326. # Make sure that each request has a proper database connection
  327. self.before_request(self.request_setup)
  328. self.teardown_request(self.request_teardown)
  329. # set up site and admin blueprints
  330. self.site = Pooprint('site', 'site')
  331. self.admin = Pooprint('admin', 'admin')
  332. def main(self):
  333. #self.cli(obj={})
  334. self.cli()
  335. def select_jinja_autoescape(self, filename):
  336. if filename is None:
  337. return super(Poobrain, self).select_jinja_autoescape(filename)
  338. return not filename.endswith(('.safe')) # Don't even know if I ever want to use .safe files, but hey, it's there.
  339. def try_trigger_before_first_request_functions(self):
  340. if not self.setup in self.before_first_request_funcs:
  341. self.before_first_request_funcs.append(self.setup)
  342. super(Poobrain, self).try_trigger_before_first_request_functions()
  343. def setup(self):
  344. self.register_blueprint(self.site)
  345. self.register_blueprint(self.admin, url_prefix='/admin/')
  346. @property
  347. def theme_paths(self):
  348. paths = []
  349. if self.config['THEME'] != 'default':
  350. paths.append(os.path.join(self.root_path, 'themes', self.config['THEME']))
  351. paths.append(os.path.join(self.poobrain_path, 'themes', self.config['THEME']))
  352. paths.append(os.path.join(self.root_path, 'themes', 'default'))
  353. paths.append(os.path.join(self.poobrain_path, 'themes', 'default'))
  354. return paths
  355. def serve_theme_resources(self, resource):
  356. r = False
  357. extension = resource.split('.')
  358. if len(extension) > 1:
  359. extension = extension[-1]
  360. else:
  361. abort(404)
  362. if extension not in self.resource_extension_whitelist:
  363. abort(404) # extension not allowed
  364. if extension == 'svg':
  365. try:
  366. r = flask.Response(
  367. flask.render_template(
  368. resource,
  369. style=self.scss_compiler.compile_string("@import 'svg';")
  370. ),
  371. mimetype='image/svg+xml'
  372. )
  373. except scss.errors.SassImportError:
  374. r = flask.Response(
  375. flask.render_template(
  376. resource,
  377. style=''),
  378. mimetype='image/svg+xml'
  379. )
  380. except jinja2.exceptions.TemplateNotFound:
  381. abort(404)
  382. else:
  383. paths = [os.path.join(path, resource) for path in app.theme_paths]
  384. for current_path in paths:
  385. if os.path.exists(current_path):
  386. if extension == 'scss':
  387. r = flask.Response(self.scss_compiler.compile(current_path), mimetype='text/css')
  388. else:
  389. r = flask.send_from_directory(os.path.dirname(current_path), os.path.basename(current_path))
  390. if r:
  391. r.cache_control.public = True
  392. r.cache_control.max_age = app.config['CACHE_LONG']
  393. return r
  394. abort(404)
  395. def request_setup(self):
  396. flask.g.boxes = {}
  397. flask.g.forms = {}
  398. #self.db.close() # fails first request and thus always on sqlite
  399. if self.db.is_closed():
  400. self.db.connect()
  401. #connection = self.db.get_conn()
  402. flask.g.user = None
  403. if not 'SSL_CLIENT_VERIFY' in flask.request.environ:
  404. if self.debug:
  405. flask.request.environ['SSL_CLIENT_VERIFY'] = 'FAILURE'
  406. else:
  407. raise werkzeug.exceptions.InternalServerError("httpd configuration problem. SSL_CLIENT_VERIFY not set in request environment.")
  408. if flask.request.environ['SSL_CLIENT_VERIFY'] == 'SUCCESS':
  409. try:
  410. #cert_info = auth.ClientCert.get(auth.ClientCert.subject_name == flask.request.environ['SSL_CLIENT_S_DN'])
  411. cert = openssl.crypto.load_certificate(openssl.crypto.FILETYPE_PEM, flask.request.environ['SSL_CLIENT_CERT'])
  412. cert_info = auth.ClientCert.get(auth.ClientCert.fingerprint == cert.digest('sha512').replace(b':', b'')) # fuck colons
  413. flask.g.user = cert_info.user
  414. except auth.ClientCert.DoesNotExist:
  415. self.logger.error("httpd verified client certificate successfully, but it's not known at this site. CN: %s, digest: %s" % (cert.get_subject().CN, cert.digest('sha512')))
  416. if flask.g.user == None:
  417. try:
  418. flask.g.user = auth.User.get(auth.User.id == 1) # loads "anonymous".
  419. except:
  420. pass
  421. self.box_setup()
  422. def request_teardown(self, exception):
  423. if not self.db.is_closed():
  424. self.db.close()
  425. def box_setup(self):
  426. for name, f in self.boxes.items():
  427. flask.g.boxes[name] = f()
  428. def box(self, name):
  429. def decorator(f):
  430. self.boxes[name] = f
  431. return f
  432. return decorator
  433. def expose(self, rule, mode='full', extra_modes=None, title=None, force_secure=False):
  434. def decorator(cls):
  435. if issubclass(cls, storage.Storable):
  436. self.site.add_listing(cls, rule, mode='teaser', title=title, force_secure=force_secure)
  437. self.site.add_view(cls, os.path.join(rule, '<handle>/'), mode=mode, force_secure=force_secure)
  438. if not extra_modes is None:
  439. for extra_mode in extra_modes:
  440. self.site.add_view(cls, os.path.join(rule, '<handle>/%s' % extra_mode), mode=extra_mode, force_secure=force_secure)
  441. for related_model, related_fields in cls._meta.model_backrefs.items(): # Add Models that are associated by ForeignKeyField, like /user/foo/userpermissions
  442. if len(related_fields) > 1:
  443. self.logger.debug("!!! Apparent multi-field relation for %s: %s !!!" % (related_model.__name__, related_fields))
  444. if issubclass(related_model, auth.Administerable):
  445. self.site.add_related_view(cls, related_fields[0], os.path.join(rule, '<handle>/'))
  446. elif issubclass(cls, rendering.Renderable):
  447. self.site.add_view(cls, rule, mode=mode, force_secure=force_secure)
  448. self.site.add_view(cls, os.path.join(rule, '<handle>/'), mode=mode, force_secure=force_secure) # TODO: Only needed for Renderables that actually use handles
  449. return cls
  450. return decorator
  451. def get_url(self, cls, mode=None, **url_params):
  452. if flask.request.blueprint is not None:
  453. try:
  454. if flask.request.blueprint == 'admin':
  455. auth.AccessAdminArea.check(flask.g.user)
  456. except auth.AccessDenied:
  457. blueprint = self.site
  458. else:
  459. blueprint = self.blueprints[flask.request.blueprint]
  460. else:
  461. blueprint = self.site
  462. try:
  463. return blueprint.get_url(cls, mode=mode, **url_params)
  464. except LookupError:
  465. blueprint_names = list(self.blueprints.keys())
  466. blueprint_names.pop(blueprint_names.index('admin'))
  467. blueprint_names.insert(0, 'admin')
  468. blueprint_names.pop(blueprint_names.index('site'))
  469. blueprint_names.insert(0, 'site')
  470. for bp_name in blueprint_names:
  471. if bp_name != flask.request.blueprint:
  472. if bp_name == 'admin':
  473. try:
  474. auth.AccessAdminArea.check(flask.g.user)
  475. except auth.AccessDenied:
  476. continue
  477. blueprint = self.blueprints[bp_name]
  478. try:
  479. return blueprint.get_url(cls, mode=mode, **url_params)
  480. except LookupError:
  481. pass
  482. raise LookupError("Failed generating URL for %s[%s]-%s. No matching route found." % (cls.__name__, url_params.get('handle', None), mode))
  483. def get_related_view_url(self, cls, handle, related_field, add=None):
  484. blueprint = self.blueprints[flask.request.blueprint]
  485. return blueprint.get_related_view_url(cls, handle, related_field, add=add)
  486. def cron(self, func):
  487. self.cronjobs.append(func)
  488. return func
  489. def cron_run(self):
  490. self.logger.info("Starting cron run.")
  491. for func in self.cronjobs:
  492. func()
  493. self.logger.info("Finished cron run.")
  494. @locked_cached_property
  495. def jinja_loader(self):
  496. return jinja2.FileSystemLoader(self.theme_paths)
  497. class Pooprint(flask.Blueprint):
  498. app = None
  499. db = None
  500. views = None
  501. listings = None
  502. related_views = None
  503. boxes = None
  504. poobrain_path = None
  505. def __init__(self, *args, **kwargs):
  506. super(Pooprint, self).__init__(*args, **kwargs)
  507. self.views = collections.OrderedDict() # reverse lookup for URL endpoints of Renderable views by mode
  508. self.listings = collections.OrderedDict() # listings ordered by
  509. self.related_views = collections.OrderedDict() # reverse lookup for endpoints of (fk) related views
  510. self.related_views_add = collections.OrderedDict() # reverse lookup for endpoints to add new related items
  511. self.boxes = collections.OrderedDict()
  512. self.poobrain_path = os.path.dirname(__file__)
  513. self.before_request(self.box_setup)
  514. def register(self, app, options, first_registration=False):
  515. super(Pooprint, self).register(app, options, first_registration=first_registration)
  516. self.app = app
  517. self.db = app.db
  518. def add_view(self, cls, rule, endpoint=None, view_func=None, mode='full', force_secure=False, **options):
  519. if not cls in self.views:
  520. self.views[cls] = collections.OrderedDict()
  521. if not mode in self.views[cls]:
  522. self.views[cls][mode] = []
  523. # Why the fuck does HTML not support DELETE!?
  524. options['methods'] = ['GET', 'POST']
  525. if mode == 'delete':
  526. options['methods'].append('DELETE')
  527. def view_func(**kwargs):
  528. kwargs['mode'] = mode
  529. return cls.class_view(**kwargs)
  530. if force_secure:
  531. view_func = helpers.is_secure(view_func) # manual decoration, cause I don't know how to do this cleaner
  532. if endpoint is None:
  533. endpoint = self.next_endpoint(cls, mode, 'view')
  534. self.add_url_rule(rule, endpoint, view_func, **options)
  535. self.views[cls][mode].append(endpoint)
  536. def add_related_view(self, cls, related_field, rule, endpoint=None, view_func=None, force_secure=False, **options):
  537. related_model = related_field.model
  538. if not endpoint:
  539. endpoint = self.next_endpoint(cls, related_field, 'related')
  540. if not cls in self.related_views:
  541. self.related_views[cls] = collections.OrderedDict()
  542. if not related_field in self.related_views[cls]:
  543. url_segment = '%s:%s' % (related_model.__name__.lower(), related_field.name.lower())
  544. rule = os.path.join(rule, url_segment, "") # empty string to get trailing slash
  545. self.related_views[cls][related_field] = []
  546. if not cls in self.related_views_add:
  547. self.related_views_add[cls] = collections.OrderedDict()
  548. if not related_field in self.related_views_add[cls]:
  549. self.related_views_add[cls][related_field] = []
  550. def view_func(*args, **kwargs):
  551. kwargs['related_field'] = related_field
  552. return cls.related_view(*args, **kwargs)
  553. def view_func_add(*args, **kwargs):
  554. kwargs['related_field'] = related_field
  555. return cls.related_view_add(*args, **kwargs)
  556. offset_rule = os.path.join(rule, '+<int:offset>')
  557. offset_endpoint = '%s_offset' % (endpoint,)
  558. add_rule = os.path.join(rule, 'add')
  559. add_endpoint = endpoint + '_add'
  560. self.add_url_rule(rule, endpoint, view_func, methods=['GET', 'POST'])
  561. self.related_views[cls][related_field].append(endpoint)
  562. self.add_url_rule(offset_rule, offset_endpoint, view_func, methods=['GET', 'POST'])
  563. #self.related_views[cls][related_field].append(offset_endpoint)
  564. self.add_url_rule(add_rule, add_endpoint, view_func_add, methods=['GET', 'POST'])
  565. self.related_views_add[cls][related_field].append(add_endpoint)
  566. def box_setup(self):
  567. for name, f in self.boxes.items():
  568. flask.g.boxes[name] = f()
  569. def box(self, name):
  570. def decorator(f):
  571. self.boxes[name] = f
  572. return f
  573. return decorator
  574. def add_listing(self, cls, rule, title=None, mode=None, endpoint=None, view_func=None, action_func=None, force_secure=False, **options):
  575. if not mode:
  576. mode = 'teaser'
  577. if endpoint is None:
  578. endpoint = self.next_endpoint(cls, mode, 'listing')
  579. rule = os.path.join(rule, '') # make sure rule has trailing slash
  580. if not cls in self.listings:
  581. self.listings[cls] = collections.OrderedDict()
  582. if not mode in self.listings[cls]:
  583. self.listings[cls][mode] = []
  584. if view_func is None:
  585. @helpers.themed
  586. def view_func(offset=0):
  587. if action_func:
  588. menu_actions = action_func()
  589. else:
  590. menu_actions = None
  591. return storage.Listing(cls, offset=offset, title=title, mode=mode, menu_actions=menu_actions)
  592. if force_secure:
  593. view_func = helpers.is_secure(view_func) # manual decoration, cause I don't know how to do this cleaner
  594. offset_rule = rule+'+<int:offset>'
  595. offset_endpoint = '%s_offset' % (endpoint,)
  596. self.add_url_rule(rule, endpoint=endpoint, view_func=view_func, **options)
  597. self.add_url_rule(offset_rule, endpoint=offset_endpoint, view_func=view_func, **options)
  598. self.listings[cls][mode].append(endpoint)
  599. #self.listings[cls][mode].append(offset_endpoint)
  600. def listing(self, cls, rule, mode='teaser', title=None, **options):
  601. # TODO: Is this even used? Does keeping it make sense?
  602. def decorator(f):
  603. @functools.wraps(f)
  604. @helpers.themed
  605. def real(offset=0):
  606. instance = storage.Listing(cls, title=title, offset=offset, mode=mode)
  607. return f(instance)
  608. self.add_listing(cls, rule, view_func=real, **options)
  609. return real
  610. return decorator
  611. def choose_endpoint(self, endpoints, **url_params):
  612. for rule in self.app.url_map.iter_rules():
  613. if rule.endpoint in endpoints:
  614. endpoint = rule.endpoint
  615. not_too_many_params = set(url_params.keys()).issubset(rule.arguments)
  616. missing_params = rule.arguments - set(url_params.keys())
  617. missing_all_optional = all([param in rule.defaults.keys() for param in missing_params])
  618. #if sorted(rule.arguments) == sorted(url_params.keys()): # means url parameters match perfectly
  619. #if set(url_params.keys()).issubset(rule.arguments):
  620. if not_too_many_params and missing_all_optional:
  621. return endpoint
  622. raise ValueError("No fitting url rule found for all params: %s", ','.join(url_params.keys()))
  623. def get_url(self, cls, mode=None, **url_params):
  624. if not issubclass(cls, storage.Model) or \
  625. mode == 'add' or \
  626. 'handle' in url_params and (mode is None or not mode.startswith('teaser')):
  627. return self.get_view_url(cls, mode=mode, **url_params)
  628. return self.get_listing_url(cls, mode=mode, **url_params)
  629. def get_view_url(self, cls, mode=None, **url_params):
  630. if mode == None:
  631. mode = 'full'
  632. if not cls in self.views:
  633. raise LookupError("No registered views for class %s." % (cls.__name__,))
  634. if not mode in self.views[cls]:
  635. raise LookupError("No registered views for class %s with mode %s." % (cls.__name__, mode))
  636. endpoints = ['%s.%s' % (self.name, x) for x in self.views[cls][mode]]
  637. if len(endpoints) > 1:
  638. endpoint = self.choose_endpoint(endpoints, **url_params)
  639. else:
  640. endpoint = endpoints[0]
  641. return flask.url_for(endpoint, **url_params)
  642. def get_listing_url(self, cls, handle=None, mode=None, offset=0, **url_params):
  643. if mode == None:
  644. mode = 'teaser'
  645. if handle is not None:
  646. instance = cls.load(handle)
  647. clauses = []
  648. for ordering in cls._meta.order_by: # Ordering is a peewee.WrappedNode, its .node property is the field
  649. if ordering.direction == 'ASC':
  650. clauses.append(ordering.node <= getattr(instance, ordering.node.name))
  651. else: # We'll just assume there can only be ASC and DESC
  652. clauses.append(ordering.node >= getattr(instance, ordering.node.name))
  653. offset = cls.select().where(*clauses).count() - 1
  654. if not cls in self.listings:
  655. raise LookupError("No registered listings for class %s." % (cls.__name__,))
  656. if not mode in self.listings[cls]:
  657. raise LookupError("No registered listings for class %s with mode %s." % (cls.__name__, mode))
  658. endpoints = ['%s.%s' % (self.name, x) for x in self.listings[cls][mode]]
  659. endpoint = self.choose_endpoint(endpoints)
  660. # if isinstance(offset, int) and offset > 0:
  661. # return flask.url_for(endpoint+'_offset', offset=offset)
  662. kw = copy.copy(url_params)
  663. if offset > 0:
  664. kw['offset'] = offset
  665. endpoint = "%s_offset" % endpoint
  666. return flask.url_for(endpoint, **kw)
  667. def get_related_view_url(self, cls, handle, related_field, add=False):
  668. if add:
  669. lookup = self.related_views_add
  670. else:
  671. lookup = self.related_views
  672. if not cls in lookup:
  673. raise LookupError("No registered related views for class %s." % (cls.__name__,))
  674. if not related_field in lookup[cls]:
  675. raise LookupError("No registered related views for %s[%s]<-%s.%s." % (cls.__name__, handle, related_field.model.__name__, related_field.name))
  676. endpoints = ['%s.%s' % (self.name, x) for x in lookup[cls][related_field]]
  677. endpoint = self.choose_endpoint(endpoints, **{'handle': handle})
  678. return flask.url_for(endpoint, handle=handle)
  679. def next_endpoint(self, cls, mode, context): # TODO: rename mode because it's not an applicable name for 'related' context
  680. format = '%s_%s_%s_autogen_%%d' % (cls.__name__, context, mode)
  681. try:
  682. if context == 'view':
  683. endpoints = self.views[cls][mode]
  684. elif context == 'listing':
  685. endpoints = self.listings[cls][mode]
  686. elif context == 'related':
  687. # mode is actually a foreign key field
  688. format = '%s_%s_%s-%s_autogen_%%d' % (cls.__name__, context, mode.model.__name__, mode.name)
  689. endpoints = self.related_views[cls][mode]
  690. except KeyError: # means no view/listing has been registered yet
  691. endpoints = []
  692. i = 1
  693. endpoint = format % (i,)
  694. while endpoint in endpoints:
  695. endpoint = format % (i,)
  696. i += 1
  697. return endpoint
  698. app = Poobrain('poobrains') # TODO: Make app class configurable.
  699. app.jinja_env.tests['renderable'] = is_renderable
  700. app.url_map.converters['regex'] = RegexConverter
  701. if app.config['PROFILE']:
  702. from werkzeug.contrib.profiler import ProfilerMiddleware
  703. app.wsgi_app = ProfilerMiddleware(app.wsgi_app, profile_dir='profiling')
  704. # delayed internal imports which may depend on app
  705. from . import mailing
  706. from . import rendering
  707. from . import form
  708. from . import storage
  709. from . import md
  710. from . import auth
  711. from . import upload
  712. from . import tagging
  713. from . import commenting
  714. from . import svg
  715. from . import search
  716. from . import profile
  717. from . import cli
  718. from . import doc
  719. class ErrorPage(rendering.Renderable):
  720. title = None
  721. error = None
  722. code = None
  723. message = None
  724. def __init__(self, error, traceback):
  725. super(ErrorPage, self).__init__()
  726. self.error = error
  727. self.traceback = traceback
  728. if hasattr(error, 'code'):
  729. self.code = error.code
  730. else:
  731. # default to 500, but use more specific code if a matching exception is found in app.error_codes
  732. self.code = 500
  733. for cls, code in app.error_codes.items():
  734. if isinstance(error, cls):
  735. self.code = code
  736. break
  737. self.title = "Ermahgerd, %s!" % self.code
  738. if isinstance(self.error, errors.ExposedError):
  739. self.message = str(error)
  740. elif isinstance(self.error, werkzeug.exceptions.HTTPException):
  741. self.message = error.description
  742. #elif app.debug:
  743. #self.message = str(error.message) # verbatim error messages in debug mode
  744. # raise error
  745. else:
  746. self.message = "Weasels on PCP gnawed through our server internals."
  747. @helpers.themed
  748. def errorpage(error):
  749. tb = None
  750. app.logger.error('Error %s when accessing %s: %s' % (type(error).__name__, flask.request.path, str(error)))
  751. if app.config['DEBUG']:
  752. import traceback
  753. tb = traceback.format_exc()
  754. app.logger.debug(tb)
  755. page = ErrorPage(error, traceback=tb)
  756. return (page, page.code)
  757. @app.box('breadcrumb')
  758. def menu_breadcrumb():
  759. """ HELLO, I'M A POTENTIAL XSS VULNERABILITY! """
  760. m = rendering.Menu('breadcrumb')
  761. segments = flask.request.path.split('/')
  762. for i in range(0, len(segments)):
  763. segment = segments[i]
  764. if i == 0:
  765. m.append('/', 'home')
  766. continue
  767. elif segment != '':
  768. if ''.join(segments[i+1:]) == '': # means the rest of segments just appears empty strings
  769. path = flask.request.path # makes sure we don't fuck over any trailing-slash rules
  770. else:
  771. path = '/' + os.path.join(*segments[0:i+1]) + '/'
  772. m.append(path, segment)
  773. return m
  774. @app.route('/robots.txt')
  775. def robots_txt():
  776. """
  777. Supply robots.txt for crawlers.
  778. Allow everything except /doc/ by default, lets you add a custom
  779. robots.txt to a projects' root directory.
  780. """
  781. if os.path.exists(os.path.join(app.root_path, 'robots.txt')):
  782. response = flask.send_from_directory(app.root_path, 'robots.txt')
  783. else:
  784. response = flask.Response("User-agent: *\nDisallow: /doc/")
  785. response.cache_control.public = True
  786. response.cache_control.max_age = app.config['CACHE_LONG']
  787. return response
  788. app.register_error_handler(400, errorpage)
  789. app.register_error_handler(403, errorpage)
  790. app.register_error_handler(404, errorpage)
  791. app.register_error_handler(errors.ExposedError, errorpage)
  792. app.register_error_handler(peewee.OperationalError, errorpage)
  793. app.register_error_handler(peewee.IntegrityError, errorpage)
  794. app.register_error_handler(peewee.DoesNotExist, errorpage)
  795. #if not app.config['DEBUG']:
  796. # app.register_error_handler(Exception, errorpage) # Catch all in production