001/*
002 *  Copyright 2012 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016
017package org.ametys.plugins.core.impl.upload;
018
019import java.io.File;
020import java.io.FileInputStream;
021import java.io.FileNotFoundException;
022import java.io.FileOutputStream;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.OutputStream;
026import java.io.UnsupportedEncodingException;
027import java.net.URLDecoder;
028import java.net.URLEncoder;
029import java.util.Calendar;
030import java.util.Collection;
031import java.util.Date;
032import java.util.GregorianCalendar;
033import java.util.NoSuchElementException;
034import java.util.Timer;
035import java.util.TimerTask;
036import java.util.UUID;
037
038import org.apache.avalon.framework.activity.Disposable;
039import org.apache.avalon.framework.activity.Initializable;
040import org.apache.avalon.framework.context.Context;
041import org.apache.avalon.framework.context.ContextException;
042import org.apache.avalon.framework.context.Contextualizable;
043import org.apache.avalon.framework.logger.LogEnabled;
044import org.apache.avalon.framework.logger.Logger;
045import org.apache.avalon.framework.thread.ThreadSafe;
046import org.apache.cocoon.Constants;
047import org.apache.commons.io.FileUtils;
048import org.apache.commons.io.IOUtils;
049import org.apache.commons.io.filefilter.AbstractFileFilter;
050import org.apache.commons.io.filefilter.DirectoryFileFilter;
051import org.apache.commons.io.filefilter.FalseFileFilter;
052import org.apache.commons.io.filefilter.TrueFileFilter;
053
054import org.ametys.core.upload.Upload;
055import org.ametys.core.upload.UploadManager;
056import org.ametys.core.user.UserIdentity;
057import org.ametys.runtime.util.AmetysHomeHelper;
058
059/**
060 * {@link UploadManager} which stores uploaded files into the
061 * <code>uploads-user</code> directory located in Ametys home
062 * <p>
063 * Note that this implementation is not cluster safe.
064 */
065public class FSUploadManager extends TimerTask implements UploadManager, ThreadSafe, Initializable, Contextualizable, LogEnabled, Disposable
066{
067    /**
068     * The path to the global uploads directory relative to ametys home
069     */
070    public static final String UPLOADS_DIRECTORY = "uploads-user";
071
072    /** Context. */
073    protected org.apache.cocoon.environment.Context _context;
074    /** Global uploads directory. */
075    protected File _globalUploadsDir;
076    /** Timer. */
077    protected Timer _timer;
078    /** Logger available to subclasses. */
079    private Logger _logger;
080    
081    @Override
082    public void enableLogging(Logger logger)
083    {
084        _logger = logger;
085    }
086    
087    @Override
088    public void contextualize(Context context) throws ContextException
089    {
090        // Retrieve context-root from context
091        _context = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
092    }
093    
094    @Override
095    public void initialize() throws Exception
096    {
097        if (_logger.isDebugEnabled())
098        {
099            _logger.debug("Starting timer");
100        }
101        
102        _globalUploadsDir = new File(AmetysHomeHelper.getAmetysHomeData(), UPLOADS_DIRECTORY);
103        
104        // Daemon thread
105        _timer = new Timer("FSUploadManager", true);
106        
107        // Start in 15 minutes and refresh each 24 hours
108        _timer.scheduleAtFixedRate(this, 15 * 60 * 1000, 24 * 60 * 60 * 1000);
109    }
110    
111    @Override
112    public void run()
113    {
114        if (_logger.isInfoEnabled())
115        {
116            _logger.info("Time to clean old uploads");
117        }
118        
119        try
120        {
121            Calendar calendar = new GregorianCalendar();
122            calendar.add(Calendar.DAY_OF_YEAR, -1);
123            final long yesterday = calendar.getTimeInMillis();
124            
125            // Retrieve login directories
126            String[] loginDirs = _globalUploadsDir.list(DirectoryFileFilter.INSTANCE);
127            
128            if (loginDirs != null)
129            {
130                for (String loginDir : loginDirs)
131                {
132                    File effectiveLoginDir = new File(_globalUploadsDir, loginDir);
133                    // Compute upload directories to remove
134                    String[] dirsToRemove = effectiveLoginDir.list(new AbstractFileFilter()
135                    {
136                        @Override
137                        public boolean accept(File file)
138                        {
139                            if (!file.isDirectory())
140                            {
141                                return false;
142                            }
143                            
144                            Collection<File> uploadFiles = FileUtils.listFiles(file, TrueFileFilter.INSTANCE, null);
145
146                            if (uploadFiles.isEmpty())
147                            {
148                                // Remove empty directory
149                                return true;
150                            }
151                            else
152                            {
153                                File firstFile = uploadFiles.iterator().next();
154    
155                                // Remove directory if the first file is one day old
156                                return firstFile.lastModified() < yesterday;
157                            }
158                        }
159                    });
160                    
161                    if (dirsToRemove != null)
162                    {
163                        for (String dirToRemove : dirsToRemove)
164                        {
165                            File uploadDir = new File(effectiveLoginDir, dirToRemove);
166                            
167                            if (_logger.isDebugEnabled())
168                            {
169                                _logger.debug("Removing directory: " + uploadDir);
170                            }
171            
172                            FileUtils.deleteDirectory(uploadDir);
173                        }
174                    }
175                }
176            }
177        }
178        catch (Exception e)
179        {
180            _logger.error("Unable to clean old uploads", e);
181        }
182    }
183    
184    @Override
185    public Upload storeUpload(UserIdentity user, String filename, InputStream is) throws IOException
186    {
187        if (!_globalUploadsDir.exists())
188        {
189            if (!_globalUploadsDir.mkdirs())
190            {
191                throw new IOException("Unable to create directory: " + _globalUploadsDir);
192            }
193        }
194        
195        // Create unique id
196        String id = UUID.randomUUID().toString();
197        
198        File uploadFile = null;
199
200        try
201        {
202            uploadFile = new File(_getUploadDir(user != null ? UserIdentity.userIdentityToString(user) : null, id), URLEncoder.encode(filename, "UTF-8"));
203        }
204        catch (UnsupportedEncodingException e)
205        {
206            throw new RuntimeException("Unsupported encoding", e);
207        }
208        
209        if (_logger.isInfoEnabled())
210        {
211            _logger.info("Using file: " + uploadFile);
212        }
213        
214        if (!uploadFile.getParentFile().mkdirs())
215        {
216            throw new IOException("Unable to create directory: " + uploadFile.getParent());
217        }
218        
219        try (OutputStream os = new FileOutputStream(uploadFile))
220        {
221            IOUtils.copy(is, os);
222        }
223        finally
224        {
225            IOUtils.closeQuietly(is);
226        }
227        
228        return new FSUpload(_context, uploadFile);
229    }
230    
231    @Override
232    public Upload getUpload(UserIdentity user, String id) throws NoSuchElementException
233    {
234        File uploadDir = _getUploadDir(user != null ? UserIdentity.userIdentityToString(user) : null, id);
235        
236        if (_logger.isDebugEnabled())
237        {
238            _logger.debug("Using directory: " + uploadDir);
239        }
240        
241        if (!uploadDir.exists() || !uploadDir.isDirectory())
242        {
243            throw new NoSuchElementException("No directory: " + uploadDir);
244        }
245        
246        Collection<File> files = FileUtils.listFiles(uploadDir, TrueFileFilter.INSTANCE, FalseFileFilter.INSTANCE);
247        
248        if (_logger.isInfoEnabled())
249        {
250            _logger.info("Found files: " + files);
251        }
252        
253        if (files.isEmpty())
254        {
255            throw new NoSuchElementException("No files in directory: " + uploadDir);
256        }
257        
258        // Use first file found
259        return new FSUpload(_context, files.iterator().next());
260    }
261    
262    @Override
263    public void dispose()
264    {
265        cancel();
266        _timer.cancel();
267        _globalUploadsDir = null;
268        _logger = null;
269    }
270    
271    /**
272     * Retrieves the upload directory for a login and an upload id.
273     * @param login the login.
274     * @param id the upload id.
275     * @return the upload directory.
276     */
277    protected File _getUploadDir(String login, String id)
278    {
279        try
280        {
281            String fileName = login != null ? URLEncoder.encode(login, "UTF-8") : "_Anonymous";
282            return new File(new File(_globalUploadsDir, fileName), id);
283        }
284        catch (UnsupportedEncodingException e)
285        {
286            throw new RuntimeException("Unsupported encoding", e);
287        }
288    }
289    
290    /**
291     * {@link Upload} implementation on file system.
292     */
293    protected static class FSUpload implements Upload
294    {
295        private org.apache.cocoon.environment.Context _context;
296        private File _file;
297        
298        /**
299         * Creates a FSUpload from a file.
300         * @param context the context.
301         * @param file the file.
302         */
303        public FSUpload(org.apache.cocoon.environment.Context context, File file)
304        {
305            _context = context;
306            _file = file;
307        }
308
309        @Override
310        public String getId()
311        {
312            return _file.getParentFile().getName();
313        }
314        
315        @Override
316        public Date getUploadedDate()
317        {
318            return new Date(_file.lastModified());
319        }
320        
321        @Override
322        public String getFilename()
323        {
324            try
325            {
326                return URLDecoder.decode(_file.getName(), "UTF-8");
327            }
328            catch (UnsupportedEncodingException e)
329            {
330                throw new RuntimeException("Unsupported encoding", e);
331            }
332        }
333        
334        @Override
335        public String getMimeType()
336        {
337            String mimeType = _context.getMimeType(getFilename().toLowerCase());
338            
339            if (mimeType == null)
340            {
341                mimeType = "application/unknown";
342            }
343            
344            return mimeType;
345        }
346        
347        @Override
348        public long getLength()
349        {
350            return _file.length();
351        }
352        
353        @Override
354        public InputStream getInputStream()
355        {
356            try
357            {
358                return new FileInputStream(_file);
359            }
360            catch (FileNotFoundException e)
361            {
362                throw new RuntimeException("Missing file: " + _file, e);
363            }
364        }
365    }
366}