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.time.ZonedDateTime;
027import java.util.Calendar;
028import java.util.Collection;
029import java.util.GregorianCalendar;
030import java.util.NoSuchElementException;
031import java.util.Timer;
032import java.util.TimerTask;
033import java.util.UUID;
034
035import org.apache.avalon.framework.activity.Disposable;
036import org.apache.avalon.framework.activity.Initializable;
037import org.apache.avalon.framework.context.Context;
038import org.apache.avalon.framework.context.ContextException;
039import org.apache.avalon.framework.context.Contextualizable;
040import org.apache.avalon.framework.logger.LogEnabled;
041import org.apache.avalon.framework.logger.Logger;
042import org.apache.avalon.framework.thread.ThreadSafe;
043import org.apache.cocoon.Constants;
044import org.apache.commons.io.FileUtils;
045import org.apache.commons.io.IOUtils;
046import org.apache.commons.io.filefilter.AbstractFileFilter;
047import org.apache.commons.io.filefilter.DirectoryFileFilter;
048import org.apache.commons.io.filefilter.FalseFileFilter;
049import org.apache.commons.io.filefilter.TrueFileFilter;
050
051import org.ametys.core.upload.Upload;
052import org.ametys.core.upload.UploadManager;
053import org.ametys.core.user.UserIdentity;
054import org.ametys.core.util.DateUtils;
055import org.ametys.core.util.FilenameUtils;
056import org.ametys.core.util.URIUtils;
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        uploadFile = new File(_getUploadDir(user != null ? UserIdentity.userIdentityToString(user) : null, id), FilenameUtils.encodeName(filename));
201        
202        if (_logger.isInfoEnabled())
203        {
204            _logger.info("Using file: " + uploadFile);
205        }
206        
207        if (!uploadFile.getParentFile().mkdirs())
208        {
209            throw new IOException("Unable to create directory: " + uploadFile.getParent());
210        }
211        
212        try (OutputStream os = new FileOutputStream(uploadFile))
213        {
214            IOUtils.copy(is, os);
215        }
216        
217        return new FSUpload(_context, uploadFile);
218    }
219    
220    @Override
221    public Upload getUpload(UserIdentity user, String id) throws NoSuchElementException
222    {
223        File uploadDir = _getUploadDir(user != null ? UserIdentity.userIdentityToString(user) : null, id);
224        
225        if (_logger.isDebugEnabled())
226        {
227            _logger.debug("Using directory: " + uploadDir);
228        }
229        
230        if (!uploadDir.exists() || !uploadDir.isDirectory())
231        {
232            throw new NoSuchElementException("No directory: " + uploadDir);
233        }
234        
235        Collection<File> files = FileUtils.listFiles(uploadDir, TrueFileFilter.INSTANCE, FalseFileFilter.INSTANCE);
236        
237        if (_logger.isInfoEnabled())
238        {
239            _logger.info("Found files: " + files);
240        }
241        
242        if (files.isEmpty())
243        {
244            throw new NoSuchElementException("No files in directory: " + uploadDir);
245        }
246        
247        // Use first file found
248        return new FSUpload(_context, files.iterator().next());
249    }
250    
251    @Override
252    public void dispose()
253    {
254        cancel();
255        _timer.cancel();
256        _globalUploadsDir = null;
257        _logger = null;
258    }
259    
260    /**
261     * Retrieves the upload directory for a login and an upload id.
262     * @param login the login.
263     * @param id the upload id.
264     * @return the upload directory.
265     */
266    protected File _getUploadDir(String login, String id)
267    {
268        String fileName = login != null ? FilenameUtils.encodeName(login) : "_Anonymous";
269        return new File(new File(_globalUploadsDir, fileName), id);
270    }
271    
272    /**
273     * {@link Upload} implementation on file system.
274     */
275    protected static class FSUpload implements Upload
276    {
277        private org.apache.cocoon.environment.Context _context;
278        private File _file;
279        
280        /**
281         * Creates a FSUpload from a file.
282         * @param context the context.
283         * @param file the file.
284         */
285        public FSUpload(org.apache.cocoon.environment.Context context, File file)
286        {
287            _context = context;
288            _file = file;
289        }
290
291        @Override
292        public String getId()
293        {
294            return _file.getParentFile().getName();
295        }
296        
297        @Override
298        public ZonedDateTime getUploadedDate()
299        {
300            return DateUtils.asZonedDateTime(_file.lastModified());
301        }
302        
303        @Override
304        public String getFilename()
305        {
306            return URIUtils.decode(_file.getName());
307        }
308        
309        @Override
310        public String getMimeType()
311        {
312            String mimeType = _context.getMimeType(getFilename().toLowerCase());
313            
314            if (mimeType == null)
315            {
316                mimeType = "application/unknown";
317            }
318            
319            return mimeType;
320        }
321        
322        @Override
323        public long getLength()
324        {
325            return _file.length();
326        }
327        
328        @Override
329        public InputStream getInputStream()
330        {
331            try
332            {
333                return new FileInputStream(_file);
334            }
335            catch (FileNotFoundException e)
336            {
337                throw new RuntimeException("Missing file: " + _file, e);
338            }
339        }
340    }
341}