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