MoinMoin/action/AttachFile.py
author Thomas Waldmann <tw AT waldmann-edv DOT de>
Wed, 11 Feb 2009 02:34:33 +0100
changeset 4569 3caaa8c74c41
parent 4549 173c67aff3d6
child 4599 9275fb7a2bc2
permissions -rw-r--r--
wikiutil: replace moin's cgi/urllib wrappers by calls to werkzeug.utils code
     1 # -*- coding: iso-8859-1 -*-
     2 """
     3     MoinMoin - AttachFile action
     4 
     5     This action lets a page have multiple attachment files.
     6     It creates a folder <data>/pages/<pagename>/attachments
     7     and keeps everything in there.
     8 
     9     Form values: action=Attachment
    10     1. with no 'do' key: returns file upload form
    11     2. do=attach: accept file upload and saves the file in
    12        ../attachment/pagename/
    13     3. /pagename/fname?action=Attachment&do=get[&mimetype=type]:
    14        return contents of the attachment file with the name fname.
    15     4. /pathname/fname, do=view[&mimetype=type]:create a page
    16        to view the content of the file
    17 
    18     To link to an attachment, use [[attachment:file.txt]],
    19     to embed an attachment, use {{attachment:file.png}}.
    20 
    21     @copyright: 2001 by Ken Sugino (sugino@mediaone.net),
    22                 2001-2004 by Juergen Hermann <jh@web.de>,
    23                 2005 MoinMoin:AlexanderSchremmer,
    24                 2005 DiegoOngaro at ETSZONE (diego@etszone.com),
    25                 2005-2007 MoinMoin:ReimarBauer,
    26                 2007-2008 MoinMoin:ThomasWaldmann
    27     @license: GNU GPL, see COPYING for details.
    28 """
    29 
    30 import os, time, zipfile, mimetypes, errno, datetime
    31 from StringIO import StringIO
    32 
    33 from MoinMoin import log
    34 logging = log.getLogger(__name__)
    35 
    36 from MoinMoin import config, wikiutil, packages
    37 from MoinMoin.Page import Page
    38 from MoinMoin.util import filesys, timefuncs
    39 from MoinMoin.security.textcha import TextCha
    40 from MoinMoin.events import FileAttachedEvent, send_event
    41 from MoinMoin.support import tarfile
    42 
    43 action_name = __name__.split('.')[-1]
    44 
    45 #############################################################################
    46 ### External interface - these are called from the core code
    47 #############################################################################
    48 
    49 class AttachmentAlreadyExists(Exception):
    50     pass
    51 
    52 
    53 def getBasePath(request):
    54     """ Get base path where page dirs for attachments are stored. """
    55     return request.rootpage.getPagePath('pages')
    56 
    57 
    58 def getAttachDir(request, pagename, create=0):
    59     """ Get directory where attachments for page `pagename` are stored. """
    60     if request.page and pagename == request.page.page_name:
    61         page = request.page # reusing existing page obj is faster
    62     else:
    63         page = Page(request, pagename)
    64     return page.getPagePath("attachments", check_create=create)
    65 
    66 
    67 def absoluteName(url, pagename):
    68     """ Get (pagename, filename) of an attachment: link
    69         @param url: PageName/filename.ext or filename.ext (unicode)
    70         @param pagename: name of the currently processed page (unicode)
    71         @rtype: tuple of unicode
    72         @return: PageName, filename.ext
    73     """
    74     url = wikiutil.AbsPageName(pagename, url)
    75     pieces = url.split(u'/')
    76     if len(pieces) == 1:
    77         return pagename, pieces[0]
    78     else:
    79         return u"/".join(pieces[:-1]), pieces[-1]
    80 
    81 def getAttachUrl(pagename, filename, request, addts=0, escaped=0, do='get', drawing='', upload=False):
    82     """ Get URL that points to attachment `filename` of page `pagename`. """
    83     if upload:
    84         if not drawing:
    85             url = request.href(pagename, rename=wikiutil.taintfilename(filename),
    86                                action=action_name)
    87         else:
    88             url = request.href(pagename, rename=wikiutil.taintfilename(filename),
    89                                drawing=drawing, action=action_name)
    90     else:
    91         if not drawing:
    92             url = request.href(pagename, target=filename, action=action_name, do=do)
    93         else:
    94             url = request.href(pagename, drawing=drawing, action=action_name)
    95     return url
    96 
    97 
    98 def getIndicator(request, pagename):
    99     """ Get an attachment indicator for a page (linked clip image) or
   100         an empty string if not attachments exist.
   101     """
   102     _ = request.getText
   103     attach_dir = getAttachDir(request, pagename)
   104     if not os.path.exists(attach_dir):
   105         return ''
   106 
   107     files = os.listdir(attach_dir)
   108     if not files:
   109         return ''
   110 
   111     fmt = request.formatter
   112     attach_count = _('[%d attachments]') % len(files)
   113     attach_icon = request.theme.make_icon('attach', vars={'attach_count': attach_count})
   114     attach_link = (fmt.url(1, request.href(pagename, action=action_name), rel='nofollow') +
   115                    attach_icon +
   116                    fmt.url(0))
   117     return attach_link
   118 
   119 
   120 def getFilename(request, pagename, filename):
   121     """ make complete pathfilename of file "name" attached to some page "pagename"
   122         @param request: request object
   123         @param pagename: name of page where the file is attached to (unicode)
   124         @param filename: filename of attached file (unicode)
   125         @rtype: string (in config.charset encoding)
   126         @return: complete path/filename of attached file
   127     """
   128     if isinstance(filename, unicode):
   129         filename = filename.encode(config.charset)
   130     return os.path.join(getAttachDir(request, pagename, create=1), filename)
   131 
   132 
   133 def exists(request, pagename, filename):
   134     """ check if page <pagename> has a file <filename> attached """
   135     fpath = getFilename(request, pagename, filename)
   136     return os.path.exists(fpath)
   137 
   138 
   139 def size(request, pagename, filename):
   140     """ return file size of file attachment """
   141     fpath = getFilename(request, pagename, filename)
   142     return os.path.getsize(fpath)
   143 
   144 
   145 def info(pagename, request):
   146     """ Generate snippet with info on the attachment for page `pagename`. """
   147     _ = request.getText
   148 
   149     attach_dir = getAttachDir(request, pagename)
   150     files = []
   151     if os.path.isdir(attach_dir):
   152         files = os.listdir(attach_dir)
   153     page = Page(request, pagename)
   154     link = page.url(request, {'action': action_name})
   155     attach_info = _('There are <a href="%(link)s">%(count)s attachment(s)</a> stored for this page.') % {
   156         'count': len(files),
   157         'link': wikiutil.escape(link)
   158         }
   159     return "\n<p>\n%s\n</p>\n" % attach_info
   160 
   161 
   162 def _write_stream(content, stream, bufsize=8192):
   163     if hasattr(content, 'read'): # looks file-like
   164         import shutil
   165         shutil.copyfileobj(content, stream, bufsize)
   166     elif isinstance(content, str):
   167         stream.write(content)
   168     else:
   169         logging.error("unsupported content object: %r" % content)
   170         raise
   171 
   172 def add_attachment(request, pagename, target, filecontent, overwrite=0):
   173     """ save <filecontent> to an attachment <target> of page <pagename>
   174 
   175         filecontent can be either a str (in memory file content),
   176         or an open file object (file content in e.g. a tempfile).
   177     """
   178     _ = request.getText
   179 
   180     # replace illegal chars
   181     target = wikiutil.taintfilename(target)
   182 
   183     # get directory, and possibly create it
   184     attach_dir = getAttachDir(request, pagename, create=1)
   185     # save file
   186     fpath = os.path.join(attach_dir, target).encode(config.charset)
   187     exists = os.path.exists(fpath)
   188     if exists and not overwrite:
   189         raise AttachmentAlreadyExists
   190     else:
   191         if exists:
   192             try:
   193                 os.remove(fpath)
   194             except:
   195                 pass
   196         stream = open(fpath, 'wb')
   197         try:
   198             _write_stream(filecontent, stream)
   199         finally:
   200             stream.close()
   201 
   202         _addLogEntry(request, 'ATTNEW', pagename, target)
   203 
   204         filesize = os.path.getsize(fpath)
   205         event = FileAttachedEvent(request, pagename, target, filesize)
   206         send_event(event)
   207 
   208     return target, filesize
   209 
   210 
   211 #############################################################################
   212 ### Internal helpers
   213 #############################################################################
   214 
   215 def _addLogEntry(request, action, pagename, filename):
   216     """ Add an entry to the edit log on uploads and deletes.
   217 
   218         `action` should be "ATTNEW" or "ATTDEL"
   219     """
   220     from MoinMoin.logfile import editlog
   221     t = wikiutil.timestamp2version(time.time())
   222     fname = wikiutil.url_quote(filename)
   223 
   224     # Write to global log
   225     log = editlog.EditLog(request)
   226     log.add(request, t, 99999999, action, pagename, request.remote_addr, fname)
   227 
   228     # Write to local log
   229     log = editlog.EditLog(request, rootpagename=pagename)
   230     log.add(request, t, 99999999, action, pagename, request.remote_addr, fname)
   231 
   232 
   233 def _access_file(pagename, request):
   234     """ Check form parameter `target` and return a tuple of
   235         `(pagename, filename, filepath)` for an existing attachment.
   236 
   237         Return `(pagename, None, None)` if an error occurs.
   238     """
   239     _ = request.getText
   240 
   241     error = None
   242     if not request.values.get('target'):
   243         error = _("Filename of attachment not specified!")
   244     else:
   245         filename = wikiutil.taintfilename(request.values['target'])
   246         fpath = getFilename(request, pagename, filename)
   247 
   248         if os.path.isfile(fpath):
   249             return (pagename, filename, fpath)
   250         error = _("Attachment '%(filename)s' does not exist!") % {'filename': filename}
   251 
   252     error_msg(pagename, request, error)
   253     return (pagename, None, None)
   254 
   255 
   256 def _build_filelist(request, pagename, showheader, readonly, mime_type='*'):
   257     _ = request.getText
   258     fmt = request.html_formatter
   259 
   260     # access directory
   261     attach_dir = getAttachDir(request, pagename)
   262     files = _get_files(request, pagename)
   263 
   264     if mime_type != '*':
   265         files = [fname for fname in files if mime_type == mimetypes.guess_type(fname)[0]]
   266 
   267     html = []
   268     if files:
   269         if showheader:
   270             html.append(fmt.rawHTML(_(
   271                 "To refer to attachments on a page, use '''{{{attachment:filename}}}''', \n"
   272                 "as shown below in the list of files. \n"
   273                 "Do '''NOT''' use the URL of the {{{[get]}}} link, \n"
   274                 "since this is subject to change and can break easily.",
   275                 wiki=True
   276             )))
   277 
   278         label_del = _("del")
   279         label_move = _("move")
   280         label_get = _("get")
   281         label_edit = _("edit")
   282         label_view = _("view")
   283         label_unzip = _("unzip")
   284         label_install = _("install")
   285 
   286         html.append(fmt.bullet_list(1))
   287         for file in files:
   288             mt = wikiutil.MimeType(filename=file)
   289             fullpath = os.path.join(attach_dir, file).encode(config.charset)
   290             st = os.stat(fullpath)
   291             base, ext = os.path.splitext(file)
   292             parmdict = {'file': wikiutil.escape(file),
   293                         'fsize': "%.1f" % (float(st.st_size) / 1024),
   294                         'fmtime': request.user.getFormattedDateTime(st.st_mtime),
   295                        }
   296 
   297             links = []
   298             may_delete = request.user.may.delete(pagename)
   299             if may_delete and not readonly:
   300                 links.append(fmt.url(1, getAttachUrl(pagename, file, request, do='del')) +
   301                              fmt.text(label_del) +
   302                              fmt.url(0))
   303 
   304             if may_delete and not readonly:
   305                 links.append(fmt.url(1, getAttachUrl(pagename, file, request, do='move')) +
   306                              fmt.text(label_move) +
   307                              fmt.url(0))
   308 
   309             links.append(fmt.url(1, getAttachUrl(pagename, file, request)) +
   310                          fmt.text(label_get) +
   311                          fmt.url(0))
   312 
   313             if ext == '.tdraw':
   314                 links.append(fmt.url(1, getAttachUrl(pagename, file, request, drawing=base)) +
   315                              fmt.text(label_edit) +
   316                              fmt.url(0))
   317             else:
   318                 links.append(fmt.url(1, getAttachUrl(pagename, file, request, do='view')) +
   319                              fmt.text(label_view) +
   320                              fmt.url(0))
   321 
   322             try:
   323                 is_zipfile = zipfile.is_zipfile(fullpath)
   324                 if is_zipfile:
   325                     is_package = packages.ZipPackage(request, fullpath).isPackage()
   326                     if is_package and request.user.isSuperUser():
   327                         links.append(fmt.url(1, getAttachUrl(pagename, file, request, do='install')) +
   328                                      fmt.text(label_install) +
   329                                      fmt.url(0))
   330                     elif (not is_package and mt.minor == 'zip' and
   331                           may_delete and
   332                           request.user.may.read(pagename) and
   333                           request.user.may.write(pagename)):
   334                         links.append(fmt.url(1, getAttachUrl(pagename, file, request, do='unzip')) +
   335                                      fmt.text(label_unzip) +
   336                                      fmt.url(0))
   337             except RuntimeError:
   338                 # We don't want to crash with a traceback here (an exception
   339                 # here could be caused by an uploaded defective zip file - and
   340                 # if we crash here, the user does not get a UI to remove the
   341                 # defective zip file again).
   342                 # RuntimeError is raised by zipfile stdlib module in case of
   343                 # problems (like inconsistent slash and backslash usage in the
   344                 # archive).
   345                 logging.exception("An exception within zip file attachment handling occurred:")
   346 
   347             html.append(fmt.listitem(1))
   348             html.append("[%s]" % "&nbsp;| ".join(links))
   349             html.append(" (%(fmtime)s, %(fsize)s KB) [[attachment:%(file)s]]" % parmdict)
   350             html.append(fmt.listitem(0))
   351         html.append(fmt.bullet_list(0))
   352 
   353     else:
   354         if showheader:
   355             html.append(fmt.paragraph(1))
   356             html.append(fmt.text(_("No attachments stored for %(pagename)s") % {
   357                                    'pagename': pagename}))
   358             html.append(fmt.paragraph(0))
   359 
   360     return ''.join(html)
   361 
   362 
   363 def _get_files(request, pagename):
   364     attach_dir = getAttachDir(request, pagename)
   365     if os.path.isdir(attach_dir):
   366         files = [fn.decode(config.charset) for fn in os.listdir(attach_dir)]
   367         files.sort()
   368     else:
   369         files = []
   370     return files
   371 
   372 
   373 def _get_filelist(request, pagename):
   374     return _build_filelist(request, pagename, 1, 0)
   375 
   376 
   377 def error_msg(pagename, request, msg):
   378     request.theme.add_msg(msg, "error")
   379     Page(request, pagename).send_page()
   380 
   381 
   382 #############################################################################
   383 ### Create parts of the Web interface
   384 #############################################################################
   385 
   386 def send_link_rel(request, pagename):
   387     files = _get_files(request, pagename)
   388     for fname in files:
   389         url = getAttachUrl(pagename, fname, request, do='view', escaped=1)
   390         request.write(u'<link rel="Appendix" title="%s" href="%s">\n' % (
   391                       wikiutil.escape(fname, 1), url))
   392 
   393 
   394 def send_hotdraw(pagename, request):
   395     _ = request.getText
   396 
   397     now = time.time()
   398     pubpath = request.cfg.url_prefix_static + "/applets/TWikiDrawPlugin"
   399     basename = request.values['drawing']
   400     ci = ContainerItem(request, pagename, basename + '.tdraw')
   401     drawpath = ci.member_url(basename + '.draw')
   402     pngpath = ci.member_url(basename + '.png')
   403     pagelink = request.href(pagename, action=action_name, ts=now)
   404     helplink = Page(request, "HelpOnActions/AttachFile").url(request)
   405     savelink = request.href(pagename, action=action_name, do='savedrawing')
   406     #savelink = Page(request, pagename).url(request) # XXX include target filename param here for twisted
   407                                            # request, {'savename': request.values['drawing']+'.draw'}
   408     #savelink = '/cgi-bin/dumpform.bat'
   409 
   410     timestamp = '&amp;ts=%s' % now
   411 
   412     request.write('<h2>' + _("Edit drawing") + '</h2>')
   413     request.write("""
   414 <p>
   415 <img src="%(pngpath)s%(timestamp)s">
   416 <applet code="CH.ifa.draw.twiki.TWikiDraw.class"
   417         archive="%(pubpath)s/twikidraw.jar" width="640" height="480">
   418 <param name="drawpath" value="%(drawpath)s">
   419 <param name="pngpath"  value="%(pngpath)s">
   420 <param name="savepath" value="%(savelink)s">
   421 <param name="basename" value="%(basename)s">
   422 <param name="viewpath" value="%(pagelink)s">
   423 <param name="helppath" value="%(helplink)s">
   424 <strong>NOTE:</strong> You need a Java enabled browser to edit the drawing example.
   425 </applet>
   426 </p>""" % {
   427     'pngpath': pngpath, 'timestamp': timestamp,
   428     'pubpath': pubpath, 'drawpath': drawpath,
   429     'savelink': savelink, 'pagelink': pagelink, 'helplink': helplink,
   430     'basename': wikiutil.escape(basename, 1),
   431 })
   432 
   433 
   434 def send_uploadform(pagename, request):
   435     """ Send the HTML code for the list of already stored attachments and
   436         the file upload form.
   437     """
   438     _ = request.getText
   439 
   440     if not request.user.may.read(pagename):
   441         request.write('<p>%s</p>' % _('You are not allowed to view this page.'))
   442         return
   443 
   444     writeable = request.user.may.write(pagename)
   445 
   446     # First send out the upload new attachment form on top of everything else.
   447     # This avoids usability issues if you have to scroll down a lot to upload
   448     # a new file when the page already has lots of attachments:
   449     if writeable:
   450         request.write('<h2>' + _("New Attachment") + '</h2>')
   451         request.write("""
   452 <form action="%(url)s" method="POST" enctype="multipart/form-data">
   453 <dl>
   454 <dt>%(upload_label_file)s</dt>
   455 <dd><input type="file" name="file" size="50"></dd>
   456 <dt>%(upload_label_rename)s</dt>
   457 <dd><input type="text" name="rename" size="50" value="%(rename)s"></dd>
   458 <dt>%(upload_label_overwrite)s</dt>
   459 <dd><input type="checkbox" name="overwrite" value="1" %(overwrite_checked)s></dd>
   460 </dl>
   461 %(textcha)s
   462 <p>
   463 <input type="hidden" name="action" value="%(action_name)s">
   464 <input type="hidden" name="do" value="upload">
   465 <input type="submit" value="%(upload_button)s">
   466 </p>
   467 </form>
   468 """ % {
   469     'url': request.href(pagename),
   470     'action_name': action_name,
   471     'upload_label_file': _('File to upload'),
   472     'upload_label_rename': _('Rename to'),
   473     'rename': wikiutil.escape(request.form.get('rename', ''), 1),
   474     'upload_label_overwrite': _('Overwrite existing attachment of same name'),
   475     'overwrite_checked': ('', 'checked')[request.form.get('overwrite', '0') == '1'],
   476     'upload_button': _('Upload'),
   477     'textcha': TextCha(request).render(),
   478 })
   479 
   480     request.write('<h2>' + _("Attached Files") + '</h2>')
   481     request.write(_get_filelist(request, pagename))
   482 
   483     if not writeable:
   484         request.write('<p>%s</p>' % _('You are not allowed to attach a file to this page.'))
   485 
   486     if writeable and request.values.get('drawing'):
   487         send_hotdraw(pagename, request)
   488 
   489 
   490 #############################################################################
   491 ### Web interface for file upload, viewing and deletion
   492 #############################################################################
   493 
   494 def execute(pagename, request):
   495     """ Main dispatcher for the 'AttachFile' action. """
   496     _ = request.getText
   497 
   498     do = request.values.get('do', 'upload_form')
   499     handler = globals().get('_do_%s' % do)
   500     if handler:
   501         msg = handler(pagename, request)
   502     else:
   503         msg = _('Unsupported AttachFile sub-action: %s') % wikiutil.escape(do)
   504     if msg:
   505         error_msg(pagename, request, msg)
   506 
   507 
   508 def _do_upload_form(pagename, request):
   509     upload_form(pagename, request)
   510 
   511 
   512 def upload_form(pagename, request, msg=''):
   513     _ = request.getText
   514 
   515     # Use user interface language for this generated page
   516     request.setContentLanguage(request.lang)
   517     request.theme.add_msg(msg, "dialog")
   518     request.theme.send_title(_('Attachments for "%(pagename)s"') % {'pagename': pagename}, pagename=pagename)
   519     request.write('<div id="content">\n') # start content div
   520     send_uploadform(pagename, request)
   521     request.write('</div>\n') # end content div
   522     request.theme.send_footer(pagename)
   523     request.theme.send_closing_html()
   524 
   525 
   526 def preprocess_filename(filename):
   527     """ preprocess the filename we got from upload form,
   528         strip leading drive and path (IE misbehaviour)
   529     """
   530     if filename and len(filename) > 1 and (filename[1] == ':' or filename[0] == '\\'): # C:.... or \path... or \\server\...
   531         bsindex = filename.rfind('\\')
   532         if bsindex >= 0:
   533             filename = filename[bsindex+1:]
   534     return filename
   535 
   536 
   537 def _do_upload(pagename, request):
   538     _ = request.getText
   539     # Currently we only check TextCha for upload (this is what spammers ususally do),
   540     # but it could be extended to more/all attachment write access
   541     if not TextCha(request).check_answer_from_form():
   542         return _('TextCha: Wrong answer! Go back and try again...')
   543 
   544     form = request.form
   545 
   546     file_upload = request.files.get('file')
   547     if not file_upload:
   548         # This might happen when trying to upload file names
   549         # with non-ascii characters on Safari.
   550         return _("No file content. Delete non ASCII characters from the file name and try again.")
   551 
   552     try:
   553         overwrite = int(form.get('overwrite', '0'))
   554     except:
   555         overwrite = 0
   556 
   557     if not request.user.may.write(pagename):
   558         return _('You are not allowed to attach a file to this page.')
   559 
   560     if overwrite and not request.user.may.delete(pagename):
   561         return _('You are not allowed to overwrite a file attachment of this page.')
   562 
   563     rename = form.get('rename', u'').strip()
   564     if rename:
   565         target = rename
   566     else:
   567         target = file_upload.filename
   568 
   569     target = wikiutil.clean_input(target)
   570 
   571     if not target:
   572         return _("Filename of attachment not specified!")
   573 
   574     # add the attachment
   575     try:
   576         target, bytes = add_attachment(request, pagename, target, file_upload.stream, overwrite=overwrite)
   577         msg = _("Attachment '%(target)s' (remote name '%(filename)s')"
   578                 " with %(bytes)d bytes saved.") % {
   579                 'target': target, 'filename': file_upload.filename, 'bytes': bytes}
   580     except AttachmentAlreadyExists:
   581         msg = _("Attachment '%(target)s' (remote name '%(filename)s') already exists.") % {
   582             'target': target, 'filename': file_upload.filename}
   583 
   584     # return attachment list
   585     upload_form(pagename, request, msg)
   586 
   587 
   588 class ContainerItem:
   589     """ A storage container (multiple objects in 1 tarfile) """
   590 
   591     def __init__(self, request, pagename, containername):
   592         self.request = request
   593         self.pagename = pagename
   594         self.containername = containername
   595         self.container_filename = getFilename(request, pagename, containername)
   596 
   597     def member_url(self, member):
   598         """ return URL for accessing container member
   599             (we use same URL for get (GET) and put (POST))
   600         """
   601         url = Page(self.request, self.pagename).url(self.request, {
   602             'action': 'AttachFile',
   603             'do': 'box',  # shorter to type than 'container'
   604             'target': self.containername,
   605             #'member': member,
   606         })
   607         return url + '&member=%s' % member
   608         # member needs to be last in qs because twikidraw looks for "file extension" at the end
   609 
   610     def get(self, member):
   611         """ return a file-like object with the member file data
   612         """
   613         tf = tarfile.TarFile(self.container_filename)
   614         return tf.extractfile(member)
   615 
   616     def put(self, member, content, content_length=None):
   617         """ save data into a container's member """
   618         tf = tarfile.TarFile(self.container_filename, mode='a')
   619         if isinstance(member, unicode):
   620             member = member.encode('utf-8')
   621         ti = tarfile.TarInfo(member)
   622         if isinstance(content, str):
   623             if content_length is None:
   624                 content_length = len(content)
   625             content = StringIO(content) # we need a file obj
   626         elif not hasattr(content, 'read'):
   627             logging.error("unsupported content object: %r" % content)
   628             raise
   629         assert content_length >= 0  # we don't want -1 interpreted as 4G-1
   630         ti.size = content_length
   631         tf.addfile(ti, content)
   632         tf.close()
   633 
   634     def truncate(self):
   635         f = open(self.container_filename, 'w')
   636         f.close()
   637 
   638     def exists(self):
   639         return os.path.exists(self.container_filename)
   640 
   641 def _do_savedrawing(pagename, request):
   642     _ = request.getText
   643 
   644     if not request.user.may.write(pagename):
   645         return _('You are not allowed to save a drawing on this page.')
   646 
   647     file_upload = request.files.get('filepath')
   648     if not file_upload:
   649         # This might happen when trying to upload file names
   650         # with non-ascii characters on Safari.
   651         return _("No file content. Delete non ASCII characters from the file name and try again.")
   652 
   653     filename = request.form['filename']
   654     basepath, basename = os.path.split(filename)
   655     basename, ext = os.path.splitext(basename)
   656 
   657     ci = ContainerItem(request, pagename, basename + '.tdraw')
   658     filecontent = file_upload.stream
   659     content_length = None
   660     if ext == '.draw': # TWikiDraw POSTs this first
   661         _addLogEntry(request, 'ATTDRW', pagename, basename + '.tdraw')
   662         ci.truncate()
   663         filecontent = filecontent.read() # read file completely into memory
   664         filecontent = filecontent.replace("\r", "")
   665     elif ext == '.map':
   666         # touch attachment directory to invalidate cache if new map is saved
   667         attach_dir = getAttachDir(request, pagename)
   668         os.utime(attach_dir, None)
   669         filecontent = filecontent.read() # read file completely into memory
   670         filecontent = filecontent.strip()
   671     else:
   672         #content_length = file_upload.content_length
   673         # XXX gives -1 for wsgiref :( If this is fixed, we could use the file obj,
   674         # without reading it into memory completely:
   675         filecontent = filecontent.read()
   676 
   677     ci.put(basename + ext, filecontent, content_length)
   678 
   679     request.write("OK")
   680 
   681 
   682 def _do_del(pagename, request):
   683     _ = request.getText
   684 
   685     pagename, filename, fpath = _access_file(pagename, request)
   686     if not request.user.may.delete(pagename):
   687         return _('You are not allowed to delete attachments on this page.')
   688     if not filename:
   689         return # error msg already sent in _access_file
   690 
   691     # delete file
   692     os.remove(fpath)
   693     _addLogEntry(request, 'ATTDEL', pagename, filename)
   694 
   695     if request.cfg.xapian_search:
   696         from MoinMoin.search.Xapian import Index
   697         index = Index(request)
   698         if index.exists:
   699             index.remove_item(pagename, filename)
   700 
   701     upload_form(pagename, request, msg=_("Attachment '%(filename)s' deleted.") % {'filename': filename})
   702 
   703 
   704 def move_file(request, pagename, new_pagename, attachment, new_attachment):
   705     _ = request.getText
   706 
   707     newpage = Page(request, new_pagename)
   708     if newpage.exists(includeDeleted=1) and request.user.may.write(new_pagename) and request.user.may.delete(pagename):
   709         new_attachment_path = os.path.join(getAttachDir(request, new_pagename,
   710                               create=1), new_attachment).encode(config.charset)
   711         attachment_path = os.path.join(getAttachDir(request, pagename),
   712                           attachment).encode(config.charset)
   713 
   714         if os.path.exists(new_attachment_path):
   715             upload_form(pagename, request,
   716                 msg=_("Attachment '%(new_pagename)s/%(new_filename)s' already exists.") % {
   717                     'new_pagename': new_pagename,
   718                     'new_filename': new_attachment})
   719             return
   720 
   721         if new_attachment_path != attachment_path:
   722             # move file
   723             filesys.rename(attachment_path, new_attachment_path)
   724             _addLogEntry(request, 'ATTDEL', pagename, attachment)
   725             _addLogEntry(request, 'ATTNEW', new_pagename, new_attachment)
   726             upload_form(pagename, request,
   727                         msg=_("Attachment '%(pagename)s/%(filename)s' moved to '%(new_pagename)s/%(new_filename)s'.") % {
   728                             'pagename': pagename,
   729                             'filename': attachment,
   730                             'new_pagename': new_pagename,
   731                             'new_filename': new_attachment})
   732         else:
   733             upload_form(pagename, request, msg=_("Nothing changed"))
   734     else:
   735         upload_form(pagename, request, msg=_("Page '%(new_pagename)s' does not exist or you don't have enough rights.") % {
   736             'new_pagename': new_pagename})
   737 
   738 
   739 def _do_attachment_move(pagename, request):
   740     _ = request.getText
   741 
   742     if 'cancel' in request.form:
   743         return _('Move aborted!')
   744     if not wikiutil.checkTicket(request, request.form['ticket']):
   745         return _('Please use the interactive user interface to move attachments!')
   746     if not request.user.may.delete(pagename):
   747         return _('You are not allowed to move attachments from this page.')
   748 
   749     if 'newpagename' in request.form:
   750         new_pagename = request.form.get('newpagename')
   751     else:
   752         upload_form(pagename, request, msg=_("Move aborted because new page name is empty."))
   753     if 'newattachmentname' in request.form:
   754         new_attachment = request.form.get('newattachmentname')
   755         if new_attachment != wikiutil.taintfilename(new_attachment):
   756             upload_form(pagename, request, msg=_("Please use a valid filename for attachment '%(filename)s'.") % {
   757                                   'filename': new_attachment})
   758             return
   759     else:
   760         upload_form(pagename, request, msg=_("Move aborted because new attachment name is empty."))
   761 
   762     attachment = request.form.get('oldattachmentname')
   763     move_file(request, pagename, new_pagename, attachment, new_attachment)
   764 
   765 
   766 def _do_move(pagename, request):
   767     _ = request.getText
   768 
   769     pagename, filename, fpath = _access_file(pagename, request)
   770     if not request.user.may.delete(pagename):
   771         return _('You are not allowed to move attachments from this page.')
   772     if not filename:
   773         return # error msg already sent in _access_file
   774 
   775     # move file
   776     d = {'action': action_name,
   777          'url': request.href(pagename),
   778          'do': 'attachment_move',
   779          'ticket': wikiutil.createTicket(request),
   780          'pagename': pagename,
   781          'attachment_name': filename,
   782          'move': _('Move'),
   783          'cancel': _('Cancel'),
   784          'newname_label': _("New page name"),
   785          'attachment_label': _("New attachment name"),
   786         }
   787     formhtml = '''
   788 <form action="%(url)s" method="POST">
   789 <input type="hidden" name="action" value="%(action)s">
   790 <input type="hidden" name="do" value="%(do)s">
   791 <input type="hidden" name="ticket" value="%(ticket)s">
   792 <table>
   793     <tr>
   794         <td class="label"><label>%(newname_label)s</label></td>
   795         <td class="content">
   796             <input type="text" name="newpagename" value="%(pagename)s" size="80">
   797         </td>
   798     </tr>
   799     <tr>
   800         <td class="label"><label>%(attachment_label)s</label></td>
   801         <td class="content">
   802             <input type="text" name="newattachmentname" value="%(attachment_name)s" size="80">
   803         </td>
   804     </tr>
   805     <tr>
   806         <td></td>
   807         <td class="buttons">
   808             <input type="hidden" name="oldattachmentname" value="%(attachment_name)s">
   809             <input type="submit" name="move" value="%(move)s">
   810             <input type="submit" name="cancel" value="%(cancel)s">
   811         </td>
   812     </tr>
   813 </table>
   814 </form>''' % d
   815     thispage = Page(request, pagename)
   816     request.theme.add_msg(formhtml, "dialog")
   817     return thispage.send_page()
   818 
   819 
   820 def _do_box(pagename, request):
   821     _ = request.getText
   822 
   823     pagename, filename, fpath = _access_file(pagename, request)
   824     if not request.user.may.read(pagename):
   825         return _('You are not allowed to get attachments from this page.')
   826     if not filename:
   827         return # error msg already sent in _access_file
   828 
   829     timestamp = datetime.datetime.fromtimestamp(os.path.getmtime(fpath))
   830     if_modified = request.if_modified_since
   831     if if_modified and if_modified >= timestamp:
   832         request.status_code = 304
   833     else:
   834         ci = ContainerItem(request, pagename, filename)
   835         filename = wikiutil.taintfilename(request.values['member'])
   836         mt = wikiutil.MimeType(filename=filename)
   837         content_type = mt.content_type()
   838         mime_type = mt.mime_type()
   839 
   840         # TODO: fix the encoding here, plain 8 bit is not allowed according to the RFCs
   841         # There is no solution that is compatible to IE except stripping non-ascii chars
   842         filename_enc = filename.encode(config.charset)
   843 
   844         # for dangerous files (like .html), when we are in danger of cross-site-scripting attacks,
   845         # we just let the user store them to disk ('attachment').
   846         # For safe files, we directly show them inline (this also works better for IE).
   847         dangerous = mime_type in request.cfg.mimetypes_xss_protect
   848         content_dispo = dangerous and 'attachment' or 'inline'
   849 
   850         request.content_type = content_type
   851         request.last_modified = timestamp
   852         #request.content_length = os.path.getsize(fpath)
   853         content_dispo_string = '%s; filename="%s"' % (content_dispo, filename_enc)
   854         request.headers.add('Content-Disposition', content_dispo_string)
   855 
   856         # send data
   857         request.send_file(ci.get(filename))
   858 
   859 
   860 def _do_get(pagename, request):
   861     _ = request.getText
   862 
   863     pagename, filename, fpath = _access_file(pagename, request)
   864     if not request.user.may.read(pagename):
   865         return _('You are not allowed to get attachments from this page.')
   866     if not filename:
   867         return # error msg already sent in _access_file
   868 
   869     timestamp = datetime.datetime.fromtimestamp(os.path.getmtime(fpath))
   870     if_modified = request.if_modified_since
   871     if if_modified and if_modified >= timestamp:
   872         request.status_code = 304
   873     else:
   874         mt = wikiutil.MimeType(filename=filename)
   875         content_type = mt.content_type()
   876         mime_type = mt.mime_type()
   877 
   878         # TODO: fix the encoding here, plain 8 bit is not allowed according to the RFCs
   879         # There is no solution that is compatible to IE except stripping non-ascii chars
   880         filename_enc = filename.encode(config.charset)
   881 
   882         # for dangerous files (like .html), when we are in danger of cross-site-scripting attacks,
   883         # we just let the user store them to disk ('attachment').
   884         # For safe files, we directly show them inline (this also works better for IE).
   885         dangerous = mime_type in request.cfg.mimetypes_xss_protect
   886         content_dispo = dangerous and 'attachment' or 'inline'
   887 
   888         request.content_type = content_type
   889         request.last_modified = timestamp
   890         request.content_length = os.path.getsize(fpath)
   891         content_dispo_string = '%s; filename="%s"' % (content_dispo, filename_enc)
   892         request.headers.add('Content-Disposition', content_dispo_string)
   893 
   894         # send data
   895         request.send_file(open(fpath, 'rb'))
   896 
   897 
   898 def _do_install(pagename, request):
   899     _ = request.getText
   900 
   901     pagename, target, targetpath = _access_file(pagename, request)
   902     if not request.user.isSuperUser():
   903         return _('You are not allowed to install files.')
   904     if not target:
   905         return
   906 
   907     package = packages.ZipPackage(request, targetpath)
   908 
   909     if package.isPackage():
   910         if package.installPackage():
   911             msg = _("Attachment '%(filename)s' installed.") % {'filename': wikiutil.escape(target)}
   912         else:
   913             msg = _("Installation of '%(filename)s' failed.") % {'filename': wikiutil.escape(target)}
   914         if package.msg:
   915             msg += "<br><pre>%s</pre>" % wikiutil.escape(package.msg)
   916     else:
   917         msg = _('The file %s is not a MoinMoin package file.') % wikiutil.escape(target)
   918 
   919     upload_form(pagename, request, msg=msg)
   920 
   921 
   922 def _do_unzip(pagename, request, overwrite=False):
   923     _ = request.getText
   924     pagename, filename, fpath = _access_file(pagename, request)
   925 
   926     if not (request.user.may.delete(pagename) and request.user.may.read(pagename) and request.user.may.write(pagename)):
   927         return _('You are not allowed to unzip attachments of this page.')
   928 
   929     if not filename:
   930         return # error msg already sent in _access_file
   931 
   932     try:
   933         if not zipfile.is_zipfile(fpath):
   934             return _('The file %(filename)s is not a .zip file.') % {'filename': filename}
   935 
   936         # determine how which attachment names we have and how much space each is occupying
   937         curr_fsizes = dict([(f, size(request, pagename, f)) for f in _get_files(request, pagename)])
   938 
   939         # Checks for the existance of one common prefix path shared among
   940         # all files in the zip file. If this is the case, remove the common prefix.
   941         # We also prepare a dict of the new filenames->filesizes.
   942         zip_path_sep = '/'  # we assume '/' is as zip standard suggests
   943         fname_index = None
   944         mapping = []
   945         new_fsizes = {}
   946         zf = zipfile.ZipFile(fpath)
   947         for zi in zf.infolist():
   948             name = zi.filename
   949             if not name.endswith(zip_path_sep):  # a file (not a directory)
   950                 if fname_index is None:
   951                     fname_index = name.rfind(zip_path_sep) + 1
   952                     path = name[:fname_index]
   953                 if (name.rfind(zip_path_sep) + 1 != fname_index  # different prefix len
   954                     or
   955                     name[:fname_index] != path): # same len, but still different
   956                     mapping = []  # zip is not acceptable
   957                     break
   958                 if zi.file_size >= request.cfg.unzip_single_file_size:  # file too big
   959                     mapping = []  # zip is not acceptable
   960                     break
   961                 finalname = name[fname_index:]  # remove common path prefix
   962                 finalname = finalname.decode(config.charset, 'replace')  # replaces trash with \uFFFD char
   963                 mapping.append((name, finalname))
   964                 new_fsizes[finalname] = zi.file_size
   965 
   966         # now we either have an empty mapping (if the zip is not acceptable),
   967         # an identity mapping (no subdirs in zip, just all flat), or
   968         # a mapping (origname, finalname) where origname is the zip member filename
   969         # (including some prefix path) and finalname is a simple filename.
   970 
   971         # calculate resulting total file size / count after unzipping:
   972         if overwrite:
   973             curr_fsizes.update(new_fsizes)
   974             total = curr_fsizes
   975         else:
   976             new_fsizes.update(curr_fsizes)
   977             total = new_fsizes
   978         total_count = len(total)
   979         total_size = sum(total.values())
   980 
   981         if not mapping:
   982             msg = _("Attachment '%(filename)s' not unzipped because some files in the zip "
   983                     "are either not in the same directory or exceeded the single file size limit (%(maxsize_file)d kB)."
   984                    ) % {'filename': filename,
   985                         'maxsize_file': request.cfg.unzip_single_file_size / 1000, }
   986         elif total_size > request.cfg.unzip_attachments_space:
   987             msg = _("Attachment '%(filename)s' not unzipped because it would have exceeded "
   988                     "the per page attachment storage size limit (%(size)d kB).") % {
   989                         'filename': filename,
   990                         'size': request.cfg.unzip_attachments_space / 1000, }
   991         elif total_count > request.cfg.unzip_attachments_count:
   992             msg = _("Attachment '%(filename)s' not unzipped because it would have exceeded "
   993                     "the per page attachment count limit (%(count)d).") % {
   994                         'filename': filename,
   995                         'count': request.cfg.unzip_attachments_count, }
   996         else:
   997             not_overwritten = []
   998             for origname, finalname in mapping:
   999                 try:
  1000                     # Note: reads complete zip member file into memory. ZipFile does not offer block-wise reading:
  1001                     add_attachment(request, pagename, finalname, zf.read(origname), overwrite)
  1002                 except AttachmentAlreadyExists:
  1003                     not_overwritten.append(finalname)
  1004             if not_overwritten:
  1005                 msg = _("Attachment '%(filename)s' partially unzipped (did not overwrite: %(filelist)s).") % {
  1006                         'filename': filename,
  1007                         'filelist': ', '.join(not_overwritten), }
  1008             else:
  1009                 msg = _("Attachment '%(filename)s' unzipped.") % {'filename': filename}
  1010     except RuntimeError, err:
  1011         # We don't want to crash with a traceback here (an exception
  1012         # here could be caused by an uploaded defective zip file - and
  1013         # if we crash here, the user does not get a UI to remove the
  1014         # defective zip file again).
  1015         # RuntimeError is raised by zipfile stdlib module in case of
  1016         # problems (like inconsistent slash and backslash usage in the
  1017         # archive).
  1018         logging.exception("An exception within zip file attachment handling occurred:")
  1019         msg = _("A severe error occurred:") + ' ' + str(err)
  1020 
  1021     upload_form(pagename, request, msg=wikiutil.escape(msg))
  1022 
  1023 
  1024 def send_viewfile(pagename, request):
  1025     _ = request.getText
  1026     fmt = request.html_formatter
  1027 
  1028     pagename, filename, fpath = _access_file(pagename, request)
  1029     if not filename:
  1030         return
  1031 
  1032     request.write('<h2>' + _("Attachment '%(filename)s'") % {'filename': filename} + '</h2>')
  1033     # show a download link above the content
  1034     label = _('Download')
  1035     link = (fmt.url(1, getAttachUrl(pagename, filename, request, do='get'), css_class="download") +
  1036             fmt.text(label) +
  1037             fmt.url(0))
  1038     request.write('%s<br><br>' % link)
  1039 
  1040     mt = wikiutil.MimeType(filename=filename)
  1041 
  1042     # destinguishs if browser need a plugin in place
  1043     if mt.major == 'image' and mt.minor in config.browser_supported_images:
  1044         request.write('<img src="%s" alt="%s">' % (
  1045             getAttachUrl(pagename, filename, request, escaped=1),
  1046             wikiutil.escape(filename, 1)))
  1047         return
  1048     elif mt.major == 'text':
  1049         ext = os.path.splitext(filename)[1]
  1050         Parser = wikiutil.getParserForExtension(request.cfg, ext)
  1051         if Parser is not None:
  1052             try:
  1053                 content = file(fpath, 'r').read()
  1054                 content = wikiutil.decodeUnknownInput(content)
  1055                 colorizer = Parser(content, request, filename=filename)
  1056                 colorizer.format(request.formatter)
  1057                 return
  1058             except IOError:
  1059                 pass
  1060 
  1061         request.write(request.formatter.preformatted(1))
  1062         # If we have text but no colorizing parser we try to decode file contents.
  1063         content = open(fpath, 'r').read()
  1064         content = wikiutil.decodeUnknownInput(content)
  1065         content = wikiutil.escape(content)
  1066         request.write(request.formatter.text(content))
  1067         request.write(request.formatter.preformatted(0))
  1068         return
  1069 
  1070     try:
  1071         package = packages.ZipPackage(request, fpath)
  1072         if package.isPackage():
  1073             request.write("<pre><b>%s</b>\n%s</pre>" % (_("Package script:"), wikiutil.escape(package.getScript())))
  1074             return
  1075 
  1076         if zipfile.is_zipfile(fpath) and mt.minor == 'zip':
  1077             zf = zipfile.ZipFile(fpath, mode='r')
  1078             request.write("<pre>%-46s %19s %12s\n" % (_("File Name"), _("Modified")+" "*5, _("Size")))
  1079             for zinfo in zf.filelist:
  1080                 date = "%d-%02d-%02d %02d:%02d:%02d" % zinfo.date_time
  1081                 request.write(wikiutil.escape("%-46s %s %12d\n" % (zinfo.filename, date, zinfo.file_size)))
  1082             request.write("</pre>")
  1083             return
  1084     except RuntimeError:
  1085         # We don't want to crash with a traceback here (an exception
  1086         # here could be caused by an uploaded defective zip file - and
  1087         # if we crash here, the user does not get a UI to remove the
  1088         # defective zip file again).
  1089         # RuntimeError is raised by zipfile stdlib module in case of
  1090         # problems (like inconsistent slash and backslash usage in the
  1091         # archive).
  1092         logging.exception("An exception within zip file attachment handling occurred:")
  1093         return
  1094 
  1095     from MoinMoin import macro
  1096     from MoinMoin.parser.text import Parser
  1097 
  1098     macro.request = request
  1099     macro.formatter = request.html_formatter
  1100     p = Parser("##\n", request)
  1101     m = macro.Macro(p)
  1102 
  1103     # use EmbedObject to view valid mime types
  1104     if mt is None:
  1105         request.write('<p>' + _("Unknown file type, cannot display this attachment inline.") + '</p>')
  1106         link = (fmt.url(1, getAttachUrl(pagename, filename, request)) +
  1107                 fmt.text(filename) +
  1108                 fmt.url(0))
  1109         request.write('For using an external program follow this link %s' % link)
  1110         return
  1111     request.write(m.execute('EmbedObject', u'target=%s, pagename=%s' % (filename, pagename)))
  1112     return
  1113 
  1114 
  1115 def _do_view(pagename, request):
  1116     _ = request.getText
  1117 
  1118     orig_pagename = pagename
  1119     pagename, filename, fpath = _access_file(pagename, request)
  1120     if not request.user.may.read(pagename):
  1121         return _('You are not allowed to view attachments of this page.')
  1122     if not filename:
  1123         return
  1124 
  1125     # send header & title
  1126     # Use user interface language for this generated page
  1127     request.setContentLanguage(request.lang)
  1128     title = _('attachment:%(filename)s of %(pagename)s') % {
  1129         'filename': filename, 'pagename': pagename}
  1130     request.theme.send_title(title, pagename=pagename)
  1131 
  1132     # send body
  1133     request.write(request.formatter.startContent())
  1134     send_viewfile(orig_pagename, request)
  1135     send_uploadform(pagename, request)
  1136     request.write(request.formatter.endContent())
  1137 
  1138     request.theme.send_footer(pagename)
  1139     request.theme.send_closing_html()
  1140 
  1141 
  1142 #############################################################################
  1143 ### File attachment administration
  1144 #############################################################################
  1145 
  1146 def do_admin_browser(request):
  1147     """ Browser for SystemAdmin macro. """
  1148     from MoinMoin.util.dataset import TupleDataset, Column
  1149     _ = request.getText
  1150 
  1151     data = TupleDataset()
  1152     data.columns = [
  1153         Column('page', label=('Page')),
  1154         Column('file', label=('Filename')),
  1155         Column('size', label=_('Size'), align='right'),
  1156     ]
  1157 
  1158     # iterate over pages that might have attachments
  1159     pages = request.rootpage.getPageList()
  1160     for pagename in pages:
  1161         # check for attachments directory
  1162         page_dir = getAttachDir(request, pagename)
  1163         if os.path.isdir(page_dir):
  1164             # iterate over files of the page
  1165             files = os.listdir(page_dir)
  1166             for filename in files:
  1167                 filepath = os.path.join(page_dir, filename)
  1168                 data.addRow((
  1169                     Page(request, pagename).link_to(request, querystr="action=AttachFile"),
  1170                     wikiutil.escape(filename.decode(config.charset)),
  1171                     os.path.getsize(filepath),
  1172                 ))
  1173 
  1174     if data:
  1175         from MoinMoin.widget.browser import DataBrowserWidget
  1176 
  1177         browser = DataBrowserWidget(request)
  1178         browser.setData(data)
  1179         return browser.render(method="GET")
  1180 
  1181     return ''
  1182