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.StringUtils; 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 _unzip(parentDir, new ZipFile(uploadedFile, "cp437")); 342 343 result.put("unzip", true); 344 result.put("success", true); 345 return result; 346 } 347 catch (IOException e) 348 { 349 getLogger().error("Failed to unzip file " + uploadedFile.getPath(), e); 350 result.put("success", false); 351 result.put("error", "unzip-error"); 352 return result; 353 } 354 } 355 else if (file.exists()) 356 { 357 if ("add-rename".equals(mode)) 358 { 359 // Find a new name 360 String[] f = fileName.split("\\."); 361 int index = 1; 362 while (parentDir.getChild(fileName).exists()) 363 { 364 fileName = f[0] + "-" + (index++) + '.' + f[1]; 365 } 366 367 file = (FileSource) parentDir.getChild(fileName); 368 } 369 else if (!"update".equals(mode)) 370 { 371 result.put("success", false); 372 result.put("error", "already-exist"); 373 return result; 374 } 375 } 376 else 377 { 378 file.getFile().createNewFile(); 379 } 380 381 InputStream is = new FileInputStream(uploadedFile); 382 383 SourceUtil.copy(is, file.getOutputStream()); 384 385 result.put("name", file.getName()); 386 result.put("uri", file.getURI()); 387 result.put("success", true); 388 389 return result; 390 } 391 392 private void _unzip(FileSource destSrc, ZipFile zipFile) throws IOException 393 { 394 Enumeration<ZipArchiveEntry> entries = zipFile.getEntries(); 395 while (entries.hasMoreElements()) 396 { 397 FileSource parentCollection = destSrc; 398 399 ZipArchiveEntry zipEntry = entries.nextElement(); 400 401 String zipName = zipEntry.getName(); 402 String[] path = zipName.split("/"); 403 404 for (int i = 0; i < path.length - 1; i++) 405 { 406 String name = path[i]; 407 parentCollection = _addCollection(parentCollection, name); 408 } 409 410 String name = path[path.length - 1]; 411 if (zipEntry.isDirectory()) 412 { 413 parentCollection = _addCollection(parentCollection, name); 414 } 415 else 416 { 417 _addZipEntry(parentCollection, zipFile, zipEntry, name); 418 } 419 } 420 } 421 422 private FileSource _addCollection(FileSource collection, String name) throws IOException 423 { 424 FileSource src = (FileSource) collection.getChild(name); 425 if (!src.exists()) 426 { 427 src.makeCollection(); 428 } 429 430 return src; 431 } 432 433 private void _addZipEntry(FileSource collection, ZipFile zipFile, ZipArchiveEntry zipEntry, String fileName) throws IOException 434 { 435 FileSource fileSrc = (FileSource) collection.getChild(fileName); 436 437 try (InputStream is = zipFile.getInputStream(zipEntry)) 438 { 439 SourceUtil.copy(is, fileSrc.getOutputStream()); 440 } 441 catch (IOException e) 442 { 443 // Do nothing 444 } 445 } 446 447 /** 448 * Remove a folder or a file 449 * 450 * @param fileUri the file/folder URI 451 * @return the result map. 452 * @throws IOException If an error occurs while removing the folder/file 453 */ 454 public Map<String, Object> deleteFile(String fileUri) throws IOException 455 { 456 Map<String, Object> result = new HashMap<>(); 457 458 FileSource file = (FileSource) _srcResolver.resolveURI(fileUri); 459 460 if (file.exists()) 461 { 462 FileUtils.deleteQuietly(file.getFile()); 463 result.put("success", true); 464 } 465 else 466 { 467 result.put("success", false); 468 result.put("error", "no-exists"); 469 } 470 471 return result; 472 } 473 474 /** 475 * Delete all files corresponding to the file filter into the file tree. 476 * @param path the path to delete (can be a file or a directory) 477 * @param fileFilter the file filter to apply 478 * @param recursiveDelete if <code>true</code>, the file tree will be explored to delete files 479 * @param deleteEmptyDirs if <code>true</code>, empty dirs will be deleted 480 * @throws IOException if an error occured while exploring or deleting files 481 */ 482 public void delete(Path path, DirectoryStream.Filter<Path> fileFilter, boolean recursiveDelete, boolean deleteEmptyDirs) throws IOException 483 { 484 if (Files.isDirectory(path)) 485 { 486 if (recursiveDelete) 487 { 488 try (DirectoryStream<Path> entries = Files.newDirectoryStream(path)) 489 { 490 for (Path entry : entries) 491 { 492 delete(entry, fileFilter, recursiveDelete, deleteEmptyDirs); 493 } 494 } 495 } 496 497 if (deleteEmptyDirs && PathUtils.isEmptyDirectory(path)) 498 { 499 Files.delete(path); 500 } 501 } 502 else if (fileFilter.accept(path)) 503 { 504 Files.delete(path); 505 } 506 } 507 508 /** 509 * Rename a file or a folder 510 * 511 * @param fileUri the relative URI of the file or folder to rename 512 * @param name the new name of the file/folder 513 * @return The result Map with the name, path of the renamed file/folder, or 514 * a boolean "already-exist" is a file/folder already exists with 515 * this name. 516 * @throws IOException if an error occurs while renaming the file/folder 517 */ 518 public Map<String, Object> renameFile(String fileUri, String name) throws IOException 519 { 520 Map<String, Object> result = new HashMap<>(); 521 522 FileSource file = (FileSource) _srcResolver.resolveURI(fileUri); 523 FileSource parentDir = (FileSource) file.getParent(); 524 525 // Case sensitive exists 526 if (file.getFile().getName().equals(name) && parentDir.getChild(name).exists()) 527 { 528 result.put("success", false); 529 result.put("error", "already-exist"); 530 } 531 else 532 { 533 Source dest = _srcResolver.resolveURI(parentDir.getURI() + name); 534 file.moveTo(dest); 535 536 result.put("success", true); 537 result.put("uri", parentDir.getURI() + name); 538 result.put("name", name); 539 } 540 541 return result; 542 } 543 544 /** 545 * Tests if a file/folder with given name exists 546 * 547 * @param parentUri the parent folder URI 548 * @param name the name of the child 549 * @return true if the file exists 550 * @throws IOException if an error occurred 551 */ 552 public boolean hasChild(String parentUri, String name) throws IOException 553 { 554 FileSource currentDir = (FileSource) _srcResolver.resolveURI(parentUri); 555 return currentDir.getChild(name).exists(); 556 } 557 558 /** 559 * Copy a file or folder 560 * 561 * @param srcUri The URI of file/folder to copy 562 * @param parentTargetUri The URI of parent target file 563 * @return a result map with the name and uri of copied file in case of 564 * success. 565 * @throws IOException If an error occured manipulating the source 566 */ 567 public Map<String, Object> copySource(String srcUri, String parentTargetUri) throws IOException 568 { 569 Map<String, Object> result = new HashMap<>(); 570 571 FileSource srcFile = (FileSource) _srcResolver.resolveURI(srcUri); 572 573 if (!srcFile.exists()) 574 { 575 result.put("success", false); 576 result.put("error", "no-exists"); 577 return result; 578 } 579 580 String srcFileName = srcFile.getName(); 581 FileSource targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (srcFileName.length() > 0 ? "/" + srcFileName : "")); 582 583 // Find unique file name 584 int index = 2; 585 String fileName = srcFileName; 586 while (targetFile.exists()) 587 { 588 fileName = srcFileName + " (" + index + ")"; 589 targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (fileName.length() > 0 ? "/" + fileName : "")); 590 index++; 591 } 592 593 if (srcFile.getFile().isDirectory()) 594 { 595 FileUtils.copyDirectory(srcFile.getFile(), targetFile.getFile()); 596 } 597 else 598 { 599 FileUtils.copyFile(srcFile.getFile(), targetFile.getFile()); 600 } 601 602 result.put("success", true); 603 result.put("name", targetFile.getName()); 604 result.put("uri", targetFile.getURI()); 605 606 return result; 607 } 608 609 /** 610 * Move a file or folder 611 * 612 * @param srcUri The URI of file/folder to move 613 * @param parentTargetUri The URI of parent target file 614 * @return a result map with the name and uri of moved file in case of 615 * success. 616 * @throws IOException If an error occurred manipulating the source 617 */ 618 public Map<String, Object> moveSource(String srcUri, String parentTargetUri) throws IOException 619 { 620 Map<String, Object> result = new HashMap<>(); 621 622 FileSource srcFile = (FileSource) _srcResolver.resolveURI(srcUri); 623 624 if (!srcFile.exists()) 625 { 626 result.put("success", false); 627 result.put("error", "no-exists"); 628 return result; 629 } 630 631 FileSource parentDargetDir = (FileSource) _srcResolver.resolveURI(parentTargetUri); 632 String fileName = srcFile.getName(); 633 FileSource targetFile = (FileSource) _srcResolver.resolveURI(parentTargetUri + (fileName.length() > 0 ? "/" + fileName : "")); 634 635 if (targetFile.exists()) 636 { 637 result.put("msg", "already-exists"); 638 return result; 639 } 640 641 FileUtils.moveToDirectory(srcFile.getFile(), parentDargetDir.getFile(), false); 642 643 result.put("success", true); 644 result.put("name", targetFile.getName()); 645 result.put("uri", targetFile.getURI()); 646 647 return result; 648 } 649 650 /** 651 * Get the URIs of sources which match filter value. Source are filtered both on their filename and in their content for text file. 652 * The search will be performed on the current source and all its descendants 653 * @param source The source to start search 654 * @param value the value to match 655 * @return the URIs of matching source 656 */ 657 public List<String> filterSources(TraversableSource source, String value) 658 { 659 return _filterSources(source, _standardizeValue(value)); 660 } 661 662 private List<String> _filterSources(TraversableSource source, String value) 663 { 664 List<String> matches = new ArrayList<>(); 665 if (source.isCollection()) 666 { 667 // Check if the collection match 668 if (_sourceNameMatch(source, value)) 669 { 670 matches.add(source.getURI()); 671 } 672 673 // Check if any children match 674 try 675 { 676 Collection<TraversableSource> children = source.getChildren(); 677 for (TraversableSource child : children) 678 { 679 matches.addAll(_filterSources(child, value)); 680 } 681 } 682 catch (IOException e) 683 { 684 getLogger().error("Failed to retrieve children for source '" + source.getURI() + "'. Potential children will be ignored."); 685 } 686 } 687 else if (_resourceMatch(source, value)) 688 { 689 matches.add(source.getURI()); 690 } 691 692 return matches; 693 694 } 695 // pre-process string before comparison 696 private String _standardizeValue(String value) 697 { 698 return value.toLowerCase(); 699 } 700 701 private boolean _resourceMatch(TraversableSource currentSrc, String value) 702 { 703 if (_sourceNameMatch(currentSrc, value)) 704 { 705 return true; 706 } 707 else 708 { 709 // detect always return something as "application/octet-stream" if nothing else 710 MediaType mediaType = MediaType.parse(_tikaProvider.getTika().detect(currentSrc.getName())); 711 // Only read the file if its a text file. 712 // We will be able to read line by line that way 713 // without loading all the file at once 714 if (_isSupportedType(mediaType)) 715 { 716 try (InputStream is = currentSrc.getInputStream()) 717 { 718 BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); // We just hope its actually UTF-8 719 String line; 720 while ((line = reader.readLine()) != null) 721 { 722 if (_standardizeValue(line).contains(value)) 723 { 724 return true; 725 } 726 } 727 } 728 catch (IOException e) 729 { 730 getLogger().error("An error occurred while trying to read the definition file at '" + currentSrc.getURI() + "'", e); 731 } 732 } 733 } 734 return false; 735 } 736 737 private boolean _isSupportedType(MediaType mediaType) 738 { 739 String type = mediaType.getType(); 740 if (StringUtils.equals(type, "text")) 741 { 742 return true; 743 } 744 else if (StringUtils.equals(type, "application")) 745 { 746 String subtype = mediaType.getSubtype(); 747 return StringUtils.equals(subtype, "xml") 748 || StringUtils.contains(subtype, "+xml") // + to avoid matching mybinaryxmltype 749 || StringUtils.equals(subtype, "json") 750 || StringUtils.contains(subtype, "+json"); // + to avoid matching mybinaryjsontype 751 } 752 return false; 753 } 754 755 private boolean _sourceNameMatch(TraversableSource currentSrc, String value) 756 { 757 return _standardizeValue(currentSrc.getName()).contains(value); 758 } 759}