001/* 002 * Copyright 2019 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.file; 017 018import java.io.BufferedReader; 019import java.io.File; 020import java.io.FileInputStream; 021import java.io.IOException; 022import java.io.InputStream; 023import java.io.InputStreamReader; 024import java.io.OutputStream; 025import java.nio.charset.StandardCharsets; 026import java.nio.file.DirectoryStream; 027import java.nio.file.Files; 028import java.nio.file.Path; 029import java.util.ArrayList; 030import java.util.Collection; 031import java.util.Date; 032import java.util.Enumeration; 033import java.util.HashMap; 034import java.util.List; 035import java.util.Map; 036 037import org.apache.avalon.framework.component.Component; 038import org.apache.avalon.framework.context.Context; 039import org.apache.avalon.framework.context.ContextException; 040import org.apache.avalon.framework.context.Contextualizable; 041import org.apache.avalon.framework.logger.AbstractLogEnabled; 042import org.apache.avalon.framework.service.ServiceException; 043import org.apache.avalon.framework.service.ServiceManager; 044import org.apache.avalon.framework.service.Serviceable; 045import org.apache.cocoon.servlet.multipart.Part; 046import org.apache.cocoon.servlet.multipart.PartOnDisk; 047import org.apache.cocoon.servlet.multipart.RejectedPart; 048import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; 049import org.apache.commons.compress.archivers.zip.ZipFile; 050import org.apache.commons.io.FileUtils; 051import org.apache.commons.io.IOUtils; 052import org.apache.commons.io.file.PathUtils; 053import org.apache.commons.lang3.Strings; 054import org.apache.excalibur.source.ModifiableTraversableSource; 055import org.apache.excalibur.source.Source; 056import org.apache.excalibur.source.SourceResolver; 057import org.apache.excalibur.source.SourceUtil; 058import org.apache.excalibur.source.TraversableSource; 059import org.apache.excalibur.source.impl.FileSource; 060import org.apache.tika.mime.MediaType; 061 062import org.ametys.core.user.CurrentUserProvider; 063import org.ametys.core.util.DateUtils; 064 065 066/** 067 * Helper for managing files and folders of a application directory such as 068 * WEB-INF/params 069 */ 070public final class FileHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable 071{ 072 /** The Avalon role name */ 073 public static final String ROLE = FileHelper.class.getName(); 074 075 /** The current user provider. */ 076 protected CurrentUserProvider _currentUserProvider; 077 078 /** The tika provider */ 079 protected TikaProvider _tikaProvider; 080 /** The source resolver */ 081 protected SourceResolver _srcResolver; 082 083 private org.apache.cocoon.environment.Context _cocoonContext; 084 085 @Override 086 public void service(ServiceManager serviceManager) throws ServiceException 087 { 088 _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE); 089 _srcResolver = (org.apache.excalibur.source.SourceResolver) serviceManager.lookup(org.apache.excalibur.source.SourceResolver.ROLE); 090 _tikaProvider = (TikaProvider) serviceManager.lookup(TikaProvider.ROLE); 091 } 092 093 public void contextualize(Context context) throws ContextException 094 { 095 _cocoonContext = (org.apache.cocoon.environment.Context) context.get(org.apache.cocoon.Constants.CONTEXT_ENVIRONMENT_CONTEXT); 096 } 097 098 /** 099 * Get the files/folders of a given source 100 * @param rootURI The uri of root folder 101 * @param path The path of folder in root folder 102 * @return the child files and folders as JSON representation 103 * @throws IOException if an error occurred 104 */ 105 public List<Map<String, Object>> getFiles(String rootURI, String path) throws IOException 106 { 107 return getFiles(rootURI, path, List.of()); 108 } 109 110 /** 111 * Get files and folders contained in the given path. 112 * @param rootURI The uri of root folder 113 * @param path the relative file's path from files root directory 114 * @param ignoredSources The names of source to ignore 115 * @return the list of parameters files and folders as JSON representation 116 * @throws IOException If an error occurred while listing files 117 */ 118 public List<Map<String, Object>> getFiles(String rootURI, String path, List<String> ignoredSources) throws IOException 119 { 120 TraversableSource rootDir = (TraversableSource) _srcResolver.resolveURI(rootURI); 121 TraversableSource currentDir = (TraversableSource) _srcResolver.resolveURI(rootURI + (path.length() > 0 ? "/" + path : "")); 122 123 if (!currentDir.exists() || !currentDir.isCollection()) 124 { 125 throw new IOException("The source folder '" + currentDir.getURI() + "' does not exist or is not a folder."); 126 } 127 128 List<Map<String, Object>> nodes = new ArrayList<>(); 129 130 for (TraversableSource child : (Collection<TraversableSource>) currentDir.getChildren()) 131 { 132 if (!ignoredSources.contains(child.getName())) 133 { 134 if (child.isCollection()) 135 { 136 nodes.add(getFolderProperties(child, rootDir)); 137 } 138 else 139 { 140 nodes.add(getFileProperties(child, rootDir)); 141 } 142 } 143 } 144 return nodes; 145 } 146 147 /** 148 * Convert collection to JSON object 149 * @param folder the folder 150 * @param root the root directory 151 * @return JSON object 152 */ 153 public Map<String, Object> getFolderProperties (TraversableSource folder, TraversableSource root) 154 { 155 Map<String, Object> jsonObject = new HashMap<>(); 156 jsonObject.put("type", "collection"); 157 jsonObject.put("name", folder.getName()); 158 jsonObject.put("path", _getRelativePath(root, folder)); 159 return jsonObject; 160 } 161 162 /** 163 * Convert file to JSON object 164 * @param file the file 165 * @param root the root directory 166 * @return JSON object 167 */ 168 public Map<String, Object> getFileProperties (TraversableSource file, TraversableSource root) 169 { 170 Map<String, Object> jsonObject = new HashMap<>(); 171 172 jsonObject.put("type", "resource"); 173 jsonObject.put("name", file.getName()); 174 jsonObject.put("path", _getRelativePath(root, file)); 175 176 jsonObject.put("size", file.getContentLength()); 177 jsonObject.put("lastModified", DateUtils.dateToString(new Date(file.getLastModified()))); 178 179 String mimeType = _cocoonContext.getMimeType(file.getName().toLowerCase()); 180 jsonObject.put("mimetype", mimeType != null ? mimeType : "application/unknown"); 181 182 return jsonObject; 183 } 184 185 /** 186 * Get the relative path from root directory 187 * @param root The root directory 188 * @param file The file 189 * @return The relative path 190 */ 191 protected String _getRelativePath (TraversableSource root, TraversableSource file) 192 { 193 String relPath = file.getURI().substring(root.getURI().length()); 194 195 if (relPath.endsWith("/")) 196 { 197 relPath = relPath.substring(0, relPath.length() - 1); 198 } 199 200 return relPath; 201 } 202 203 /** 204 * Saves text to given file in UTF-8 format 205 * 206 * @param fileURI the file URI. Must point to an existing file. 207 * @param text the UTF-8 file content 208 * @return A result map. 209 * @throws IOException If an error occurred while saving 210 */ 211 public Map<String, Object> saveFile(String fileURI, String text) throws IOException 212 { 213 Map<String, Object> result = new HashMap<>(); 214 215 ModifiableTraversableSource src = null; 216 try 217 { 218 src = (ModifiableTraversableSource) _srcResolver.resolveURI(fileURI); 219 220 if (!src.exists()) 221 { 222 result.put("success", false); 223 result.put("error", "unknown-file"); 224 return result; 225 } 226 227 if (src.isCollection()) 228 { 229 result.put("success", false); 230 result.put("error", "is-not-file"); 231 return result; 232 } 233 234 try (OutputStream os = src.getOutputStream()) 235 { 236 IOUtils.write(text, os, StandardCharsets.UTF_8); 237 } 238 239 if (src.getName().startsWith("messages") && src.getName().endsWith(".xml")) 240 { 241 result.put("isI18n", true); 242 } 243 } 244 finally 245 { 246 _srcResolver.release(src); 247 } 248 249 result.put("success", true); 250 return result; 251 } 252 253 /** 254 * Create a folder 255 * 256 * @param parentURI the parent URI, relative to the root 257 * @param name the name of the new folder to create 258 * @param renameIfExists true if the folder have to be renamed if the folder 259 * with same name already exits. 260 * @return The result Map with the name and uri of created folder, or a 261 * boolean "success" to false if an error occurs. 262 * @throws IOException If an error occurred adding the folder 263 */ 264 public Map<String, Object> addFolder(String parentURI, String name, boolean renameIfExists) throws IOException 265 { 266 Map<String, Object> result = new HashMap<>(); 267 268 FileSource parentDir = (FileSource) _srcResolver.resolveURI(parentURI); 269 270 if (!parentDir.isCollection()) 271 { 272 result.put("success", false); 273 result.put("error", "is-not-folder"); 274 return result; 275 } 276 277 int index = 2; 278 String folderName = name; 279 280 if (!renameIfExists && parentDir.getChild(folderName).exists()) 281 { 282 result.put("success", false); 283 result.put("error", "already-exist"); 284 return result; 285 } 286 287 while (parentDir.getChild(folderName).exists()) 288 { 289 folderName = name + " (" + index + ")"; 290 index++; 291 } 292 293 FileSource folder = (FileSource) parentDir.getChild(folderName); 294 folder.makeCollection(); 295 296 result.put("success", true); 297 result.put("name", folder.getName()); 298 result.put("uri", folder.getURI()); 299 300 return result; 301 } 302 303 /** 304 * Add or update a file 305 * 306 * @param part The file multipart to upload 307 * @param parentDir The parent directory 308 * @param mode The insertion mode: 'add-rename' or 'update' or null. 309 * @param unzip true to unzip .zip file 310 * @return the result map 311 * @throws IOException If an error occurred manipulating the file 312 */ 313 public Map<String, Object> addOrUpdateFile(Part part, FileSource parentDir, String mode, boolean unzip) throws IOException 314 { 315 Map<String, Object> result = new HashMap<>(); 316 317 if (!(part instanceof PartOnDisk)) 318 { 319 result.put("success", false); 320 if (part instanceof RejectedPart rejectedPart && rejectedPart.getMaxContentLength() == 0) 321 { 322 result.put("error", "infected"); 323 } 324 else // if (part == null || partUploaded instanceof RejectedPart) 325 { 326 result.put("error", "rejected"); 327 } 328 return result; 329 } 330 331 PartOnDisk uploadedFilePart = (PartOnDisk) part; 332 File uploadedFile = uploadedFilePart.getFile(); 333 334 String fileName = uploadedFile.getName(); 335 FileSource file = (FileSource) parentDir.getChild(fileName); 336 if (fileName.toLowerCase().endsWith(".zip") && unzip) 337 { 338 try 339 { 340 // Unzip the uploaded file 341 ZipFile zipFile = ZipFile.builder().setFile(uploadedFile).setCharset("cp437").get(); 342 _unzip(parentDir, zipFile); 343 344 result.put("unzip", true); 345 result.put("success", true); 346 return result; 347 } 348 catch (IOException e) 349 { 350 getLogger().error("Failed to unzip file " + uploadedFile.getPath(), e); 351 result.put("success", false); 352 result.put("error", "unzip-error"); 353 return result; 354 } 355 } 356 else if (file.exists()) 357 { 358 if ("add-rename".equals(mode)) 359 { 360 // Find a new name 361 String[] f = fileName.split("\\."); 362 int index = 1; 363 while (parentDir.getChild(fileName).exists()) 364 { 365 fileName = f[0] + "-" + (index++) + '.' + f[1]; 366 } 367 368 file = (FileSource) parentDir.getChild(fileName); 369 } 370 else if (!"update".equals(mode)) 371 { 372 result.put("success", false); 373 result.put("error", "already-exist"); 374 return result; 375 } 376 } 377 else 378 { 379 file.getFile().createNewFile(); 380 } 381 382 InputStream is = new FileInputStream(uploadedFile); 383 384 SourceUtil.copy(is, file.getOutputStream()); 385 386 result.put("name", file.getName()); 387 result.put("uri", file.getURI()); 388 result.put("success", true); 389 390 return result; 391 } 392 393 private void _unzip(FileSource destSrc, ZipFile zipFile) throws IOException 394 { 395 Enumeration<ZipArchiveEntry> entries = zipFile.getEntries(); 396 while (entries.hasMoreElements()) 397 { 398 FileSource parentCollection = destSrc; 399 400 ZipArchiveEntry zipEntry = entries.nextElement(); 401 402 String zipName = zipEntry.getName(); 403 String[] path = zipName.split("/"); 404 405 for (int i = 0; i < path.length - 1; i++) 406 { 407 String name = path[i]; 408 parentCollection = _addCollection(parentCollection, name); 409 } 410 411 String name = path[path.length - 1]; 412 if (zipEntry.isDirectory()) 413 { 414 parentCollection = _addCollection(parentCollection, name); 415 } 416 else 417 { 418 _addZipEntry(parentCollection, zipFile, zipEntry, name); 419 } 420 } 421 } 422 423 private FileSource _addCollection(FileSource collection, String name) throws IOException 424 { 425 FileSource src = (FileSource) collection.getChild(name); 426 if (!src.exists()) 427 { 428 src.makeCollection(); 429 } 430 431 return src; 432 } 433 434 private void _addZipEntry(FileSource collection, ZipFile zipFile, ZipArchiveEntry zipEntry, String fileName) throws IOException 435 { 436 FileSource fileSrc = (FileSource) collection.getChild(fileName); 437 438 try (InputStream is = zipFile.getInputStream(zipEntry)) 439 { 440 SourceUtil.copy(is, fileSrc.getOutputStream()); 441 } 442 catch (IOException e) 443 { 444 // Do nothing 445 } 446 } 447 448 /** 449 * Remove a folder or a file 450 * 451 * @param fileUri the file/folder URI 452 * @return the result map. 453 * @throws IOException If an error occurs while removing the folder/file 454 */ 455 public Map<String, Object> deleteFile(String fileUri) throws IOException 456 { 457 Map<String, Object> result = new HashMap<>(); 458 459 FileSource file = (FileSource) _srcResolver.resolveURI(fileUri); 460 461 if (file.exists()) 462 { 463 FileUtils.deleteQuietly(file.getFile()); 464 result.put("success", true); 465 } 466 else 467 { 468 result.put("success", false); 469 result.put("error", "no-exists"); 470 } 471 472 return result; 473 } 474 475 /** 476 * Delete all files corresponding to the file filter into the file tree. 477 * @param path the path to delete (can be a file or a directory) 478 * @param fileFilter the file filter to apply 479 * @param recursiveDelete if <code>true</code>, the file tree will be explored to delete files 480 * @param deleteEmptyDirs if <code>true</code>, empty dirs will be deleted 481 * @throws IOException if an error occured while exploring or deleting files 482 */ 483 public void delete(Path path, DirectoryStream.Filter<Path> fileFilter, boolean recursiveDelete, boolean deleteEmptyDirs) throws IOException 484 { 485 if (Files.isDirectory(path)) 486 { 487 if (recursiveDelete) 488 { 489 try (DirectoryStream<Path> entries = Files.newDirectoryStream(path)) 490 { 491 for (Path entry : entries) 492 { 493 delete(entry, fileFilter, recursiveDelete, deleteEmptyDirs); 494 } 495 } 496 } 497 498 if (deleteEmptyDirs && PathUtils.isEmptyDirectory(path)) 499 { 500 Files.delete(path); 501 } 502 } 503 else if (fileFilter.accept(path)) 504 { 505 Files.delete(path); 506 } 507 } 508 509 /** 510 * Rename a file or a folder 511 * 512 * @param fileUri the relative URI of the file or folder to rename 513 * @param name the new name of the file/folder 514 * @return The result Map with the name, path of the renamed file/folder, or 515 * a boolean "already-exist" is a file/folder already exists with 516 * this name. 517 * @throws IOException if an error occurs while renaming the file/folder 518 */ 519 public Map<String, Object> renameFile(String fileUri, String name) throws IOException 520 { 521 Map<String, Object> result = new HashMap<>(); 522 523 FileSource file = (FileSource) _srcResolver.resolveURI(fileUri); 524 FileSource parentDir = (FileSource) file.getParent(); 525 526 // Case sensitive exists 527 if (file.getFile().getName().equals(name) && parentDir.getChild(name).exists()) 528 { 529 result.put("success", false); 530 result.put("error", "already-exist"); 531 } 532 else 533 { 534 Source dest = _srcResolver.resolveURI(parentDir.getURI() + name); 535 file.moveTo(dest); 536 537 result.put("success", true); 538 result.put("uri", parentDir.getURI() + name); 539 result.put("name", name); 540 } 541 542 return result; 543 } 544 545 /** 546 * Tests if a file/folder with given name exists 547 * 548 * @param parentUri the parent folder URI 549 * @param name the name of the child 550 * @return true if the file exists 551 * @throws IOException if an error occurred 552 */ 553 public boolean hasChild(String parentUri, String name) throws IOException 554 { 555 FileSource currentDir = (FileSource) _srcResolver.resolveURI(parentUri); 556 return currentDir.getChild(name).exists(); 557 } 558 559 /** 560 * Copy a file or folder 561 * 562 * @param srcUri The URI of file/folder to copy 563 * @param parentTargetUri The URI of parent target file 564 * @return a result map with the name and uri of copied file in case of 565 * success. 566 * @throws IOException If an error occured manipulating the source 567 */ 568 public Map<String, Object> copySource(String srcUri, String parentTargetUri) throws IOException 569 { 570 Map<String, Object> result = new HashMap<>(); 571 572 FileSource srcFile = (FileSource) _srcResolver.resolveURI(srcUri); 573 574 if (!srcFile.exists()) 575 { 576 result.put("success", false); 577 result.put("error", "no-exists"); 578 return result; 579 } 580 581 String srcFileName = srcFile.getName(); 582 FileSource targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (srcFileName.length() > 0 ? "/" + srcFileName : "")); 583 584 // Find unique file name 585 int index = 2; 586 String fileName = srcFileName; 587 while (targetFile.exists()) 588 { 589 fileName = srcFileName + " (" + index + ")"; 590 targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (fileName.length() > 0 ? "/" + fileName : "")); 591 index++; 592 } 593 594 if (srcFile.getFile().isDirectory()) 595 { 596 FileUtils.copyDirectory(srcFile.getFile(), targetFile.getFile()); 597 } 598 else 599 { 600 FileUtils.copyFile(srcFile.getFile(), targetFile.getFile()); 601 } 602 603 result.put("success", true); 604 result.put("name", targetFile.getName()); 605 result.put("uri", targetFile.getURI()); 606 607 return result; 608 } 609 610 /** 611 * Move a file or folder 612 * 613 * @param srcUri The URI of file/folder to move 614 * @param parentTargetUri The URI of parent target file 615 * @return a result map with the name and uri of moved file in case of 616 * success. 617 * @throws IOException If an error occurred manipulating the source 618 */ 619 public Map<String, Object> moveSource(String srcUri, String parentTargetUri) throws IOException 620 { 621 Map<String, Object> result = new HashMap<>(); 622 623 FileSource srcFile = (FileSource) _srcResolver.resolveURI(srcUri); 624 625 if (!srcFile.exists()) 626 { 627 result.put("success", false); 628 result.put("error", "no-exists"); 629 return result; 630 } 631 632 FileSource parentDargetDir = (FileSource) _srcResolver.resolveURI(parentTargetUri); 633 String fileName = srcFile.getName(); 634 FileSource targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (fileName.length() > 0 ? "/" + fileName : "")); 635 636 if (targetFile.exists()) 637 { 638 result.put("msg", "already-exists"); 639 return result; 640 } 641 642 FileUtils.moveToDirectory(srcFile.getFile(), parentDargetDir.getFile(), false); 643 644 result.put("success", true); 645 result.put("name", targetFile.getName()); 646 result.put("uri", targetFile.getURI()); 647 648 return result; 649 } 650 651 /** 652 * Get the URIs of sources which match filter value. Source are filtered both on their filename and in their content for text file. 653 * The search will be performed on the current source and all its descendants 654 * @param source The source to start search 655 * @param value the value to match 656 * @return the URIs of matching source 657 */ 658 public List<String> filterSources(TraversableSource source, String value) 659 { 660 return _filterSources(source, _standardizeValue(value)); 661 } 662 663 private List<String> _filterSources(TraversableSource source, String value) 664 { 665 List<String> matches = new ArrayList<>(); 666 if (source.isCollection()) 667 { 668 // Check if the collection match 669 if (_sourceNameMatch(source, value)) 670 { 671 matches.add(source.getURI()); 672 } 673 674 // Check if any children match 675 try 676 { 677 Collection<TraversableSource> children = source.getChildren(); 678 for (TraversableSource child : children) 679 { 680 matches.addAll(_filterSources(child, value)); 681 } 682 } 683 catch (IOException e) 684 { 685 getLogger().error("Failed to retrieve children for source '" + source.getURI() + "'. Potential children will be ignored."); 686 } 687 } 688 else if (_resourceMatch(source, value)) 689 { 690 matches.add(source.getURI()); 691 } 692 693 return matches; 694 695 } 696 // pre-process string before comparison 697 private String _standardizeValue(String value) 698 { 699 return value.toLowerCase(); 700 } 701 702 private boolean _resourceMatch(TraversableSource currentSrc, String value) 703 { 704 if (_sourceNameMatch(currentSrc, value)) 705 { 706 return true; 707 } 708 else 709 { 710 // detect always return something as "application/octet-stream" if nothing else 711 MediaType mediaType = MediaType.parse(_tikaProvider.getTika().detect(currentSrc.getName())); 712 // Only read the file if its a text file. 713 // We will be able to read line by line that way 714 // without loading all the file at once 715 if (_isSupportedType(mediaType)) 716 { 717 try (InputStream is = currentSrc.getInputStream()) 718 { 719 BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); // We just hope its actually UTF-8 720 String line; 721 while ((line = reader.readLine()) != null) 722 { 723 if (_standardizeValue(line).contains(value)) 724 { 725 return true; 726 } 727 } 728 } 729 catch (IOException e) 730 { 731 getLogger().error("An error occurred while trying to read the definition file at '" + currentSrc.getURI() + "'", e); 732 } 733 } 734 } 735 return false; 736 } 737 738 private boolean _isSupportedType(MediaType mediaType) 739 { 740 String type = mediaType.getType(); 741 if (Strings.CS.equals(type, "text")) 742 { 743 return true; 744 } 745 else if (Strings.CS.equals(type, "application")) 746 { 747 String subtype = mediaType.getSubtype(); 748 return Strings.CS.equals(subtype, "xml") 749 || Strings.CS.contains(subtype, "+xml") // + to avoid matching mybinaryxmltype 750 || Strings.CS.equals(subtype, "json") 751 || Strings.CS.contains(subtype, "+json"); // + to avoid matching mybinaryjsontype 752 } 753 return false; 754 } 755 756 private boolean _sourceNameMatch(TraversableSource currentSrc, String value) 757 { 758 return _standardizeValue(currentSrc.getName()).contains(value); 759 } 760}