X-Git-Url: https://scm.cri.minesparis.psl.eu/git/Photo.git/blobdiff_plain/b0a7e10b4f32cf74864bb53268ca4d3080f23bc0..6c41809185e322ce2d30e98234f71144f78f06c0:/Products/Photo/blobbases.py diff --git a/Products/Photo/blobbases.py b/Products/Photo/blobbases.py new file mode 100755 index 0000000..964ffe3 --- /dev/null +++ b/Products/Photo/blobbases.py @@ -0,0 +1,819 @@ +# -*- coding: utf-8 -*- +############################################################################## +# This module is based on OFS.Image originaly copyrighted as: +# +# Copyright (c) 2002 Zope Corporation and Contributors. All Rights Reserved. +# +# This software is subject to the provisions of the Zope Public License, +# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. +# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED +# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS +# FOR A PARTICULAR PURPOSE +# +############################################################################## +"""Image object + +""" + +from cgi import escape +from cStringIO import StringIO +from mimetools import choose_boundary +import struct +from warnings import warn + +from AccessControl.Permissions import change_images_and_files +from AccessControl.Permissions import view_management_screens +from AccessControl.Permissions import view as View +from AccessControl.Permissions import ftp_access +from AccessControl.Permissions import delete_objects +from AccessControl.Role import RoleManager +from AccessControl.SecurityInfo import ClassSecurityInfo +from Acquisition import Implicit +from App.class_init import InitializeClass +from App.special_dtml import DTMLFile +from DateTime.DateTime import DateTime +from Persistence import Persistent +from webdav.common import rfc1123_date +from webdav.interfaces import IWriteLock +from webdav.Lockable import ResourceLockedError +from ZPublisher import HTTPRangeSupport +from ZPublisher.HTTPRequest import FileUpload +from ZPublisher.Iterators import filestream_iterator +from zExceptions import Redirect +from zope.contenttype import guess_content_type +from zope.interface import implementedBy +from zope.interface import implements + +from OFS.Cache import Cacheable +from OFS.PropertyManager import PropertyManager +from OFS.SimpleItem import Item_w__name__ + +from zope.event import notify +from zope.lifecycleevent import ObjectModifiedEvent +from zope.lifecycleevent import ObjectCreatedEvent + +from ZODB.blob import Blob + +CHUNK_SIZE = 1 << 16 + + +manage_addFileForm = DTMLFile('dtml/imageAdd', + globals(), + Kind='File', + kind='file', + ) +def manage_addFile(self, id, file='', title='', precondition='', + content_type='', REQUEST=None): + """Add a new File object. + + Creates a new File object 'id' with the contents of 'file'""" + + id = str(id) + title = str(title) + content_type = str(content_type) + precondition = str(precondition) + + id, title = cookId(id, title, file) + + self=self.this() + self._setObject(id, File(id,title,file,content_type, precondition)) + + newFile = self._getOb(id) + notify(ObjectCreatedEvent(newFile)) + + if REQUEST is not None: + REQUEST['RESPONSE'].redirect(self.absolute_url()+'/manage_main') + + +class File(Persistent, Implicit, PropertyManager, + RoleManager, Item_w__name__, Cacheable): + """A File object is a content object for arbitrary files.""" + + implements(implementedBy(Persistent), + implementedBy(Implicit), + implementedBy(PropertyManager), + implementedBy(RoleManager), + implementedBy(Item_w__name__), + implementedBy(Cacheable), + IWriteLock, + HTTPRangeSupport.HTTPRangeInterface, + ) + meta_type='Blob File' + + security = ClassSecurityInfo() + security.declareObjectProtected(View) + + precondition='' + size=None + + manage_editForm =DTMLFile('dtml/fileEdit',globals(), + Kind='File',kind='file') + manage_editForm._setName('manage_editForm') + + security.declareProtected(view_management_screens, 'manage') + security.declareProtected(view_management_screens, 'manage_main') + manage=manage_main=manage_editForm + manage_uploadForm=manage_editForm + + manage_options=( + ( + {'label':'Edit', 'action':'manage_main', + 'help':('OFSP','File_Edit.stx')}, + {'label':'View', 'action':'', + 'help':('OFSP','File_View.stx')}, + ) + + PropertyManager.manage_options + + RoleManager.manage_options + + Item_w__name__.manage_options + + Cacheable.manage_options + ) + + _properties=({'id':'title', 'type': 'string'}, + {'id':'content_type', 'type':'string'}, + ) + + def __init__(self, id, title, file, content_type='', precondition=''): + self.__name__=id + self.title=title + self.precondition=precondition + self.uploaded_filename = cookId('', '', file)[0] + self.bdata = Blob() + + content_type=self._get_content_type(file, id, content_type) + self.update_data(file, content_type) + + security.declarePrivate('save') + def save(self, file): + bf = self.bdata.open('w') + bf.write(file.read()) + self.size = bf.tell() + bf.close() + + security.declarePrivate('open') + def open(self, mode='r'): + bf = self.bdata.open(mode) + return bf + + security.declarePrivate('updateSize') + def updateSize(self, size=None): + if size is None : + bf = self.open('r') + bf.seek(0,2) + self.size = bf.tell() + bf.close() + else : + self.size = size + + def _getLegacyData(self) : + warn("Accessing 'data' attribute may be inefficient with " + "this blob based file. You should refactor your product " + "by accessing data like: " + "f = self.open('r') " + "data = f.read()", + DeprecationWarning, stacklevel=2) + f = self.open() + data = f.read() + f.close() + return data + + def _setLegacyData(self, data) : + warn("Accessing 'data' attribute may be inefficient with " + "this blob based file. You should refactor your product " + "by accessing data like: " + "f = self.save(data)", + DeprecationWarning, stacklevel=2) + if isinstance(data, str) : + sio = StringIO() + sio.write(data) + sio.seek(0) + data = sio + self.save(data) + + data = property(_getLegacyData, _setLegacyData, + "Data Legacy attribute to ensure compatibility " + "with derived classes that access data by this way.") + + def id(self): + return self.__name__ + + def _if_modified_since_request_handler(self, REQUEST, RESPONSE): + # HTTP If-Modified-Since header handling: return True if + # we can handle this request by returning a 304 response + header=REQUEST.get_header('If-Modified-Since', None) + if header is not None: + header=header.split( ';')[0] + # Some proxies seem to send invalid date strings for this + # header. If the date string is not valid, we ignore it + # rather than raise an error to be generally consistent + # with common servers such as Apache (which can usually + # understand the screwy date string as a lucky side effect + # of the way they parse it). + # This happens to be what RFC2616 tells us to do in the face of an + # invalid date. + try: mod_since=long(DateTime(header).timeTime()) + except: mod_since=None + if mod_since is not None: + if self._p_mtime: + last_mod = long(self._p_mtime) + else: + last_mod = long(0) + if last_mod > 0 and last_mod <= mod_since: + RESPONSE.setHeader('Last-Modified', + rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', self.content_type) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + RESPONSE.setStatus(304) + return True + + def _range_request_handler(self, REQUEST, RESPONSE): + # HTTP Range header handling: return True if we've served a range + # chunk out of our data. + range = REQUEST.get_header('Range', None) + request_range = REQUEST.get_header('Request-Range', None) + if request_range is not None: + # Netscape 2 through 4 and MSIE 3 implement a draft version + # Later on, we need to serve a different mime-type as well. + range = request_range + if_range = REQUEST.get_header('If-Range', None) + if range is not None: + ranges = HTTPRangeSupport.parseRange(range) + + if if_range is not None: + # Only send ranges if the data isn't modified, otherwise send + # the whole object. Support both ETags and Last-Modified dates! + if len(if_range) > 1 and if_range[:2] == 'ts': + # ETag: + if if_range != self.http__etag(): + # Modified, so send a normal response. We delete + # the ranges, which causes us to skip to the 200 + # response. + ranges = None + else: + # Date + date = if_range.split( ';')[0] + try: mod_since=long(DateTime(date).timeTime()) + except: mod_since=None + if mod_since is not None: + if self._p_mtime: + last_mod = long(self._p_mtime) + else: + last_mod = long(0) + if last_mod > mod_since: + # Modified, so send a normal response. We delete + # the ranges, which causes us to skip to the 200 + # response. + ranges = None + + if ranges: + # Search for satisfiable ranges. + satisfiable = 0 + for start, end in ranges: + if start < self.size: + satisfiable = 1 + break + + if not satisfiable: + RESPONSE.setHeader('Content-Range', + 'bytes */%d' % self.size) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + RESPONSE.setHeader('Last-Modified', + rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', self.content_type) + RESPONSE.setHeader('Content-Length', self.size) + RESPONSE.setStatus(416) + return True + + ranges = HTTPRangeSupport.expandRanges(ranges, self.size) + + if len(ranges) == 1: + # Easy case, set extra header and return partial set. + start, end = ranges[0] + size = end - start + + RESPONSE.setHeader('Last-Modified', + rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', self.content_type) + RESPONSE.setHeader('Content-Length', size) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + RESPONSE.setHeader('Content-Range', + 'bytes %d-%d/%d' % (start, end - 1, self.size)) + RESPONSE.setStatus(206) # Partial content + + bf = self.open('r') + bf.seek(start) + RESPONSE.write(bf.read(size)) + bf.close() + return True + + else: + boundary = choose_boundary() + + # Calculate the content length + size = (8 + len(boundary) + # End marker length + len(ranges) * ( # Constant lenght per set + 49 + len(boundary) + len(self.content_type) + + len('%d' % self.size))) + for start, end in ranges: + # Variable length per set + size = (size + len('%d%d' % (start, end - 1)) + + end - start) + + + # Some clients implement an earlier draft of the spec, they + # will only accept x-byteranges. + draftprefix = (request_range is not None) and 'x-' or '' + + RESPONSE.setHeader('Content-Length', size) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + RESPONSE.setHeader('Last-Modified', + rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', + 'multipart/%sbyteranges; boundary=%s' % ( + draftprefix, boundary)) + RESPONSE.setStatus(206) # Partial content + + + bf = self.open('r') +# data = self.data +# # The Pdata map allows us to jump into the Pdata chain +# # arbitrarily during out-of-order range searching. +# pdata_map = {} +# pdata_map[0] = data + + for start, end in ranges: + RESPONSE.write('\r\n--%s\r\n' % boundary) + RESPONSE.write('Content-Type: %s\r\n' % + self.content_type) + RESPONSE.write( + 'Content-Range: bytes %d-%d/%d\r\n\r\n' % ( + start, end - 1, self.size)) + + + size = end - start + bf.seek(start) + RESPONSE.write(bf.read(size)) + + bf.close() + + RESPONSE.write('\r\n--%s--\r\n' % boundary) + return True + + security.declareProtected(View, 'index_html') + def index_html(self, REQUEST, RESPONSE): + """ + The default view of the contents of a File or Image. + + Returns the contents of the file or image. Also, sets the + Content-Type HTTP header to the objects content type. + """ + + if self._if_modified_since_request_handler(REQUEST, RESPONSE): + # we were able to handle this by returning a 304 + # unfortunately, because the HTTP cache manager uses the cache + # API, and because 304 responses are required to carry the Expires + # header for HTTP/1.1, we need to call ZCacheable_set here. + # This is nonsensical for caches other than the HTTP cache manager + # unfortunately. + self.ZCacheable_set(None) + return '' + + if self.precondition and hasattr(self, str(self.precondition)): + # Grab whatever precondition was defined and then + # execute it. The precondition will raise an exception + # if something violates its terms. + c=getattr(self, str(self.precondition)) + if hasattr(c,'isDocTemp') and c.isDocTemp: + c(REQUEST['PARENTS'][1],REQUEST) + else: + c() + + if self._range_request_handler(REQUEST, RESPONSE): + # we served a chunk of content in response to a range request. + return '' + + RESPONSE.setHeader('Last-Modified', rfc1123_date(self._p_mtime)) + RESPONSE.setHeader('Content-Type', self.content_type) + RESPONSE.setHeader('Content-Length', self.size) + RESPONSE.setHeader('Accept-Ranges', 'bytes') + + if self.ZCacheable_isCachingEnabled(): + result = self.ZCacheable_get(default=None) + if result is not None: + # We will always get None from RAMCacheManager and HTTP + # Accelerated Cache Manager but we will get + # something implementing the IStreamIterator interface + # from a "FileCacheManager" + return result + + self.ZCacheable_set(None) + + bf = self.open('r') + chunk = bf.read(CHUNK_SIZE) + while chunk : + RESPONSE.write(chunk) + chunk = bf.read(CHUNK_SIZE) + bf.close() + return '' + + security.declareProtected(View, 'view_image_or_file') + def view_image_or_file(self, URL1): + """ + The default view of the contents of the File or Image. + """ + raise Redirect, URL1 + + security.declareProtected(View, 'PrincipiaSearchSource') + def PrincipiaSearchSource(self): + """ Allow file objects to be searched. + """ + if self.content_type.startswith('text/'): + bf = self.open('r') + data = bf.read() + bf.close() + return data + return '' + + security.declarePrivate('update_data') + def update_data(self, file, content_type=None): + if isinstance(file, unicode): + raise TypeError('Data can only be str or file-like. ' + 'Unicode objects are expressly forbidden.') + elif isinstance(file, str) : + sio = StringIO() + sio.write(file) + sio.seek(0) + file = sio + + if content_type is not None: self.content_type=content_type + self.save(file) + self.ZCacheable_invalidate() + self.ZCacheable_set(None) + self.http__refreshEtag() + + security.declareProtected(change_images_and_files, 'manage_edit') + def manage_edit(self, title, content_type, precondition='', + filedata=None, REQUEST=None): + """ + Changes the title and content type attributes of the File or Image. + """ + if self.wl_isLocked(): + raise ResourceLockedError, "File is locked via WebDAV" + + self.title=str(title) + self.content_type=str(content_type) + if precondition: self.precondition=str(precondition) + elif self.precondition: del self.precondition + if filedata is not None: + self.update_data(filedata, content_type) + else: + self.ZCacheable_invalidate() + + notify(ObjectModifiedEvent(self)) + + if REQUEST: + message="Saved changes." + return self.manage_main(self,REQUEST,manage_tabs_message=message) + + security.declareProtected(change_images_and_files, 'manage_upload') + def manage_upload(self,file='',REQUEST=None): + """ + Replaces the current contents of the File or Image object with file. + + The file or images contents are replaced with the contents of 'file'. + """ + if self.wl_isLocked(): + raise ResourceLockedError, "File is locked via WebDAV" + + content_type=self._get_content_type(file, self.__name__, + 'application/octet-stream') + self.update_data(file, content_type) + notify(ObjectModifiedEvent(self)) + + if REQUEST: + message="Saved changes." + return self.manage_main(self,REQUEST,manage_tabs_message=message) + + def _get_content_type(self, file, id, content_type=None): + headers=getattr(file, 'headers', None) + if headers and headers.has_key('content-type'): + content_type=headers['content-type'] + else: + name = getattr(file, 'filename', self.uploaded_filename) or id + content_type, enc=guess_content_type(name, '', content_type) + return content_type + + security.declareProtected(delete_objects, 'DELETE') + + security.declareProtected(change_images_and_files, 'PUT') + def PUT(self, REQUEST, RESPONSE): + """Handle HTTP PUT requests""" + self.dav__init(REQUEST, RESPONSE) + self.dav__simpleifhandler(REQUEST, RESPONSE, refresh=1) + type=REQUEST.get_header('content-type', None) + + file=REQUEST['BODYFILE'] + + content_type = self._get_content_type(file, self.__name__, + type or self.content_type) + self.update_data(file, content_type) + + RESPONSE.setStatus(204) + return RESPONSE + + security.declareProtected(View, 'get_size') + def get_size(self): + """Get the size of a file or image. + + Returns the size of the file or image. + """ + size=self.size + if size is None : + bf = self.open('r') + bf.seek(0,2) + self.size = size = bf.tell() + bf.close() + return size + + # deprecated; use get_size! + getSize=get_size + + security.declareProtected(View, 'getContentType') + def getContentType(self): + """Get the content type of a file or image. + + Returns the content type (MIME type) of a file or image. + """ + return self.content_type + + + def __str__(self): return str(self.data) + def __len__(self): return 1 + + security.declareProtected(ftp_access, 'manage_FTPstat') + security.declareProtected(ftp_access, 'manage_FTPlist') + + security.declareProtected(ftp_access, 'manage_FTPget') + def manage_FTPget(self): + """Return body for ftp.""" + RESPONSE = self.REQUEST.RESPONSE + + if self.ZCacheable_isCachingEnabled(): + result = self.ZCacheable_get(default=None) + if result is not None: + # We will always get None from RAMCacheManager but we will get + # something implementing the IStreamIterator interface + # from FileCacheManager. + # the content-length is required here by HTTPResponse, even + # though FTP doesn't use it. + RESPONSE.setHeader('Content-Length', self.size) + return result + + bf = self.open('r') + data = bf.read() + bf.close() + RESPONSE.setBase(None) + return data + +manage_addImageForm=DTMLFile('dtml/imageAdd',globals(), + Kind='Image',kind='image') +def manage_addImage(self, id, file, title='', precondition='', content_type='', + REQUEST=None): + """ + Add a new Image object. + + Creates a new Image object 'id' with the contents of 'file'. + """ + + id=str(id) + title=str(title) + content_type=str(content_type) + precondition=str(precondition) + + id, title = cookId(id, title, file) + + self=self.this() + self._setObject(id, Image(id,title,file,content_type, precondition)) + + newFile = self._getOb(id) + notify(ObjectCreatedEvent(newFile)) + + if REQUEST is not None: + try: url=self.DestinationURL() + except: url=REQUEST['URL1'] + REQUEST.RESPONSE.redirect('%s/manage_main' % url) + return id + + +def getImageInfo(file): + height = -1 + width = -1 + content_type = '' + + # handle GIFs + data = file.read(24) + size = len(data) + if (size >= 10) and data[:6] in ('GIF87a', 'GIF89a'): + # Check to see if content_type is correct + content_type = 'image/gif' + w, h = struct.unpack("= 24) and (data[:8] == '\211PNG\r\n\032\n') + and (data[12:16] == 'IHDR')): + content_type = 'image/png' + w, h = struct.unpack(">LL", data[16:24]) + width = int(w) + height = int(h) + + # Maybe this is for an older PNG version. + elif (size >= 16) and (data[:8] == '\211PNG\r\n\032\n'): + # Check to see if we have the right content type + content_type = 'image/png' + w, h = struct.unpack(">LL", data[8:16]) + width = int(w) + height = int(h) + + # handle JPEGs + elif (size >= 2) and (data[:2] == '\377\330'): + content_type = 'image/jpeg' + jpeg = file + jpeg.seek(0) + jpeg.read(2) + b = jpeg.read(1) + try: + while (b and ord(b) != 0xDA): + while (ord(b) != 0xFF): b = jpeg.read(1) + while (ord(b) == 0xFF): b = jpeg.read(1) + if (ord(b) >= 0xC0 and ord(b) <= 0xC3): + jpeg.read(3) + h, w = struct.unpack(">HH", jpeg.read(4)) + break + else: + jpeg.read(int(struct.unpack(">H", jpeg.read(2))[0])-2) + b = jpeg.read(1) + width = int(w) + height = int(h) + except: pass + + return content_type, width, height + + +class Image(File): + """Image objects can be GIF, PNG or JPEG and have the same methods + as File objects. Images also have a string representation that + renders an HTML 'IMG' tag. + """ + meta_type='Blob Image' + + security = ClassSecurityInfo() + security.declareObjectProtected(View) + + alt='' + height='' + width='' + + # FIXME: Redundant, already in base class + security.declareProtected(change_images_and_files, 'manage_edit') + security.declareProtected(change_images_and_files, 'manage_upload') + security.declareProtected(change_images_and_files, 'PUT') + security.declareProtected(View, 'index_html') + security.declareProtected(View, 'get_size') + security.declareProtected(View, 'getContentType') + security.declareProtected(ftp_access, 'manage_FTPstat') + security.declareProtected(ftp_access, 'manage_FTPlist') + security.declareProtected(ftp_access, 'manage_FTPget') + security.declareProtected(delete_objects, 'DELETE') + + _properties=({'id':'title', 'type': 'string'}, + {'id':'alt', 'type':'string'}, + {'id':'content_type', 'type':'string','mode':'w'}, + {'id':'height', 'type':'string'}, + {'id':'width', 'type':'string'}, + ) + + manage_options=( + ({'label':'Edit', 'action':'manage_main', + 'help':('OFSP','Image_Edit.stx')}, + {'label':'View', 'action':'view_image_or_file', + 'help':('OFSP','Image_View.stx')},) + + PropertyManager.manage_options + + RoleManager.manage_options + + Item_w__name__.manage_options + + Cacheable.manage_options + ) + + manage_editForm =DTMLFile('dtml/imageEdit',globals(), + Kind='Image',kind='image') + manage_editForm._setName('manage_editForm') + + security.declareProtected(View, 'view_image_or_file') + view_image_or_file =DTMLFile('dtml/imageView',globals()) + + security.declareProtected(view_management_screens, 'manage') + security.declareProtected(view_management_screens, 'manage_main') + manage=manage_main=manage_editForm + manage_uploadForm=manage_editForm + + security.declarePrivate('update_data') + def update_data(self, file, content_type=None): + super(Image, self).update_data(file, content_type) + self.updateFormat(size=self.size, content_type=content_type) + + security.declarePrivate('updateFormat') + def updateFormat(self, size=None, dimensions=None, content_type=None): + self.updateSize(size=size) + + if dimensions is None or content_type is None : + bf = self.open('r') + ct, width, height = getImageInfo(bf) + bf.close() + if ct: + content_type = ct + if width >= 0 and height >= 0: + self.width = width + self.height = height + + # Now we should have the correct content type, or still None + if content_type is not None: self.content_type = content_type + else : + self.width, self.height = dimensions + self.content_type = content_type + + def __str__(self): + return self.tag() + + security.declareProtected(View, 'tag') + def tag(self, height=None, width=None, alt=None, + scale=0, xscale=0, yscale=0, css_class=None, title=None, **args): + """ + Generate an HTML IMG tag for this image, with customization. + Arguments to self.tag() can be any valid attributes of an IMG tag. + 'src' will always be an absolute pathname, to prevent redundant + downloading of images. Defaults are applied intelligently for + 'height', 'width', and 'alt'. If specified, the 'scale', 'xscale', + and 'yscale' keyword arguments will be used to automatically adjust + the output height and width values of the image tag. + + Since 'class' is a Python reserved word, it cannot be passed in + directly in keyword arguments which is a problem if you are + trying to use 'tag()' to include a CSS class. The tag() method + will accept a 'css_class' argument that will be converted to + 'class' in the output tag to work around this. + """ + if height is None: height=self.height + if width is None: width=self.width + + # Auto-scaling support + xdelta = xscale or scale + ydelta = yscale or scale + + if xdelta and width: + width = str(int(round(int(width) * xdelta))) + if ydelta and height: + height = str(int(round(int(height) * ydelta))) + + result='' % result + + +def cookId(id, title, file): + if not id and hasattr(file,'filename'): + filename=file.filename + title=title or filename + id=filename[max(filename.rfind('/'), + filename.rfind('\\'), + filename.rfind(':'), + )+1:] + return id, title