Show More
@@ -29,15 +29,16 b' import collections' | |||||
29 | import os |
|
29 | import os | |
30 | import random |
|
30 | import random | |
31 | import hashlib |
|
31 | import hashlib | |
32 | from io import StringIO |
|
32 | import io | |
33 | import textwrap |
|
33 | import textwrap | |
34 |
import urllib.request |
|
34 | import urllib.request | |
|
35 | import urllib.parse | |||
|
36 | import urllib.error | |||
35 | import math |
|
37 | import math | |
36 | import logging |
|
38 | import logging | |
37 | import re |
|
39 | import re | |
38 | import time |
|
40 | import time | |
39 | import string |
|
41 | import string | |
40 | import hashlib |
|
|||
41 | import regex |
|
42 | import regex | |
42 | from collections import OrderedDict |
|
43 | from collections import OrderedDict | |
43 |
|
44 | |||
@@ -70,19 +71,22 b' from webhelpers2.html.tags import (' | |||||
70 | form as insecure_form, |
|
71 | form as insecure_form, | |
71 | auto_discovery_link, checkbox, end_form, file, |
|
72 | auto_discovery_link, checkbox, end_form, file, | |
72 | hidden, image, javascript_link, link_to, link_to_if, link_to_unless, ol, |
|
73 | hidden, image, javascript_link, link_to, link_to_if, link_to_unless, ol, | |
73 |
|
|
74 | stylesheet_link, submit, text, password, textarea, | |
74 | ul, radio, Options) |
|
75 | ul, radio, Options) | |
75 |
|
76 | |||
76 | from webhelpers2.number import format_byte_size |
|
77 | from webhelpers2.number import format_byte_size | |
|
78 | # python3.11 backport fixes for webhelpers2 | |||
|
79 | from rhodecode.lib._vendor.webhelpers_backports import raw_select | |||
77 |
|
80 | |||
78 | from rhodecode.lib.action_parser import action_parser |
|
81 | from rhodecode.lib.action_parser import action_parser | |
79 | from rhodecode.lib.pagination import Page, RepoPage, SqlPage |
|
82 | from rhodecode.lib.pagination import Page, RepoPage, SqlPage | |
80 | from rhodecode.lib import ext_json |
|
83 | from rhodecode.lib import ext_json | |
81 | from rhodecode.lib.ext_json import json |
|
84 | from rhodecode.lib.ext_json import json | |
82 | from rhodecode.lib.str_utils import safe_bytes |
|
85 | from rhodecode.lib.str_utils import safe_bytes, convert_special_chars | |
83 | from rhodecode.lib.utils import repo_name_slug, get_custom_lexer |
|
86 | from rhodecode.lib.utils import repo_name_slug, get_custom_lexer | |
|
87 | from rhodecode.lib.str_utils import safe_str | |||
84 | from rhodecode.lib.utils2 import ( |
|
88 | from rhodecode.lib.utils2 import ( | |
85 | str2bool, safe_unicode, safe_str, |
|
89 | str2bool, | |
86 | get_commit_safe, datetime_to_time, time_to_datetime, time_to_utcdatetime, |
|
90 | get_commit_safe, datetime_to_time, time_to_datetime, time_to_utcdatetime, | |
87 | AttributeDict, safe_int, md5, md5_safe, get_host_info) |
|
91 | AttributeDict, safe_int, md5, md5_safe, get_host_info) | |
88 | from rhodecode.lib.markup_renderer import MarkupRenderer, relative_links |
|
92 | from rhodecode.lib.markup_renderer import MarkupRenderer, relative_links | |
@@ -123,11 +127,11 b' def asset(path, ver=None, **kwargs):' | |||||
123 |
|
127 | |||
124 |
|
128 | |||
125 | default_html_escape_table = { |
|
129 | default_html_escape_table = { | |
126 |
ord('&'): |
|
130 | ord('&'): '&', | |
127 |
ord('<'): |
|
131 | ord('<'): '<', | |
128 |
ord('>'): |
|
132 | ord('>'): '>', | |
129 |
ord('"'): |
|
133 | ord('"'): '"', | |
130 |
ord("'"): |
|
134 | ord("'"): ''', | |
131 | } |
|
135 | } | |
132 |
|
136 | |||
133 |
|
137 | |||
@@ -265,14 +269,12 b' class _ToolTip(object):' | |||||
265 |
|
269 | |||
266 | tooltip = _ToolTip() |
|
270 | tooltip = _ToolTip() | |
267 |
|
271 | |||
268 |
files_icon = |
|
272 | files_icon = '<i class="file-breadcrumb-copy tooltip icon-clipboard clipboard-action" data-clipboard-text="{}" title="Copy file path"></i>' | |
269 |
|
273 | |||
270 |
|
274 | |||
271 | def files_breadcrumbs(repo_name, repo_type, commit_id, file_path, landing_ref_name=None, at_ref=None, |
|
275 | def files_breadcrumbs(repo_name, repo_type, commit_id, file_path, landing_ref_name=None, at_ref=None, | |
272 | limit_items=False, linkify_last_item=False, hide_last_item=False, |
|
276 | limit_items=False, linkify_last_item=False, hide_last_item=False, | |
273 | copy_path_icon=True): |
|
277 | copy_path_icon=True): | |
274 | if isinstance(file_path, str): |
|
|||
275 | file_path = safe_unicode(file_path) |
|
|||
276 |
|
278 | |||
277 | if at_ref: |
|
279 | if at_ref: | |
278 | route_qry = {'at': at_ref} |
|
280 | route_qry = {'at': at_ref} | |
@@ -282,7 +284,7 b' def files_breadcrumbs(repo_name, repo_ty' | |||||
282 | default_landing_ref = commit_id |
|
284 | default_landing_ref = commit_id | |
283 |
|
285 | |||
284 | # first segment is a `HOME` link to repo files root location |
|
286 | # first segment is a `HOME` link to repo files root location | |
285 |
root_name = literal( |
|
287 | root_name = literal('<i class="icon-home"></i>') | |
286 |
|
288 | |||
287 | url_segments = [ |
|
289 | url_segments = [ | |
288 | link_to( |
|
290 | link_to( | |
@@ -345,7 +347,6 b' def files_breadcrumbs(repo_name, repo_ty' | |||||
345 |
|
347 | |||
346 |
|
348 | |||
347 | def files_url_data(request): |
|
349 | def files_url_data(request): | |
348 | import urllib.request, urllib.parse, urllib.error |
|
|||
349 | matchdict = request.matchdict |
|
350 | matchdict = request.matchdict | |
350 |
|
351 | |||
351 | if 'f_path' not in matchdict: |
|
352 | if 'f_path' not in matchdict: | |
@@ -426,17 +427,17 b' class CodeHtmlFormatter(HtmlFormatter):' | |||||
426 | My code Html Formatter for source codes |
|
427 | My code Html Formatter for source codes | |
427 | """ |
|
428 | """ | |
428 |
|
429 | |||
429 |
def wrap(self, source |
|
430 | def wrap(self, source): | |
430 | return self._wrap_div(self._wrap_pre(self._wrap_code(source))) |
|
431 | return self._wrap_div(self._wrap_pre(self._wrap_code(source))) | |
431 |
|
432 | |||
432 | def _wrap_code(self, source): |
|
433 | def _wrap_code(self, source): | |
433 | for cnt, it in enumerate(source): |
|
434 | for cnt, it in enumerate(source): | |
434 | i, t = it |
|
435 | i, t = it | |
435 |
t = '<div id="L |
|
436 | t = f'<div id="L{cnt+1}">{t}</div>' | |
436 | yield i, t |
|
437 | yield i, t | |
437 |
|
438 | |||
438 | def _wrap_tablelinenos(self, inner): |
|
439 | def _wrap_tablelinenos(self, inner): | |
439 |
dummyoutfile = |
|
440 | dummyoutfile = io.StringIO() | |
440 | lncount = 0 |
|
441 | lncount = 0 | |
441 | for t, line in inner: |
|
442 | for t, line in inner: | |
442 | if t: |
|
443 | if t: | |
@@ -594,7 +595,7 b' def unique_color_generator(n=10000, satu' | |||||
594 | h %= 1 |
|
595 | h %= 1 | |
595 | HSV_tuple = [h, saturation, lightness] |
|
596 | HSV_tuple = [h, saturation, lightness] | |
596 | RGB_tuple = hsv_to_rgb(*HSV_tuple) |
|
597 | RGB_tuple = hsv_to_rgb(*HSV_tuple) | |
597 |
yield |
|
598 | yield [str(int(x * 256)) for x in RGB_tuple] | |
598 |
|
599 | |||
599 |
|
600 | |||
600 | def color_hasher(n=10000, saturation=0.10, lightness=0.95): |
|
601 | def color_hasher(n=10000, saturation=0.10, lightness=0.95): | |
@@ -692,7 +693,7 b' class _Message(object):' | |||||
692 | __unicode__ = __str__ |
|
693 | __unicode__ = __str__ | |
693 |
|
694 | |||
694 | def __html__(self): |
|
695 | def __html__(self): | |
695 |
return escape(safe_ |
|
696 | return escape(safe_str(self.message)) | |
696 |
|
697 | |||
697 |
|
698 | |||
698 | class Flash(object): |
|
699 | class Flash(object): | |
@@ -771,7 +772,7 b' class Flash(object):' | |||||
771 | for message in messages: |
|
772 | for message in messages: | |
772 | payloads.append({ |
|
773 | payloads.append({ | |
773 | 'message': { |
|
774 | 'message': { | |
774 |
'message': |
|
775 | 'message': '{}'.format(message.message), | |
775 | 'level': message.category, |
|
776 | 'level': message.category, | |
776 | 'force': True, |
|
777 | 'force': True, | |
777 | 'subdata': message.sub_data |
|
778 | 'subdata': message.sub_data | |
@@ -816,8 +817,7 b' def hide_credentials(url):' | |||||
816 | from rhodecode.lib.utils2 import credentials_filter |
|
817 | from rhodecode.lib.utils2 import credentials_filter | |
817 | return credentials_filter(url) |
|
818 | return credentials_filter(url) | |
818 |
|
819 | |||
819 |
|
820 | import zoneinfo | ||
820 | import pytz |
|
|||
821 | import tzlocal |
|
821 | import tzlocal | |
822 | local_timezone = tzlocal.get_localzone() |
|
822 | local_timezone = tzlocal.get_localzone() | |
823 |
|
823 | |||
@@ -829,9 +829,10 b' def get_timezone(datetime_iso, time_is_l' | |||||
829 | if time_is_local and isinstance(datetime_iso, datetime) and not datetime_iso.tzinfo: |
|
829 | if time_is_local and isinstance(datetime_iso, datetime) and not datetime_iso.tzinfo: | |
830 | force_timezone = os.environ.get('RC_TIMEZONE', '') |
|
830 | force_timezone = os.environ.get('RC_TIMEZONE', '') | |
831 | if force_timezone: |
|
831 | if force_timezone: | |
832 |
force_timezone = |
|
832 | force_timezone = zoneinfo.ZoneInfo(force_timezone) | |
833 | timezone = force_timezone or local_timezone |
|
833 | timezone = force_timezone or local_timezone | |
834 | offset = timezone.localize(datetime_iso).strftime('%z') |
|
834 | ||
|
835 | offset = datetime_iso.replace(tzinfo=timezone).strftime('%z') | |||
835 | tzinfo = '{}:{}'.format(offset[:-2], offset[-2:]) |
|
836 | tzinfo = '{}:{}'.format(offset[:-2], offset[-2:]) | |
836 | return tzinfo |
|
837 | return tzinfo | |
837 |
|
838 | |||
@@ -883,9 +884,9 b' def format_date(date):' | |||||
883 |
|
884 | |||
884 | if date: |
|
885 | if date: | |
885 | _fmt = "%a, %d %b %Y %H:%M:%S" |
|
886 | _fmt = "%a, %d %b %Y %H:%M:%S" | |
886 |
return safe_ |
|
887 | return safe_str(date.strftime(_fmt)) | |
887 |
|
888 | |||
888 |
return |
|
889 | return "" | |
889 |
|
890 | |||
890 |
|
891 | |||
891 | class _RepoChecker(object): |
|
892 | class _RepoChecker(object): | |
@@ -1021,7 +1022,8 b' def author_string(email):' | |||||
1021 |
|
1022 | |||
1022 | def person_by_id(id_, show_attr="username_and_name"): |
|
1023 | def person_by_id(id_, show_attr="username_and_name"): | |
1023 | # attr to return from fetched user |
|
1024 | # attr to return from fetched user | |
1024 | person_getter = lambda usr: getattr(usr, show_attr) |
|
1025 | def person_getter(usr): | |
|
1026 | return getattr(usr, show_attr) | |||
1025 |
|
1027 | |||
1026 | #maybe it's an ID ? |
|
1028 | #maybe it's an ID ? | |
1027 | if str(id_).isdigit() or isinstance(id_, int): |
|
1029 | if str(id_).isdigit() or isinstance(id_, int): | |
@@ -1074,7 +1076,7 b' def extract_metatags(value):' | |||||
1074 | if not value: |
|
1076 | if not value: | |
1075 | return tags, '' |
|
1077 | return tags, '' | |
1076 |
|
1078 | |||
1077 | for key, val in tags_paterns.items(): |
|
1079 | for key, val in list(tags_paterns.items()): | |
1078 | pat, replace_html = val |
|
1080 | pat, replace_html = val | |
1079 | tags.extend([(key, x.group()) for x in pat.finditer(value)]) |
|
1081 | tags.extend([(key, x.group()) for x in pat.finditer(value)]) | |
1080 | value = pat.sub('', value) |
|
1082 | value = pat.sub('', value) | |
@@ -1093,8 +1095,8 b' def style_metatag(tag_type, value):' | |||||
1093 | tag_data = tags_paterns.get(tag_type) |
|
1095 | tag_data = tags_paterns.get(tag_type) | |
1094 | if tag_data: |
|
1096 | if tag_data: | |
1095 | pat, replace_html = tag_data |
|
1097 | pat, replace_html = tag_data | |
1096 |
# convert to plain ` |
|
1098 | # convert to plain `str` instead of a markup tag to be used in | |
1097 |
# regex expressions. safe_ |
|
1099 | # regex expressions. safe_str doesn't work here | |
1098 | html_value = pat.sub(replace_html, value) |
|
1100 | html_value = pat.sub(replace_html, value) | |
1099 |
|
1101 | |||
1100 | return html_value |
|
1102 | return html_value | |
@@ -1117,7 +1119,7 b' def bool2icon(value, show_at_false=True)' | |||||
1117 |
|
1119 | |||
1118 |
|
1120 | |||
1119 | def b64(inp): |
|
1121 | def b64(inp): | |
1120 | return base64.b64encode(inp) |
|
1122 | return base64.b64encode(safe_bytes(inp)) | |
1121 |
|
1123 | |||
1122 | #============================================================================== |
|
1124 | #============================================================================== | |
1123 | # PERMS |
|
1125 | # PERMS | |
@@ -1230,23 +1232,22 b' class InitialsGravatar(object):' | |||||
1230 | return color_bank[pos] |
|
1232 | return color_bank[pos] | |
1231 |
|
1233 | |||
1232 | def normalize_email(self, email_address): |
|
1234 | def normalize_email(self, email_address): | |
1233 | import unicodedata |
|
|||
1234 | # default host used to fill in the fake/missing email |
|
1235 | # default host used to fill in the fake/missing email | |
1235 | default_host = 'localhost' |
|
1236 | default_host = 'localhost' | |
1236 |
|
1237 | |||
1237 | if not email_address: |
|
1238 | if not email_address: | |
1238 |
email_address = ' |
|
1239 | email_address = f'{User.DEFAULT_USER}@{default_host}' | |
1239 |
|
1240 | |||
1240 |
email_address = safe_ |
|
1241 | email_address = safe_str(email_address) | |
1241 |
|
1242 | |||
1242 |
if |
|
1243 | if '@' not in email_address: | |
1243 |
email_address = |
|
1244 | email_address = f'{email_address}@{default_host}' | |
1244 |
|
1245 | |||
1245 |
if email_address.endswith( |
|
1246 | if email_address.endswith('@'): | |
1246 |
email_address = |
|
1247 | email_address = f'{email_address}{default_host}' | |
1247 |
|
1248 | |||
1248 |
email_address = |
|
1249 | email_address = convert_special_chars(email_address) | |
1249 | .encode('ascii', 'ignore') |
|
1250 | ||
1250 | return email_address |
|
1251 | return email_address | |
1251 |
|
1252 | |||
1252 | def get_initials(self): |
|
1253 | def get_initials(self): | |
@@ -1266,12 +1267,11 b' class InitialsGravatar(object):' | |||||
1266 | Function also normalizes the non-ascii characters to they ascii |
|
1267 | Function also normalizes the non-ascii characters to they ascii | |
1267 | representation, eg Δ => A |
|
1268 | representation, eg Δ => A | |
1268 | """ |
|
1269 | """ | |
1269 | import unicodedata |
|
|||
1270 | # replace non-ascii to ascii |
|
1270 | # replace non-ascii to ascii | |
1271 | first_name = unicodedata.normalize( |
|
1271 | first_name = convert_special_chars(self.first_name) | |
1272 | 'NFKD', safe_unicode(self.first_name)).encode('ascii', 'ignore') |
|
1272 | last_name = convert_special_chars(self.last_name) | |
1273 | last_name = unicodedata.normalize( |
|
1273 | # multi word last names, Guido Von Rossum, we take the last part only | |
1274 | 'NFKD', safe_unicode(self.last_name)).encode('ascii', 'ignore') |
|
1274 | last_name = last_name.split(' ', 1)[-1] | |
1275 |
|
1275 | |||
1276 | # do NFKD encoding, and also make sure email has proper format |
|
1276 | # do NFKD encoding, and also make sure email has proper format | |
1277 | email_address = self.normalize_email(self.email_address) |
|
1277 | email_address = self.normalize_email(self.email_address) | |
@@ -1286,9 +1286,9 b' class InitialsGravatar(object):' | |||||
1286 | else: |
|
1286 | else: | |
1287 | initials = [prefix[0], server[0]] |
|
1287 | initials = [prefix[0], server[0]] | |
1288 |
|
1288 | |||
1289 | # then try to replace either first_name or last_name |
|
1289 | # get first letter of first and last names to create initials | |
1290 | fn_letter = (first_name or " ")[0].strip() |
|
1290 | fn_letter = (first_name or " ")[0].strip() | |
1291 |
ln_letter = (last_name |
|
1291 | ln_letter = (last_name or " ")[0].strip() | |
1292 |
|
1292 | |||
1293 | if fn_letter: |
|
1293 | if fn_letter: | |
1294 | initials[0] = fn_letter |
|
1294 | initials[0] = fn_letter | |
@@ -1305,6 +1305,7 b' class InitialsGravatar(object):' | |||||
1305 | viewBox="-15 -10 439.165 429.164" |
|
1305 | viewBox="-15 -10 439.165 429.164" | |
1306 |
|
1306 | |||
1307 | xml:space="preserve" |
|
1307 | xml:space="preserve" | |
|
1308 | font-family="{font_family} | |||
1308 | style="background:{background};" > |
|
1309 | style="background:{background};" > | |
1309 |
|
1310 | |||
1310 | <path d="M204.583,216.671c50.664,0,91.74-48.075, |
|
1311 | <path d="M204.583,216.671c50.664,0,91.74-48.075, | |
@@ -1374,8 +1375,8 b' class InitialsGravatar(object):' | |||||
1374 | return img_data |
|
1375 | return img_data | |
1375 |
|
1376 | |||
1376 | def generate_svg(self, svg_type=None): |
|
1377 | def generate_svg(self, svg_type=None): | |
1377 | img_data = self.get_img_data(svg_type) |
|
1378 | img_data = safe_bytes(self.get_img_data(svg_type)) | |
1378 | return "data:image/svg+xml;base64,%s" % base64.b64encode(img_data) |
|
1379 | return "data:image/svg+xml;base64,%s" % safe_str(base64.b64encode(img_data)) | |
1379 |
|
1380 | |||
1380 |
|
1381 | |||
1381 | def initials_gravatar(request, email_address, first_name, last_name, size=30, store_on_disk=False): |
|
1382 | def initials_gravatar(request, email_address, first_name, last_name, size=30, store_on_disk=False): | |
@@ -1421,7 +1422,7 b' def initials_gravatar(request, email_add' | |||||
1421 | file_uid=store_uid, filename=metadata["filename"], |
|
1422 | file_uid=store_uid, filename=metadata["filename"], | |
1422 | file_hash=metadata["sha256"], file_size=metadata["size"], |
|
1423 | file_hash=metadata["sha256"], file_size=metadata["size"], | |
1423 | file_display_name=filename, |
|
1424 | file_display_name=filename, | |
1424 |
file_description= |
|
1425 | file_description=f'user gravatar `{safe_str(filename)}`', | |
1425 | hidden=True, check_acl=False, user_id=1 |
|
1426 | hidden=True, check_acl=False, user_id=1 | |
1426 | ) |
|
1427 | ) | |
1427 | Session().add(entry) |
|
1428 | Session().add(entry) | |
@@ -1694,7 +1695,7 b' def process_patterns(text_string, repo_n' | |||||
1694 |
|
1695 | |||
1695 | log.debug('Got %s pattern entries to process', len(active_entries)) |
|
1696 | log.debug('Got %s pattern entries to process', len(active_entries)) | |
1696 |
|
1697 | |||
1697 | for uid, entry in active_entries.items(): |
|
1698 | for uid, entry in list(active_entries.items()): | |
1698 |
|
1699 | |||
1699 | if not (entry['pat'] and entry['url']): |
|
1700 | if not (entry['pat'] and entry['url']): | |
1700 | log.debug('skipping due to missing data') |
|
1701 | log.debug('skipping due to missing data') | |
@@ -1841,10 +1842,10 b" def render(source, renderer='rst', menti" | |||||
1841 | for issue in issues: |
|
1842 | for issue in issues: | |
1842 | issues_container_callback(issue) |
|
1843 | issues_container_callback(issue) | |
1843 |
|
1844 | |||
1844 | return literal( |
|
1845 | rendered_block = maybe_convert_relative_links( | |
1845 | '<div class="rst-block">%s</div>' % |
|
1846 | MarkupRenderer.rst(source, mentions=mentions)) | |
1846 | maybe_convert_relative_links( |
|
1847 | ||
1847 | MarkupRenderer.rst(source, mentions=mentions))) |
|
1848 | return literal(f'<div class="rst-block">{rendered_block}</div>') | |
1848 |
|
1849 | |||
1849 | elif renderer == 'markdown': |
|
1850 | elif renderer == 'markdown': | |
1850 | if repo_name: |
|
1851 | if repo_name: | |
@@ -1856,18 +1857,14 b" def render(source, renderer='rst', menti" | |||||
1856 | for issue in issues: |
|
1857 | for issue in issues: | |
1857 | issues_container_callback(issue) |
|
1858 | issues_container_callback(issue) | |
1858 |
|
1859 | |||
1859 |
|
1860 | rendered_block = maybe_convert_relative_links( | ||
1860 | return literal( |
|
1861 | MarkupRenderer.markdown(source, flavored=True, mentions=mentions)) | |
1861 |
|
|
1862 | return literal(f'<div class="markdown-block">{rendered_block}</div>') | |
1862 | maybe_convert_relative_links( |
|
|||
1863 | MarkupRenderer.markdown(source, flavored=True, |
|
|||
1864 | mentions=mentions))) |
|
|||
1865 |
|
1863 | |||
1866 | elif renderer == 'jupyter': |
|
1864 | elif renderer == 'jupyter': | |
1867 | return literal( |
|
1865 | rendered_block = maybe_convert_relative_links( | |
1868 | '<div class="ipynb">%s</div>' % |
|
1866 | MarkupRenderer.jupyter(source)) | |
1869 | maybe_convert_relative_links( |
|
1867 | return literal(f'<div class="ipynb">{rendered_block}</div>') | |
1870 | MarkupRenderer.jupyter(source))) |
|
|||
1871 |
|
1868 | |||
1872 | # None means just show the file-source |
|
1869 | # None means just show the file-source | |
1873 | return None |
|
1870 | return None | |
@@ -2020,10 +2017,10 b' def get_visual_attr(tmpl_context_var, at' | |||||
2020 |
|
2017 | |||
2021 | def get_last_path_part(file_node): |
|
2018 | def get_last_path_part(file_node): | |
2022 | if not file_node.path: |
|
2019 | if not file_node.path: | |
2023 |
return |
|
2020 | return '/' | |
2024 |
|
2021 | |||
2025 |
path = safe_ |
|
2022 | path = safe_str(file_node.path.split('/')[-1]) | |
2026 |
return |
|
2023 | return '../' + path | |
2027 |
|
2024 | |||
2028 |
|
2025 | |||
2029 | def route_url(*args, **kwargs): |
|
2026 | def route_url(*args, **kwargs): |
General Comments 0
You need to be logged in to leave comments.
Login now