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