001/*
002 *  Copyright 2021 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 */
016package org.ametys.core.util.path;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.io.OutputStream;
021import java.net.MalformedURLException;
022import java.net.URLConnection;
023import java.nio.charset.StandardCharsets;
024import java.nio.file.Files;
025import java.nio.file.NoSuchFileException;
026import java.nio.file.Path;
027import java.util.Base64;
028import java.util.Collection;
029import java.util.ConcurrentModificationException;
030import java.util.stream.Collectors;
031import java.util.stream.Stream;
032
033import org.apache.commons.lang3.StringUtils;
034import org.apache.excalibur.source.ModifiableSource;
035import org.apache.excalibur.source.ModifiableTraversableSource;
036import org.apache.excalibur.source.MoveableSource;
037import org.apache.excalibur.source.Source;
038import org.apache.excalibur.source.SourceException;
039import org.apache.excalibur.source.SourceNotFoundException;
040import org.apache.excalibur.source.SourceUtil;
041import org.apache.excalibur.source.SourceValidity;
042import org.apache.excalibur.source.impl.FileSource;
043
044import org.ametys.core.util.LambdaUtils;
045import org.ametys.core.util.LambdaUtils.LambdaException;
046
047/**
048 * A {@link ModifiableTraversableSource} for path objects.
049 */
050public class PathSource implements ModifiableTraversableSource, MoveableSource
051{
052    /** The file */
053    protected Path _path;
054
055    /** The scheme */
056    protected String _scheme;
057
058    /** The URI of this source */
059    protected String _uri;
060    
061    /** The uri taking in account the final file */
062    protected String _externalUri;
063
064    /**
065     * Empty constructor
066     */
067    protected PathSource()
068    {
069        // Nothing
070    }
071    
072    /**
073     * Builds a PathSource given an URI, which doesn't necessarily have to start with "file:"
074     * @param uri The filURI
075     * @throws SourceException If URL cannot be created
076     * @throws MalformedURLException If URL is malformed
077     */
078    public PathSource(String uri) throws SourceException, MalformedURLException
079    {
080        int pos = SourceUtil.indexOfSchemeColon(uri);
081        if (pos == -1)
082        {
083            throw new MalformedURLException("Invalid URI : " + uri);
084        }
085
086        String scheme = uri.substring(0, pos);
087        String fileName = uri.substring(pos + 1);
088        fileName = SourceUtil.decodePath(fileName);
089        init(scheme, Path.of(fileName));
090    }
091
092    /**
093     * Builds a PathSource, given an URI scheme and a Path.
094     * @param scheme The scheme
095     * @param path The file
096     * @throws SourceException If url cannot be created
097     */
098    public PathSource(String scheme, Path path) throws SourceException
099    {
100        init(scheme, path);
101    }
102    
103    /**
104     * Builds a PathSource, given an URI scheme, URI and a Path.
105     * @param scheme The scheme
106     * @param uri the URI
107     * @param path The file
108     */
109    public PathSource(String scheme, String uri, Path path)
110    {
111        _scheme = scheme;
112        _uri = uri.replace('\\', '/');
113        _path = path;
114    }
115
116    private void init(String scheme, Path path) throws SourceException
117    {
118        _scheme = scheme;
119
120        String uri;
121        try
122        {
123            uri = path.toUri().toURL().toExternalForm();
124        }
125        catch (MalformedURLException mue)
126        {
127            // Can this really happen ?
128            throw new SourceException("Failed to get URL for file " + path, mue);
129        }
130
131        if (!uri.startsWith(scheme))
132        {
133            // Scheme is not "file:"
134            uri = scheme + ':' + uri.substring(uri.indexOf(':') + 1);
135        }
136
137        _uri = uri;
138
139        _path = path;
140    }
141
142    /**
143     * Get the associated file
144     * @return The underlying path
145     */
146    public Path getFile()
147    {
148        return _path;
149    }
150
151    //----------------------------------------------------------------------------------
152    //  Source interface methods
153    //----------------------------------------------------------------------------------
154
155    public long getContentLength()
156    {
157        try
158        {
159            return Files.size(_path);
160        }
161        catch (IOException e)
162        {
163            return -1;
164        }
165    }
166
167    public InputStream getInputStream() throws IOException, SourceNotFoundException
168    {
169        try
170        {
171            return Files.newInputStream(_path);
172        }
173        catch (NoSuchFileException e)
174        {
175            throw new SourceNotFoundException(_uri + " doesn't exist.", e);
176        }
177        catch (IOException e)
178        {
179            throw new SourceException("An error occurred while opening " + _uri + ".", e);
180        }
181    }
182
183    public long getLastModified()
184    {
185        try
186        {
187            return Files.getLastModifiedTime(_path).toMillis();
188        }
189        catch (IOException e)
190        {
191            return 0;
192        }
193    }
194
195    public String getMimeType()
196    {
197        return URLConnection.getFileNameMap().getContentTypeFor(_path.getFileName().toString());
198    }
199
200    public String getScheme()
201    {
202        return _scheme;
203
204    }
205
206    public String getURI()
207    {
208        try
209        {
210            if (_externalUri == null)
211            {
212                _externalUri = _uri + "?path=" + new String(Base64.getEncoder().withoutPadding().encode(_path.toUri().toURL().toExternalForm().getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); 
213            }
214            return _externalUri;
215        }
216        catch (MalformedURLException e)
217        {
218            throw new RuntimeException(e);
219        }
220    }
221
222    public SourceValidity getValidity()
223    {
224        if (Files.exists(_path))
225        {
226            return new PathTimeStampValidity(_path);
227        }
228        else
229        {
230            return null;
231        }
232    }
233
234    public void refresh()
235    {
236        // Nothing to do...
237    }
238
239    public boolean exists()
240    {
241        return Files.exists(getFile());
242    }
243
244    //----------------------------------------------------------------------------------
245    //  TraversableSource interface methods
246    //----------------------------------------------------------------------------------
247
248    public Source getChild(String name) throws SourceException
249    {
250        if (!Files.isDirectory(_path))
251        {
252            throw new SourceException(getURI() + " is not a directory");
253        }
254
255        Path subPath = _path.resolve(name);
256        return new PathSource(this.getScheme(), _uri + "/" + subPath.getFileName().toString(), subPath);
257
258    }
259
260    public Collection getChildren() throws SourceException
261    {
262        if (!Files.isDirectory(_path))
263        {
264            throw new SourceException(getURI() + " is not a directory");
265        }
266
267        // Build a PathSource object for each of the children
268        try (Stream<Path> files = Files.list(_path))
269        {
270            return files
271                    .map(LambdaUtils.wrap(path -> new PathSource(this.getScheme(), _uri + "/" + path.getFileName().toString(), path)))
272                    .collect(Collectors.toList());
273        }
274        catch (LambdaException e)
275        {
276            throw (SourceException) e.getCause();
277        }
278        catch (IOException e)
279        {
280            throw new SourceException("Cannot list files under " + _path, e);
281        }
282    }
283
284    public String getName()
285    {
286        return _path.getFileName().toString();
287    }
288
289    public Source getParent() throws SourceException
290    {
291        String specificPath = StringUtils.substringAfter(_uri, "://");
292        int lastIndexOf = specificPath.lastIndexOf('/');
293        if (lastIndexOf == -1)
294        {
295            return null;
296        }
297               
298        String parentPath = specificPath.substring(0, lastIndexOf);
299        if (parentPath.matches("^/*$"))
300        {
301            return null;
302        }
303        
304        return new PathSource(getScheme(), parentPath, _path.getParent());
305    }
306
307    public boolean isCollection()
308    {
309        return Files.isDirectory(_path);
310    }
311
312    //----------------------------------------------------------------------------------
313    //  ModifiableSource interface methods
314    //----------------------------------------------------------------------------------
315
316    /**
317     * Get an <code>InputStream</code> where raw bytes can be written to.
318     * The signification of these bytes is implementation-dependent and
319     * is not restricted to a serialized XML document.
320     *
321     * The output stream returned actually writes to a temp file that replaces
322     * the real one on close. This temp file is used as lock to forbid multiple
323     * simultaneous writes. The real file is updated atomically when the output
324     * stream is closed.
325     *
326     * The returned stream must be closed or cancelled by the calling code.
327     *
328     * @return a stream to write to
329     * @throws ConcurrentModificationException if another thread is currently
330     *         writing to this file.
331     */
332    public OutputStream getOutputStream() throws IOException
333    {
334        // Create a temp file. It will replace the right one when writing terminates,
335        // and serve as a lock to prevent concurrent writes.
336        Path tmpFile = getFile().getParent().resolve(getFile().getFileName() + ".tmp");
337
338        // Can we write the file ?
339        if (Files.exists(getFile()) && !Files.isWritable(getFile()))
340        {
341            throw new IOException("Cannot write to file " + getFile().toString());
342        }
343
344        // Check if it temp file already exists, meaning someone else currently writing
345        try 
346        {
347            Files.createFile(tmpFile);
348        }
349        catch (IOException e)
350        {
351            throw new ConcurrentModificationException("File " + getFile().toString() + " is already being written by another thread", e);
352        }
353
354        // Return a stream that will rename the temp file on close.
355        return new PathSourceOutputStream(tmpFile, this);
356    }
357
358    /**
359     * Can the data sent to an <code>OutputStream</code> returned by
360     * {@link #getOutputStream()} be cancelled ?
361     *
362     * @return true if the stream can be cancelled
363     */
364    public boolean canCancel(OutputStream stream)
365    {
366        if (stream instanceof PathSourceOutputStream)
367        {
368            PathSourceOutputStream fsos = (PathSourceOutputStream) stream;
369            if (fsos.getSource() == this)
370            {
371                return fsos.canCancel();
372            }
373        }
374
375        // Not a valid stream for this source
376        throw new IllegalArgumentException("The stream is not associated to this source");
377    }
378
379    /**
380     * Cancel the data sent to an <code>OutputStream</code> returned by
381     * {@link #getOutputStream()}.
382     * <p>
383     * After cancel, the stream should no more be used.
384     */
385    public void cancel(OutputStream stream) throws SourceException
386    {
387        if (stream instanceof PathSourceOutputStream)
388        {
389            PathSourceOutputStream fsos = (PathSourceOutputStream) stream;
390            if (fsos.getSource() == this)
391            {
392                try
393                {
394                    fsos.cancel();
395                }
396                catch (Exception e)
397                {
398                    throw new SourceException("Exception during cancel.", e);
399                }
400                return;
401            }
402        }
403
404        // Not a valid stream for this source
405        throw new IllegalArgumentException("The stream is not associated to this source");
406    }
407
408    /**
409     * Delete the source.
410     */
411    public void delete() throws SourceException
412    {
413        if (!Files.exists(_path))
414        {
415            throw new SourceNotFoundException("Cannot delete non-existing file " + _path.toString());
416        }
417        
418        try
419        {
420            Files.deleteIfExists(_path);
421        }
422        catch (IOException e)
423        {
424            throw new SourceException("Could not delete " + _path.toString() + " (unknown reason)", e);
425        } 
426    }
427
428    //----------------------------------------------------------------------------------
429    //  ModifiableTraversableSource interface methods
430    //----------------------------------------------------------------------------------
431
432    public void makeCollection() throws SourceException
433    {
434        try
435        {
436            Files.createDirectories(_path);
437        }
438        catch (IOException e)
439        {
440            throw new SourceException("Could not create collection " + this.getFile().toString(), e); 
441        }
442    }
443
444    //----------------------------------------------------------------------------------
445    //  MoveableSource interface methods
446    //----------------------------------------------------------------------------------
447
448    public void copyTo(Source destination) throws SourceException
449    {
450        try (InputStream is = this.getInputStream(); 
451             OutputStream os = ((ModifiableSource) destination).getOutputStream())
452        {
453            SourceUtil.copy(is, os);
454        }
455        catch (IOException ioe)
456        {
457            throw new SourceException("Couldn't copy " + getURI() + " to " + destination.getURI(), ioe);
458        }
459    }
460
461    public void moveTo(Source destination) throws SourceException
462    {
463        if (destination instanceof FileSource)
464        {
465            final Path dest = ((PathSource) destination).getFile();
466            final Path parent = dest.getParent();
467
468            if (parent != null)
469            {
470                try
471                {
472                 // ensure parent directories exist
473                    Files.createDirectories(parent);
474                }
475                catch (IOException e)
476                {
477                    throw new SourceException("Couldn't move " + getURI() + " to " + destination.getURI(), e);
478                } 
479            }
480
481            try
482            {
483                Files.move(_path, dest);
484            }
485            catch (IOException e)
486            {
487                throw new SourceException("Couldn't move " + getURI() + " to " + destination.getURI(), e);
488            }
489        }
490        else
491        {
492            SourceUtil.move(this, destination);
493        }
494
495    }
496
497    //----------------------------------------------------------------------------------
498    //  Private helper class for ModifiableSource implementation
499    //----------------------------------------------------------------------------------
500
501    /**
502     * A file outputStream that will rename the temp file to the destination file upon close()
503     * and discard the temp file upon cancel().
504     */
505    private static class PathSourceOutputStream extends OutputStream
506    {
507
508        private OutputStream _os;
509        private boolean _isClosed;
510        private PathSource _source;
511        private Path _tmpFile;
512
513        public PathSourceOutputStream(Path tmpFile, PathSource source) throws IOException
514        {
515            _tmpFile = tmpFile;
516            _os = Files.newOutputStream(tmpFile);
517            _source = source;
518        }
519
520        @Override
521        public void write(int b) throws IOException
522        {
523            _os.write(b);
524        }
525        
526        @Override
527        public void flush() throws IOException
528        {
529            _os.flush();
530        }
531        
532        @Override
533        public void close() throws IOException
534        {
535            if (!_isClosed)
536            {
537                _os.close();
538                try
539                {
540                    // Delete destination file
541                    Files.deleteIfExists(_source.getFile());
542                    
543                    // Rename temp file to destination file
544                    Files.move(_tmpFile, _source.getFile());
545                }
546                finally
547                {
548                    // Ensure temp file is deleted, ie lock is released.
549                    // If there was a failure above, written data is lost.
550                    Files.deleteIfExists(_tmpFile);
551                    _isClosed = true;
552                }
553            }
554
555        }
556
557        public boolean canCancel()
558        {
559            return !_isClosed;
560        }
561
562        public void cancel() throws Exception
563        {
564            if (_isClosed)
565            {
566                throw new IllegalStateException("Cannot cancel : outputstrem is already closed");
567            }
568
569            _isClosed = true;
570            _os.close();
571            Files.deleteIfExists(_tmpFile);
572        }
573
574        @SuppressWarnings("deprecation")
575        @Override
576        public void finalize() throws Throwable
577        {
578            super.finalize();
579            if (!_isClosed)
580            {
581                // Something wrong happened while writing : delete temp file
582                PathUtils.deleteQuietly(_tmpFile);
583            }
584        }
585
586        public PathSource getSource()
587        {
588            return _source;
589        }
590    }
591}