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}