Commit 59e64416 authored by Jérome Perrin's avatar Jérome Perrin

ERP5Py3: Zope5.11.1, zodbtools

- update zope to 5.11.1
- update zodbtools to current master ( `0.0.0.dev9-21-gb0fdb5f` )

See merge request nexedi/slapos!1670
parents dc5330c5 54606432
Pipeline #38030 failed with stage
in 0 seconds
......@@ -105,17 +105,18 @@ egg = ${:_buildout_section_name_}
setup-eggs = ${python-cffi:egg}
# eggs that are common to ZODB4, ZODB5 and ZODB6.
# python3 versions for ZODB6.
[versions]
BTrees = 5.1.0
BTrees = 6.1.0
persistent = 5.1.0
zodbpickle = 3.3.0
zodbpickle = 4.1.1
# Provide ZODB3 for those eggs that still care about ZODB3 compatibility -
# for example wendelin.core. ZODB3 3.11 is just a dependency egg on _latest_
# ZODB, persistent, BTrees and ZEO.
ZODB3 = 3.11.0
# eggs that are common to ZODB4 and ZODB5.
[versions:python2]
BTrees = 4.11.3
persistent = 4.9.3
......
From 27d88c40e251b370f4dd2fcc7ae03c2967c68e4c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9rome=20Perrin?= <jerome@nexedi.com>
Date: Mon, 2 Sep 2024 04:41:13 +0000
Subject: [PATCH] checkPermission: align behavior with objects raising in
__getattr__
The observed problem was a behavior different between C and python
implementation on python 3, happening with Zope python script. When the
context can not be accessed by the current user, Zope binds a
`Shared.DC.Scripts.Bindings.UnauthorizedBinding`, a class that raises an
Unauthorized error when the context is actually accessed, in order to
postpone the Unauthorized if something is actually accessed. This class
does implements this by raising Unauthorized in __getattr__.
The python implementation of `checkPermission` uses `hasattr` and
`hasattr` has changed between python2 and python3, on python2 it was
ignoring all exceptions, including potential Unauthorized errors and
just returning False, but on python3 these errors are raised.
This change of behavior of python causes checkPermission to behave
differently: when using python implementation on python2 or when using
C implementation, such Unauthorized errors were gracefully handled and
caused checkPermission to return False, but on python3 checkPermission
raises.
This change make this scenario behave the same between python2, python3
and C implementation: Unauthorized errors raised in __getattr__ are
supported. The code is also micro-simplified by doing only one getattr
instead of hasattr and then getattr.
---
src/AccessControl/ImplPython.py | 6 +++++-
src/AccessControl/cAccessControl.c | 7 +++++--
src/AccessControl/tests/testZopeSecurityPolicy.py | 15 +++++++++++++++
4 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/src/AccessControl/ImplPython.py b/src/AccessControl/ImplPython.py
index 1a7788b..0a9326b 100644
--- a/src/AccessControl/ImplPython.py
+++ b/src/AccessControl/ImplPython.py
@@ -31,6 +31,7 @@
from Acquisition import aq_parent
from ExtensionClass import Base
from zope.interface import implementer
+from zExceptions import Unauthorized as zExceptions_Unauthorized
PURE_PYTHON = int(os.environ.get('PURE_PYTHON', '0'))
if PURE_PYTHON:
@@ -71,8 +72,11 @@ def rolesForPermissionOn(perm, object, default=_default_roles, n=None):
r = None
while True:
- if hasattr(object, n):
+ try:
roles = getattr(object, n)
+ except (AttributeError, zExceptions_Unauthorized):
+ pass
+ else:
if roles is None:
if _embed_permission_in_roles:
return (('Anonymous',), n)
diff --git a/src/AccessControl/cAccessControl.c b/src/AccessControl/cAccessControl.c
index 403ed67..1a109fa 100644
--- a/src/AccessControl/cAccessControl.c
+++ b/src/AccessControl/cAccessControl.c
@@ -1847,13 +1847,16 @@ c_rolesForPermissionOn(PyObject *perm, PyObject *object,
Py_INCREF(r);
/*
- while 1:
+ while True:
*/
while (1)
{
/*
- if hasattr(object, n):
+ try:
roles = getattr(object, n)
+ except (AttributeError, zExceptions_Unauthorized):
+ pass
+ else:
*/
PyObject *roles = PyObject_GetAttr(object, n);
if (roles != NULL)
diff --git a/src/AccessControl/tests/testZopeSecurityPolicy.py b/src/AccessControl/tests/testZopeSecurityPolicy.py
index 9b12a0f..ee74bad 100644
--- a/src/AccessControl/tests/testZopeSecurityPolicy.py
+++ b/src/AccessControl/tests/testZopeSecurityPolicy.py
@@ -157,6 +157,15 @@ class PartlyProtectedSimpleItem3 (PartlyProtectedSimpleItem1):
__roles__ = sysadmin_roles
+class DynamicallyUnauthorized(SimpleItemish):
+ # This class raises an Unauthorized on attribute access,
+ # similar to Zope's Shared.DC.Scripts.Bindings.UnauthorizedBinding
+ __ac_local_roles__ = {}
+
+ def __getattr__(self, name):
+ raise Unauthorized('Not authorized to access: %s' % name)
+
+
class SimpleClass:
attr = 1
@@ -173,6 +182,7 @@ def setUp(self):
a.item1 = PartlyProtectedSimpleItem1()
a.item2 = PartlyProtectedSimpleItem2()
a.item3 = PartlyProtectedSimpleItem3()
+ a.d_item = DynamicallyUnauthorized()
uf = UserFolder()
a.acl_users = uf
self.uf = a.acl_users
@@ -351,6 +361,11 @@ def test_checkPermission_proxy_role_scope(self):
r_subitem,
context))
+ def test_checkPermission_dynamically_unauthorized(self):
+ d_item = self.a.d_item
+ context = self.context
+ self.assertFalse(self.policy.checkPermission('View', d_item, context))
+
def testUnicodeRolesForPermission(self):
r_item = self.a.r_item
context = self.context
From a037f2a2e2090dcd63b83af9b06427dd8c7e9536 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9rome=20Perrin?= <jerome@nexedi.com>
Date: Wed, 22 May 2024 23:58:45 +0900
Subject: [PATCH] Show Python Script source code in tracebacks
Expose a __loader__ in globals so that linecache module is able to use
it to display the source code.
This requires changing the "filename" used when compiling function,
because linecache uses code.co_filename as a cache key, so it's
necessary that each python script use a different filename.
WIP from https://github.com/zopefoundation/Products.PythonScripts/pull/65
---
CHANGES.rst | 2 +
src/Products/PythonScripts/PythonScript.py | 19 ++++++-
.../PythonScripts/tests/testPythonScript.py | 50 ++++++++++++++++++-
3 files changed, 67 insertions(+), 4 deletions(-)
diff --git a/src/Products/PythonScripts/PythonScript.py b/src/Products/PythonScripts/PythonScript.py
index fe4223a..5cb7f37 100644
--- a/src/Products/PythonScripts/PythonScript.py
+++ b/src/Products/PythonScripts/PythonScript.py
@@ -16,7 +16,9 @@
Python code.
"""
+import importlib.abc
import importlib.util
+import linecache
import marshal
import os
import re
@@ -56,7 +58,7 @@
Python_magic = importlib.util.MAGIC_NUMBER
# This should only be incremented to force recompilation.
-Script_magic = 4
+Script_magic = 5
_log_complaint = (
'Some of your Scripts have stale code cached. Since Zope cannot'
' use this code, startup will be slightly slower until these Scripts'
@@ -97,6 +99,16 @@ def manage_addPythonScript(self, id, title='', file=None, REQUEST=None,
return ''
+class PythonScriptLoader(importlib.abc.Loader):
+ """PEP302 loader to display source code in tracebacks
+ """
+ def __init__(self, source):
+ self._source = source
+
+ def get_source(self, name):
+ return self._source
+
+
class PythonScript(Script, Historical, Cacheable):
"""Web-callable scripts written in a safe subset of Python.
@@ -234,7 +246,7 @@ def _compile(self):
self._params,
body=self._body or 'pass',
name=self.id,
- filename=self.meta_type,
+ filename=getattr(self, '_filepath', None) or self.get_filepath(),
globalize=bind_names)
code = compile_result.code
@@ -261,6 +273,7 @@ def _compile(self):
fc.co_argcount)
self.Python_magic = Python_magic
self.Script_magic = Script_magic
+ linecache.clearcache()
self._v_change = 0
def _newfun(self, code):
@@ -331,6 +344,8 @@ def _exec(self, bound_names, args, kw):
PythonScriptTracebackSupplement, self, -1)
safe_globals['__file__'] = getattr(
self, '_filepath', None) or self.get_filepath()
+ safe_globals['__loader__'] = PythonScriptLoader(self._body)
+
function = types.FunctionType(
function_code, safe_globals, None, function_argument_definitions)
diff --git a/src/Products/PythonScripts/tests/testPythonScript.py b/src/Products/PythonScripts/tests/testPythonScript.py
index 60ef6c3..7cd2266 100644
--- a/src/Products/PythonScripts/tests/testPythonScript.py
+++ b/src/Products/PythonScripts/tests/testPythonScript.py
@@ -15,6 +15,7 @@
import io
import os
import sys
+import traceback
import unittest
import warnings
from urllib.error import HTTPError
@@ -241,7 +242,8 @@ def test_manage_DAVget(self):
self.assertEqual(ps.read(), ps.manage_DAVget())
def test_PUT_native_string(self):
- ps = makerequest(self._filePS('complete'))
+ container = DummyFolder('container')
+ ps = makerequest(self._filePS('complete').__of__(container))
self.assertEqual(ps.title, 'This is a title')
self.assertEqual(ps.body(), 'print(foo+bar+baz)\nreturn printed\n')
self.assertEqual(ps.params(), 'foo, bar, baz=1')
@@ -265,7 +267,8 @@ def test_PUT_native_string(self):
self.assertEqual(ps.params(), 'oops')
def test_PUT_bytes(self):
- ps = makerequest(self._filePS('complete'))
+ container = DummyFolder('container')
+ ps = makerequest(self._filePS('complete').__of__(container))
self.assertEqual(ps.title, 'This is a title')
self.assertEqual(ps.body(), 'print(foo+bar+baz)\nreturn printed\n')
self.assertEqual(ps.params(), 'foo, bar, baz=1')
@@ -588,3 +591,46 @@ def test_PythonScript_proxyroles_nonmanager(self):
# Cleanup
noSecurityManager()
+
+
+class TestTraceback(FunctionalTestCase, PythonScriptTestBase):
+
+ def _format_exception(self):
+ return "".join(traceback.format_exception(*sys.exc_info()))
+
+ def test_source_code_in_traceback(self):
+ ps = self._newPS("1 / 0")
+ try:
+ ps()
+ except ZeroDivisionError:
+ formatted_exception = self._format_exception()
+ self.assertIn("1 / 0", formatted_exception)
+
+ ps.write("2 / 0")
+ try:
+ ps()
+ except ZeroDivisionError:
+ formatted_exception = self._format_exception()
+ self.assertIn("2 / 0", formatted_exception)
+
+ def test_multiple_scripts_in_traceback(self):
+ from Products.PythonScripts.PythonScript import manage_addPythonScript
+
+ script1_body = "container.script2()"
+ manage_addPythonScript(
+ self.folder,
+ "script1",
+ file=script1_body,
+ )
+ script2_body = "1 / 0"
+ manage_addPythonScript(
+ self.folder,
+ "script2",
+ file=script2_body,
+ )
+ try:
+ self.folder.script1()
+ except ZeroDivisionError:
+ formatted_exception = self._format_exception()
+ self.assertIn(script1_body, formatted_exception)
+ self.assertIn(script2_body, formatted_exception)
......@@ -8,7 +8,7 @@ extends =
parts =
pygolang
gpython
gpython-interpreter
# pygolang is installed from git checkout
......@@ -21,14 +21,6 @@ setup-eggs =
setuptools-dso
gevent
# gpython program
[gpython]
recipe = zc.recipe.egg:scripts
eggs = ${pygolang:egg}
scripts = gpython
# convenience for gpython users
exe = ${buildout:bin-directory}/gpython
[pygolang-repository]
recipe = slapos.recipe.build:gitclone
repository = https://lab.nexedi.com/nexedi/pygolang
......@@ -63,6 +55,16 @@ scripts = ${:interpreter}
exe = ${buildout:bin-directory}/${:interpreter}
# gpython-interpreter is like python-interpreter, but runs gpython instead of standard python.
[gpython-interpreter]
<= python-interpreter
interpreter = gpython
initialization =
from gpython import main
main()
sys.exit(0)
# pyprog provides macro recipe to build python programs.
#
# Contrary to zc.recipe.egg:scripts it generates scripts that are run with
......@@ -77,6 +79,13 @@ exe = ${buildout:bin-directory}/${:interpreter}
# exe = ${buildout:bin-directory}/myprog
# entry = my.py.mod:main
# eggs = ...
#
# By default python interpreter defined in [python-interpreter] section is used
# to run the program. The default can be adjusted as illustrated below:
#
# [myprog]
# <= pyprog
# python-interpreter = gpython-interpreter # set to name of the section that defines the interpreter
[pyprog]
recipe = slapos.recipe.build
initialization =
......@@ -88,6 +97,7 @@ init =
entry = options['entry']
eggs = options['eggs']
pyinit = options['initialization']
pyinterpreter = options.get('python-interpreter', 'python-interpreter')
options['depends'] += '$${.%s.pyprog:recipe}' % name
......@@ -97,6 +107,7 @@ init =
# indent pyinit with ' '
__pyinit = '\n'.join([' '+_ for _ in pyinit.splitlines()])
__eggs = '\n'.join([' '+_ for _ in eggs.splitlines()])
self.buildout.parse("""
# .X.pyprog is python program to start and run entry
......@@ -113,7 +124,7 @@ init =
# .X.pyexe is python interpreter used by .X.pyprog
[.%(name)s.pyexe]
<= python-interpreter
eggs += %(eggs)s
<= %(pyinterpreter)s
eggs += %(__eggs)s
interpreter = $${:_buildout_section_name_}
""" % locals())
# SlapOS component for zodbtools development.
[buildout]
extends =
buildout.cfg
../git/buildout.cfg
# override zodbtools to install it from latest git version
[zodbtools]
recipe = zc.recipe.egg:develop
setup = ${zodbtools-repository:location}
[zodbtools-repository]
recipe = slapos.recipe.build:gitclone
repository = https://lab.nexedi.com/nexedi/zodbtools.git
branch = master
location = ${buildout:parts-directory}/zodbtools-dev
git-executable = ${git:location}/bin/git
# unpin zodbtools from versions, so that buildout does not fallback to
# installing non-dev egg if dev one has changed its version.
[versions]
zodbtools =
......@@ -6,13 +6,15 @@ extends =
../pygolang/buildout.cfg
../ZODB/buildout.cfg
../ZEO/buildout.cfg
../git/buildout.cfg
parts =
zodbtools/scripts
zodbtools
[zodbtools]
recipe = zc.recipe.egg:eggs
recipe = zc.recipe.egg:develop
setup = ${zodbtools-repository:location}
egg = zodbtools
eggs =
${:egg}
......@@ -29,5 +31,9 @@ recipe = zc.recipe.egg:scripts
eggs = ${zodbtools:eggs}
[versions]
zodbtools = 0.0.0.dev9
[zodbtools-repository]
recipe = slapos.recipe.build:gitclone
repository = https://lab.nexedi.com/nexedi/zodbtools.git
location = ${buildout:parts-directory}/zodbtools
git-executable = ${git:location}/bin/git
revision = 0.0.0.dev9-21-gb0fdb5f
......@@ -4,10 +4,9 @@
[buildout]
extends =
# test<X>.cfg configures ZODB.major=<X>.
../../stack/nxdtest.cfg
../pytest/buildout.cfg
buildout-dev.cfg
buildout.cfg
parts =
# for instance
......
......@@ -82,7 +82,6 @@ class TestRepozo(ZEOTestCase, CrontabMixin):
with self.db() as db:
with db.transaction() as cnx:
cnx.root.state = "after backup"
db.close()
restore_script = self.computer_partition_root_path / "srv" / "runner-import-restore"
self.assertTrue(restore_script.exists())
......
......@@ -2,7 +2,7 @@
extends =
# versions pins from zope, vendored with:
# curl https://zopefoundation.github.io/Zope/releases/5.10/versions-prod.cfg > zope-versions.cfg
# curl https://zopefoundation.github.io/Zope/releases/5.11.1/versions-prod.cfg > zope-versions.cfg
# When updating, keep in mind that some versions are defined in other places,
# for example component/ZEO , component/ZODB and stack/slapos
zope-versions.cfg
......@@ -619,7 +619,7 @@ eggs =
Products.ZSQLMethods
Products.ExternalMethod
Products.SiteErrorLog
tempstorage
Products.TemporaryFolder
Products.Sessions
Products.ZODBMountPoint
Record
......@@ -662,16 +662,12 @@ SOAPpy-py3-patches = ${:_profile_base_location_}/../../component/egg-patch/SOAPp
SOAPpy-py3-patch-options = -p1
[eggs:python3]
AccessControl-patches = ${:_profile_base_location_}/../../component/egg-patch/AccessControl/157.patch#9b01341bd4271555c9caa66cb9d0f098
AccessControl-patch-options = -p1
interval-patches = ${:_profile_base_location_}/../../component/egg-patch/interval/0001-python3-support.patch#66ac345f0a6d73e0bd29e394b7646311
interval-patch-options = -p1
Products.DCWorkflow-patches = ${:_profile_base_location_}/../../component/egg-patch/Products.DCWorkflow/workflow_method-3.0.0.patch#4cc8607213b1ef08331366d9873becaa
Products.DCWorkflow-patch-options = -p1
Products.MimetypesRegistry-patches = ${:_profile_base_location_}/../../component/egg-patch/Products.MimetypesRegistry/40.patch#1e85995d08747f73df5ea7353a41453d
Products.MimetypesRegistry-patch-options = -p1
Products.PythonScripts-patches = ${:_profile_base_location_}/../../component/egg-patch/Products.PythonScripts/65.patch#61bd90d4c1ead3669bfe7c959d957ab6
Products.PythonScripts-patch-options = -p1
[eggs:python2]
DateTime-patches =
......@@ -755,8 +751,7 @@ depends =
# neoppod, mysqlclient, slapos.recipe.template
# patched eggs
AccessControl = 7.0+SlapOSPatched001
Acquisition = 5.2+SlapOSPatched001
Acquisition = 6.1+SlapOSPatched001
PyPDF2 = 1.26.0+SlapOSPatched002
pysvn = 1.9.15+SlapOSPatched001
python-magic = 0.4.12+SlapOSPatched001
......@@ -818,10 +813,11 @@ Products.MailHost = 5.2
Products.MimetypesRegistry = 3.0.1+SlapOSPatched001
Products.PluggableAuthService = 3.0
Products.PluginRegistry = 2.0
Products.PythonScripts = 5.0+SlapOSPatched001
Products.PythonScripts = 5.1
Products.Sessions = 5.0
Products.SiteErrorLog = 6.0
Products.StandardCacheManagers = 5.0
Products.TemporaryFolder = 7.0
Products.ZCatalog = 7.0
Products.ZODBMountPoint = 2.0
Products.ZSQLMethods = 4.1
......@@ -890,10 +886,6 @@ zope.password = 4.4
zope.sendmail = 6.1
zope.session = 4.5
# temporary versions, until updated in zope-versions.cfg
[versions]
DateTime = 5.5
[versions:python2]
AccessControl = 4.4
......@@ -939,6 +931,7 @@ Products.PythonScripts = 4.15
Products.Sessions = 4.15
Products.SiteErrorLog = 5.7
Products.StandardCacheManagers = 4.2
Products.TemporaryFolder = 3.0
Products.TIDStorage = 5.5.0
Products.ZCatalog = 5.4
Products.ZODBMountPoint = 1.3
......
......@@ -2,43 +2,43 @@
# Version pins for required and commonly used dependencies.
[versions]
Zope = 5.10
Zope = 5.11.1
Zope2 = 4.0
AccessControl = 6.3
Acquisition = 5.1
AccessControl = 7.2
Acquisition = 6.1
AuthEncoding = 5.0
BTrees = 5.1
Chameleon = 4.2.0
DateTime = 5.3
BTrees = 6.1
Chameleon = 4.4.4
DateTime = 5.5
DocumentTemplate = 4.6
ExtensionClass = 5.1
ExtensionClass = 6.0
MultiMapping = 5.0
Paste = 3.7.1
Paste = 3.10.1
PasteDeploy = 3.1.0
Persistence = 4.1
RestrictedPython = 7.1
WebTest = 3.0.0
Persistence = 5.1
RestrictedPython = 7.4
WebTest = 3.0.1
WSGIProxy2 = 0.5.1
WebOb = 1.8.7
ZConfig = 4.0
ZODB = 5.8.1
beautifulsoup4 = 4.12.2
cffi = 1.16.0
multipart = 0.2.4
persistent = 5.1
pycparser = 2.21
WebOb = 1.8.9
ZConfig = 4.1
ZODB = 6.0
beautifulsoup4 = 4.12.3
cffi = 1.17.1
multipart = 0.2.5
persistent = 6.1
pycparser = 2.22
python-gettext = 5.0
pytz = 2023.3.post1
pytz = 2024.2
six = 1.16.0
roman = 4.1
soupsieve = 2.5
transaction = 4.0
waitress = 2.1.2
z3c.pt = 4.0
roman = 4.2
soupsieve = 2.6
transaction = 5.0
waitress = 3.0.1
z3c.pt = 4.4
zExceptions = 5.0
zc.lockfile = 3.0.post1
zc.recipe.egg = 2.0.7
zodbpickle = 3.1
zodbpickle = 4.1.1
zope.annotation = 5.0
zope.browser = 3.0
zope.browsermenu = 5.0
......@@ -46,47 +46,45 @@ zope.browserpage = 5.0
zope.browserresource = 5.1
zope.cachedescriptors = 5.0
zope.component = 6.0
zope.configuration = 5.0
zope.container = 5.2
zope.contentprovider = 5.0
zope.configuration = 5.0.1
zope.container = 6.1
zope.contentprovider = 6.0
zope.contenttype = 5.1
zope.datetime = 5.0.0
zope.deferredimport = 5.0
zope.deprecation = 5.0
zope.dottedname = 6.0
zope.event = 5.0
zope.exceptions = 5.0.1
zope.exceptions = 5.2
zope.filerepresentation = 6.0
zope.globalrequest = 2.0
zope.hookable = 6.0
zope.i18n = 5.1
zope.i18nmessageid = 6.1.0
zope.interface = 6.3
zope.hookable = 7.0
zope.i18n = 5.2
zope.i18nmessageid = 7.0
zope.interface = 7.1.1
zope.lifecycleevent = 5.0
zope.location = 5.0
zope.pagetemplate = 5.0
zope.pagetemplate = 5.1
zope.processlifetime = 3.0
zope.proxy = 5.1
zope.proxy = 6.1
zope.ptresource = 5.0
zope.publisher = 7.0
zope.publisher = 7.1
zope.schema = 7.0.1
zope.security = 6.2
zope.security = 7.3
zope.sequencesort = 5.0
zope.site = 5.0
zope.size = 5.0
zope.structuredtext = 5.0
zope.tal = 5.0.1
zope.tales = 6.0
zope.testbrowser = 6.0
zope.testbrowser = 7.0
zope.testing = 5.0.1
zope.traversing = 5.0
zope.viewlet = 5.0
## XXX our old buildout for bootstrap does not support `python37`
## [versions:python37]
## # PasteDeploy 3.x works on Python 3.7 but pulls tons of dependencies
## PasteDeploy = 2.1.1
## # SoupSieve 2.5 and up requires Python 3.8
## soupsieve = 2.4.1
## # cffi 1.16.0 requires Python 3.8
## cffi = 1.15.1
## XXX our old buildout for bootstrap does not support `python38`
## [versions:python38]
## # Chameleon >= 4.5 requires Python 3.9
## Chameleon = 4.4.4
## # waitress >= 3.0.1 requires Python 3.9
## waitress = 3.0.0
......@@ -417,10 +417,10 @@ zeroconf = 0.62.0:whl
zipp = 3.12.0:whl
zodburi = 2.5.0
zope.event = 5.0
zope.exceptions = 5.0.1
zope.interface = 6.3
zope.exceptions = 5.2
zope.interface = 7.1.1
zope.testing = 5.0.1
zope.testrunner = 6.4
zope.testrunner = 6.6
[versions:sys.version_info < (3,10)]
# keep old statsmodels by default until slapos.toolbox is updated
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment