collective.xsendfile 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,97 @@
1
+ <configure
2
+ xmlns="http://namespaces.zope.org/zope"
3
+ xmlns:monkey="http://namespaces.plone.org/monkey"
4
+ xmlns:five="http://namespaces.zope.org/five"
5
+ xmlns:browser="http://namespaces.zope.org/browser"
6
+ xmlns:zcml="http://namespaces.zope.org/zcml"
7
+ xmlns:genericsetup="http://namespaces.zope.org/genericsetup"
8
+ i18n_domain="collective.xsendfile"
9
+ >
10
+
11
+ <five:registerPackage package="." initialize=".zope2.initialize"/>
12
+
13
+ <!-- Control panel -->
14
+ <include package="plone.registry"/>
15
+ <include package="plone.app.registry"/>
16
+ <browser:page
17
+ name="xsendfile-settings"
18
+ for="Products.CMFPlone.interfaces.IPloneSiteRoot"
19
+ class=".controlpanel.xsendfileSettingsControlPanel"
20
+ permission="cmf.ManagePortal"
21
+ />
22
+
23
+ <!-- Monkey patcher -->
24
+ <include package="collective.monkeypatcher"/>
25
+ <configure zcml:condition="installed plone.app.blob">
26
+ <monkey:patch
27
+ description="Add support for XSendfile"
28
+ class="plone.app.blob.field.BlobWrapper"
29
+ original="index_html"
30
+ replacement=".utils.plone_app_blob_field_BlobWrapper_index_html"
31
+ preserveOriginal="true"
32
+ />
33
+ <monkey:patch
34
+ description="Add support for XSendfile"
35
+ class="plone.app.blob.field.BlobWrapper"
36
+ original="getIterator"
37
+ replacement=".utils.plone_app_blob_field_BlobWrapper_getIterator"
38
+ preserveOriginal="true"
39
+ />
40
+ </configure>
41
+ <monkey:patch
42
+ description="Create blobstorage and internal folders/files with proper permissions for groups"
43
+ class="ZODB.blob.FilesystemHelper"
44
+ original="create"
45
+ replacement=".monkeypatch.FilesystemHelper_create"
46
+ preserveOriginal="true"
47
+ preconditions="ZODB-=5.2.1"
48
+ />
49
+ <monkey:patch
50
+ description="Create blobstorage and internal folders/files with proper permissions for groups"
51
+ class="ZODB.blob.FilesystemHelper"
52
+ original="isSecure"
53
+ replacement=".monkeypatch.FilesystemHelper_isSecure"
54
+ preserveOriginal="true"
55
+ preconditions="ZODB-=5.2.1"
56
+ />
57
+ <monkey:patch
58
+ description="Create blobstorage and internal folders/files with proper permissions for groups"
59
+ class="ZODB.blob.FilesystemHelper"
60
+ original="getPathForOID"
61
+ replacement=".monkeypatch.FilesystemHelper_getPathForOID"
62
+ preserveOriginal="true"
63
+ preconditions="ZODB-=5.2.1"
64
+ />
65
+ <configure zcml:condition="installed plone.namedfile">
66
+ <monkey:patch
67
+ description="Add support for XSendfile"
68
+ class="plone.namedfile.browser.Download"
69
+ original="__call__"
70
+ replacement=".utils.monkeypatch_plone_namedfile_browser_Download__call__"
71
+ preserveOriginal="true"
72
+ />
73
+ <monkey:patch
74
+ description="Add support for XSendfile"
75
+ class="plone.formwidget.namedfile.widget.Download"
76
+ original="__call__"
77
+ replacement=".utils.monkeypatch_plone_formwidget_namedfile_widget_download__call__"
78
+ preserveOriginal="true"
79
+ />
80
+ <monkey:patch
81
+ description="Add support for XSendfile for image scales"
82
+ class="plone.namedfile.scaling.ImageScale"
83
+ original="index_html"
84
+ replacement=".utils.plone_namedfile_scaling_ImageScale_index_html"
85
+ preserveOriginal="true"
86
+ />
87
+ </configure>
88
+
89
+ <genericsetup:registerProfile
90
+ name="default"
91
+ title="XSendFile support"
92
+ directory="profiles/default"
93
+ description="Installs the collective.xsendfile package"
94
+ provides="Products.GenericSetup.interfaces.EXTENSION"
95
+ />
96
+
97
+ </configure>
@@ -0,0 +1,30 @@
1
+ # -*- coding: utf-8 -*-
2
+ from collective.xsendfile.interfaces import IxsendfileSettings
3
+ from collective.xsendfile.interfaces import _
4
+ from plone.app.registry.browser import controlpanel
5
+
6
+ import os
7
+
8
+ if 'XSENDFILE_RESPONSEHEADER' in os.environ:
9
+ DESC = _(
10
+ 'WARNING: xsendfile settings configured by environment. These settings '
11
+ 'will be ignored.')
12
+ else:
13
+ DESC = _(u'')
14
+
15
+
16
+ class xsendfileSettingsEditForm(controlpanel.RegistryEditForm):
17
+ schema = IxsendfileSettings
18
+ label = _(u'xsendfile settings')
19
+
20
+ description = DESC
21
+
22
+ def updateFields(self):
23
+ super(xsendfileSettingsEditForm, self).updateFields()
24
+
25
+ def updateWidgets(self):
26
+ super(xsendfileSettingsEditForm, self).updateWidgets()
27
+
28
+
29
+ class xsendfileSettingsControlPanel(controlpanel.ControlPanelFormWrapper):
30
+ form = xsendfileSettingsEditForm
@@ -0,0 +1,51 @@
1
+ # -*- coding: utf-8 -*-
2
+ from zope import schema
3
+ from zope.i18nmessageid import MessageFactory
4
+ from zope.interface import Interface
5
+ from zope.schema.vocabulary import SimpleTerm
6
+ from zope.schema.vocabulary import SimpleVocabulary
7
+
8
+ _ = MessageFactory('collective.xsendfile')
9
+ http_response_headers = SimpleVocabulary(
10
+ [
11
+ SimpleTerm(value=u'X-Sendfile', title=_(u'X-Sendfile (Apache, LHTTPd)')),
12
+ SimpleTerm(value=u'X-Accel-Redirect',
13
+ title=_(u'X-Accel-Redirect (nginx, needs path rewriting)')),
14
+ ]
15
+ )
16
+
17
+
18
+ class IxsendfileSettings(Interface):
19
+ """Global xsendfile settings. This describes records stored in the
20
+ configuration registry and obtainable via plone.registry.
21
+ """
22
+
23
+ xsendfile_responseheader = schema.Choice(
24
+ title=_(u'HTTP Response Header'),
25
+ vocabulary=http_response_headers,
26
+ required=False,
27
+ )
28
+
29
+ xsendfile_enable_fallback = schema.Bool(
30
+ title=_(u'Enable fallback based on HTTP_X_FORWARDED_FOR proxy header'),
31
+ description=_(
32
+ u'Check if REQUEST header contains HTTP_X_FORWARDED_FOR and re-enable fallback (zope) file delivery if no proxy is detected.'),
33
+ default=True,
34
+ required=False,
35
+ )
36
+
37
+ xsendfile_pathregex_search = schema.TextLine(
38
+ title=_(u'Blob-path rewriting regex'),
39
+ description=_(
40
+ u'This regex will be used to modify the files path, in case you are using a different mountpoint on the xsendfile Server. If you are using nginx you have to prepend the id of the location you configured.'),
41
+ default=u'(.*)',
42
+ required=False,
43
+ )
44
+
45
+ xsendfile_pathregex_substitute = schema.TextLine(
46
+ title=_(u'Blob-path rewriting substitution'),
47
+ description=_(
48
+ u'This regex will be used to modify the files path, in case you are using a different mountpoint on the xsendfile Server. If you are using nginx you have to prepend the id of the location you configured.'),
49
+ default=u'\\1',
50
+ required=False,
51
+ )
@@ -0,0 +1,117 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+
4
+ Monkey-patch ZODB to support blob storage FS permissios
5
+ which allows reading them by Apache.
6
+ http://stackoverflow.com/questions/6168566/collective-xsendfile-zodb-blobs-and-unix-file-permissions/6169177#6169177
7
+
8
+ ZODB was changed in 5.2.2 to no longer set permissions. As such, these patches aren't needed on later versions.
9
+ https://github.com/zopefoundation/ZODB/commit/12eaf5cbc379257ed4b1ed7eece386329796b560
10
+
11
+ """
12
+ from ZODB import utils
13
+ from ZODB import blob
14
+ from ZODB.blob import LAYOUT_MARKER
15
+ from ZODB.blob import log
16
+ from importlib.metadata import version as distribution_version
17
+
18
+ import itertools
19
+ import logging
20
+ import os
21
+ import stat
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def FilesystemHelper_create(self):
27
+ if not os.path.exists(self.base_dir):
28
+ os.makedirs(self.base_dir, 0o750)
29
+ log('Blob directory \'%s\' does not exist. '
30
+ 'Created new directory.' % self.base_dir)
31
+ if not os.path.exists(self.temp_dir):
32
+ os.makedirs(self.temp_dir, 0o750)
33
+ log('Blob temporary directory \'%s\' does not exist. '
34
+ 'Created new directory.' % self.temp_dir)
35
+
36
+ layout_marker_path = os.path.join(self.base_dir, LAYOUT_MARKER)
37
+ if not os.path.exists(layout_marker_path):
38
+ with open(layout_marker_path, 'w') as layout_marker:
39
+ layout_marker.write(self.layout_name)
40
+ else:
41
+ with open(layout_marker_path, 'r') as layout_marker:
42
+ layout = layout_marker.read().strip()
43
+ if layout != self.layout_name:
44
+ raise ValueError(
45
+ "Directory layout `%s` selected for blob directory %s, but "
46
+ "marker found for layout `%s`" %
47
+ (self.layout_name, self.base_dir, layout))
48
+
49
+ def FilesystemHelper_isSecure(self, path):
50
+ """Ensure that (POSIX) path mode bits are 0750."""
51
+ return (os.stat(path).st_mode & 0o27) == 0
52
+
53
+ def FilesystemHelper_getPathForOID(self, oid, create=False):
54
+ """Given an OID, return the path on the filesystem where
55
+ the blob data relating to that OID is stored.
56
+
57
+ If the create flag is given, the path is also created if it didn't
58
+ exist already.
59
+
60
+ """
61
+ # OIDs are numbers and sometimes passed around as integers. For our
62
+ # computations we rely on the 64-bit packed string representation.
63
+ if isinstance(oid, int):
64
+ oid = utils.p64(oid)
65
+
66
+ path = self.layout.oid_to_path(oid)
67
+ path = os.path.join(self.base_dir, path)
68
+
69
+ if create and not os.path.exists(path):
70
+ try:
71
+ os.makedirs(path, 0o750)
72
+ except OSError:
73
+ # We might have lost a race. If so, the directory
74
+ # must exist now
75
+ logger.exception('Error creating blob directory.')
76
+ assert os.path.exists(path)
77
+ return path
78
+
79
+
80
+ def rename_or_copy_blob(f1, f2, chmod=True):
81
+ """Try to rename f1 to f2, fallback to copy.
82
+
83
+ Under certain conditions a rename might not work, e.g. because the target
84
+ directory is on a different partition. In this case we try to copy the
85
+ data and remove the old file afterwards.
86
+
87
+ """
88
+ try:
89
+ os.rename(f1, f2)
90
+ except OSError:
91
+ logger.debug("Copied blob file %r to %r.", f1, f2)
92
+ file1 = open(f1, 'rb')
93
+ file2 = open(f2, 'wb')
94
+ try:
95
+ utils.cp(file1, file2)
96
+ finally:
97
+ file1.close()
98
+ file2.close()
99
+ blob.remove_committed(f1)
100
+
101
+ os.chmod(f2, stat.S_IREAD | stat.S_IRGRP)
102
+
103
+
104
+ def _version_tuple(version):
105
+ parts = []
106
+ for part in version.split('.'):
107
+ digits = ''.join(itertools.takewhile(str.isdigit, part))
108
+ if not digits:
109
+ break
110
+ parts.append(int(digits))
111
+ return tuple(parts)
112
+
113
+
114
+ if _version_tuple(distribution_version('ZODB')) < (5, 2, 2):
115
+ blob.rename_or_copy_blob = rename_or_copy_blob
116
+ else:
117
+ logger.info('Not patching rename_or_copy_blob as ZODB >= 5.2.2')
@@ -0,0 +1,20 @@
1
+ <?xml version="1.0"?>
2
+ <object
3
+ name="portal_controlpanel"
4
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
5
+ i18n:domain="plone"
6
+ purge="False">
7
+
8
+ <configlet
9
+ title="XSendFile Settings"
10
+ action_id="plone.app.event"
11
+ appId="plone.app.event"
12
+ category="Plone"
13
+ condition_expr=""
14
+ url_expr="string:${portal_url}/@@xsendfile-settings"
15
+ visible="True"
16
+ i18n:attributes="title">
17
+ <permission>Manage portal</permission>
18
+ </configlet>
19
+
20
+ </object>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <metadata>
3
+ <dependencies>
4
+ <dependency>profile-plone.app.registry:default</dependency>
5
+ </dependencies>
6
+ </metadata>
7
+
8
+
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0"?>
2
+ <registry>
3
+ <records interface="collective.xsendfile.interfaces.IxsendfileSettings"/>
4
+ </registry>
@@ -0,0 +1,60 @@
1
+ from plone.app.testing import FunctionalTesting
2
+ from plone.app.testing import IntegrationTesting
3
+ from plone.app.testing import PLONE_FIXTURE
4
+ from plone.app.testing import PloneSandboxLayer
5
+ from plone.app.testing import TEST_USER_ID
6
+ from plone.app.testing import TEST_USER_NAME
7
+ from plone.app.testing import login
8
+ from plone.app.testing import setRoles
9
+ from plone.namedfile.file import NamedBlobFile
10
+ from plone.namedfile.file import NamedBlobImage
11
+
12
+ import collective.xsendfile
13
+ import os
14
+
15
+
16
+ def getData(filename):
17
+ """Return contents of a file from the tests directory."""
18
+ path = os.path.join(os.path.dirname(__file__), 'tests', filename)
19
+ with open(path, 'rb') as f:
20
+ return f.read()
21
+
22
+
23
+ class TestLayer(PloneSandboxLayer):
24
+ defaultBases = (PLONE_FIXTURE,)
25
+
26
+ def setUpZope(self, app, configurationContext):
27
+ self.loadZCML(package=collective.xsendfile)
28
+ import plone.app.registry
29
+ self.loadZCML(package=plone.app.registry)
30
+
31
+ def setUpPloneSite(self, portal):
32
+ setRoles(portal, TEST_USER_ID, ['Manager'])
33
+ login(portal, TEST_USER_NAME)
34
+
35
+ data = getData('image.gif')
36
+ portal.invokeFactory(
37
+ 'Image', id='image',
38
+ image=NamedBlobImage(data=data, filename='image.gif'),
39
+ )
40
+ portal.invokeFactory(
41
+ 'File', id='file',
42
+ file=NamedBlobFile(data=data, filename='image.gif'),
43
+ )
44
+
45
+ # Pre-generate the default image scale so its blob is committed by
46
+ # the layer fixture. Otherwise the xsendfile patch on ImageScale
47
+ # bails out for uncommitted blobs and never sets X-SENDFILE.
48
+ portal['image'].unrestrictedTraverse('@@images').scale(
49
+ fieldname='image',
50
+ )
51
+
52
+
53
+ FIXTURE = TestLayer()
54
+
55
+ INTEGRATION_TESTING = IntegrationTesting(
56
+ bases=(FIXTURE,),
57
+ name='collective.xsendfile:Integration')
58
+ FUNCTIONAL_TESTING = FunctionalTesting(
59
+ bases=(FIXTURE,),
60
+ name='collective.xsendfile:Functional')
File without changes
Binary file
@@ -0,0 +1,139 @@
1
+ import os
2
+ import unittest
3
+
4
+ from collective.xsendfile.interfaces import IxsendfileSettings
5
+ from collective.xsendfile.testing import INTEGRATION_TESTING
6
+ from plone.app.testing import applyProfile
7
+ from plone.registry.interfaces import IRegistry
8
+ from zope.component import getUtility
9
+
10
+
11
+ def clean_env():
12
+ for e in ['XSENDFILE_RESPONSEHEADER',
13
+ 'XSENDFILE_PATHREGEX_SEARCH',
14
+ 'XSENDFILE_PATHREGEX_SUBSTITUTE',
15
+ 'XSENDFILE_ENABLE_FALLBACK']:
16
+ if e in os.environ:
17
+ del os.environ[e]
18
+
19
+
20
+ class BaseTestCase(unittest.TestCase):
21
+ layer = INTEGRATION_TESTING
22
+
23
+ def setUp(self):
24
+ self.portal = self.layer['portal']
25
+ self.request = self.layer['request']
26
+
27
+ def tearDown(self):
28
+ clean_env()
29
+
30
+ def _download(self, content_id='file', field='file'):
31
+ view = self.portal[content_id].unrestrictedTraverse('@@download')
32
+ field_view = view.publishTraverse(self.request, field)
33
+ field_view()
34
+ return self.request.RESPONSE
35
+
36
+
37
+ class DownloadTestCase(BaseTestCase):
38
+
39
+ def test_download(self):
40
+ os.environ['XSENDFILE_RESPONSEHEADER'] = 'X-SENDFILE'
41
+ self.request.set('HTTP_X_FORWARDED_FOR', '0.0.0.0')
42
+
43
+ response = self._download()
44
+ xsendfile = response.getHeader('X-SENDFILE')
45
+ self.assertIsNotNone(xsendfile)
46
+ self.assertTrue(os.path.isfile(xsendfile))
47
+
48
+ def test_download_with_filename(self):
49
+ os.environ['XSENDFILE_RESPONSEHEADER'] = 'X-SENDFILE'
50
+ self.request.set('HTTP_X_FORWARDED_FOR', '0.0.0.0')
51
+
52
+ view = self.portal['file'].unrestrictedTraverse('@@download')
53
+ field_view = view.publishTraverse(self.request, 'file')
54
+ filename = field_view.publishTraverse(self.request, 'image.gif')
55
+ filename()
56
+
57
+ xsendfile = self.request.RESPONSE.getHeader('X-SENDFILE')
58
+ self.assertIsNotNone(xsendfile)
59
+ self.assertTrue(os.path.isfile(xsendfile))
60
+
61
+ def test_substitute(self):
62
+ os.environ['XSENDFILE_RESPONSEHEADER'] = 'X-SENDFILE'
63
+ os.environ['XSENDFILE_PATHREGEX_SEARCH'] = r'(.*)'
64
+ os.environ['XSENDFILE_PATHREGEX_SUBSTITUTE'] = r'/xsendfile/\1'
65
+ self.request.set('HTTP_X_FORWARDED_FOR', '0.0.0.0')
66
+
67
+ response = self._download()
68
+ xsendfile = response.getHeader('X-SENDFILE')
69
+ self.assertIsNotNone(xsendfile)
70
+ self.assertIn('/xsendfile/', xsendfile)
71
+
72
+ def test_fallback(self):
73
+ os.environ['XSENDFILE_RESPONSEHEADER'] = 'X-SENDFILE'
74
+ os.environ['XSENDFILE_ENABLE_FALLBACK'] = 'True'
75
+
76
+ response = self._download()
77
+ self.assertIsNone(response.getHeader('X-SENDFILE'))
78
+
79
+ def test_not_configured(self):
80
+ response = self._download()
81
+ self.assertIsNone(response.getHeader('X-SENDFILE'))
82
+
83
+ def _image_scale(self):
84
+ scales = self.portal['image'].unrestrictedTraverse('@@images')
85
+ scale = scales.publishTraverse(self.request, 'image')
86
+ scale.index_html()
87
+ return self.request.RESPONSE
88
+
89
+ def test_image_scale(self):
90
+ os.environ['XSENDFILE_RESPONSEHEADER'] = 'X-SENDFILE'
91
+ self.request.set('HTTP_X_FORWARDED_FOR', '0.0.0.0')
92
+
93
+ response = self._image_scale()
94
+ xsendfile = response.getHeader('X-SENDFILE')
95
+ self.assertIsNotNone(xsendfile)
96
+ self.assertTrue(os.path.isfile(xsendfile))
97
+
98
+ def test_image_scale_not_configured(self):
99
+ self.request.set('HTTP_X_FORWARDED_FOR', '0.0.0.0')
100
+
101
+ response = self._image_scale()
102
+ self.assertIsNone(response.getHeader('X-SENDFILE'))
103
+
104
+ def _generate_image_scale(self, scalename):
105
+ # Traverse @@images/image/<scalename>. Unlike _image_scale (which
106
+ # serves the pre-generated default field scale), <scalename> has not
107
+ # been generated by the layer, so this both generates and serves it
108
+ # within the same, still-uncommitted transaction -- reproducing the
109
+ # on-the-fly generation an acceptance/prod first-access request hits.
110
+ scales = self.portal['image'].unrestrictedTraverse('@@images')
111
+ self.request['TraversalRequestNameStack'] = [scalename]
112
+ scale = scales.publishTraverse(self.request, 'image')
113
+ scale.index_html()
114
+ return self.request.RESPONSE
115
+
116
+ def test_image_scale_on_the_fly_generation(self):
117
+ # A scale generated on-the-fly is uncommitted while the generating
118
+ # request is still running, so Blob.committed() raises BlobError. The
119
+ # xsendfile ImageScale patch must detect that and fall back to
120
+ # streaming rather than propagating a 500. Before the fix this call
121
+ # raises ZODB.interfaces.BlobError: Uncommitted changes.
122
+ os.environ['XSENDFILE_RESPONSEHEADER'] = 'X-SENDFILE'
123
+ self.request.set('HTTP_X_FORWARDED_FOR', '0.0.0.0')
124
+
125
+ response = self._generate_image_scale('preview')
126
+ # Uncommitted blob -> no delegation, streamed by Zope instead.
127
+ self.assertIsNone(response.getHeader('X-SENDFILE'))
128
+
129
+ def test_registry(self):
130
+ self.request.set('HTTP_X_FORWARDED_FOR', '0.0.0.0')
131
+
132
+ applyProfile(self.portal, 'plone.app.registry:default')
133
+ applyProfile(self.portal, 'collective.xsendfile:default')
134
+ registry = getUtility(IRegistry)
135
+ settings = registry.forInterface(IxsendfileSettings)
136
+ settings.xsendfile_responseheader = 'X-Sendfile'
137
+
138
+ response = self._download()
139
+ self.assertIsNotNone(response.getHeader('X-Sendfile'))