1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
|
from django.http import Http404, HttpResponse, HttpResponseNotModified
from django.template.backends.utils import csrf_input_lazy, csrf_token_lazy
from django.template import defaultfilters
from django.core.exceptions import ValidationError, FieldDoesNotExist
from django.contrib.messages.api import get_messages
from django.utils.text import slugify
from django.utils.timesince import timesince
from django.utils import timezone
from django.conf import settings
import django.db.models
import os.path
import random
from itertools import groupby
from datetime import datetime, date, time
import dateutil.parser
import textwrap
from Cryptodome.Hash import SHA
from postgresqleu.confreg.templatetags.currency import format_currency
from postgresqleu.confreg.templatetags.leadingnbsp import leadingnbsp
from postgresqleu.confreg.templatetags.formutil import field_class
from postgresqleu.util.templatetags import svgcharts
from postgresqleu.util.templatetags.assets import do_render_asset
from postgresqleu.util.messaging import get_messaging_class_from_typename
import markupsafe
import jinja2
import jinja2.sandbox
try:
from jinja2 import pass_context
except ImportError:
# Try Jinja2 2.x version
from jinja2 import contextfilter as pass_context
import markdown
from .contextutil import load_all_context
# We use a separate root directory for jinja2 templates, so find that
# directory by searching relative to ourselves.
JINJA_TEMPLATE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../template.jinja'))
#
# A template loader specifically for confreg. It will
# - load user-supplied templates from the specified conferences's
# <jinjadir>/templates (and subdirectories)
# - the specified template from the confreg namespace (but *not* other templates
# in the conference namespace)
# - specific whitelisted templates elsewhere
#
# This will make it impossible for a user-supplied templates to "break out"
# by including or inheriting templates from other parts of the system.
class ConfTemplateLoader(jinja2.FileSystemLoader):
# Templates that are whitelisted for inclusion.
WHITELISTED_TEMPLATES = ('invoices/userinvoice_spec.html',)
def __init__(self, conference, roottemplate, disableconferencetemplates=False):
self.conference = conference
self.roottemplate = roottemplate
self.disableconferencetemplates = disableconferencetemplates
pathlist = []
if conference and conference.jinjaenabled and conference.jinjadir and not disableconferencetemplates:
pathlist.append(os.path.join(conference.jinjadir, 'templates'))
if getattr(settings, 'SYSTEM_SKIN_DIRECTORY', False):
pathlist.append(os.path.join(settings.SYSTEM_SKIN_DIRECTORY, 'template.jinja'))
pathlist.append(JINJA_TEMPLATE_ROOT)
# Process it all with os.fspath. That's what the inherited
# FileSystemLoader does, but we also need the ability to
# override it in get_source() so we do it as well.
self.pathlist = [os.fspath(p) for p in pathlist]
self.cutlevel = 0
super(ConfTemplateLoader, self).__init__(self.pathlist)
def get_source(self, environment, template):
# Only allow loading of the root template from confreg. Everything else we allow
# only from the conference specific directory. This is so we don't end up
# loading a template with the wrong parameters passed to it.
# If no conference is specified, then we allow loading all entries from the root,
# for obvious reasons.
if self.conference and self.conference.jinjaenabled and self.conference.jinjadir and template != self.roottemplate:
if not os.path.exists(os.path.join(self.conference.jinjadir, 'templates', template)):
# This template may exist in pgeu, so reject it unless it's specifically
# whitelisted as something we want to load.
if template not in self.WHITELISTED_TEMPLATES:
raise jinja2.TemplateNotFound(template, "Rejecting attempt to load from incorrect location")
if self.cutlevel:
# Override the searchpath to drop one or more levels, to
# handle inheritance of "the same template"
self.searchpath = self.pathlist[self.cutlevel:]
else:
self.searchpath = self.pathlist
return super(ConfTemplateLoader, self).get_source(environment, template)
#
# A jinja2 sandbox for rendering confreg templates.
#
# It's designed for confreg only, and as such applies a number of restrictions on
# which attributes can be accessed of the objects that's passed to it.
#
# - Restrictions are applied to all pgeu models:
# - For any models outside the confreg and confwiki namespaces, only attributes
# specifically listed in the models _safe_attributes are allowed.
# - The same applies to any model wihin confreg that has a _safe_attributes set
# - Any model that has a member named conference are considered part of confreg,
# and access will be allowed to all attributes on it.
# - Except if it has a member called _unsafe_attributes, in which case they are
# restricted.
# - Specifically for InvoicePresentationWrapper, access is allowed except for
# things listed in _unsafe_attributes.
#
# For all other access, the jinja2 default sandbox rules apply.
#
class ConfSandbox(jinja2.sandbox.SandboxedEnvironment):
def __init__(self, *args, **kwargs):
# We have to disable the cache for our extend-from-parent support, since the cache key
# for confreg/foo.html would become the same regardless of if the template is from the
# base, from the skin or from the conference. Given that we currently recreate the
# environment once for each request, the caching doesn't really make any difference
# anyway. Should we in the future want to use the caching, we have to take this into
# account though.
super().__init__(*args, cache_size=0, **kwargs)
def get_template(self, name, parent=None, globals=None):
if name == parent:
self.loader.cutlevel += 1
else:
self.loader.cutlevel = 0
return super().get_template(name, parent, globals)
def is_safe_attribute(self, obj, attr, value):
modname = obj.__class__.__module__
if obj.__class__.__name__ in ('str', 'unicode') and attr in ('format', 'format_map'):
# We reject all format strings for now, due to
# https://www.palletsprojects.com/blog/jinja-281-released/
# (until we have it safely patched everywhere, *if* we need this elsewhere)
return False
if modname.startswith('postgresqleu.') and modname.endswith('models'):
# This is a pgeu model. So we only allow access to the
# ones in confreg directly.
if not (modname.endswith('.confreg.models') or modname.endswith('.confwiki.models')):
# If the object lists a number of safe attributes,
# then allow them and nothing else.
if hasattr(obj, '_safe_attributes'):
if attr not in getattr(obj, '_safe_attributes'):
return False
else:
# No safe attributes specified, so assume none
return False
# Some objects in the confreg model are not safe, because
# they might leak data between conferences. In general,
# these are objects that don't have a link to a
# conference.
try:
obj._meta.get_field('conference')
# Has a conference, but we can still specify unsafe ones
if hasattr(obj, '_unsafe_attributes'):
if attr in getattr(obj, '_unsafe_attributes'):
return False
except FieldDoesNotExist:
# No conference field on this model. If it has a list of safe attributes, allow the field
# if it's in there, otherwise reject all.
if hasattr(obj, '_safe_attributes'):
# If the object lists a number of safe attributes,
# then allow them and nothing else.
if attr not in getattr(obj, '_safe_attributes'):
return False
else:
return False
elif modname == 'postgresqleu.invoices.util' and obj.__class__.__name__ == 'InvoicePresentationWrapper':
# This is ugly, but we special-case the invoice information
if attr in obj._unsafe_attributes:
return False
return super(ConfSandbox, self).is_safe_attribute(obj, attr, value)
# Enumerate all available attributes (in the postgresqleu scope), showing their
# availability.
def get_all_available_attributes(objclass, depth=0):
modname = objclass.__module__
if not (modname.startswith('postgresqleu.') and modname.endswith('models')):
# Outside of models, we also specifically allow the InvoicePresentationWrapper
if modname != 'postgresqleu.invoices.util' or obj.__class__.__name__ != 'InvoicePresentationWrapper':
return
for attname, attref in objclass.__dict__.items():
def _is_visible():
# Implement the same rules as above, because reusing the sandbox is painful as it
# works with objects and not models.
if attname in getattr(objclass, '_unsafe_attributes', []):
return False
if hasattr(objclass, '_safe_attributes'):
return attname in getattr(objclass, '_safe_attributes')
# If neither safe nor unsafe is specified, we only allow access if the model has
# a conference field specified.
return hasattr(objclass, 'conference')
if issubclass(type(attref), django.db.models.query_utils.DeferredAttribute):
if _is_visible():
yield attname, attref.field.verbose_name
elif issubclass(type(attref), django.db.models.fields.related_descriptors.ForwardManyToOneDescriptor):
# Special case, don't recurse into conference model if we're not at the top object (to keep smaller)
if attname == 'conference' and depth > 0:
continue
if _is_visible():
yield attname, dict(get_all_available_attributes(type(attref.field.related_model()), depth + 1))
elif issubclass(type(attref), django.db.models.fields.related_descriptors.ManyToManyDescriptor) and not attref.reverse:
if _is_visible():
yield attname, [dict(get_all_available_attributes(type(attref.field.related_model()), depth + 1))]
# A couple of useful filters that we publish everywhere:
# Like |groupby, except support grouping by objects and not just by values, and sort by
# attributes on the grouped objects.
def filter_groupby_sort(objects, keyfield, sortkey):
group = [(key, list(group)) for key, group in groupby(objects, lambda x: getattr(x, keyfield))]
return sorted(group, key=lambda y: y[0] and getattr(y[0], sortkey) or 0)
# Shuffle the order in a list, for example to randomize the order of sponsors
def filter_shuffle(thelist):
try:
r = list(thelist)
random.shuffle(r)
return r
except Exception as e:
return thelist
def filter_float_str(f, n):
return '{{0:.{0}f}}'.format(int(n)).format(f)
# Format a datetime. If it's a datetime, call strftime. If it's a
# string, assume it's iso format and convert it to a date first.
def filter_datetimeformat(value, fmt):
if isinstance(value, date) or isinstance(value, datetime) or isinstance(value, time):
if isinstance(value, datetime) and timezone.is_aware(value):
value = timezone.localtime(value)
return value.strftime(fmt)
else:
return dateutil.parser.parse(value).strftime(fmt)
# Take a multiline text and turn it into what's needed to create a multiline svg text
# using <tspan>. Linebreak at <linelength> characters.
def filter_svgparagraph(value, linelength, x, y, dy, parady):
def _svgparagraph():
for j, p in enumerate(value.split("\n")):
for i, l in enumerate(textwrap.wrap(p, width=linelength, expand_tabs=False)):
_dy = dy
if i == 0 and j != 0:
_dy += parady
yield '<tspan x="{}" dy="{}">{}</tspan>'.format(x, _dy, jinja2.escape(l))
return '<text x="{}" y="{}">{}</text>'.format(x, y, "\n".join(_svgparagraph()))
@pass_context
def filter_applymacro(context, obj, macroname):
return context.resolve(macroname)(obj)
@pass_context
def filter_lookup(context, name, default=None):
if not name:
if default is not None:
return default
raise KeyError("No key specified")
c = context
parts = name.split('.')
while parts:
p = parts.pop(0)
if p not in c:
if default is not None:
return default
raise KeyError("Key {} not found".format(name))
c = c[p]
return str(c)
# Unpack a social media link for the specific social media being rendered for.
# This filter is *not* enabled by default.
@pass_context
def filter_social(context, attr):
if not context.get('messaging', None):
return None
name = context['messaging'].typename.lower()
return getattr(attr, 'social', {}).get(name, None)
# Get social media profiles including links from a structure.
# Returns a list of (provider, handle, link) for each configured
# social media identity.
@pass_context
def filter_social_links(context, attr):
if attr:
for k, v in attr.items():
m = get_messaging_class_from_typename(k)
if m:
yield (k, v, m.get_link_from_identifier(v))
extra_filters = {
'format_currency': format_currency,
'escapejs': defaultfilters.escapejs_filter,
'field_class': field_class,
'floatstr': filter_float_str,
'datetimeformat': filter_datetimeformat,
'timesince': timesince,
'groupby_sort': filter_groupby_sort,
'leadingnbsp': leadingnbsp,
'markdown': lambda t: markupsafe.Markup(markdown.markdown(t, extensions=['tables', ])),
'shuffle': filter_shuffle,
'slugify': slugify,
'yesno': lambda b, v: v.split(',')[not b],
'wordwraptolist': lambda t, w: textwrap.wrap(t, width=w, expand_tabs=False),
'svgparagraph': filter_svgparagraph,
'applymacro': filter_applymacro,
'lookup': filter_lookup,
'social_links': filter_social_links,
}
extra_globals = {
'svgcharts': svgcharts,
}
# We can resolve assets only when the template is in our main site. Anything running with
# deploystatic is going to have to solve this outside anyway. That means we can safely
# reference internal functions.
def _resolve_asset(assettype, assetname):
return do_render_asset(assettype, assetname)
def render_jinja_conference_template(conference, templatename, dictionary, disableconferencetemplates=False):
# It all starts from the base template for this conference. If it
# does not exist, just throw a 404 early.
if conference and conference.jinjaenabled and conference.jinjadir and not os.path.exists(os.path.join(conference.jinjadir, 'templates/base.html')):
raise Http404()
if jinja2.__version__ > '3.1':
extensions = []
else:
extensions = ['jinja2.ext.with_']
env = ConfSandbox(
loader=ConfTemplateLoader(conference, templatename, disableconferencetemplates=disableconferencetemplates),
extensions=extensions,
)
env.filters.update(extra_filters)
env.globals.update(extra_globals)
t = env.get_template(templatename)
c = load_all_context(conference,
{
'pgeu_hosted': True,
'now': timezone.now(),
'conference': conference,
'asset': _resolve_asset,
},
dictionary)
return t.render(**c)
# Render a conference response based on jinja2 templates configured for the conference.
# Returns the appropriate django HttpResponse object.
def render_jinja_conference_response(request, conference, pagemagic, templatename, dictionary):
# If ?test=1 is specified, try to load a template with .test in the
# name.
if request.GET.get('test', None) == '1':
templatename = templatename + '.test'
d = {
'pagemagic': pagemagic,
'csrf_input': csrf_input_lazy(request),
'csrf_token': csrf_token_lazy(request),
'messages': get_messages(request),
}
if request.user and request.user.is_authenticated:
d.update({
'username': request.user.username,
'userfullname': '{0} {1}'.format(request.user.first_name, request.user.last_name),
'useremail': request.user.email,
})
else:
d.update({
'username': None,
'userfullname': None,
'useremail': None,
})
if dictionary:
d.update(dictionary)
try:
r = HttpResponse(render_jinja_conference_template(conference, templatename, d))
except jinja2.exceptions.TemplateError as e:
# If we have a template syntax error in a conference template, retry without it.
r = HttpResponse(render_jinja_conference_template(conference, templatename, d, disableconferencetemplates=True))
r['X-Conference-Template-Error'] = str(e)
r.content_type = 'text/html'
return r
def render_jinja_conference_svg(request, conference, cardformat, templatename, dictionary):
svg = render_jinja_conference_template(conference, templatename, dictionary)
if cardformat == 'svg':
return HttpResponse(svg, 'image/svg+xml')
else:
try:
import cairosvg
except ImportError:
# No cairosvg available, so just 404 on this.
raise Http404()
# Since turning SVG into PNG is a lot more expensive than just rendering the SVG,
# generate an appropriate ETag for it, and verify that one.
etag = '"{}"'.format(SHA.new(svg.encode('utf8')).hexdigest())
if request.META.get('HTTP_IF_NONE_MATCH', None) == etag:
return HttpResponseNotModified()
r = HttpResponse(cairosvg.svg2png(svg), content_type='image/png')
r['ETag'] = etag
return r
# Small sandboxed jinja templates that can be configured in system
def render_sandboxed_template(templatestr, context, filters=None):
env = ConfSandbox(loader=jinja2.DictLoader({'t': templatestr}))
env.filters.update(extra_filters)
if filters:
env.filters.update(filters)
t = env.get_template('t')
return t.render(context)
class JinjaTemplateValidator(object):
def __init__(self, context={}, filters=None):
self.context = context
self.filters = filters
def __call__(self, s):
try:
render_sandboxed_template(s, self.context, self.filters)
except jinja2.TemplateSyntaxError as e:
raise ValidationError("Template syntax error: %s" % e)
except Exception as e:
raise ValidationError("Failed to parse template: %s" % e)
|