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

zopefoundation / Zope / 3956162881

pending completion
3956162881

push

github

Michael Howitz
Update to deprecation warning free releases.

4401 of 7036 branches covered (62.55%)

Branch coverage included in aggregate %.

27161 of 31488 relevant lines covered (86.26%)

0.86 hits per line

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

81.36
/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 struct
1✔
18
from email.generator import _make_boundary
1✔
19
from io import BytesIO
1✔
20

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

49

50
manage_addFileForm = DTMLFile(
1✔
51
    'dtml/imageAdd',
52
    globals(),
53
    Kind='File',
54
    kind='file',
55
)
56

57

58
def manage_addFile(
1✔
59
    self,
60
    id,
61
    file=b'',
62
    title='',
63
    precondition='',
64
    content_type='',
65
    REQUEST=None
66
):
67
    """Add a new File object.
68

69
    Creates a new File object 'id' with the contents of 'file'"""
70

71
    id = str(id)
1✔
72
    title = str(title)
1✔
73
    content_type = str(content_type)
1✔
74
    precondition = str(precondition)
1✔
75

76
    id, title = cookId(id, title, file)
1✔
77

78
    self = self.this()
1✔
79

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

83
    newFile = self._getOb(id)
1✔
84

85
    # Now we "upload" the data.  By doing this in two steps, we
86
    # can use a database trick to make the upload more efficient.
87
    if file:
1✔
88
        newFile.manage_upload(file)
1✔
89
    if content_type:
1✔
90
        newFile.content_type = content_type
1✔
91

92
    notify(ObjectCreatedEvent(newFile))
1✔
93

94
    if REQUEST is not None:
1!
95
        REQUEST.RESPONSE.redirect(self.absolute_url() + '/manage_main')
×
96

97

98
@implementer(IWriteLock, HTTPRangeSupport.HTTPRangeInterface)
1✔
99
class File(
1✔
100
    PathReprProvider,
101
    Persistent,
102
    Implicit,
103
    PropertyManager,
104
    RoleManager,
105
    Item_w__name__,
106
    Cacheable
107
):
108
    """A File object is a content object for arbitrary files."""
109

110
    meta_type = 'File'
1✔
111
    zmi_icon = 'far fa-file-archive'
1✔
112

113
    security = ClassSecurityInfo()
1✔
114
    security.declareObjectProtected(View)
1✔
115

116
    precondition = ''
1✔
117
    size = None
1✔
118

119
    manage_editForm = DTMLFile('dtml/fileEdit', globals(),
1✔
120
                               Kind='File', kind='file')
121
    manage_editForm._setName('manage_editForm')
1✔
122

123
    security.declareProtected(view_management_screens, 'manage')  # NOQA: D001
1✔
124
    security.declareProtected(view_management_screens, 'manage_main')  # NOQA: D001,E501
1✔
125
    manage = manage_main = manage_editForm
1✔
126
    manage_uploadForm = manage_editForm
1✔
127

128
    manage_options = (({'label': 'Edit', 'action': 'manage_main'},
1✔
129
                       {'label': 'View', 'action': ''})
130
                      + PropertyManager.manage_options
131
                      + RoleManager.manage_options
132
                      + Item_w__name__.manage_options
133
                      + Cacheable.manage_options)
134

135
    _properties = (
1✔
136
        {'id': 'title', 'type': 'string'},
137
        {'id': 'content_type', 'type': 'string'},
138
    )
139

140
    def __init__(self, id, title, file, content_type='', precondition=''):
1✔
141
        self.__name__ = id
1✔
142
        self.title = title
1✔
143
        self.precondition = precondition
1✔
144

145
        data, size = self._read_data(file)
1✔
146
        content_type = self._get_content_type(file, data, id, content_type)
1✔
147
        self.update_data(data, content_type, size)
1✔
148

149
    def _if_modified_since_request_handler(self, REQUEST, RESPONSE):
1✔
150
        # HTTP If-Modified-Since header handling: return True if
151
        # we can handle this request by returning a 304 response
152
        header = REQUEST.get_header('If-Modified-Since', None)
1✔
153
        if header is not None:
1✔
154
            header = header.split(';')[0]
1✔
155
            # Some proxies seem to send invalid date strings for this
156
            # header. If the date string is not valid, we ignore it
157
            # rather than raise an error to be generally consistent
158
            # with common servers such as Apache (which can usually
159
            # understand the screwy date string as a lucky side effect
160
            # of the way they parse it).
161
            # This happens to be what RFC2616 tells us to do in the face of an
162
            # invalid date.
163
            try:
1✔
164
                mod_since = int(DateTime(header).timeTime())
1✔
165
            except Exception:
×
166
                mod_since = None
×
167
            if mod_since is not None:
1!
168
                if self._p_mtime:
1!
169
                    last_mod = int(self._p_mtime)
1✔
170
                else:
171
                    last_mod = 0
×
172
                if last_mod > 0 and last_mod <= mod_since:
1✔
173
                    RESPONSE.setHeader(
1✔
174
                        'Last-Modified', rfc1123_date(self._p_mtime)
175
                    )
176
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
177
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
178
                    RESPONSE.setStatus(304)
1✔
179
                    return True
1✔
180

181
    def _range_request_handler(self, REQUEST, RESPONSE):
1✔
182
        # HTTP Range header handling: return True if we've served a range
183
        # chunk out of our data.
184
        range = REQUEST.get_header('Range', None)
1✔
185
        request_range = REQUEST.get_header('Request-Range', None)
1✔
186
        if request_range is not None:
1✔
187
            # Netscape 2 through 4 and MSIE 3 implement a draft version
188
            # Later on, we need to serve a different mime-type as well.
189
            range = request_range
1✔
190
        if_range = REQUEST.get_header('If-Range', None)
1✔
191
        if range is not None:
1✔
192
            ranges = HTTPRangeSupport.parseRange(range)
1✔
193

194
            if if_range is not None:
1✔
195
                # Only send ranges if the data isn't modified, otherwise send
196
                # the whole object. Support both ETags and Last-Modified dates!
197
                if len(if_range) > 1 and if_range[:2] == 'ts':
1✔
198
                    # ETag:
199
                    if if_range != self.http__etag():
1✔
200
                        # Modified, so send a normal response. We delete
201
                        # the ranges, which causes us to skip to the 200
202
                        # response.
203
                        ranges = None
1✔
204
                else:
205
                    # Date
206
                    date = if_range.split(';')[0]
1✔
207
                    try:
1✔
208
                        mod_since = int(DateTime(date).timeTime())
1✔
209
                    except Exception:
1✔
210
                        mod_since = None
1✔
211
                    if mod_since is not None:
1✔
212
                        if self._p_mtime:
1!
213
                            last_mod = int(self._p_mtime)
1✔
214
                        else:
215
                            last_mod = 0
×
216
                        if last_mod > mod_since:
1✔
217
                            # Modified, so send a normal response. We delete
218
                            # the ranges, which causes us to skip to the 200
219
                            # response.
220
                            ranges = None
1✔
221

222
            if ranges:
1✔
223
                # Search for satisfiable ranges.
224
                satisfiable = 0
1✔
225
                for start, end in ranges:
1✔
226
                    if start < self.size:
1✔
227
                        satisfiable = 1
1✔
228
                        break
1✔
229

230
                if not satisfiable:
1✔
231
                    RESPONSE.setHeader(
1✔
232
                        'Content-Range', 'bytes */%d' % self.size
233
                    )
234
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
235
                    RESPONSE.setHeader(
1✔
236
                        'Last-Modified', rfc1123_date(self._p_mtime)
237
                    )
238
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
239
                    RESPONSE.setHeader('Content-Length', self.size)
1✔
240
                    RESPONSE.setStatus(416)
1✔
241
                    return True
1✔
242

243
                ranges = HTTPRangeSupport.expandRanges(ranges, self.size)
1✔
244

245
                if len(ranges) == 1:
1✔
246
                    # Easy case, set extra header and return partial set.
247
                    start, end = ranges[0]
1✔
248
                    size = end - start
1✔
249

250
                    RESPONSE.setHeader(
1✔
251
                        'Last-Modified', rfc1123_date(self._p_mtime)
252
                    )
253
                    RESPONSE.setHeader('Content-Type', self.content_type)
1✔
254
                    RESPONSE.setHeader('Content-Length', size)
1✔
255
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
256
                    RESPONSE.setHeader(
1✔
257
                        'Content-Range',
258
                        'bytes %d-%d/%d' % (start, end - 1, self.size)
259
                    )
260
                    RESPONSE.setStatus(206)  # Partial content
1✔
261

262
                    data = self.data
1✔
263
                    if isinstance(data, bytes):
1✔
264
                        RESPONSE.write(data[start:end])
1✔
265
                        return True
1✔
266

267
                    # Linked Pdata objects. Urgh.
268
                    pos = 0
1✔
269
                    while data is not None:
1!
270
                        length = len(data.data)
1✔
271
                        pos = pos + length
1✔
272
                        if pos > start:
1✔
273
                            # We are within the range
274
                            lstart = length - (pos - start)
1✔
275

276
                            if lstart < 0:
1!
277
                                lstart = 0
×
278

279
                            # find the endpoint
280
                            if end <= pos:
1!
281
                                lend = length - (pos - end)
1✔
282

283
                                # Send and end transmission
284
                                RESPONSE.write(data[lstart:lend])
1✔
285
                                break
1✔
286

287
                            # Not yet at the end, transmit what we have.
288
                            RESPONSE.write(data[lstart:])
×
289

290
                        data = data.next
1✔
291

292
                    return True
1✔
293

294
                else:
295
                    boundary = _make_boundary()
1✔
296

297
                    # Calculate the content length
298
                    size = (8 + len(boundary)  # End marker length
1✔
299
                            + len(ranges) * (  # Constant lenght per set
300
                                49 + len(boundary)
301
                                + len(self.content_type)
302
                                + len('%d' % self.size)))
303
                    for start, end in ranges:
1✔
304
                        # Variable length per set
305
                        size = (size + len('%d%d' % (start, end - 1))
1✔
306
                                + end - start)
307

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

312
                    RESPONSE.setHeader('Content-Length', size)
1✔
313
                    RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
314
                    RESPONSE.setHeader(
1✔
315
                        'Last-Modified', rfc1123_date(self._p_mtime)
316
                    )
317
                    RESPONSE.setHeader(
1✔
318
                        'Content-Type',
319
                        f'multipart/{draftprefix}byteranges;'
320
                        f' boundary={boundary}'
321
                    )
322
                    RESPONSE.setStatus(206)  # Partial content
1✔
323

324
                    data = self.data
1✔
325
                    # The Pdata map allows us to jump into the Pdata chain
326
                    # arbitrarily during out-of-order range searching.
327
                    pdata_map = {}
1✔
328
                    pdata_map[0] = data
1✔
329

330
                    for start, end in ranges:
1✔
331
                        RESPONSE.write(
1✔
332
                            b'\r\n--'
333
                            + boundary.encode('ascii')
334
                            + b'\r\n'
335
                        )
336
                        RESPONSE.write(
1✔
337
                            b'Content-Type: '
338
                            + self.content_type.encode('ascii')
339
                            + b'\r\n'
340
                        )
341
                        RESPONSE.write(
1✔
342
                            b'Content-Range: bytes '
343
                            + str(start).encode('ascii')
344
                            + b'-'
345
                            + str(end - 1).encode('ascii')
346
                            + b'/'
347
                            + str(self.size).encode('ascii')
348
                            + b'\r\n\r\n'
349
                        )
350

351
                        if isinstance(data, bytes):
1✔
352
                            RESPONSE.write(data[start:end])
1✔
353

354
                        else:
355
                            # Yippee. Linked Pdata objects. The following
356
                            # calculations allow us to fast-forward through the
357
                            # Pdata chain without a lot of dereferencing if we
358
                            # did the work already.
359
                            first_size = len(pdata_map[0].data)
1✔
360
                            if start < first_size:
1✔
361
                                closest_pos = 0
1✔
362
                            else:
363
                                closest_pos = (
1✔
364
                                    ((start - first_size) >> 16 << 16)
365
                                    + first_size
366
                                )
367
                            pos = min(closest_pos, max(pdata_map.keys()))
1✔
368
                            data = pdata_map[pos]
1✔
369

370
                            while data is not None:
1!
371
                                length = len(data.data)
1✔
372
                                pos = pos + length
1✔
373
                                if pos > start:
1✔
374
                                    # We are within the range
375
                                    lstart = length - (pos - start)
1✔
376

377
                                    if lstart < 0:
1✔
378
                                        lstart = 0
1✔
379

380
                                    # find the endpoint
381
                                    if end <= pos:
1✔
382
                                        lend = length - (pos - end)
1✔
383

384
                                        # Send and loop to next range
385
                                        RESPONSE.write(data[lstart:lend])
1✔
386
                                        break
1✔
387

388
                                    # Not yet at the end,
389
                                    # transmit what we have.
390
                                    RESPONSE.write(data[lstart:])
1✔
391

392
                                data = data.next
1✔
393
                                # Store a reference to a Pdata chain link
394
                                # so we don't have to deref during
395
                                # this request again.
396
                                pdata_map[pos] = data
1✔
397

398
                    # Do not keep the link references around.
399
                    del pdata_map
1✔
400

401
                    RESPONSE.write(
1✔
402
                        b'\r\n--' + boundary.encode('ascii') + b'--\r\n')
403
                    return True
1✔
404

405
    @security.protected(View)
1✔
406
    def index_html(self, REQUEST, RESPONSE):
1✔
407
        """
408
        The default view of the contents of a File or Image.
409

410
        Returns the contents of the file or image.  Also, sets the
411
        Content-Type HTTP header to the objects content type.
412
        """
413

414
        if self._if_modified_since_request_handler(REQUEST, RESPONSE):
1✔
415
            # we were able to handle this by returning a 304
416
            # unfortunately, because the HTTP cache manager uses the cache
417
            # API, and because 304 responses are required to carry the Expires
418
            # header for HTTP/1.1, we need to call ZCacheable_set here.
419
            # This is nonsensical for caches other than the HTTP cache manager
420
            # unfortunately.
421
            self.ZCacheable_set(None)
1✔
422
            return b''
1✔
423

424
        if self.precondition and hasattr(self, str(self.precondition)):
1!
425
            # Grab whatever precondition was defined and then
426
            # execute it.  The precondition will raise an exception
427
            # if something violates its terms.
428
            c = getattr(self, str(self.precondition))
×
429
            if hasattr(c, 'isDocTemp') and c.isDocTemp:
×
430
                c(REQUEST['PARENTS'][1], REQUEST)
×
431
            else:
432
                c()
×
433

434
        if self._range_request_handler(REQUEST, RESPONSE):
1✔
435
            # we served a chunk of content in response to a range request.
436
            return b''
1✔
437

438
        RESPONSE.setHeader('Last-Modified', rfc1123_date(self._p_mtime))
1✔
439
        RESPONSE.setHeader('Content-Type', self.content_type)
1✔
440
        RESPONSE.setHeader('Content-Length', self.size)
1✔
441
        RESPONSE.setHeader('Accept-Ranges', 'bytes')
1✔
442

443
        if self.ZCacheable_isCachingEnabled():
1✔
444
            result = self.ZCacheable_get(default=None)
1✔
445
            if result is not None:
1!
446
                # We will always get None from RAMCacheManager and HTTP
447
                # Accelerated Cache Manager but we will get
448
                # something implementing the IStreamIterator interface
449
                # from a "FileCacheManager"
450
                return result
×
451

452
        self.ZCacheable_set(None)
1✔
453

454
        data = self.data
1✔
455
        if isinstance(data, bytes):
1✔
456
            RESPONSE.setBase(None)
1✔
457
            return data
1✔
458

459
        while data is not None:
1✔
460
            RESPONSE.write(data.data)
1✔
461
            data = data.next
1✔
462

463
        return b''
1✔
464

465
    @security.protected(View)
1✔
466
    def view_image_or_file(self, URL1):
1✔
467
        """The default view of the contents of the File or Image."""
468
        raise Redirect(URL1)
1✔
469

470
    @security.protected(View)
1✔
471
    def PrincipiaSearchSource(self):
1✔
472
        """Allow file objects to be searched."""
473
        if self.content_type.startswith('text/'):
1✔
474
            return bytes(self.data)
1✔
475
        return b''
1✔
476

477
    @security.private
1✔
478
    def update_data(self, data, content_type=None, size=None):
1✔
479
        if isinstance(data, str):
1✔
480
            raise TypeError('Data can only be bytes or file-like. '
1✔
481
                            'Unicode objects are expressly forbidden.')
482

483
        if content_type is not None:
1✔
484
            self.content_type = content_type
1✔
485
        if size is None:
1✔
486
            size = len(data)
1✔
487
        self.size = size
1✔
488
        self.data = data
1✔
489
        self.ZCacheable_invalidate()
1✔
490
        self.ZCacheable_set(None)
1✔
491
        self.http__refreshEtag()
1✔
492

493
    def _get_encoding(self):
1✔
494
        """Get the canonical encoding for ZMI."""
495
        return ZPublisher.HTTPRequest.default_encoding
1✔
496

497
    @security.protected(change_images_and_files)
1✔
498
    def manage_edit(
1✔
499
        self,
500
        title,
501
        content_type,
502
        precondition='',
503
        filedata=None,
504
        REQUEST=None
505
    ):
506
        """
507
        Changes the title and content type attributes of the File or Image.
508
        """
509
        if self.wl_isLocked():
1!
510
            raise ResourceLockedError("File is locked.")
×
511

512
        self.title = str(title)
1✔
513
        self.content_type = str(content_type)
1✔
514
        if precondition:
1!
515
            self.precondition = str(precondition)
×
516
        elif self.precondition:
1!
517
            del self.precondition
×
518
        if filedata is not None:
1✔
519
            if isinstance(filedata, str):
1✔
520
                filedata = filedata.encode(self._get_encoding())
1✔
521
            self.update_data(filedata, content_type, len(filedata))
1✔
522
        else:
523
            self.ZCacheable_invalidate()
1✔
524

525
        notify(ObjectModifiedEvent(self))
1✔
526

527
        if REQUEST:
1✔
528
            message = "Saved changes."
1✔
529
            return self.manage_main(
1✔
530
                self, REQUEST, manage_tabs_message=message)
531

532
    @security.protected(change_images_and_files)
1✔
533
    def manage_upload(self, file='', REQUEST=None):
1✔
534
        """
535
        Replaces the current contents of the File or Image object with file.
536

537
        The file or images contents are replaced with the contents of 'file'.
538
        """
539
        if self.wl_isLocked():
1!
540
            raise ResourceLockedError("File is locked.")
×
541

542
        if file:
1✔
543
            data, size = self._read_data(file)
1✔
544
            content_type = self._get_content_type(file, data, self.__name__,
1✔
545
                                                  'application/octet-stream')
546
            self.update_data(data, content_type, size)
1✔
547
            notify(ObjectModifiedEvent(self))
1✔
548
            msg = 'Saved changes.'
1✔
549
        else:
550
            msg = 'Please select a file to upload.'
1✔
551

552
        if REQUEST:
1✔
553
            return self.manage_main(
1✔
554
                self, REQUEST, manage_tabs_message=msg)
555

556
    def _get_content_type(self, file, body, id, content_type=None):
1✔
557
        headers = getattr(file, 'headers', None)
1✔
558
        if headers and 'content-type' in headers:
1✔
559
            content_type = headers['content-type']
1✔
560
        else:
561
            if not isinstance(body, bytes):
1✔
562
                body = body.data
1✔
563
            content_type, enc = guess_content_type(
1✔
564
                getattr(file, 'filename', id), body, content_type)
565
        return content_type
1✔
566

567
    def _read_data(self, file):
1✔
568
        import transaction
1✔
569

570
        n = 1 << 16
1✔
571

572
        if isinstance(file, str):
1!
573
            raise ValueError("Must be bytes")
×
574

575
        if isinstance(file, bytes):
1✔
576
            size = len(file)
1✔
577
            if size < n:
1✔
578
                return (file, size)
1✔
579
            # Big string: cut it into smaller chunks
580
            file = BytesIO(file)
1✔
581

582
        if isinstance(file, FileUpload) and not file:
1!
583
            raise ValueError('File not specified')
×
584

585
        if hasattr(file, '__class__') and file.__class__ is Pdata:
1!
586
            size = len(file)
×
587
            return (file, size)
×
588

589
        seek = file.seek
1✔
590
        read = file.read
1✔
591

592
        seek(0, 2)
1✔
593
        size = end = file.tell()
1✔
594

595
        if size <= 2 * n:
1✔
596
            seek(0)
1✔
597
            if size < n:
1✔
598
                return read(size), size
1✔
599
            return Pdata(read(size)), size
1✔
600

601
        # Make sure we have an _p_jar, even if we are a new object, by
602
        # doing a sub-transaction commit.
603
        transaction.savepoint(optimistic=True)
1✔
604

605
        if self._p_jar is None:
1!
606
            # Ugh
607
            seek(0)
×
608
            return Pdata(read(size)), size
×
609

610
        # Now we're going to build a linked list from back
611
        # to front to minimize the number of database updates
612
        # and to allow us to get things out of memory as soon as
613
        # possible.
614
        _next = None
1✔
615
        while end > 0:
1✔
616
            pos = end - n
1✔
617
            if pos < n:
1✔
618
                pos = 0  # we always want at least n bytes
1✔
619
            seek(pos)
1✔
620

621
            # Create the object and assign it a next pointer
622
            # in the same transaction, so that there is only
623
            # a single database update for it.
624
            data = Pdata(read(end - pos))
1✔
625
            self._p_jar.add(data)
1✔
626
            data.next = _next
1✔
627

628
            # Save the object so that we can release its memory.
629
            transaction.savepoint(optimistic=True)
1✔
630
            data._p_deactivate()
1✔
631
            # The object should be assigned an oid and be a ghost.
632
            assert data._p_oid is not None
1✔
633
            assert data._p_state == -1
1✔
634

635
            _next = data
1✔
636
            end = pos
1✔
637

638
        return (_next, size)
1✔
639

640
    @security.protected(change_images_and_files)
1✔
641
    def PUT(self, REQUEST, RESPONSE):
1✔
642
        """Handle HTTP PUT requests"""
643
        self.dav__init(REQUEST, RESPONSE)
1✔
644
        self.dav__simpleifhandler(REQUEST, RESPONSE, refresh=1)
1✔
645
        type = REQUEST.get_header('content-type', None)
1✔
646

647
        file = REQUEST['BODYFILE']
1✔
648

649
        data, size = self._read_data(file)
1✔
650
        if isinstance(data, str):
1!
651
            data = data.encode('UTF-8')
×
652
        content_type = self._get_content_type(file, data, self.__name__,
1✔
653
                                              type or self.content_type)
654
        self.update_data(data, content_type, size)
1✔
655

656
        RESPONSE.setStatus(204)
1✔
657
        return RESPONSE
1✔
658

659
    @security.protected(View)
1✔
660
    def get_size(self):
1✔
661
        # Get the size of a file or image.
662
        # Returns the size of the file or image.
663
        size = self.size
1✔
664
        if size is None:
1!
665
            size = len(self.data)
×
666
        return size
1✔
667

668
    # deprecated; use get_size!
669
    getSize = get_size
1✔
670

671
    @security.protected(View)
1✔
672
    def getContentType(self):
1✔
673
        # Get the content type of a file or image.
674
        # Returns the content type (MIME type) of a file or image.
675
        return self.content_type
1✔
676

677
    def __bytes__(self):
1✔
678
        return bytes(self.data)
×
679

680
    def __str__(self):
1✔
681
        """In most cases, this is probably not what you want. Use ``bytes``."""
682
        if isinstance(self.data, Pdata):
1✔
683
            return bytes(self.data).decode(self._get_encoding())
1✔
684
        else:
685
            return self.data.decode(self._get_encoding())
1✔
686

687
    def __bool__(self):
1✔
688
        return True
1✔
689

690
    __nonzero__ = __bool__
1✔
691

692
    def __len__(self):
1✔
693
        data = bytes(self.data)
×
694
        return len(data)
×
695

696
    @security.protected(webdav_access)
1✔
697
    def manage_DAVget(self):
1✔
698
        """Return body for WebDAV."""
699
        RESPONSE = self.REQUEST.RESPONSE
1✔
700

701
        if self.ZCacheable_isCachingEnabled():
1!
702
            result = self.ZCacheable_get(default=None)
1✔
703
            if result is not None:
1!
704
                # We will always get None from RAMCacheManager but we will
705
                # get something implementing the IStreamIterator interface
706
                # from FileCacheManager.
707
                # the content-length is required here by HTTPResponse.
708
                RESPONSE.setHeader('Content-Length', self.size)
×
709
                return result
×
710

711
        data = self.data
1✔
712
        if isinstance(data, bytes):
1!
713
            RESPONSE.setBase(None)
1✔
714
            return data
1✔
715

716
        while data is not None:
×
717
            RESPONSE.write(data.data)
×
718
            data = data.next
×
719

720
        return b''
×
721

722

723
InitializeClass(File)
1✔
724

725

726
manage_addImageForm = DTMLFile(
1✔
727
    'dtml/imageAdd',
728
    globals(),
729
    Kind='Image',
730
    kind='image',
731
)
732

733

734
def manage_addImage(
1✔
735
    self,
736
    id,
737
    file,
738
    title='',
739
    precondition='',
740
    content_type='',
741
    REQUEST=None
742
):
743
    """
744
    Add a new Image object.
745

746
    Creates a new Image object 'id' with the contents of 'file'.
747
    """
748
    id = str(id)
1✔
749
    title = str(title)
1✔
750
    content_type = str(content_type)
1✔
751
    precondition = str(precondition)
1✔
752

753
    id, title = cookId(id, title, file)
1✔
754

755
    self = self.this()
1✔
756

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

760
    newFile = self._getOb(id)
1✔
761

762
    # Now we "upload" the data.  By doing this in two steps, we
763
    # can use a database trick to make the upload more efficient.
764
    if file:
1!
765
        newFile.manage_upload(file)
1✔
766
    if content_type:
1!
767
        newFile.content_type = content_type
1✔
768

769
    notify(ObjectCreatedEvent(newFile))
1✔
770

771
    if REQUEST is not None:
1!
772
        try:
×
773
            url = self.DestinationURL()
×
774
        except Exception:
×
775
            url = REQUEST['URL1']
×
776
        REQUEST.RESPONSE.redirect('%s/manage_main' % url)
×
777
    return id
1✔
778

779

780
def getImageInfo(data):
1✔
781
    data = bytes(data)
1✔
782
    size = len(data)
1✔
783
    height = -1
1✔
784
    width = -1
1✔
785
    content_type = ''
1✔
786

787
    # handle GIFs
788
    if (size >= 10) and data[:6] in (b'GIF87a', b'GIF89a'):
1✔
789
        # Check to see if content_type is correct
790
        content_type = 'image/gif'
1✔
791
        w, h = struct.unpack("<HH", data[6:10])
1✔
792
        width = int(w)
1✔
793
        height = int(h)
1✔
794

795
    # See PNG v1.2 spec (http://www.cdrom.com/pub/png/spec/)
796
    # Bytes 0-7 are below, 4-byte chunk length, then 'IHDR'
797
    # and finally the 4-byte width, height
798
    elif (size >= 24
1!
799
          and data[:8] == b'\211PNG\r\n\032\n'
800
          and data[12:16] == b'IHDR'):
801
        content_type = 'image/png'
×
802
        w, h = struct.unpack(">LL", data[16:24])
×
803
        width = int(w)
×
804
        height = int(h)
×
805

806
    # Maybe this is for an older PNG version.
807
    elif (size >= 16) and (data[:8] == b'\211PNG\r\n\032\n'):
1!
808
        # Check to see if we have the right content type
809
        content_type = 'image/png'
×
810
        w, h = struct.unpack(">LL", data[8:16])
×
811
        width = int(w)
×
812
        height = int(h)
×
813

814
    # handle JPEGs
815
    elif (size >= 2) and (data[:2] == b'\377\330'):
1!
816
        content_type = 'image/jpeg'
×
817
        jpeg = BytesIO(data)
×
818
        jpeg.read(2)
×
819
        b = jpeg.read(1)
×
820
        try:
×
821
            while (b and ord(b) != 0xDA):
×
822
                while (ord(b) != 0xFF):
×
823
                    b = jpeg.read(1)
×
824
                while (ord(b) == 0xFF):
×
825
                    b = jpeg.read(1)
×
826
                if (ord(b) >= 0xC0 and ord(b) <= 0xC3):
×
827
                    jpeg.read(3)
×
828
                    h, w = struct.unpack(">HH", jpeg.read(4))
×
829
                    break
×
830
                else:
831
                    jpeg.read(int(struct.unpack(">H", jpeg.read(2))[0]) - 2)
×
832
                b = jpeg.read(1)
×
833
            width = int(w)
×
834
            height = int(h)
×
835
        except Exception:
×
836
            pass
×
837

838
    return content_type, width, height
1✔
839

840

841
class Image(File):
1✔
842
    """Image objects can be GIF, PNG or JPEG and have the same methods
843
    as File objects.  Images also have a string representation that
844
    renders an HTML 'IMG' tag.
845
    """
846

847
    meta_type = 'Image'
1✔
848
    zmi_icon = 'far fa-file-image'
1✔
849

850
    security = ClassSecurityInfo()
1✔
851
    security.declareObjectProtected(View)
1✔
852

853
    alt = ''
1✔
854
    height = ''
1✔
855
    width = ''
1✔
856

857
    # FIXME: Redundant, already in base class
858
    security.declareProtected(change_images_and_files, 'manage_edit')  # NOQA: D001,E501
1✔
859
    security.declareProtected(change_images_and_files, 'manage_upload')  # NOQA: D001,E501
1✔
860
    security.declareProtected(View, 'index_html')  # NOQA: D001
1✔
861
    security.declareProtected(View, 'get_size')  # NOQA: D001
1✔
862
    security.declareProtected(View, 'getContentType')  # NOQA: D001
1✔
863

864
    _properties = (
1✔
865
        {'id': 'title', 'type': 'string'},
866
        {'id': 'alt', 'type': 'string'},
867
        {'id': 'content_type', 'type': 'string', 'mode': 'w'},
868
        {'id': 'height', 'type': 'string'},
869
        {'id': 'width', 'type': 'string'},
870
    )
871

872
    manage_options = (
1✔
873
        ({'label': 'Edit', 'action': 'manage_main'},
874
         {'label': 'View', 'action': 'view_image_or_file'})
875
        + PropertyManager.manage_options
876
        + RoleManager.manage_options
877
        + Item_w__name__.manage_options
878
        + Cacheable.manage_options
879
    )
880

881
    manage_editForm = DTMLFile(
1✔
882
        'dtml/imageEdit',
883
        globals(),
884
        Kind='Image',
885
        kind='image',
886
    )
887
    manage_editForm._setName('manage_editForm')
1✔
888

889
    security.declareProtected(View, 'view_image_or_file')  # NOQA: D001
1✔
890
    view_image_or_file = DTMLFile('dtml/imageView', globals())
1✔
891

892
    security.declareProtected(view_management_screens, 'manage')  # NOQA: D001
1✔
893
    security.declareProtected(view_management_screens, 'manage_main')  # NOQA: D001,E501
1✔
894
    manage = manage_main = manage_editForm
1✔
895
    manage_uploadForm = manage_editForm
1✔
896

897
    @security.private
1✔
898
    def update_data(self, data, content_type=None, size=None):
1✔
899
        if isinstance(data, str):
1✔
900
            raise TypeError('Data can only be bytes or file-like.  '
1✔
901
                            'Unicode objects are expressly forbidden.')
902

903
        if size is None:
1✔
904
            size = len(data)
1✔
905

906
        self.size = size
1✔
907
        self.data = data
1✔
908

909
        ct, width, height = getImageInfo(data)
1✔
910
        if ct:
1✔
911
            content_type = ct
1✔
912
        if width >= 0 and height >= 0:
1✔
913
            self.width = width
1✔
914
            self.height = height
1✔
915

916
        # Now we should have the correct content type, or still None
917
        if content_type is not None:
1!
918
            self.content_type = content_type
1✔
919

920
        self.ZCacheable_invalidate()
1✔
921
        self.ZCacheable_set(None)
1✔
922
        self.http__refreshEtag()
1✔
923

924
    def __bytes__(self):
1✔
925
        return self.tag().encode('utf-8')
×
926

927
    def __str__(self):
1✔
928
        return self.tag()
1✔
929

930
    @security.protected(View)
1✔
931
    def tag(
1✔
932
        self,
933
        height=None,
934
        width=None,
935
        alt=None,
936
        scale=0,
937
        xscale=0,
938
        yscale=0,
939
        css_class=None,
940
        title=None,
941
        **args
942
    ):
943
        """Generate an HTML IMG tag for this image, with customization.
944

945
        Arguments to self.tag() can be any valid attributes of an IMG tag.
946
        'src' will always be an absolute pathname, to prevent redundant
947
        downloading of images. Defaults are applied intelligently for
948
        'height', 'width', and 'alt'. If specified, the 'scale', 'xscale',
949
        and 'yscale' keyword arguments will be used to automatically adjust
950
        the output height and width values of the image tag.
951
        #
952
        Since 'class' is a Python reserved word, it cannot be passed in
953
        directly in keyword arguments which is a problem if you are
954
        trying to use 'tag()' to include a CSS class. The tag() method
955
        will accept a 'css_class' argument that will be converted to
956
        'class' in the output tag to work around this.
957
        """
958
        if height is None:
1!
959
            height = self.height
1✔
960
        if width is None:
1!
961
            width = self.width
1✔
962

963
        # Auto-scaling support
964
        xdelta = xscale or scale
1✔
965
        ydelta = yscale or scale
1✔
966

967
        if xdelta and width:
1!
968
            width = str(int(round(int(width) * xdelta)))
×
969
        if ydelta and height:
1!
970
            height = str(int(round(int(height) * ydelta)))
×
971

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

974
        if alt is None:
1!
975
            alt = getattr(self, 'alt', '')
1✔
976
        result = f'{result} alt="{html.escape(alt, True)}"'
1✔
977

978
        if title is None:
1!
979
            title = getattr(self, 'title', '')
1✔
980
        result = f'{result} title="{html.escape(title, True)}"'
1✔
981

982
        if height:
1!
983
            result = f'{result} height="{height}"'
1✔
984

985
        if width:
1!
986
            result = f'{result} width="{width}"'
1✔
987

988
        if css_class is not None:
1!
989
            result = f'{result} class="{css_class}"'
×
990

991
        for key in list(args.keys()):
1!
992
            value = args.get(key)
×
993
            if value:
×
994
                result = f'{result} {key}="{value}"'
×
995

996
        return '%s />' % result
1✔
997

998

999
InitializeClass(Image)
1✔
1000

1001

1002
def cookId(id, title, file):
1✔
1003
    if not id and hasattr(file, 'filename'):
1!
1004
        filename = file.filename
×
1005
        title = title or filename
×
1006
        id = filename[max(filename.rfind('/'),
×
1007
                          filename.rfind('\\'),
1008
                          filename.rfind(':'),
1009
                          ) + 1:]
1010
    return id, title
1✔
1011

1012

1013
class Pdata(Persistent, Implicit):
1✔
1014
    # Wrapper for possibly large data
1015

1016
    next = None
1✔
1017

1018
    def __init__(self, data):
1✔
1019
        self.data = data
1✔
1020

1021
    def __getitem__(self, key):
1✔
1022
        return self.data[key]
1✔
1023

1024
    def __len__(self):
1✔
1025
        data = bytes(self)
×
1026
        return len(data)
×
1027

1028
    def __bytes__(self):
1✔
1029
        _next = self.next
1✔
1030
        if _next is None:
1!
1031
            return self.data
1✔
1032

1033
        r = [self.data]
×
1034
        while _next is not None:
×
1035
            self = _next
×
1036
            r.append(self.data)
×
1037
            _next = self.next
×
1038

1039
        return b''.join(r)
×
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