001: /*
002: JSPWiki - a JSP-based WikiWiki clone.
003:
004: Copyright (C) 2001-2002 Janne Jalkanen (Janne.Jalkanen@iki.fi)
005:
006: This program is free software; you can redistribute it and/or modify
007: it under the terms of the GNU Lesser General Public License as published by
008: the Free Software Foundation; either version 2.1 of the License, or
009: (at your option) any later version.
010:
011: This program is distributed in the hope that it will be useful,
012: but WITHOUT ANY WARRANTY; without even the implied warranty of
013: MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
014: GNU Lesser General Public License for more details.
015:
016: You should have received a copy of the GNU Lesser General Public License
017: along with this program; if not, write to the Free Software
018: Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
019: */
020: package com.ecyrd.jspwiki.attachment;
021:
022: import java.io.File;
023: import java.io.IOException;
024: import java.io.InputStream;
025: import java.io.OutputStream;
026: import java.net.SocketException;
027: import java.security.Permission;
028: import java.security.Principal;
029: import java.util.Enumeration;
030: import java.util.Properties;
031:
032: import javax.servlet.ServletConfig;
033: import javax.servlet.ServletContext;
034: import javax.servlet.ServletException;
035: import javax.servlet.http.HttpServletRequest;
036: import javax.servlet.http.HttpServletResponse;
037:
038: import net.iamvegan.multipartrequest.HttpServletMultipartRequest;
039: import net.iamvegan.multipartrequest.MultipartFile;
040: import net.iamvegan.multipartrequest.ProgressListener;
041:
042: import org.apache.commons.lang.StringUtils;
043: import org.apache.log4j.Logger;
044:
045: import com.ecyrd.jspwiki.*;
046: import com.ecyrd.jspwiki.auth.AuthorizationManager;
047: import com.ecyrd.jspwiki.auth.permissions.PermissionFactory;
048: import com.ecyrd.jspwiki.dav.AttachmentDavProvider;
049: import com.ecyrd.jspwiki.dav.DavPath;
050: import com.ecyrd.jspwiki.dav.DavProvider;
051: import com.ecyrd.jspwiki.dav.WebdavServlet;
052: import com.ecyrd.jspwiki.dav.methods.DavMethod;
053: import com.ecyrd.jspwiki.dav.methods.PropFindMethod;
054: import com.ecyrd.jspwiki.filters.RedirectException;
055: import com.ecyrd.jspwiki.providers.ProviderException;
056: import com.ecyrd.jspwiki.ui.progress.ProgressItem;
057: import com.ecyrd.jspwiki.util.HttpUtil;
058:
059: /**
060: * This is the chief JSPWiki attachment management servlet. It is used for
061: * both uploading new content and downloading old content. It can handle
062: * most common cases, e.g. check for modifications and return 304's as necessary.
063: * <p>
064: * Authentication is done using JSPWiki's normal AAA framework.
065: * <p>
066: * This servlet is also capable of managing dynamically created attachments.
067: *
068: * @author Erik Bunn
069: * @author Janne Jalkanen
070: *
071: * @since 1.9.45.
072: */
073: public class AttachmentServlet extends WebdavServlet {
074: private static final int BUFFER_SIZE = 8192;
075:
076: private static final long serialVersionUID = 3257282552187531320L;
077:
078: private WikiEngine m_engine;
079: static Logger log = Logger.getLogger(AttachmentServlet.class
080: .getName());
081:
082: private static final String HDR_VERSION = "version";
083: // private static final String HDR_NAME = "page";
084:
085: /** Default expiry period is 1 day */
086: protected static final long DEFAULT_EXPIRY = 1 * 24 * 60 * 60
087: * 1000;
088:
089: private String m_tmpDir;
090:
091: private DavProvider m_attachmentProvider;
092:
093: /**
094: * The maximum size that an attachment can be.
095: */
096: private int m_maxSize = Integer.MAX_VALUE;
097:
098: /**
099: * List of attachment types which are allowed
100: */
101:
102: private String[] m_allowedPatterns;
103:
104: private String[] m_forbiddenPatterns;
105:
106: //
107: // Not static as DateFormat objects are not thread safe.
108: // Used to handle the RFC date format = Sat, 13 Apr 2002 13:23:01 GMT
109: //
110: //private final DateFormat rfcDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z");
111:
112: /**
113: * Initializes the servlet from WikiEngine properties.
114: *
115: * {@inheritDoc}
116: */
117: public void init(ServletConfig config) throws ServletException {
118: super .init(config);
119:
120: m_engine = WikiEngine.getInstance(config);
121: Properties props = m_engine.getWikiProperties();
122:
123: m_attachmentProvider = new AttachmentDavProvider(m_engine);
124: m_tmpDir = m_engine.getWorkDir() + File.separator
125: + "attach-tmp";
126:
127: m_maxSize = TextUtil.getIntegerProperty(props,
128: AttachmentManager.PROP_MAXSIZE, Integer.MAX_VALUE);
129:
130: String allowed = TextUtil.getStringProperty(props,
131: AttachmentManager.PROP_ALLOWEDEXTENSIONS, null);
132:
133: if (allowed != null && allowed.length() > 0)
134: m_allowedPatterns = allowed.toLowerCase().split("\\s");
135: else
136: m_allowedPatterns = new String[0];
137:
138: String forbidden = TextUtil.getStringProperty(props,
139: AttachmentManager.PROP_FORDBIDDENEXTENSIONS, null);
140:
141: if (forbidden != null && forbidden.length() > 0)
142: m_forbiddenPatterns = forbidden.toLowerCase().split("\\s");
143: else
144: m_forbiddenPatterns = new String[0];
145:
146: File f = new File(m_tmpDir);
147: if (!f.exists()) {
148: f.mkdirs();
149: } else if (!f.isDirectory()) {
150: log
151: .fatal("A file already exists where the temporary dir is supposed to be: "
152: + m_tmpDir + ". Please remove it.");
153: }
154:
155: log.debug("UploadServlet initialized. Using " + m_tmpDir
156: + " for temporary storage.");
157: }
158:
159: private boolean isTypeAllowed(String name) {
160: if (name == null || name.length() == 0)
161: return false;
162:
163: name = name.toLowerCase();
164:
165: for (int i = 0; i < m_forbiddenPatterns.length; i++) {
166: if (name.endsWith(m_forbiddenPatterns[i])
167: && m_forbiddenPatterns[i].length() > 0)
168: return false;
169: }
170:
171: for (int i = 0; i < m_allowedPatterns.length; i++) {
172: if (name.endsWith(m_allowedPatterns[i])
173: && m_allowedPatterns[i].length() > 0)
174: return true;
175: }
176:
177: return m_allowedPatterns.length == 0;
178: }
179:
180: /**
181: * Implements the PROPFIND method.
182: *
183: * @param req The servlet request
184: * @param res The servlet response
185: * @throws IOException If input/output fails
186: * @throws ServletException If the servlet has issues
187: */
188: public void doPropFind(HttpServletRequest req,
189: HttpServletResponse res) throws IOException,
190: ServletException {
191: DavMethod dm = new PropFindMethod(m_attachmentProvider);
192:
193: String p = new String(req.getPathInfo().getBytes("ISO-8859-1"),
194: "UTF-8");
195:
196: DavPath path = new DavPath(p);
197:
198: dm.execute(req, res, path);
199: }
200:
201: /**
202: * Implements the OPTIONS method.
203: *
204: * @param req The servlet request
205: * @param res The servlet response
206: */
207:
208: protected void doOptions(HttpServletRequest req,
209: HttpServletResponse res) {
210: res.setHeader("DAV", "1"); // We support only Class 1
211: res
212: .setHeader("Allow",
213: "GET, PUT, POST, OPTIONS, PROPFIND, PROPPATCH, MOVE, COPY, DELETE");
214: res.setStatus(HttpServletResponse.SC_OK);
215: }
216:
217: /**
218: * Serves a GET with two parameters: 'wikiname' specifying the wikiname
219: * of the attachment, 'version' specifying the version indicator.
220: *
221: * {@inheritDoc}
222: */
223:
224: // FIXME: Messages would need to be localized somehow.
225: public void doGet(HttpServletRequest req, HttpServletResponse res)
226: throws IOException, ServletException {
227: WikiContext context = m_engine.createContext(req,
228: WikiContext.ATTACH);
229:
230: String version = req.getParameter(HDR_VERSION);
231: String nextPage = req.getParameter("nextpage");
232:
233: String msg = "An error occurred. Ouch.";
234: int ver = WikiProvider.LATEST_VERSION;
235:
236: AttachmentManager mgr = m_engine.getAttachmentManager();
237: AuthorizationManager authmgr = m_engine
238: .getAuthorizationManager();
239:
240: String page = context.getPage().getName();
241:
242: if (page == null) {
243: log.info("Invalid attachment name.");
244: res.sendError(HttpServletResponse.SC_BAD_REQUEST);
245: return;
246: }
247:
248: OutputStream out = null;
249: InputStream in = null;
250:
251: try {
252: log.debug("Attempting to download att " + page
253: + ", version " + version);
254: if (version != null) {
255: ver = Integer.parseInt(version);
256: }
257:
258: Attachment att = mgr.getAttachmentInfo(page, ver);
259:
260: if (att != null) {
261: //
262: // Check if the user has permission for this attachment
263: //
264:
265: Permission permission = PermissionFactory
266: .getPagePermission(att, "view");
267: if (!authmgr.checkPermission(context.getWikiSession(),
268: permission)) {
269: log.debug("User does not have permission for this");
270: res.sendError(HttpServletResponse.SC_FORBIDDEN);
271: return;
272: }
273:
274: //
275: // Check if the client already has a version of this attachment.
276: //
277: if (HttpUtil.checkFor304(req, att)) {
278: log
279: .debug("Client has latest version already, sending 304...");
280: res.sendError(HttpServletResponse.SC_NOT_MODIFIED);
281: return;
282: }
283:
284: String mimetype = getMimeType(context, att
285: .getFileName());
286:
287: res.setContentType(mimetype);
288:
289: //
290: // We use 'inline' instead of 'attachment' so that user agents
291: // can try to automatically open the file.
292: //
293:
294: res.addHeader("Content-Disposition",
295: "inline; filename=\"" + att.getFileName()
296: + "\";");
297:
298: res.addDateHeader("Last-Modified", att
299: .getLastModified().getTime());
300:
301: if (!att.isCacheable()) {
302: res.addHeader("Pragma", "no-cache");
303: res.addHeader("Cache-control", "no-cache");
304: }
305:
306: // If a size is provided by the provider, report it.
307: if (att.getSize() >= 0) {
308: // log.info("size:"+att.getSize());
309: res.setContentLength((int) att.getSize());
310: }
311:
312: out = res.getOutputStream();
313: in = mgr.getAttachmentStream(context, att);
314:
315: int read = 0;
316: byte[] buffer = new byte[BUFFER_SIZE];
317:
318: while ((read = in.read(buffer)) > -1) {
319: out.write(buffer, 0, read);
320: }
321:
322: if (log.isDebugEnabled()) {
323: msg = "Attachment " + att.getFileName()
324: + " sent to " + req.getRemoteUser()
325: + " on " + req.getRemoteAddr();
326: log.debug(msg);
327: }
328: if (nextPage != null)
329: res.sendRedirect(nextPage);
330:
331: return;
332: }
333:
334: msg = "Attachment '" + page + "', version " + ver
335: + " does not exist.";
336:
337: log.info(msg);
338: res.sendError(HttpServletResponse.SC_NOT_FOUND, msg);
339: return;
340: } catch (ProviderException pe) {
341: msg = "Provider error: " + pe.getMessage();
342:
343: log.debug("Provider failed while reading", pe);
344: //
345: // This might fail, if the response is already committed. So in that
346: // case we just log it.
347: //
348: try {
349: res.sendError(
350: HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
351: msg);
352: } catch (IllegalStateException e) {
353: }
354: return;
355: } catch (NumberFormatException nfe) {
356: msg = "Invalid version number (" + version + ")";
357: res.sendError(HttpServletResponse.SC_BAD_REQUEST, msg);
358: return;
359: } catch (SocketException se) {
360: //
361: // These are very common in download situations due to aggressive
362: // clients. No need to try and send an error.
363: //
364: log.debug("I/O exception during download", se);
365: return;
366: } catch (IOException ioe) {
367: //
368: // Client dropped the connection or something else happened.
369: // We don't know where the error came from, so we'll at least
370: // try to send an error and catch it quietly if it doesn't quite work.
371: //
372: msg = "Error: " + ioe.getMessage();
373: log.debug("I/O exception during download", ioe);
374:
375: try {
376: res.sendError(
377: HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
378: msg);
379: } catch (IllegalStateException e) {
380: }
381: return;
382: } finally {
383: if (in != null) {
384: try {
385: in.close();
386: } catch (IOException e) {
387: }
388: }
389:
390: //
391: // Quite often, aggressive clients close the connection when they have
392: // received the last bits. Therefore, we close the output, but ignore
393: // any exception that might come out of it.
394: //
395:
396: if (out != null) {
397: try {
398: out.close();
399: } catch (IOException e) {
400: }
401: }
402: }
403: }
404:
405: /**
406: * Returns the mime type for this particular file. Case does not matter.
407: *
408: * @param ctx WikiContext; required to access the ServletContext of the request.
409: * @param fileName The name to check for.
410: * @return A valid mime type, or application/binary, if not recognized
411: */
412: private static String getMimeType(WikiContext ctx, String fileName) {
413: String mimetype = null;
414:
415: HttpServletRequest req = ctx.getHttpRequest();
416: if (req != null) {
417: ServletContext s = req.getSession().getServletContext();
418:
419: if (s != null) {
420: mimetype = s.getMimeType(fileName.toLowerCase());
421: }
422: }
423:
424: if (mimetype == null) {
425: mimetype = "application/binary";
426: }
427:
428: return mimetype;
429: }
430:
431: /**
432: * Grabs mime/multipart data and stores it into the temporary area.
433: * Uses other parameters to determine which name to store as.
434: *
435: * <p>The input to this servlet is generated by an HTML FORM with
436: * two parts. The first, named 'page', is the WikiName identifier
437: * for the parent file. The second, named 'content', is the binary
438: * content of the file.
439: *
440: * {@inheritDoc}
441: */
442: public void doPost(HttpServletRequest req, HttpServletResponse res)
443: throws IOException, ServletException {
444: try {
445: String nextPage = upload(req);
446: req.getSession().removeAttribute("msg");
447: res.sendRedirect(nextPage);
448: } catch (RedirectException e) {
449: WikiSession session = WikiSession.getWikiSession(m_engine,
450: req);
451: session.addMessage(e.getMessage());
452:
453: req.getSession().setAttribute("msg", e.getMessage());
454: res.sendRedirect(e.getRedirect());
455: }
456: }
457:
458: /**
459: * {@inheritDoc}
460: */
461: public void doPut(HttpServletRequest req, HttpServletResponse res)
462: throws IOException, ServletException {
463: String errorPage = m_engine.getURL(WikiContext.ERROR, "", null,
464: false); // If something bad happened, Upload should be able to take care of most stuff
465:
466: String p = new String(req.getPathInfo().getBytes("ISO-8859-1"),
467: "UTF-8");
468: DavPath path = new DavPath(p);
469:
470: try {
471: InputStream data = req.getInputStream();
472:
473: WikiContext context = m_engine.createContext(req,
474: WikiContext.UPLOAD);
475:
476: String wikipage = path.get(0);
477:
478: errorPage = context.getURL(WikiContext.UPLOAD, wikipage);
479:
480: String changeNote = null; // FIXME: Does not quite work
481:
482: boolean created = executeUpload(context, data, path
483: .getName(), errorPage, wikipage, changeNote, req
484: .getContentLength());
485:
486: if (created)
487: res.sendError(HttpServletResponse.SC_CREATED);
488: else
489: res.sendError(HttpServletResponse.SC_OK);
490: } catch (ProviderException e) {
491: res.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
492: e.getMessage());
493: } catch (RedirectException e) {
494: res.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
495: e.getMessage());
496: }
497: }
498:
499: /**
500: * Validates the next page to be on the same server as this webapp.
501: * Fixes [JSPWIKI-46].
502: */
503:
504: private String validateNextPage(String nextPage, String errorPage) {
505: if (nextPage.indexOf("://") != -1) {
506: // It's an absolute link, so unless it starts with our address, we'll
507: // log an error.
508:
509: if (!nextPage.startsWith(m_engine.getBaseURL())) {
510: log
511: .warn("Detected phishing attempt by redirecting to an unsecure location: "
512: + nextPage);
513: nextPage = errorPage;
514: }
515: }
516:
517: return nextPage;
518: }
519:
520: /**
521: * Uploads a specific mime multipart input set, intercepts exceptions.
522: *
523: * @param req The servlet request
524: * @return The page to which we should go next.
525: * @throws RedirectException If there's an error and a redirection is needed
526: * @throws IOException If upload fails
527: */
528: protected String upload(HttpServletRequest req)
529: throws RedirectException, IOException {
530: String msg = "";
531: String attName = "(unknown)";
532: String errorPage = m_engine.getURL(WikiContext.ERROR, "", null,
533: false); // If something bad happened, Upload should be able to take care of most stuff
534: String nextPage = errorPage;
535:
536: String progressId = req.getParameter("progressid");
537:
538: try {
539: HttpServletMultipartRequest multi;
540:
541: // Create the context _before_ Multipart operations, otherwise
542: // strict servlet containers may fail when setting encoding.
543: WikiContext context = m_engine.createContext(req,
544: WikiContext.ATTACH);
545:
546: UploadListener pl = new UploadListener();
547:
548: m_engine.getProgressManager().startProgress(pl, progressId);
549:
550: multi = new HttpServletMultipartRequest(req,
551: Long.MAX_VALUE,
552: HttpServletMultipartRequest.SAVE_TO_TMPDIR,
553: HttpServletMultipartRequest.ABORT_ON_MAX_LENGTH,
554: "UTF-8", pl);
555:
556: nextPage = validateNextPage(multi.getParameter("nextpage"),
557: errorPage);
558: String wikipage = multi.getParameter("page");
559: String changeNote = multi.getParameter("changenote");
560:
561: //
562: // FIXME: Kludge alert. We must end up with the parent page name,
563: // if this is an upload of a new revision
564: //
565:
566: int x = wikipage.indexOf("/");
567:
568: if (x != -1)
569: wikipage = wikipage.substring(0, x);
570:
571: //
572: // Go through all files being uploaded.
573: //
574: Enumeration files = multi.getFileParameterNames();
575: long fileSize = 0L;
576: while (files.hasMoreElements()) {
577: String part = (String) files.nextElement();
578: MultipartFile multiFile = multi.getFileParameter(part);
579: fileSize += multiFile.getSize();
580: InputStream in = multiFile.getInputStream();
581:
582: String filename = multiFile.getName();
583:
584: executeUpload(context, in, filename, nextPage,
585: wikipage, changeNote, fileSize);
586: }
587:
588: // Inform the JSP page of which file we are handling:
589: // req.setAttribute( ATTR_ATTACHMENT, wikiname );
590: } catch (ProviderException e) {
591: msg = "Upload failed because the provider failed: "
592: + e.getMessage();
593: log.warn(msg + " (attachment: " + attName + ")", e);
594:
595: throw new IOException(msg);
596: } catch (IOException e) {
597: // Show the submit page again, but with a bit more
598: // intimidating output.
599: msg = "Upload failure: " + e.getMessage();
600: log.warn(msg + " (attachment: " + attName + ")", e);
601:
602: throw e;
603: } finally {
604: m_engine.getProgressManager().stopProgress(progressId);
605: // FIXME: In case of exceptions should absolutely
606: // remove the uploaded file.
607: }
608:
609: return nextPage;
610: }
611:
612: /**
613: *
614: * @param context the wiki context
615: * @param data the input stream data
616: * @param filename the name of the file to upload
617: * @param errorPage the place to which you want to get a redirection
618: * @param parentPage the page to which the file should be attached
619: * @param changenote The change note
620: * @param contentLength The content length
621: * @return <code>true</code> if upload results in the creation of a new page;
622: * <code>false</code> otherwise
623: * @throws RedirectException If the content needs to be redirected
624: * @throws IOException If there is a problem in the upload.
625: * @throws ProviderException If there is a problem in the backend.
626: */
627: protected boolean executeUpload(WikiContext context,
628: InputStream data, String filename, String errorPage,
629: String parentPage, String changenote, long contentLength)
630: throws RedirectException, IOException, ProviderException {
631: boolean created = false;
632:
633: //
634: // FIXME: This has the unfortunate side effect that it will receive the
635: // contents. But we can't figure out the page to redirect to
636: // before we receive the file, due to the stupid constructor of MultipartRequest.
637: //
638:
639: if (!context.hasAdminPermissions()) {
640: if (contentLength > m_maxSize) {
641: // FIXME: Does not delete the received files.
642: throw new RedirectException(
643: "File exceeds maximum size (" + m_maxSize
644: + " bytes)", errorPage);
645: }
646:
647: if (!isTypeAllowed(filename)) {
648: throw new RedirectException(
649: "Files of this type may not be uploaded to this wiki",
650: errorPage);
651: }
652: }
653:
654: Principal user = context.getCurrentUser();
655:
656: AttachmentManager mgr = m_engine.getAttachmentManager();
657:
658: if (filename == null || filename.trim().length() == 0) {
659: log.error("Empty file name given.");
660:
661: throw new RedirectException("Empty file name given.",
662: errorPage);
663: }
664:
665: //
666: // Should help with IE 5.22 on OSX
667: //
668: filename = filename.trim();
669:
670: //
671: // Remove any characters that might be a problem. Most
672: // importantly - characters that might stop processing
673: // of the URL.
674: //
675: filename = StringUtils.replaceChars(filename, "#?\"'", "____");
676:
677: log.debug("file=" + filename);
678:
679: if (data == null) {
680: log.error("File could not be opened.");
681:
682: throw new RedirectException("File could not be opened.",
683: errorPage);
684: }
685:
686: //
687: // Check whether we already have this kind of a page.
688: // If the "page" parameter already defines an attachment
689: // name for an update, then we just use that file.
690: // Otherwise we create a new attachment, and use the
691: // filename given. Incidentally, this will also mean
692: // that if the user uploads a file with the exact
693: // same name than some other previous attachment,
694: // then that attachment gains a new version.
695: //
696:
697: Attachment att = mgr.getAttachmentInfo(context.getPage()
698: .getName());
699:
700: if (att == null) {
701: att = new Attachment(m_engine, parentPage, filename);
702: created = true;
703: }
704: att.setSize(contentLength);
705:
706: //
707: // Check if we're allowed to do this?
708: //
709:
710: Permission permission = PermissionFactory.getPagePermission(
711: att, "upload");
712: if (m_engine.getAuthorizationManager().checkPermission(
713: context.getWikiSession(), permission)) {
714: if (user != null) {
715: att.setAuthor(user.getName());
716: }
717:
718: if (changenote != null && changenote.length() > 0) {
719: att.setAttribute(WikiPage.CHANGENOTE, changenote);
720: }
721:
722: m_engine.getAttachmentManager().storeAttachment(att, data);
723:
724: log.info("User " + user + " uploaded attachment to "
725: + parentPage + " called " + filename + ", size "
726: + att.getSize());
727: } else {
728: throw new RedirectException(
729: "No permission to upload a file", errorPage);
730: }
731:
732: return created;
733: }
734:
735: /**
736: * Provides tracking for upload progress.
737: *
738: * @author Janne Jalkanen
739: */
740: private class UploadListener extends ProgressItem implements
741: ProgressListener {
742: public long m_currentBytes;
743: public long m_totalBytes;
744: public String m_uid;
745:
746: public void update(long recvdBytes, long totalBytes, int item) {
747: m_currentBytes = recvdBytes;
748: m_totalBytes = totalBytes;
749: }
750:
751: public int getProgress() {
752: return (int) (((float) m_currentBytes / m_totalBytes) * 100 + 0.5);
753: }
754: }
755:
756: }
|