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

zopefoundation / Zope / 6516066776

14 Oct 2023 06:24AM UTC coverage: 82.225%. Remained the same
6516066776

Pull #1170

github

web-flow
Merge branch 'master' into fix_ofs_svg_dims2
Pull Request #1170: SVG-File: avoid attr error on missing dimensions (2)

4364 of 6971 branches covered (0.0%)

Branch coverage included in aggregate %.

4 of 4 new or added lines in 1 file covered. (100.0%)

27526 of 31813 relevant lines covered (86.52%)

0.87 hits per line

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

77.15
/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 html
1✔
17
import os
1✔
18
import struct
1✔
19
from email.generator import _make_boundary
1✔
20
from io import BytesIO
1✔
21
from mimetypes import guess_extension
1✔
22
from urllib.parse import quote
1✔
23
from xml.dom import minidom
1✔
24

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

53

54
def _get_list_from_env(name, default=None):
1✔
55
    """Get list from environment variable.
56

57
    Supports splitting on comma or white space.
58
    Use the default as fallback only when the variable is not set.
59
    So if the env variable is set to an empty string, this will ignore the
60
    default and return an empty list.
61
    """
62
    value = os.environ.get(name)
1✔
63
    if value is None:
1!
64
        return default or []
1✔
65
    value = value.strip()
×
66
    if "," in value:
×
67
        return value.split(",")
×
68
    return value.split()
×
69

70

71
# We have one list for allowed, and one for disallowed inline mimetypes.
72
# This is for security purposes.
73
# By default we use the allowlist.  We give integrators the option to choose
74
# the denylist via an environment variable.
75
ALLOWED_INLINE_MIMETYPES = _get_list_from_env(
1✔
76
    "ALLOWED_INLINE_MIMETYPES",
77
    default=[
78
        "image/gif",
79
        # The mimetypes registry lists several for jpeg 2000:
80
        "image/jp2",
81
        "image/jpeg",
82
        "image/jpeg2000-image",
83
        "image/jpeg2000",
84
        "image/jpx",
85
        "image/png",
86
        "image/webp",
87
        "image/x-icon",
88
        "image/x-jpeg2000-image",
89
        "text/plain",
90
        # By popular request we allow PDF:
91
        "application/pdf",
92
    ]
93
)
94
DISALLOWED_INLINE_MIMETYPES = _get_list_from_env(
1✔
95
    "DISALLOWED_INLINE_MIMETYPES",
96
    default=[
97
        "application/javascript",
98
        "application/x-javascript",
99
        "text/javascript",
100
        "text/html",
101
        "image/svg+xml",
102
        "image/svg+xml-compressed",
103
    ]
104
)
105
try:
1✔
106
    USE_DENYLIST = os.environ.get("OFS_IMAGE_USE_DENYLIST")
1✔
107
    USE_DENYLIST = bool(int(USE_DENYLIST))
1✔
108
except (ValueError, TypeError, AttributeError):
1✔
109
    USE_DENYLIST = False
1✔
110

111

112
manage_addFileForm = DTMLFile(
1✔
113
    'dtml/imageAdd',
114
    globals(),
115
    Kind='File',
116
    kind='file',
117
)
118

119

120
def manage_addFile(
1✔
121
    self,
122
    id,
123
    file=b'',
124
    title='',
125
    precondition='',
126
    content_type='',
127
    REQUEST=None
128
):
129
    """Add a new File object.
130

131
    Creates a new File object 'id' with the contents of 'file'"""
132

133
    id = str(id)
1✔
134
    title = str(title)
1✔
135
    content_type = str(content_type)
1✔
136
    precondition = str(precondition)
1✔
137

138
    id, title = cookId(id, title, file)
1✔
139

140
    self = self.this()
1✔
141

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

145
    newFile = self._getOb(id)
1✔
146

147
    # Now we "upload" the data.  By doing this in two steps, we
148
    # can use a database trick to make the upload more efficient.
149
    if file:
1✔
150
        newFile.manage_upload(file)
1✔
151
    if content_type:
1✔
152
        newFile.content_type = content_type
1✔
153

154
    notify(ObjectCreatedEvent(newFile))
1✔
155

156
    if REQUEST is not None:
1!
157
        REQUEST.RESPONSE.redirect(self.absolute_url() + '/manage_main')
×
158

159

160
@implementer(IWriteLock, HTTPRangeSupport.HTTPRangeInterface)
1✔
161
class File(
1✔
162
    PathReprProvider,
163
    Persistent,
164
    Implicit,
165
    PropertyManager,
166
    RoleManager,
167
    Item_w__name__,
168
    Cacheable
169
):
170
    """A File object is a content object for arbitrary files."""
171
    # You can control which mimetypes may be shown inline
172
    # and which must always be downloaded, for security reasons.
173
    # Make the configuration available on the class.
174
    # Then subclasses can override this.
175
    allowed_inline_mimetypes = ALLOWED_INLINE_MIMETYPES
1✔
176
    disallowed_inline_mimetypes = DISALLOWED_INLINE_MIMETYPES
1✔
177
    use_denylist = USE_DENYLIST
1✔
178

179
    meta_type = 'File'
1✔
180
    zmi_icon = 'far fa-file-archive'
1✔
181

182
    security = ClassSecurityInfo()
1✔
183
    security.declareObjectProtected(View)
1✔
184

185
    precondition = ''
1✔
186
    size = None
1✔
187

188
    manage_editForm = DTMLFile('dtml/fileEdit', globals(),
1✔
189
                               Kind='File', kind='file')
190
    manage_editForm._setName('manage_editForm')
1✔
191

192
    security.declareProtected(view_management_screens, 'manage')  # NOQA: D001
1✔
193
    security.declareProtected(view_management_screens, 'manage_main')  # NOQA: D001,E501
1✔
194
    manage = manage_main = manage_editForm
1✔
195
    manage_uploadForm = manage_editForm
1✔
196

197
    manage_options = (({'label': 'Edit', 'action': 'manage_main'},
1✔
198
                       {'label': 'View', 'action': ''})
199
                      + PropertyManager.manage_options
200
                      + RoleManager.manage_options
201
                      + Item_w__name__.manage_options
202
                      + Cacheable.manage_options)
203

204
    _properties = (
1✔
205
        {'id': 'title', 'type': 'string'},
206
        {'id': 'content_type', 'type': 'string'},
207
    )
208

209
    def __init__(self, id, title, file, content_type='', precondition=''):
1✔
210
        self.__name__ = id
1✔
211
        self.title = title
1✔
212
        self.precondition = precondition
1✔
213

214
        data, size = self._read_data(file)
1✔
215
        content_type = self._get_content_type(file, data, id, content_type)
1✔
216
        self.update_data(data, content_type, size)
1✔
217

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

250
    def _range_request_handler(self, REQUEST, RESPONSE):
1✔
251
        # HTTP Range header handling: return True if we've served a range
252
        # chunk out of our data.
253
        range = REQUEST.get_header('Range', None)
1✔
254
        request_range = REQUEST.get_header('Request-Range', None)
1✔
255
        if request_range is not None:
1✔
256
            # Netscape 2 through 4 and MSIE 3 implement a draft version
257
            # Later on, we need to serve a different mime-type as well.
258
            range = request_range
1✔
259
        if_range = REQUEST.get_header('If-Range', None)
1✔
260
        if range is not None:
1✔
261
            ranges = HTTPRangeSupport.parseRange(range)
1✔
262

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

291
            if ranges:
1✔
292
                # Search for satisfiable ranges.
293
                satisfiable = 0
1✔
294
                for start, end in ranges:
1✔
295
                    if start < self.size:
1✔
296
                        satisfiable = 1
1✔
297
                        break
1✔
298

299
                if not satisfiable:
1✔
300
                    RESPONSE.setHeader(
1✔
301
                        'Content-Range', 'bytes */%d' % self.size
302
                    )
303
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
304
                    RESPONSE.setHeader(
1✔
305
                        'Last-Modified', rfc1123_date(self._p_mtime)
306
                    )
307
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
308
                    RESPONSE.setHeader('Content-Length', self.size)
1✔
309
                    RESPONSE.setStatus(416)
1✔
310
                    return True
1✔
311

312
                ranges = HTTPRangeSupport.expandRanges(ranges, self.size)
1✔
313

314
                if len(ranges) == 1:
1✔
315
                    # Easy case, set extra header and return partial set.
316
                    start, end = ranges[0]
1✔
317
                    size = end - start
1✔
318

319
                    RESPONSE.setHeader(
1✔
320
                        'Last-Modified', rfc1123_date(self._p_mtime)
321
                    )
322
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
323
                    RESPONSE.setHeader('Content-Length', size)
1✔
324
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
325
                    RESPONSE.setHeader(
1✔
326
                        'Content-Range',
327
                        'bytes %d-%d/%d' % (start, end - 1, self.size)
328
                    )
329
                    RESPONSE.setStatus(206)  # Partial content
1✔
330

331
                    data = self.data
1✔
332
                    if isinstance(data, bytes):
1✔
333
                        RESPONSE.write(data[start:end])
1✔
334
                        return True
1✔
335

336
                    # Linked Pdata objects. Urgh.
337
                    pos = 0
1✔
338
                    while data is not None:
1!
339
                        length = len(data.data)
1✔
340
                        pos = pos + length
1✔
341
                        if pos > start:
1✔
342
                            # We are within the range
343
                            lstart = length - (pos - start)
1✔
344

345
                            if lstart < 0:
1!
346
                                lstart = 0
×
347

348
                            # find the endpoint
349
                            if end <= pos:
1!
350
                                lend = length - (pos - end)
1✔
351

352
                                # Send and end transmission
353
                                RESPONSE.write(data[lstart:lend])
1✔
354
                                break
1✔
355

356
                            # Not yet at the end, transmit what we have.
357
                            RESPONSE.write(data[lstart:])
×
358

359
                        data = data.next
1✔
360

361
                    return True
1✔
362

363
                else:
364
                    boundary = _make_boundary()
1✔
365

366
                    # Calculate the content length
367
                    size = (8 + len(boundary)  # End marker length
1✔
368
                            + len(ranges) * (  # Constant lenght per set
369
                                49 + len(boundary)
370
                                + len(self.content_type)
371
                                + len('%d' % self.size)))
372
                    for start, end in ranges:
1✔
373
                        # Variable length per set
374
                        size = (size + len('%d%d' % (start, end - 1))
1✔
375
                                + end - start)
376

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

381
                    RESPONSE.setHeader('Content-Length', size)
1✔
382
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
383
                    RESPONSE.setHeader(
1✔
384
                        'Last-Modified', rfc1123_date(self._p_mtime)
385
                    )
386
                    RESPONSE.setHeader(
1✔
387
                        'Content-Type',
388
                        f'multipart/{draftprefix}byteranges;'
389
                        f' boundary={boundary}'
390
                    )
391
                    RESPONSE.setStatus(206)  # Partial content
1✔
392

393
                    data = self.data
1✔
394
                    # The Pdata map allows us to jump into the Pdata chain
395
                    # arbitrarily during out-of-order range searching.
396
                    pdata_map = {}
1✔
397
                    pdata_map[0] = data
1✔
398

399
                    for start, end in ranges:
1✔
400
                        RESPONSE.write(
1✔
401
                            b'\r\n--'
402
                            + boundary.encode('ascii')
403
                            + b'\r\n'
404
                        )
405
                        RESPONSE.write(
1✔
406
                            b'Content-Type: '
407
                            + self.content_type.encode('ascii')
408
                            + b'\r\n'
409
                        )
410
                        RESPONSE.write(
1✔
411
                            b'Content-Range: bytes '
412
                            + str(start).encode('ascii')
413
                            + b'-'
414
                            + str(end - 1).encode('ascii')
415
                            + b'/'
416
                            + str(self.size).encode('ascii')
417
                            + b'\r\n\r\n'
418
                        )
419

420
                        if isinstance(data, bytes):
1✔
421
                            RESPONSE.write(data[start:end])
1✔
422

423
                        else:
424
                            # Yippee. Linked Pdata objects. The following
425
                            # calculations allow us to fast-forward through the
426
                            # Pdata chain without a lot of dereferencing if we
427
                            # did the work already.
428
                            first_size = len(pdata_map[0].data)
1✔
429
                            if start < first_size:
1✔
430
                                closest_pos = 0
1✔
431
                            else:
432
                                closest_pos = (
1✔
433
                                    ((start - first_size) >> 16 << 16)
434
                                    + first_size
435
                                )
436
                            pos = min(closest_pos, max(pdata_map.keys()))
1✔
437
                            data = pdata_map[pos]
1✔
438

439
                            while data is not None:
1!
440
                                length = len(data.data)
1✔
441
                                pos = pos + length
1✔
442
                                if pos > start:
1✔
443
                                    # We are within the range
444
                                    lstart = length - (pos - start)
1✔
445

446
                                    if lstart < 0:
1✔
447
                                        lstart = 0
1✔
448

449
                                    # find the endpoint
450
                                    if end <= pos:
1✔
451
                                        lend = length - (pos - end)
1✔
452

453
                                        # Send and loop to next range
454
                                        RESPONSE.write(data[lstart:lend])
1✔
455
                                        break
1✔
456

457
                                    # Not yet at the end,
458
                                    # transmit what we have.
459
                                    RESPONSE.write(data[lstart:])
1✔
460

461
                                data = data.next
1✔
462
                                # Store a reference to a Pdata chain link
463
                                # so we don't have to deref during
464
                                # this request again.
465
                                pdata_map[pos] = data
1✔
466

467
                    # Do not keep the link references around.
468
                    del pdata_map
1✔
469

470
                    RESPONSE.write(
1✔
471
                        b'\r\n--' + boundary.encode('ascii') + b'--\r\n')
472
                    return True
1✔
473

474
    def _should_force_download(self):
1✔
475
        # If this returns True, the caller should set a
476
        # Content-Disposition header with filename.
477
        mimetype = extract_media_type(self.content_type)
1✔
478
        if not mimetype:
1!
479
            return False
×
480
        if self.use_denylist:
1✔
481
            # We explicitly deny a few mimetypes, and allow the rest.
482
            return mimetype in self.disallowed_inline_mimetypes
1✔
483
        # Use the allowlist.
484
        # We only explicitly allow a few mimetypes, and deny the rest.
485
        return mimetype not in self.allowed_inline_mimetypes
1✔
486

487
    @security.protected(View)
1✔
488
    def index_html(self, REQUEST, RESPONSE):
1✔
489
        """
490
        The default view of the contents of a File or Image.
491

492
        Returns the contents of the file or image.  Also, sets the
493
        Content-Type HTTP header to the objects content type.
494
        """
495

496
        if self._if_modified_since_request_handler(REQUEST, RESPONSE):
1✔
497
            # we were able to handle this by returning a 304
498
            # unfortunately, because the HTTP cache manager uses the cache
499
            # API, and because 304 responses are required to carry the Expires
500
            # header for HTTP/1.1, we need to call ZCacheable_set here.
501
            # This is nonsensical for caches other than the HTTP cache manager
502
            # unfortunately.
503
            self.ZCacheable_set(None)
1✔
504
            return b''
1✔
505

506
        if self.precondition and hasattr(self, str(self.precondition)):
1!
507
            # Grab whatever precondition was defined and then
508
            # execute it.  The precondition will raise an exception
509
            # if something violates its terms.
510
            c = getattr(self, str(self.precondition))
×
511
            if hasattr(c, 'isDocTemp') and c.isDocTemp:
×
512
                c(REQUEST['PARENTS'][1], REQUEST)
×
513
            else:
514
                c()
×
515

516
        if self._range_request_handler(REQUEST, RESPONSE):
1✔
517
            # we served a chunk of content in response to a range request.
518
            return b''
1✔
519

520
        RESPONSE.setHeader('Last-Modified', rfc1123_date(self._p_mtime))
1✔
521
        RESPONSE.setHeader('Content-Type', self.content_type)
1✔
522
        RESPONSE.setHeader('Content-Length', self.size)
1✔
523
        RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
524

525
        if self._should_force_download():
1✔
526
            # We need a filename, even a dummy one if needed.
527
            filename = self.getId()
1✔
528
            if "." not in filename:
1!
529
                # This either returns None or ".some_extension"
530
                ext = guess_extension(self.content_type, strict=False)
1✔
531
                if not ext:
1✔
532
                    # image/svg+xml -> svg
533
                    ext = "." + self.content_type.split("/")[-1].split("+")[0]
1✔
534
                filename += f"{ext}"
1✔
535
            filename = quote(filename.encode("utf8"))
1✔
536
            RESPONSE.setHeader(
1✔
537
                "Content-Disposition",
538
                f"attachment; filename*=UTF-8''{filename}",
539
            )
540

541
        if self.ZCacheable_isCachingEnabled():
1✔
542
            result = self.ZCacheable_get(default=None)
1✔
543
            if result is not None:
1!
544
                # We will always get None from RAMCacheManager and HTTP
545
                # Accelerated Cache Manager but we will get
546
                # something implementing the IStreamIterator interface
547
                # from a "FileCacheManager"
548
                return result
×
549

550
        self.ZCacheable_set(None)
1✔
551

552
        data = self.data
1✔
553
        if isinstance(data, bytes):
1✔
554
            RESPONSE.setBase(None)
1✔
555
            return data
1✔
556

557
        while data is not None:
1✔
558
            RESPONSE.write(data.data)
1✔
559
            data = data.next
1✔
560

561
        return b''
1✔
562

563
    @security.protected(View)
1✔
564
    def view_image_or_file(self, URL1):
1✔
565
        """The default view of the contents of the File or Image."""
566
        raise Redirect(URL1)
1✔
567

568
    @security.protected(View)
1✔
569
    def PrincipiaSearchSource(self):
1✔
570
        """Allow file objects to be searched."""
571
        if self.content_type.startswith('text/'):
1✔
572
            return bytes(self.data)
1✔
573
        return b''
1✔
574

575
    @security.private
1✔
576
    def update_data(self, data, content_type=None, size=None):
1✔
577
        if isinstance(data, str):
1✔
578
            raise TypeError('Data can only be bytes or file-like. '
1✔
579
                            'Unicode objects are expressly forbidden.')
580

581
        if content_type is not None:
1✔
582
            self.content_type = content_type
1✔
583
        if size is None:
1✔
584
            size = len(data)
1✔
585
        self.size = size
1✔
586
        self.data = data
1✔
587
        self.ZCacheable_invalidate()
1✔
588
        self.ZCacheable_set(None)
1✔
589
        self.http__refreshEtag()
1✔
590

591
    def _get_encoding(self):
1✔
592
        """Get the canonical encoding for ZMI."""
593
        return ZPublisher.HTTPRequest.default_encoding
1✔
594

595
    @security.protected(change_images_and_files)
1✔
596
    def manage_edit(
1✔
597
        self,
598
        title,
599
        content_type,
600
        precondition='',
601
        filedata=None,
602
        REQUEST=None
603
    ):
604
        """
605
        Changes the title and content type attributes of the File or Image.
606
        """
607
        if self.wl_isLocked():
1!
608
            raise ResourceLockedError("File is locked.")
×
609

610
        self.title = str(title)
1✔
611
        self.content_type = str(content_type)
1✔
612
        if precondition:
1!
613
            self.precondition = str(precondition)
×
614
        elif self.precondition:
1!
615
            del self.precondition
×
616
        if filedata is not None:
1✔
617
            if isinstance(filedata, str):
1✔
618
                filedata = filedata.encode(self._get_encoding())
1✔
619
            self.update_data(filedata, content_type, len(filedata))
1✔
620
        else:
621
            self.ZCacheable_invalidate()
1✔
622

623
        notify(ObjectModifiedEvent(self))
1✔
624

625
        if REQUEST:
1✔
626
            message = "Saved changes."
1✔
627
            return self.manage_main(
1✔
628
                self, REQUEST, manage_tabs_message=message)
629

630
    @security.protected(change_images_and_files)
1✔
631
    def manage_upload(self, file='', REQUEST=None):
1✔
632
        """
633
        Replaces the current contents of the File or Image object with file.
634

635
        The file or images contents are replaced with the contents of 'file'.
636
        """
637
        if self.wl_isLocked():
1!
638
            raise ResourceLockedError("File is locked.")
×
639

640
        if file:
1✔
641
            data, size = self._read_data(file)
1✔
642
            content_type = self._get_content_type(file, data, self.__name__,
1✔
643
                                                  'application/octet-stream')
644
            self.update_data(data, content_type, size)
1✔
645
            notify(ObjectModifiedEvent(self))
1✔
646
            msg = 'Saved changes.'
1✔
647
        else:
648
            msg = 'Please select a file to upload.'
1✔
649

650
        if REQUEST:
1✔
651
            return self.manage_main(
1✔
652
                self, REQUEST, manage_tabs_message=msg)
653

654
    def _get_content_type(self, file, body, id, content_type=None):
1✔
655
        headers = getattr(file, 'headers', None)
1✔
656
        if headers and 'content-type' in headers:
1✔
657
            content_type = headers['content-type']
1✔
658
        else:
659
            if not isinstance(body, bytes):
1✔
660
                body = body.data
1✔
661
            content_type, enc = guess_content_type(
1✔
662
                getattr(file, 'filename', id), body, content_type)
663
        return content_type
1✔
664

665
    def _read_data(self, file):
1✔
666
        import transaction
1✔
667

668
        n = 1 << 16
1✔
669

670
        if isinstance(file, str):
1!
671
            raise ValueError("Must be bytes")
×
672

673
        if isinstance(file, bytes):
1✔
674
            size = len(file)
1✔
675
            if size < n:
1✔
676
                return (file, size)
1✔
677
            # Big string: cut it into smaller chunks
678
            file = BytesIO(file)
1✔
679

680
        if isinstance(file, FileUpload) and not file:
1!
681
            raise ValueError('File not specified')
×
682

683
        if hasattr(file, '__class__') and file.__class__ is Pdata:
1!
684
            size = len(file)
×
685
            return (file, size)
×
686

687
        seek = file.seek
1✔
688
        read = file.read
1✔
689

690
        seek(0, 2)
1✔
691
        size = end = file.tell()
1✔
692

693
        if size <= 2 * n:
1✔
694
            seek(0)
1✔
695
            if size < n:
1✔
696
                return read(size), size
1✔
697
            return Pdata(read(size)), size
1✔
698

699
        # Make sure we have an _p_jar, even if we are a new object, by
700
        # doing a sub-transaction commit.
701
        transaction.savepoint(optimistic=True)
1✔
702

703
        if self._p_jar is None:
1!
704
            # Ugh
705
            seek(0)
×
706
            return Pdata(read(size)), size
×
707

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

719
            # Create the object and assign it a next pointer
720
            # in the same transaction, so that there is only
721
            # a single database update for it.
722
            data = Pdata(read(end - pos))
1✔
723
            self._p_jar.add(data)
1✔
724
            data.next = _next
1✔
725

726
            # Save the object so that we can release its memory.
727
            transaction.savepoint(optimistic=True)
1✔
728
            data._p_deactivate()
1✔
729
            # The object should be assigned an oid and be a ghost.
730
            assert data._p_oid is not None
1✔
731
            assert data._p_state == -1
1✔
732

733
            _next = data
1✔
734
            end = pos
1✔
735

736
        return (_next, size)
1✔
737

738
    @security.protected(change_images_and_files)
1✔
739
    def PUT(self, REQUEST, RESPONSE):
1✔
740
        """Handle HTTP PUT requests"""
741
        self.dav__init(REQUEST, RESPONSE)
1✔
742
        self.dav__simpleifhandler(REQUEST, RESPONSE, refresh=1)
1✔
743
        type = REQUEST.get_header('content-type', None)
1✔
744

745
        file = REQUEST['BODYFILE']
1✔
746

747
        data, size = self._read_data(file)
1✔
748
        if isinstance(data, str):
1!
749
            data = data.encode('UTF-8')
×
750
        content_type = self._get_content_type(file, data, self.__name__,
1✔
751
                                              type or self.content_type)
752
        self.update_data(data, content_type, size)
1✔
753

754
        RESPONSE.setStatus(204)
1✔
755
        return RESPONSE
1✔
756

757
    @security.protected(View)
1✔
758
    def get_size(self):
1✔
759
        # Get the size of a file or image.
760
        # Returns the size of the file or image.
761
        size = self.size
1✔
762
        if size is None:
1!
763
            size = len(self.data)
×
764
        return size
1✔
765

766
    # deprecated; use get_size!
767
    getSize = get_size
1✔
768

769
    @security.protected(View)
1✔
770
    def getContentType(self):
1✔
771
        # Get the content type of a file or image.
772
        # Returns the content type (MIME type) of a file or image.
773
        return self.content_type
1✔
774

775
    def __bytes__(self):
1✔
776
        return bytes(self.data)
×
777

778
    def __str__(self):
1✔
779
        """In most cases, this is probably not what you want. Use ``bytes``."""
780
        if isinstance(self.data, Pdata):
1✔
781
            return bytes(self.data).decode(self._get_encoding())
1✔
782
        else:
783
            return self.data.decode(self._get_encoding())
1✔
784

785
    def __bool__(self):
1✔
786
        return True
1✔
787

788
    __nonzero__ = __bool__
1✔
789

790
    def __len__(self):
1✔
791
        data = bytes(self.data)
×
792
        return len(data)
×
793

794
    @security.protected(webdav_access)
1✔
795
    def manage_DAVget(self):
1✔
796
        """Return body for WebDAV."""
797
        RESPONSE = self.REQUEST.RESPONSE
1✔
798

799
        if self.ZCacheable_isCachingEnabled():
1!
800
            result = self.ZCacheable_get(default=None)
1✔
801
            if result is not None:
1!
802
                # We will always get None from RAMCacheManager but we will
803
                # get something implementing the IStreamIterator interface
804
                # from FileCacheManager.
805
                # the content-length is required here by HTTPResponse.
806
                RESPONSE.setHeader('Content-Length', self.size)
×
807
                return result
×
808

809
        data = self.data
1✔
810
        if isinstance(data, bytes):
1!
811
            RESPONSE.setBase(None)
1✔
812
            return data
1✔
813

814
        while data is not None:
×
815
            RESPONSE.write(data.data)
×
816
            data = data.next
×
817

818
        return b''
×
819

820

821
InitializeClass(File)
1✔
822

823

824
manage_addImageForm = DTMLFile(
1✔
825
    'dtml/imageAdd',
826
    globals(),
827
    Kind='Image',
828
    kind='image',
829
)
830

831

832
def manage_addImage(
1✔
833
    self,
834
    id,
835
    file,
836
    title='',
837
    precondition='',
838
    content_type='',
839
    REQUEST=None
840
):
841
    """
842
    Add a new Image object.
843

844
    Creates a new Image object 'id' with the contents of 'file'.
845
    """
846
    id = str(id)
1✔
847
    title = str(title)
1✔
848
    content_type = str(content_type)
1✔
849
    precondition = str(precondition)
1✔
850

851
    id, title = cookId(id, title, file)
1✔
852

853
    self = self.this()
1✔
854

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

858
    newFile = self._getOb(id)
1✔
859

860
    # Now we "upload" the data.  By doing this in two steps, we
861
    # can use a database trick to make the upload more efficient.
862
    if file:
1!
863
        newFile.manage_upload(file)
1✔
864
    if content_type:
1!
865
        newFile.content_type = content_type
1✔
866

867
    notify(ObjectCreatedEvent(newFile))
1✔
868

869
    if REQUEST is not None:
1!
870
        try:
×
871
            url = self.DestinationURL()
×
872
        except Exception:
×
873
            url = REQUEST['URL1']
×
874
        REQUEST.RESPONSE.redirect('%s/manage_main' % url)
×
875
    return id
1✔
876

877

878
def getImageInfo(data):
1✔
879
    data = bytes(data)
1✔
880
    size = len(data)
1✔
881
    height = -1
1✔
882
    width = -1
1✔
883
    content_type = ''
1✔
884

885
    # handle GIFs
886
    if (size >= 10) and data[:6] in (b'GIF87a', b'GIF89a'):
1✔
887
        # Check to see if content_type is correct
888
        content_type = 'image/gif'
1✔
889
        w, h = struct.unpack("<HH", data[6:10])
1✔
890
        width = int(w)
1✔
891
        height = int(h)
1✔
892

893
    # See PNG v1.2 spec (http://www.cdrom.com/pub/png/spec/)
894
    # Bytes 0-7 are below, 4-byte chunk length, then 'IHDR'
895
    # and finally the 4-byte width, height
896
    elif (size >= 24
1!
897
          and data[:8] == b'\211PNG\r\n\032\n'
898
          and data[12:16] == b'IHDR'):
899
        content_type = 'image/png'
×
900
        w, h = struct.unpack(">LL", data[16:24])
×
901
        width = int(w)
×
902
        height = int(h)
×
903

904
    # Maybe this is for an older PNG version.
905
    elif (size >= 16) and (data[:8] == b'\211PNG\r\n\032\n'):
1!
906
        # Check to see if we have the right content type
907
        content_type = 'image/png'
×
908
        w, h = struct.unpack(">LL", data[8:16])
×
909
        width = int(w)
×
910
        height = int(h)
×
911

912
    # handle JPEGs
913
    elif (size >= 2) and (data[:2] == b'\377\330'):
1!
914
        content_type = 'image/jpeg'
×
915
        jpeg = BytesIO(data)
×
916
        jpeg.read(2)
×
917
        b = jpeg.read(1)
×
918
        try:
×
919
            while (b and ord(b) != 0xDA):
×
920
                while (ord(b) != 0xFF):
×
921
                    b = jpeg.read(1)
×
922
                while (ord(b) == 0xFF):
×
923
                    b = jpeg.read(1)
×
924
                if (ord(b) >= 0xC0 and ord(b) <= 0xC3):
×
925
                    jpeg.read(3)
×
926
                    h, w = struct.unpack(">HH", jpeg.read(4))
×
927
                    break
×
928
                else:
929
                    jpeg.read(int(struct.unpack(">H", jpeg.read(2))[0]) - 2)
×
930
                b = jpeg.read(1)
×
931
            width = int(w)
×
932
            height = int(h)
×
933
        except Exception:
×
934
            pass
×
935

936
    # handle SVGs
937
    elif (size >= 16) and ((b'<?xml' in data[:16]) or (b'<svg' in data[:16])):
1!
938
        try:
×
939
            xmldoc = minidom.parseString(data)
×
940
        except Exception:
×
941
            return content_type, width, height
×
942
        w = width
×
943
        h = height
×
944
        for svg in xmldoc.getElementsByTagName('svg'):
×
945
            content_type = 'image/svg+xml'
×
946
            if 'height' in svg.attributes and 'width' in svg.attributes:
×
947
                w = svg.attributes['width'].value
×
948
                h = svg.attributes['height'].value
×
949
                try:
×
950
                    w = int(float(w))
×
951
                    h = int(float(h))
×
952
                except Exception:
×
953
                    if str(w).endswith('px'):
×
954
                        w = int(float(w[:-2]))
×
955
                        h = int(float(h[:-2]))
×
956
                    elif str(w).endswith('mm'):
×
957
                        w = int(float(w[:-2]) * 3.7795)
×
958
                        h = int(float(h[:-2]) * 3.7795)
×
959
                    elif str(w).endswith('cm'):
×
960
                        w = int(float(w[:-2]) * 37.795)
×
961
                        h = int(float(h[:-2]) * 37.795)
×
962
                break
×
963
            elif 'viewBox' in svg.attributes:
×
964
                viewBox = svg.attributes['viewBox'].value
×
965
                viewBox = [int(float(x)) for x in viewBox.split(' ')]
×
966
                w = viewBox[2] - viewBox[0]
×
967
                h = viewBox[3] - viewBox[1]
×
968
        width = int(w)
×
969
        height = int(h)
×
970

971
    return content_type, width, height
1✔
972

973

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

980
    meta_type = 'Image'
1✔
981
    zmi_icon = 'far fa-file-image'
1✔
982

983
    security = ClassSecurityInfo()
1✔
984
    security.declareObjectProtected(View)
1✔
985

986
    alt = ''
1✔
987
    height = ''
1✔
988
    width = ''
1✔
989

990
    # FIXME: Redundant, already in base class
991
    security.declareProtected(change_images_and_files, 'manage_edit')  # NOQA: D001,E501
1✔
992
    security.declareProtected(change_images_and_files, 'manage_upload')  # NOQA: D001,E501
1✔
993
    security.declareProtected(View, 'index_html')  # NOQA: D001
1✔
994
    security.declareProtected(View, 'get_size')  # NOQA: D001
1✔
995
    security.declareProtected(View, 'getContentType')  # NOQA: D001
1✔
996

997
    _properties = (
1✔
998
        {'id': 'title', 'type': 'string'},
999
        {'id': 'alt', 'type': 'string'},
1000
        {'id': 'content_type', 'type': 'string', 'mode': 'w'},
1001
        {'id': 'height', 'type': 'string'},
1002
        {'id': 'width', 'type': 'string'},
1003
    )
1004

1005
    manage_options = (
1✔
1006
        ({'label': 'Edit', 'action': 'manage_main'},
1007
         {'label': 'View', 'action': 'view_image_or_file'})
1008
        + PropertyManager.manage_options
1009
        + RoleManager.manage_options
1010
        + Item_w__name__.manage_options
1011
        + Cacheable.manage_options
1012
    )
1013

1014
    manage_editForm = DTMLFile(
1✔
1015
        'dtml/imageEdit',
1016
        globals(),
1017
        Kind='Image',
1018
        kind='image',
1019
    )
1020
    manage_editForm._setName('manage_editForm')
1✔
1021

1022
    security.declareProtected(View, 'view_image_or_file')  # NOQA: D001
1✔
1023
    view_image_or_file = DTMLFile('dtml/imageView', globals())
1✔
1024

1025
    security.declareProtected(view_management_screens, 'manage')  # NOQA: D001
1✔
1026
    security.declareProtected(view_management_screens, 'manage_main')  # NOQA: D001,E501
1✔
1027
    manage = manage_main = manage_editForm
1✔
1028
    manage_uploadForm = manage_editForm
1✔
1029

1030
    @security.private
1✔
1031
    def update_data(self, data, content_type=None, size=None):
1✔
1032
        if isinstance(data, str):
1✔
1033
            raise TypeError('Data can only be bytes or file-like.  '
1✔
1034
                            'Unicode objects are expressly forbidden.')
1035

1036
        if size is None:
1✔
1037
            size = len(data)
1✔
1038

1039
        self.size = size
1✔
1040
        self.data = data
1✔
1041

1042
        ct, width, height = getImageInfo(data)
1✔
1043
        if ct:
1✔
1044
            content_type = ct
1✔
1045
        if width >= 0 and height >= 0:
1✔
1046
            self.width = width
1✔
1047
            self.height = height
1✔
1048

1049
        # Now we should have the correct content type, or still None
1050
        if content_type is not None:
1!
1051
            self.content_type = content_type
1✔
1052

1053
        self.ZCacheable_invalidate()
1✔
1054
        self.ZCacheable_set(None)
1✔
1055
        self.http__refreshEtag()
1✔
1056

1057
    def __bytes__(self):
1✔
1058
        return self.tag().encode('utf-8')
×
1059

1060
    def __str__(self):
1✔
1061
        return self.tag()
1✔
1062

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

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

1096
        # Auto-scaling support
1097
        xdelta = xscale or scale
1✔
1098
        ydelta = yscale or scale
1✔
1099

1100
        if xdelta and width:
1!
1101
            width = str(int(round(int(width) * xdelta)))
×
1102
        if ydelta and height:
1!
1103
            height = str(int(round(int(height) * ydelta)))
×
1104

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

1107
        if alt is None:
1!
1108
            alt = getattr(self, 'alt', '')
1✔
1109
        result = f'{result} alt="{html.escape(alt, True)}"'
1✔
1110

1111
        if title is None:
1!
1112
            title = getattr(self, 'title', '')
1✔
1113
        result = f'{result} title="{html.escape(title, True)}"'
1✔
1114

1115
        if height:
1!
1116
            result = f'{result} height="{height}"'
1✔
1117

1118
        if width:
1!
1119
            result = f'{result} width="{width}"'
1✔
1120

1121
        if css_class is not None:
1!
1122
            result = f'{result} class="{css_class}"'
×
1123

1124
        for key in list(args.keys()):
1!
1125
            value = args.get(key)
×
1126
            if value:
×
1127
                result = f'{result} {key}="{value}"'
×
1128

1129
        return '%s />' % result
1✔
1130

1131

1132
InitializeClass(Image)
1✔
1133

1134

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

1145

1146
class Pdata(Persistent, Implicit):
1✔
1147
    # Wrapper for possibly large data
1148

1149
    next = None
1✔
1150

1151
    def __init__(self, data):
1✔
1152
        self.data = data
1✔
1153

1154
    def __getitem__(self, key):
1✔
1155
        return self.data[key]
1✔
1156

1157
    def __len__(self):
1✔
1158
        data = bytes(self)
×
1159
        return len(data)
×
1160

1161
    def __bytes__(self):
1✔
1162
        _next = self.next
1✔
1163
        if _next is None:
1!
1164
            return self.data
1✔
1165

1166
        r = [self.data]
×
1167
        while _next is not None:
×
1168
            self = _next
×
1169
            r.append(self.data)
×
1170
            _next = self.next
×
1171

1172
        return b''.join(r)
×
1173

1174

1175
def extract_media_type(content_type):
1✔
1176
    """extract the proper media type from *content_type*.
1177

1178
    Ignore parameters and whitespace and normalize to lower case.
1179
    """
1180
    if not content_type:
1✔
1181
        return content_type
1✔
1182
    # ignore parameters
1183
    content_type = content_type.split(";", 1)[0]
1✔
1184
    # ignore whitespace
1185
    content_type = "".join(content_type.split())
1✔
1186
    # normalize to lowercase
1187
    return content_type.lower()
1✔
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