• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / Zope / 6224812692

18 Sep 2023 03:28PM UTC coverage: 81.131% (+0.008%) from 81.123%
6224812692

push

github

mauritsvanrees
Allow only some image types to be displayed inline.

Force download for others, especially SVG images.  By default we use a list of allowed types.
You can switch a to a list of denied types by setting OS environment variable
``OFS_IMAGE_USE_DENYLIST=1``.  This change only affects direct URL access.
``<img src="image.svg" />`` works the same as before.

See security advisory:
https://github.com/zopefoundation/Zope/security/advisories/GHSA-wm8q-9975-xh5v

4332 of 7070 branches covered (0.0%)

Branch coverage included in aggregate %.

35 of 35 new or added lines in 2 files covered. (100.0%)

27197 of 31792 relevant lines covered (85.55%)

0.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.18
/src/OFS/Image.py
1
##############################################################################
2
#
3
# Copyright (c) 2002 Zope Foundation and Contributors.
4
#
5
# This software is subject to the provisions of the Zope Public License,
6
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
7
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
8
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
9
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
10
# FOR A PARTICULAR PURPOSE
11
#
12
##############################################################################
13
"""Image object
1✔
14
"""
15

16
import os
1✔
17
import struct
1✔
18
from email.generator import _make_boundary
1✔
19
from io import BytesIO
1✔
20
from io import TextIOBase
1✔
21
from tempfile import TemporaryFile
1✔
22
from urllib.parse import quote
1✔
23
from warnings import warn
1✔
24

25
from six import PY2
1✔
26
from six import binary_type
1✔
27
from six import text_type
1✔
28

29
import ZPublisher.HTTPRequest
1✔
30
from AccessControl.class_init import InitializeClass
1✔
31
from AccessControl.Permissions import change_images_and_files  # NOQA
1✔
32
from AccessControl.Permissions import ftp_access
1✔
33
from AccessControl.Permissions import view as View
1✔
34
from AccessControl.Permissions import view_management_screens
1✔
35
from AccessControl.Permissions import webdav_access
1✔
36
from AccessControl.SecurityInfo import ClassSecurityInfo
1✔
37
from Acquisition import Implicit
1✔
38
from App.special_dtml import DTMLFile
1✔
39
from DateTime.DateTime import DateTime
1✔
40
from OFS import bbb
1✔
41
from OFS.Cache import Cacheable
1✔
42
from OFS.interfaces import IWriteLock
1✔
43
from OFS.PropertyManager import PropertyManager
1✔
44
from OFS.role import RoleManager
1✔
45
from OFS.SimpleItem import Item_w__name__
1✔
46
from OFS.SimpleItem import PathReprProvider
1✔
47
from Persistence import Persistent
1✔
48
from zExceptions import Redirect
1✔
49
from zExceptions import ResourceLockedError
1✔
50
from zope.contenttype import guess_content_type
1✔
51
from zope.datetime import rfc1123_date
1✔
52
from zope.event import notify
1✔
53
from zope.interface import implementer
1✔
54
from zope.lifecycleevent import ObjectCreatedEvent
1✔
55
from zope.lifecycleevent import ObjectModifiedEvent
1✔
56
from ZPublisher import HTTPRangeSupport
1✔
57
from ZPublisher.HTTPRequest import FileUpload
1✔
58

59

60
try:
1✔
61
    from html import escape
1✔
62
except ImportError:  # PY2
63
    from cgi import escape
64

65

66
ALLOWED_INLINE_MIMETYPES = [
1✔
67
    "image/gif",
68
    # The mimetypes registry lists several for jpeg 2000:
69
    "image/jp2",
70
    "image/jpeg",
71
    "image/jpeg2000-image",
72
    "image/jpeg2000",
73
    "image/jpx",
74
    "image/png",
75
    "image/webp",
76
    "image/x-icon",
77
    "image/x-jpeg2000-image",
78
    "text/plain",
79
    # By popular request we allow PDF:
80
    "application/pdf",
81
]
82

83
# Perhaps a denylist is better.
84
DISALLOWED_INLINE_MIMETYPES = [
1✔
85
    "application/javascript",
86
    "application/x-javascript",
87
    "text/javascript",
88
    "text/html",
89
    "image/svg+xml",
90
    "image/svg+xml-compressed",
91
]
92

93
# By default we use the allowlist.  We give integrators the option to choose
94
# the denylist via an environment variable.
95
try:
1✔
96
    USE_DENYLIST = os.environ.get("OFS_IMAGE_USE_DENYLIST")
1✔
97
    USE_DENYLIST = bool(int(USE_DENYLIST))
1✔
98
except (ValueError, TypeError, AttributeError):
1✔
99
    USE_DENYLIST = False
1✔
100

101

102
manage_addFileForm = DTMLFile(
1✔
103
    'dtml/imageAdd',
104
    globals(),
105
    Kind='File',
106
    kind='file',
107
)
108

109

110
def manage_addFile(
1✔
111
    self,
112
    id,
113
    file=b'',
114
    title='',
115
    precondition='',
116
    content_type='',
117
    REQUEST=None
118
):
119
    """Add a new File object.
120

121
    Creates a new File object 'id' with the contents of 'file'"""
122

123
    id = str(id)
1✔
124
    title = str(title)
1✔
125
    content_type = str(content_type)
1✔
126
    precondition = str(precondition)
1✔
127

128
    id, title = cookId(id, title, file)
1✔
129

130
    self = self.this()
1✔
131

132
    # First, we create the file without data:
133
    self._setObject(id, File(id, title, b'', content_type, precondition))
1✔
134

135
    newFile = self._getOb(id)
1✔
136

137
    # Now we "upload" the data.  By doing this in two steps, we
138
    # can use a database trick to make the upload more efficient.
139
    if file:
1✔
140
        newFile.manage_upload(file)
1✔
141
    if content_type:
1✔
142
        newFile.content_type = content_type
1✔
143

144
    notify(ObjectCreatedEvent(newFile))
1✔
145

146
    if REQUEST is not None:
1!
147
        REQUEST.RESPONSE.redirect(self.absolute_url() + '/manage_main')
×
148

149

150
@implementer(IWriteLock, HTTPRangeSupport.HTTPRangeInterface)
1✔
151
class File(
1✔
152
    PathReprProvider,
153
    Persistent,
154
    Implicit,
155
    PropertyManager,
156
    RoleManager,
157
    Item_w__name__,
158
    Cacheable
159
):
160
    """A File object is a content object for arbitrary files."""
161
    # You can control which mimetypes may be shown inline
162
    # and which must always be downloaded, for security reasons.
163
    # Make the configuration available on the class.
164
    # Then subclasses can override this.
165
    allowed_inline_mimetypes = ALLOWED_INLINE_MIMETYPES
1✔
166
    disallowed_inline_mimetypes = DISALLOWED_INLINE_MIMETYPES
1✔
167
    use_denylist = USE_DENYLIST
1✔
168

169
    meta_type = 'File'
1✔
170
    zmi_icon = 'far fa-file-archive'
1✔
171

172
    security = ClassSecurityInfo()
1✔
173
    security.declareObjectProtected(View)
1✔
174

175
    precondition = ''
1✔
176
    size = None
1✔
177

178
    manage_editForm = DTMLFile('dtml/fileEdit', globals(),
1✔
179
                               Kind='File', kind='file')
180
    manage_editForm._setName('manage_editForm')
1✔
181

182
    security.declareProtected(view_management_screens, 'manage')  # NOQA: D001
1✔
183
    security.declareProtected(view_management_screens, 'manage_main')  # NOQA: D001,E501
1✔
184
    manage = manage_main = manage_editForm
1✔
185
    manage_uploadForm = manage_editForm
1✔
186

187
    manage_options = (({'label': 'Edit', 'action': 'manage_main'},
1✔
188
                       {'label': 'View', 'action': ''})
189
                      + PropertyManager.manage_options
190
                      + RoleManager.manage_options
191
                      + Item_w__name__.manage_options
192
                      + Cacheable.manage_options)
193

194
    _properties = (
1✔
195
        {'id': 'title', 'type': 'string'},
196
        {'id': 'content_type', 'type': 'string'},
197
    )
198

199
    def __init__(self, id, title, file, content_type='', precondition=''):
1✔
200
        self.__name__ = id
1✔
201
        self.title = title
1✔
202
        self.precondition = precondition
1✔
203

204
        data, size = self._read_data(file)
1✔
205
        content_type = self._get_content_type(file, data, id, content_type)
1✔
206
        self.update_data(data, content_type, size)
1✔
207

208
    def _if_modified_since_request_handler(self, REQUEST, RESPONSE):
1✔
209
        # HTTP If-Modified-Since header handling: return True if
210
        # we can handle this request by returning a 304 response
211
        header = REQUEST.get_header('If-Modified-Since', None)
1✔
212
        if header is not None:
1✔
213
            header = header.split(';')[0]
1✔
214
            # Some proxies seem to send invalid date strings for this
215
            # header. If the date string is not valid, we ignore it
216
            # rather than raise an error to be generally consistent
217
            # with common servers such as Apache (which can usually
218
            # understand the screwy date string as a lucky side effect
219
            # of the way they parse it).
220
            # This happens to be what RFC2616 tells us to do in the face of an
221
            # invalid date.
222
            try:
1✔
223
                mod_since = int(DateTime(header).timeTime())
1✔
224
            except Exception:
×
225
                mod_since = None
×
226
            if mod_since is not None:
1!
227
                if self._p_mtime:
1!
228
                    last_mod = int(self._p_mtime)
1✔
229
                else:
230
                    last_mod = 0
×
231
                if last_mod > 0 and last_mod <= mod_since:
1✔
232
                    RESPONSE.setHeader(
1✔
233
                        'Last-Modified', rfc1123_date(self._p_mtime)
234
                    )
235
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
236
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
237
                    RESPONSE.setStatus(304)
1✔
238
                    return True
1✔
239

240
    def _range_request_handler(self, REQUEST, RESPONSE):
1✔
241
        # HTTP Range header handling: return True if we've served a range
242
        # chunk out of our data.
243
        range = REQUEST.get_header('Range', None)
1✔
244
        request_range = REQUEST.get_header('Request-Range', None)
1✔
245
        if request_range is not None:
1✔
246
            # Netscape 2 through 4 and MSIE 3 implement a draft version
247
            # Later on, we need to serve a different mime-type as well.
248
            range = request_range
1✔
249
        if_range = REQUEST.get_header('If-Range', None)
1✔
250
        if range is not None:
1✔
251
            ranges = HTTPRangeSupport.parseRange(range)
1✔
252

253
            if if_range is not None:
1✔
254
                # Only send ranges if the data isn't modified, otherwise send
255
                # the whole object. Support both ETags and Last-Modified dates!
256
                if len(if_range) > 1 and if_range[:2] == 'ts':
1✔
257
                    # ETag:
258
                    if if_range != self.http__etag():
1✔
259
                        # Modified, so send a normal response. We delete
260
                        # the ranges, which causes us to skip to the 200
261
                        # response.
262
                        ranges = None
1✔
263
                else:
264
                    # Date
265
                    date = if_range.split(';')[0]
1✔
266
                    try:
1✔
267
                        mod_since = int(DateTime(date).timeTime())
1✔
268
                    except Exception:
1✔
269
                        mod_since = None
1✔
270
                    if mod_since is not None:
1✔
271
                        if self._p_mtime:
1!
272
                            last_mod = int(self._p_mtime)
1✔
273
                        else:
274
                            last_mod = 0
×
275
                        if last_mod > mod_since:
1✔
276
                            # Modified, so send a normal response. We delete
277
                            # the ranges, which causes us to skip to the 200
278
                            # response.
279
                            ranges = None
1✔
280

281
            if ranges:
1✔
282
                # Search for satisfiable ranges.
283
                satisfiable = 0
1✔
284
                for start, end in ranges:
1✔
285
                    if start < self.size:
1✔
286
                        satisfiable = 1
1✔
287
                        break
1✔
288

289
                if not satisfiable:
1✔
290
                    RESPONSE.setHeader(
1✔
291
                        'Content-Range', 'bytes */%d' % self.size
292
                    )
293
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
294
                    RESPONSE.setHeader(
1✔
295
                        'Last-Modified', rfc1123_date(self._p_mtime)
296
                    )
297
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
298
                    RESPONSE.setHeader('Content-Length', self.size)
1✔
299
                    RESPONSE.setStatus(416)
1✔
300
                    return True
1✔
301

302
                ranges = HTTPRangeSupport.expandRanges(ranges, self.size)
1✔
303

304
                if len(ranges) == 1:
1✔
305
                    # Easy case, set extra header and return partial set.
306
                    start, end = ranges[0]
1✔
307
                    size = end - start
1✔
308

309
                    RESPONSE.setHeader(
1✔
310
                        'Last-Modified', rfc1123_date(self._p_mtime)
311
                    )
312
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
313
                    RESPONSE.setHeader('Content-Length', size)
1✔
314
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
315
                    RESPONSE.setHeader(
1✔
316
                        'Content-Range',
317
                        'bytes %d-%d/%d' % (start, end - 1, self.size)
318
                    )
319
                    RESPONSE.setStatus(206)  # Partial content
1✔
320

321
                    data = self.data
1✔
322
                    if isinstance(data, binary_type):
1✔
323
                        RESPONSE.write(data[start:end])
1✔
324
                        return True
1✔
325

326
                    # Linked Pdata objects. Urgh.
327
                    pos = 0
1✔
328
                    while data is not None:
1!
329
                        length = len(data.data)
1✔
330
                        pos = pos + length
1✔
331
                        if pos > start:
1✔
332
                            # We are within the range
333
                            lstart = length - (pos - start)
1✔
334

335
                            if lstart < 0:
1!
336
                                lstart = 0
×
337

338
                            # find the endpoint
339
                            if end <= pos:
1!
340
                                lend = length - (pos - end)
1✔
341

342
                                # Send and end transmission
343
                                RESPONSE.write(data[lstart:lend])
1✔
344
                                break
1✔
345

346
                            # Not yet at the end, transmit what we have.
347
                            RESPONSE.write(data[lstart:])
×
348

349
                        data = data.next
1✔
350

351
                    return True
1✔
352

353
                else:
354
                    boundary = _make_boundary()
1✔
355

356
                    # Calculate the content length
357
                    size = (8 + len(boundary)  # End marker length
1✔
358
                            + len(ranges) * (  # Constant lenght per set
359
                                49 + len(boundary)
360
                                + len(self.content_type)
361
                                + len('%d' % self.size)))
362
                    for start, end in ranges:
1✔
363
                        # Variable length per set
364
                        size = (size + len('%d%d' % (start, end - 1))
1✔
365
                                + end - start)
366

367
                    # Some clients implement an earlier draft of the spec, they
368
                    # will only accept x-byteranges.
369
                    draftprefix = (request_range is not None) and 'x-' or ''
1✔
370

371
                    RESPONSE.setHeader('Content-Length', size)
1✔
372
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
373
                    RESPONSE.setHeader(
1✔
374
                        'Last-Modified', rfc1123_date(self._p_mtime)
375
                    )
376
                    RESPONSE.setHeader(
1✔
377
                        'Content-Type',
378
                        'multipart/%sbyteranges; boundary=%s' % (
379
                            draftprefix,
380
                            boundary,
381
                        )
382
                    )
383
                    RESPONSE.setStatus(206)  # Partial content
1✔
384

385
                    data = self.data
1✔
386
                    # The Pdata map allows us to jump into the Pdata chain
387
                    # arbitrarily during out-of-order range searching.
388
                    pdata_map = {}
1✔
389
                    pdata_map[0] = data
1✔
390

391
                    for start, end in ranges:
1✔
392
                        RESPONSE.write(
1✔
393
                            b'\r\n--'
394
                            + boundary.encode('ascii')
395
                            + b'\r\n'
396
                        )
397
                        RESPONSE.write(
1✔
398
                            b'Content-Type: '
399
                            + self.content_type.encode('ascii')
400
                            + b'\r\n'
401
                        )
402
                        RESPONSE.write(
1✔
403
                            b'Content-Range: bytes '
404
                            + str(start).encode('ascii')
405
                            + b'-'
406
                            + str(end - 1).encode('ascii')
407
                            + b'/'
408
                            + str(self.size).encode('ascii')
409
                            + b'\r\n\r\n'
410
                        )
411

412
                        if isinstance(data, binary_type):
1✔
413
                            RESPONSE.write(data[start:end])
1✔
414

415
                        else:
416
                            # Yippee. Linked Pdata objects. The following
417
                            # calculations allow us to fast-forward through the
418
                            # Pdata chain without a lot of dereferencing if we
419
                            # did the work already.
420
                            first_size = len(pdata_map[0].data)
1✔
421
                            if start < first_size:
1✔
422
                                closest_pos = 0
1✔
423
                            else:
424
                                closest_pos = (
1✔
425
                                    ((start - first_size) >> 16 << 16)
426
                                    + first_size
427
                                )
428
                            pos = min(closest_pos, max(pdata_map.keys()))
1✔
429
                            data = pdata_map[pos]
1✔
430

431
                            while data is not None:
1!
432
                                length = len(data.data)
1✔
433
                                pos = pos + length
1✔
434
                                if pos > start:
1✔
435
                                    # We are within the range
436
                                    lstart = length - (pos - start)
1✔
437

438
                                    if lstart < 0:
1✔
439
                                        lstart = 0
1✔
440

441
                                    # find the endpoint
442
                                    if end <= pos:
1✔
443
                                        lend = length - (pos - end)
1✔
444

445
                                        # Send and loop to next range
446
                                        RESPONSE.write(data[lstart:lend])
1✔
447
                                        break
1✔
448

449
                                    # Not yet at the end,
450
                                    # transmit what we have.
451
                                    RESPONSE.write(data[lstart:])
1✔
452

453
                                data = data.next
1✔
454
                                # Store a reference to a Pdata chain link
455
                                # so we don't have to deref during
456
                                # this request again.
457
                                pdata_map[pos] = data
1✔
458

459
                    # Do not keep the link references around.
460
                    del pdata_map
1✔
461

462
                    RESPONSE.write(
1✔
463
                        b'\r\n--' + boundary.encode('ascii') + b'--\r\n')
464
                    return True
1✔
465

466
    def _should_force_download(self):
1✔
467
        # If this returns True, the caller should set a
468
        # Content-Disposition header with filename.
469
        mimetype = self.content_type
1✔
470
        if not mimetype:
1!
471
            return False
×
472
        if self.use_denylist:
1!
473
            # We explicitly deny a few mimetypes, and allow the rest.
474
            return mimetype in self.disallowed_inline_mimetypes
×
475
        # Use the allowlist.
476
        # We only explicitly allow a few mimetypes, and deny the rest.
477
        return mimetype not in self.allowed_inline_mimetypes
1✔
478

479
    @security.protected(View)
1✔
480
    def index_html(self, REQUEST, RESPONSE):
1✔
481
        """
482
        The default view of the contents of a File or Image.
483

484
        Returns the contents of the file or image.  Also, sets the
485
        Content-Type HTTP header to the objects content type.
486
        """
487

488
        if self._if_modified_since_request_handler(REQUEST, RESPONSE):
1✔
489
            # we were able to handle this by returning a 304
490
            # unfortunately, because the HTTP cache manager uses the cache
491
            # API, and because 304 responses are required to carry the Expires
492
            # header for HTTP/1.1, we need to call ZCacheable_set here.
493
            # This is nonsensical for caches other than the HTTP cache manager
494
            # unfortunately.
495
            self.ZCacheable_set(None)
1✔
496
            return b''
1✔
497

498
        if self.precondition and hasattr(self, str(self.precondition)):
1!
499
            # Grab whatever precondition was defined and then
500
            # execute it.  The precondition will raise an exception
501
            # if something violates its terms.
502
            c = getattr(self, str(self.precondition))
×
503
            if hasattr(c, 'isDocTemp') and c.isDocTemp:
×
504
                c(REQUEST['PARENTS'][1], REQUEST)
×
505
            else:
506
                c()
×
507

508
        if self._range_request_handler(REQUEST, RESPONSE):
1✔
509
            # we served a chunk of content in response to a range request.
510
            return b''
1✔
511

512
        RESPONSE.setHeader('Last-Modified', rfc1123_date(self._p_mtime))
1✔
513
        RESPONSE.setHeader('Content-Type', self.content_type)
1✔
514
        RESPONSE.setHeader('Content-Length', self.size)
1✔
515
        RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
516

517
        if self._should_force_download():
1✔
518
            # We need a filename, even a dummy one if needed.
519
            filename = self.getId()
1✔
520
            if "." not in filename:
1!
521
                # image/svg+xml -> svg
522
                ext = self.content_type.split("/")[-1].split("+")[0]
1✔
523
                filename += "." + ext
1✔
524
            filename = quote(filename.encode("utf8"))
1✔
525
            RESPONSE.setHeader(
1✔
526
                "Content-Disposition",
527
                "attachment; filename*=UTF-8''{}".format(filename),
528
            )
529

530
        if self.ZCacheable_isCachingEnabled():
1✔
531
            result = self.ZCacheable_get(default=None)
1✔
532
            if result is not None:
1!
533
                # We will always get None from RAMCacheManager and HTTP
534
                # Accelerated Cache Manager but we will get
535
                # something implementing the IStreamIterator interface
536
                # from a "FileCacheManager"
537
                return result
×
538

539
        self.ZCacheable_set(None)
1✔
540

541
        data = self.data
1✔
542
        if isinstance(data, binary_type):
1✔
543
            RESPONSE.setBase(None)
1✔
544
            return data
1✔
545

546
        while data is not None:
1✔
547
            RESPONSE.write(data.data)
1✔
548
            data = data.next
1✔
549

550
        return b''
1✔
551

552
    @security.protected(View)
1✔
553
    def view_image_or_file(self, URL1):
1✔
554
        """The default view of the contents of the File or Image."""
555
        raise Redirect(URL1)
1✔
556

557
    @security.protected(View)
1✔
558
    def PrincipiaSearchSource(self):
1✔
559
        """Allow file objects to be searched."""
560
        if self.content_type.startswith('text/'):
1✔
561
            return bytes(self.data)
1✔
562
        return b''
1✔
563

564
    @security.private
1✔
565
    def update_data(self, data, content_type=None, size=None):
1✔
566
        if isinstance(data, text_type):
1✔
567
            raise TypeError('Data can only be bytes or file-like. '
1✔
568
                            'Unicode objects are expressly forbidden.')
569

570
        if content_type is not None:
1✔
571
            self.content_type = content_type
1✔
572
        if size is None:
1✔
573
            size = len(data)
1✔
574
        self.size = size
1✔
575
        self.data = data
1✔
576
        self.ZCacheable_invalidate()
1✔
577
        self.ZCacheable_set(None)
1✔
578
        self.http__refreshEtag()
1✔
579

580
    def _get_encoding(self):
1✔
581
        """Get the canonical encoding for ZMI."""
582
        return ZPublisher.HTTPRequest.default_encoding
1✔
583

584
    @security.protected(change_images_and_files)
1✔
585
    def manage_edit(
1✔
586
        self,
587
        title,
588
        content_type,
589
        precondition='',
590
        filedata=None,
591
        REQUEST=None
592
    ):
593
        """
594
        Changes the title and content type attributes of the File or Image.
595
        """
596
        if self.wl_isLocked():
1!
597
            raise ResourceLockedError("File is locked.")
×
598

599
        self.title = str(title)
1✔
600
        self.content_type = str(content_type)
1✔
601
        if precondition:
1!
602
            self.precondition = str(precondition)
×
603
        elif self.precondition:
1!
604
            del self.precondition
×
605
        if filedata is not None:
1✔
606
            if isinstance(filedata, text_type):
1✔
607
                filedata = filedata.encode(self._get_encoding())
1✔
608
            self.update_data(filedata, content_type, len(filedata))
1✔
609
        else:
610
            self.ZCacheable_invalidate()
1✔
611

612
        notify(ObjectModifiedEvent(self))
1✔
613

614
        if REQUEST:
1✔
615
            message = "Saved changes."
1✔
616
            return self.manage_main(
1✔
617
                self, REQUEST, manage_tabs_message=message)
618

619
    @security.protected(change_images_and_files)
1✔
620
    def manage_upload(self, file='', REQUEST=None):
1✔
621
        """
622
        Replaces the current contents of the File or Image object with file.
623

624
        The file or images contents are replaced with the contents of 'file'.
625
        """
626
        if self.wl_isLocked():
1!
627
            raise ResourceLockedError("File is locked.")
×
628

629
        if file:
1✔
630
            data, size = self._read_data(file)
1✔
631
            content_type = self._get_content_type(file, data, self.__name__,
1✔
632
                                                  'application/octet-stream')
633
            self.update_data(data, content_type, size)
1✔
634
            notify(ObjectModifiedEvent(self))
1✔
635
            msg = 'Saved changes.'
1✔
636
        else:
637
            msg = 'Please select a file to upload.'
1✔
638

639
        if REQUEST:
1✔
640
            return self.manage_main(
1✔
641
                self, REQUEST, manage_tabs_message=msg)
642

643
    def _get_content_type(self, file, body, id, content_type=None):
1✔
644
        """return content type or ``None``.
645

646
        *file* usually is a ``FileUpload`` (like) instance; if this
647
        specifies a content type, it is used. If *file*
648
        is not ``FileUpload`` like, it is ignored and the
649
        content type is guessed from the other parameters.
650

651
        *body* is either a ``bytes`` or a ``Pdata`` instance
652
        and assumed to be the *file* data.
653
        """
654
        headers = getattr(file, 'headers', None)
1✔
655
        if headers and 'content-type' in headers:
1✔
656
            content_type = headers['content-type']
1✔
657
        else:
658
            if not isinstance(body, bytes):
1✔
659
                body = body.data
1✔
660
            content_type, enc = guess_content_type(
1✔
661
                getattr(file, 'filename', id), body, content_type)
662
        return content_type
1✔
663

664
    def _read_data(self, file):
1✔
665
        """return the data and size of *file* as tuple *data*, *size*.
666

667
        *file* can be a ``bytes``, ``Pdata``, ``FileUpload`` or
668
        (binary) file like instance.
669

670
        For large files, *data* is a ``Pdata``, otherwise a ``bytes`` instance.
671
        """
672
        import transaction
1✔
673

674
        n = 1 << 16
1✔
675

676
        if isinstance(file, text_type):
1!
677
            raise ValueError("Must be bytes")
×
678

679
        if isinstance(file, bytes):
1✔
680
            size = len(file)
1✔
681
            if size < n:
1✔
682
                return (file, size)
1✔
683
            # Big string: cut it into smaller chunks
684
            file = BytesIO(file)
1✔
685

686
        if isinstance(file, FileUpload) and not file:
1!
687
            raise ValueError('File not specified')
×
688

689
        if hasattr(file, '__class__') and file.__class__ is Pdata:
1!
690
            size = len(file)
×
691
            return (file, size)
×
692

693
        seek = file.seek
1✔
694
        read = file.read
1✔
695

696
        seek(0, 2)
1✔
697
        size = end = file.tell()
1✔
698

699
        if size <= 2 * n:
1✔
700
            seek(0)
1✔
701
            if size < n:
1✔
702
                return read(size), size
1✔
703
            return Pdata(read(size)), size
1✔
704

705
        # Make sure we have an _p_jar, even if we are a new object, by
706
        # doing a sub-transaction commit.
707
        transaction.savepoint(optimistic=True)
1✔
708

709
        if self._p_jar is None:
1!
710
            # Ugh
711
            seek(0)
×
712
            return Pdata(read(size)), size
×
713

714
        # Now we're going to build a linked list from back
715
        # to front to minimize the number of database updates
716
        # and to allow us to get things out of memory as soon as
717
        # possible.
718
        _next = None
1✔
719
        while end > 0:
1✔
720
            pos = end - n
1✔
721
            if pos < n:
1✔
722
                pos = 0  # we always want at least n bytes
1✔
723
            seek(pos)
1✔
724

725
            # Create the object and assign it a next pointer
726
            # in the same transaction, so that there is only
727
            # a single database update for it.
728
            data = Pdata(read(end - pos))
1✔
729
            self._p_jar.add(data)
1✔
730
            data.next = _next
1✔
731

732
            # Save the object so that we can release its memory.
733
            transaction.savepoint(optimistic=True)
1✔
734
            data._p_deactivate()
1✔
735
            # The object should be assigned an oid and be a ghost.
736
            assert data._p_oid is not None
1✔
737
            assert data._p_state == -1
1✔
738

739
            _next = data
1✔
740
            end = pos
1✔
741

742
        return (_next, size)
1✔
743

744
    @security.protected(change_images_and_files)
1✔
745
    def PUT(self, REQUEST, RESPONSE):
1✔
746
        """Handle HTTP PUT requests"""
747
        self.dav__init(REQUEST, RESPONSE)
1✔
748
        self.dav__simpleifhandler(REQUEST, RESPONSE, refresh=1)
1✔
749

750
        type = REQUEST.get_header('content-type', None)
1✔
751
        file = REQUEST['BODYFILE']
1✔
752

753
        # Work around ``cgi`` bug
754
        # ``cgi`` can turn the request body into a text file using
755
        # the default encoding. ``File``, however, insists to work
756
        # with bytes and binary files and forbids text.
757
        # Convert back.
758
        tfile = None
1✔
759
        if isinstance(file, TextIOBase):  # ``cgi`` bug
1✔
760
            if hasattr(file, "buffer"):
1!
761
                file = file.buffer  # underlying binary buffer
×
762
            else:
763
                from ZPublisher.HTTPRequest import default_encoding
1✔
764
                tfile = TemporaryFile("wb+")
1✔
765
                bufsize = 1 << 16
1✔
766
                while True:
767
                    data = file.read(bufsize)
1✔
768
                    if not data:
1✔
769
                        break
1✔
770
                    tfile.write(data.encode(default_encoding))
1✔
771
                file.seek(0, 0)
1✔
772
                tfile.seek(0, 0)
1✔
773
                file = tfile
1✔
774

775
        data, size = self._read_data(file)
1✔
776
        if tfile is not None:
1✔
777
            tfile.close()
1✔
778
        content_type = self._get_content_type(file, data, self.__name__,
1✔
779
                                              type or self.content_type)
780
        self.update_data(data, content_type, size)
1✔
781

782
        RESPONSE.setStatus(204)
1✔
783
        return RESPONSE
1✔
784

785
    @security.protected(View)
1✔
786
    def get_size(self):
1✔
787
        # Get the size of a file or image.
788
        # Returns the size of the file or image.
789
        size = self.size
1✔
790
        if size is None:
1!
791
            size = len(self.data)
×
792
        return size
1✔
793

794
    # deprecated; use get_size!
795
    getSize = get_size
1✔
796

797
    @security.protected(View)
1✔
798
    def getContentType(self):
1✔
799
        # Get the content type of a file or image.
800
        # Returns the content type (MIME type) of a file or image.
801
        return self.content_type
1✔
802

803
    def __bytes__(self):
1✔
804
        return bytes(self.data)
×
805

806
    def __str__(self):
1✔
807
        if PY2:
1!
808
            return str(self.data)
×
809
        else:
810
            if isinstance(self.data, Pdata):
1✔
811
                return bytes(self.data).decode(self._get_encoding())
1✔
812
            else:
813
                return self.data.decode(self._get_encoding())
1✔
814

815
    def __bool__(self):
1✔
816
        return True
1✔
817

818
    __nonzero__ = __bool__
1✔
819

820
    def __len__(self):
1✔
821
        data = bytes(self.data)
×
822
        return len(data)
×
823

824
    @security.protected(webdav_access)
1✔
825
    def manage_DAVget(self):
1✔
826
        """Return body for WebDAV."""
827
        RESPONSE = self.REQUEST.RESPONSE
1✔
828

829
        if self.ZCacheable_isCachingEnabled():
1!
830
            result = self.ZCacheable_get(default=None)
1✔
831
            if result is not None:
1!
832
                # We will always get None from RAMCacheManager but we will
833
                # get something implementing the IStreamIterator interface
834
                # from FileCacheManager.
835
                # the content-length is required here by HTTPResponse.
836
                RESPONSE.setHeader('Content-Length', self.size)
×
837
                return result
×
838

839
        data = self.data
1✔
840
        if isinstance(data, binary_type):
1!
841
            RESPONSE.setBase(None)
1✔
842
            return data
1✔
843

844
        while data is not None:
×
845
            RESPONSE.write(data.data)
×
846
            data = data.next
×
847

848
        return b''
×
849

850
    if bbb.HAS_ZSERVER:
1!
851

852
        @security.protected(ftp_access)
×
853
        def manage_FTPget(self):
×
854
            """Return body for ftp."""
855
            warn(u'manage_FTPget is deprecated and will be removed in Zope 5.',
×
856
                 DeprecationWarning, stacklevel=2)
857
            return self.manage_DAVget()
×
858

859

860
InitializeClass(File)
1✔
861

862

863
manage_addImageForm = DTMLFile(
1✔
864
    'dtml/imageAdd',
865
    globals(),
866
    Kind='Image',
867
    kind='image',
868
)
869

870

871
def manage_addImage(
1✔
872
    self,
873
    id,
874
    file,
875
    title='',
876
    precondition='',
877
    content_type='',
878
    REQUEST=None
879
):
880
    """
881
    Add a new Image object.
882

883
    Creates a new Image object 'id' with the contents of 'file'.
884
    """
885
    id = str(id)
1✔
886
    title = str(title)
1✔
887
    content_type = str(content_type)
1✔
888
    precondition = str(precondition)
1✔
889

890
    id, title = cookId(id, title, file)
1✔
891

892
    self = self.this()
1✔
893

894
    # First, we create the image without data:
895
    self._setObject(id, Image(id, title, b'', content_type, precondition))
1✔
896

897
    newFile = self._getOb(id)
1✔
898

899
    # Now we "upload" the data.  By doing this in two steps, we
900
    # can use a database trick to make the upload more efficient.
901
    if file:
1!
902
        newFile.manage_upload(file)
1✔
903
    if content_type:
1!
904
        newFile.content_type = content_type
1✔
905

906
    notify(ObjectCreatedEvent(newFile))
1✔
907

908
    if REQUEST is not None:
1!
909
        try:
×
910
            url = self.DestinationURL()
×
911
        except Exception:
×
912
            url = REQUEST['URL1']
×
913
        REQUEST.RESPONSE.redirect('%s/manage_main' % url)
×
914
    return id
1✔
915

916

917
def getImageInfo(data):
1✔
918
    data = bytes(data)
1✔
919
    size = len(data)
1✔
920
    height = -1
1✔
921
    width = -1
1✔
922
    content_type = ''
1✔
923

924
    # handle GIFs
925
    if (size >= 10) and data[:6] in (b'GIF87a', b'GIF89a'):
1✔
926
        # Check to see if content_type is correct
927
        content_type = 'image/gif'
1✔
928
        w, h = struct.unpack("<HH", data[6:10])
1✔
929
        width = int(w)
1✔
930
        height = int(h)
1✔
931

932
    # See PNG v1.2 spec (http://www.cdrom.com/pub/png/spec/)
933
    # Bytes 0-7 are below, 4-byte chunk length, then 'IHDR'
934
    # and finally the 4-byte width, height
935
    elif (size >= 24
1!
936
          and data[:8] == b'\211PNG\r\n\032\n'
937
          and data[12:16] == b'IHDR'):
938
        content_type = 'image/png'
×
939
        w, h = struct.unpack(">LL", data[16:24])
×
940
        width = int(w)
×
941
        height = int(h)
×
942

943
    # Maybe this is for an older PNG version.
944
    elif (size >= 16) and (data[:8] == b'\211PNG\r\n\032\n'):
1!
945
        # Check to see if we have the right content type
946
        content_type = 'image/png'
×
947
        w, h = struct.unpack(">LL", data[8:16])
×
948
        width = int(w)
×
949
        height = int(h)
×
950

951
    # handle JPEGs
952
    elif (size >= 2) and (data[:2] == b'\377\330'):
1!
953
        content_type = 'image/jpeg'
×
954
        jpeg = BytesIO(data)
×
955
        jpeg.read(2)
×
956
        b = jpeg.read(1)
×
957
        try:
×
958
            while (b and ord(b) != 0xDA):
×
959
                while (ord(b) != 0xFF):
×
960
                    b = jpeg.read(1)
×
961
                while (ord(b) == 0xFF):
×
962
                    b = jpeg.read(1)
×
963
                if (ord(b) >= 0xC0 and ord(b) <= 0xC3):
×
964
                    jpeg.read(3)
×
965
                    h, w = struct.unpack(">HH", jpeg.read(4))
×
966
                    break
×
967
                else:
968
                    jpeg.read(int(struct.unpack(">H", jpeg.read(2))[0]) - 2)
×
969
                b = jpeg.read(1)
×
970
            width = int(w)
×
971
            height = int(h)
×
972
        except Exception:
×
973
            pass
×
974

975
    return content_type, width, height
1✔
976

977

978
class Image(File):
1✔
979
    """Image objects can be GIF, PNG or JPEG and have the same methods
980
    as File objects.  Images also have a string representation that
981
    renders an HTML 'IMG' tag.
982
    """
983

984
    meta_type = 'Image'
1✔
985
    zmi_icon = 'far fa-file-image'
1✔
986

987
    security = ClassSecurityInfo()
1✔
988
    security.declareObjectProtected(View)
1✔
989

990
    alt = ''
1✔
991
    height = ''
1✔
992
    width = ''
1✔
993

994
    # FIXME: Redundant, already in base class
995
    security.declareProtected(change_images_and_files, 'manage_edit')  # NOQA: D001,E501
1✔
996
    security.declareProtected(change_images_and_files, 'manage_upload')  # NOQA: D001,E501
1✔
997
    security.declareProtected(View, 'index_html')  # NOQA: D001
1✔
998
    security.declareProtected(View, 'get_size')  # NOQA: D001
1✔
999
    security.declareProtected(View, 'getContentType')  # NOQA: D001
1✔
1000

1001
    _properties = (
1✔
1002
        {'id': 'title', 'type': 'string'},
1003
        {'id': 'alt', 'type': 'string'},
1004
        {'id': 'content_type', 'type': 'string', 'mode': 'w'},
1005
        {'id': 'height', 'type': 'string'},
1006
        {'id': 'width', 'type': 'string'},
1007
    )
1008

1009
    manage_options = (
1✔
1010
        ({'label': 'Edit', 'action': 'manage_main'},
1011
         {'label': 'View', 'action': 'view_image_or_file'})
1012
        + PropertyManager.manage_options
1013
        + RoleManager.manage_options
1014
        + Item_w__name__.manage_options
1015
        + Cacheable.manage_options
1016
    )
1017

1018
    manage_editForm = DTMLFile(
1✔
1019
        'dtml/imageEdit',
1020
        globals(),
1021
        Kind='Image',
1022
        kind='image',
1023
    )
1024
    manage_editForm._setName('manage_editForm')
1✔
1025

1026
    security.declareProtected(View, 'view_image_or_file')  # NOQA: D001
1✔
1027
    view_image_or_file = DTMLFile('dtml/imageView', globals())
1✔
1028

1029
    security.declareProtected(view_management_screens, 'manage')  # NOQA: D001
1✔
1030
    security.declareProtected(view_management_screens, 'manage_main')  # NOQA: D001,E501
1✔
1031
    manage = manage_main = manage_editForm
1✔
1032
    manage_uploadForm = manage_editForm
1✔
1033

1034
    @security.private
1✔
1035
    def update_data(self, data, content_type=None, size=None):
1✔
1036
        if isinstance(data, text_type):
1✔
1037
            raise TypeError('Data can only be bytes or file-like.  '
1✔
1038
                            'Unicode objects are expressly forbidden.')
1039

1040
        if size is None:
1✔
1041
            size = len(data)
1✔
1042

1043
        self.size = size
1✔
1044
        self.data = data
1✔
1045

1046
        ct, width, height = getImageInfo(data)
1✔
1047
        if ct:
1✔
1048
            content_type = ct
1✔
1049
        if width >= 0 and height >= 0:
1✔
1050
            self.width = width
1✔
1051
            self.height = height
1✔
1052

1053
        # Now we should have the correct content type, or still None
1054
        if content_type is not None:
1!
1055
            self.content_type = content_type
1✔
1056

1057
        self.ZCacheable_invalidate()
1✔
1058
        self.ZCacheable_set(None)
1✔
1059
        self.http__refreshEtag()
1✔
1060

1061
    def __bytes__(self):
1✔
1062
        return self.tag().encode('utf-8')
×
1063

1064
    def __str__(self):
1✔
1065
        return self.tag()
1✔
1066

1067
    @security.protected(View)
1✔
1068
    def tag(
1✔
1069
        self,
1070
        height=None,
1071
        width=None,
1072
        alt=None,
1073
        scale=0,
1074
        xscale=0,
1075
        yscale=0,
1076
        css_class=None,
1077
        title=None,
1078
        **args
1079
    ):
1080
        """Generate an HTML IMG tag for this image, with customization.
1081

1082
        Arguments to self.tag() can be any valid attributes of an IMG tag.
1083
        'src' will always be an absolute pathname, to prevent redundant
1084
        downloading of images. Defaults are applied intelligently for
1085
        'height', 'width', and 'alt'. If specified, the 'scale', 'xscale',
1086
        and 'yscale' keyword arguments will be used to automatically adjust
1087
        the output height and width values of the image tag.
1088
        #
1089
        Since 'class' is a Python reserved word, it cannot be passed in
1090
        directly in keyword arguments which is a problem if you are
1091
        trying to use 'tag()' to include a CSS class. The tag() method
1092
        will accept a 'css_class' argument that will be converted to
1093
        'class' in the output tag to work around this.
1094
        """
1095
        if height is None:
1!
1096
            height = self.height
1✔
1097
        if width is None:
1!
1098
            width = self.width
1✔
1099

1100
        # Auto-scaling support
1101
        xdelta = xscale or scale
1✔
1102
        ydelta = yscale or scale
1✔
1103

1104
        if xdelta and width:
1!
1105
            width = str(int(round(int(width) * xdelta)))
×
1106
        if ydelta and height:
1!
1107
            height = str(int(round(int(height) * ydelta)))
×
1108

1109
        result = '<img src="%s"' % (self.absolute_url())
1✔
1110

1111
        if alt is None:
1!
1112
            alt = getattr(self, 'alt', '')
1✔
1113
        result = '%s alt="%s"' % (result, escape(alt, True))
1✔
1114

1115
        if title is None:
1!
1116
            title = getattr(self, 'title', '')
1✔
1117
        result = '%s title="%s"' % (result, escape(title, True))
1✔
1118

1119
        if height:
1!
1120
            result = '%s height="%s"' % (result, height)
1✔
1121

1122
        if width:
1!
1123
            result = '%s width="%s"' % (result, width)
1✔
1124

1125
        if css_class is not None:
1!
1126
            result = '%s class="%s"' % (result, css_class)
×
1127

1128
        for key in list(args.keys()):
1!
1129
            value = args.get(key)
×
1130
            if value:
×
1131
                result = '%s %s="%s"' % (result, key, value)
×
1132

1133
        return '%s />' % result
1✔
1134

1135

1136
InitializeClass(Image)
1✔
1137

1138

1139
def cookId(id, title, file):
1✔
1140
    if not id and hasattr(file, 'filename'):
1!
1141
        filename = file.filename
×
1142
        title = title or filename
×
1143
        id = filename[max(filename.rfind('/'),
×
1144
                          filename.rfind('\\'),
1145
                          filename.rfind(':'),
1146
                          ) + 1:]
1147
    return id, title
1✔
1148

1149

1150
class Pdata(Persistent, Implicit):
1✔
1151
    # Wrapper for possibly large data
1152

1153
    next = None
1✔
1154

1155
    def __init__(self, data):
1✔
1156
        self.data = data
1✔
1157

1158
    if PY2:
1!
1159
        def __getslice__(self, i, j):
×
1160
            return self.data[i:j]
×
1161

1162
    def __getitem__(self, key):
1✔
1163
        return self.data[key]
1✔
1164

1165
    def __len__(self):
1✔
1166
        data = bytes(self)
×
1167
        return len(data)
×
1168

1169
    def __bytes__(self):
1✔
1170
        _next = self.next
1✔
1171
        if _next is None:
1!
1172
            return self.data
1✔
1173

1174
        r = [self.data]
×
1175
        while _next is not None:
×
1176
            self = _next
×
1177
            r.append(self.data)
×
1178
            _next = self.next
×
1179

1180
        return b''.join(r)
×
1181

1182
    if PY2:
1!
1183
        __str__ = __bytes__
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc