001/* 002 * Copyright 2020 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.plugins.contentio.archive; 017 018import java.io.BufferedOutputStream; 019import java.io.File; 020import java.io.FileOutputStream; 021import java.io.IOException; 022import java.io.InputStream; 023import java.lang.reflect.Type; 024import java.nio.charset.StandardCharsets; 025import java.nio.file.Path; 026import java.util.ArrayList; 027import java.util.Collection; 028import java.util.LinkedHashMap; 029import java.util.List; 030import java.util.Map; 031import java.util.Objects; 032import java.util.function.Function; 033import java.util.stream.Collectors; 034import java.util.stream.Stream; 035import java.util.zip.ZipEntry; 036import java.util.zip.ZipOutputStream; 037 038import org.apache.avalon.framework.component.Component; 039import org.apache.avalon.framework.context.Context; 040import org.apache.avalon.framework.context.ContextException; 041import org.apache.avalon.framework.context.Contextualizable; 042import org.apache.avalon.framework.service.ServiceException; 043import org.apache.avalon.framework.service.ServiceManager; 044import org.apache.avalon.framework.service.Serviceable; 045import org.apache.commons.io.FileUtils; 046import org.apache.commons.io.FilenameUtils; 047import org.apache.commons.io.IOUtils; 048 049import org.ametys.runtime.plugin.component.AbstractLogEnabled; 050import org.ametys.runtime.util.AmetysHomeHelper; 051 052import com.fasterxml.jackson.core.JsonParseException; 053import com.google.gson.Gson; 054import com.google.gson.GsonBuilder; 055import com.google.gson.JsonSyntaxException; 056import com.google.gson.reflect.TypeToken; 057 058/** 059 * Archive data on disk. 060 */ 061public class ArchiveHandler extends AbstractLogEnabled implements Component, Serviceable, Contextualizable 062{ 063 /** Avalon role. */ 064 public static final String ROLE = ArchiveHandler.class.getName(); 065 066 /** The prefix path for exporting metadata content */ 067 public static final String METADATA_PREFIX = "_metadata/"; 068 069 static final String MANIFEST_FILENAME = "manifest.json"; 070 071 private static final Gson __GSON = new GsonBuilder() 072 .setPrettyPrinting() 073 .disableHtmlEscaping() 074 .create(); 075 076 /** The context */ 077 protected Context _context; 078 079 private ArchiverExtensionPoint _archiverEP; 080 081 @Override 082 public void service(ServiceManager manager) throws ServiceException 083 { 084 _archiverEP = (ArchiverExtensionPoint) manager.lookup(ArchiverExtensionPoint.ROLE); 085 } 086 087 @Override 088 public void contextualize(Context context) throws ContextException 089 { 090 _context = context; 091 } 092 093 /** 094 * Gets the folder containing all the managed archives 095 * @return the folder containing all the managed archives 096 */ 097 public Path getArchiveFolder() 098 { 099 Path allArchives = AmetysHomeHelper.getAmetysHomeData().toPath().resolve("archives"); 100 allArchives.toFile().mkdirs(); 101 return allArchives; 102 } 103 104 /** 105 * Gets the archive files 106 * @return the archive files 107 */ 108 public Stream<String> getArchiveFiles() 109 { 110 Path archiveFolder = getArchiveFolder(); 111 return FileUtils.listFiles(archiveFolder.toFile(), new String[] {"zip"}, false) 112 .stream() 113 .map(File::getName); 114 } 115 116 /** 117 * Gets the archive file 118 * @param name The archive name 119 * @return the archive file 120 */ 121 public File getArchiveFile(String name) 122 { 123 if (!FilenameUtils.isExtension(name, "zip")) 124 { 125 throw new IllegalArgumentException(String.format("Wrong archive name '%s', it should be a ZIP file", name)); 126 } 127 128 Path archiveFolder = getArchiveFolder(); 129 File archive = archiveFolder.resolve(name) 130 .toFile(); 131 132 if (archive.exists()) 133 { 134 return archive; 135 } 136 137 throw new IllegalArgumentException(String.format("Archive file '%s' does not exist", archive)); 138 } 139 140 /** 141 * Gets the partial imports of the given archive 142 * @param archiveName The archive name 143 * @return the partial imports 144 * @throws IOException if an I/O error occured 145 */ 146 public Stream<PartialImport> getPartialImports(String archiveName) throws IOException 147 { 148 Objects.requireNonNull(archiveName, "The archive name cannot be null"); 149 150 Path archiveFolder = getArchiveFolder(); 151 Path archive = archiveFolder.resolve(archiveName); 152 return _partialImports(archive); 153 } 154 155 private Stream<PartialImport> _partialImports(Path archive) throws IOException 156 { 157 try (InputStream manifestIs = ZipEntryHelper.zipEntryFileInputStream(archive, MANIFEST_FILENAME);) 158 { 159 String manifestContent = IOUtils.toString(manifestIs, StandardCharsets.UTF_8); 160 Type mapType = new TypeToken<Map<String, Object>>() 161 { /*empty*/ } 162 .getType(); 163 Map<String, Object> manifestJson = __GSON.fromJson(manifestContent, mapType); 164 return _partialImportsFromJson(manifestJson); 165 } 166 catch (JsonParseException | JsonSyntaxException e) 167 { 168 throw new IOException(String.format("An error occured with the manifest file '%s' of archive '%s'.\nThe cause is: %s", MANIFEST_FILENAME, archive, e.getMessage())); 169 } 170 } 171 172 private Stream<PartialImport> _partialImportsFromJson(Map<String, Object> manifestJson) 173 { 174 return _archiverEP.getExtensionsIds() 175 .stream() 176 .filter(manifestJson::containsKey) 177 .map(archiverId -> _partialImportsFromJsonAndArchiver(manifestJson, archiverId)) 178 .flatMap(Function.identity()); 179 } 180 181 private Stream<PartialImport> _partialImportsFromJsonAndArchiver(Map<String, Object> manifestJson, String archiverId) 182 { 183 Archiver archiver = _archiverEP.getExtension(archiverId); 184 ManifestReaderWriter manifestReaderWriter = archiver.getManifestReaderWriter(); 185 Object jsonData = manifestJson.get(archiverId); 186 return manifestReaderWriter.toPartialImports(jsonData); 187 } 188 189 /** 190 * Exports with {@link ArchiverExtensionPoint} mechanism into the given {@link File} 191 * @param output The output ZIP file 192 * @throws IOException if an IO exception occured 193 */ 194 public void export(File output) throws IOException 195 { 196 try (var fileOs = new FileOutputStream(output); 197 var bufferedOs = new BufferedOutputStream(fileOs); 198 var zos = new ZipOutputStream(bufferedOs, StandardCharsets.UTF_8)) 199 { 200 // Handle manifest data 201 _exportManifest(zos); 202 203 // Process actual data 204 _exportActualData(zos); 205 } 206 } 207 208 private void _exportManifest(ZipOutputStream zos) throws IOException 209 { 210 Map<String, Object> manifestData = new LinkedHashMap<>(); 211 212 for (String id : _archiverEP.getExtensionsIds()) 213 { 214 Archiver archiver = _archiverEP.getExtension(id); 215 ManifestReaderWriter manifestReaderWriter = archiver.getManifestReaderWriter(); 216 Object archiverManifestData = manifestReaderWriter.getData(); 217 manifestData.put(id, archiverManifestData); 218 } 219 220 String json = __GSON.toJson(manifestData); 221 ZipEntry manifestEntry = new ZipEntry(MANIFEST_FILENAME); 222 zos.putNextEntry(manifestEntry); 223 IOUtils.write(json, zos, StandardCharsets.UTF_8); 224 } 225 226 private void _exportActualData(ZipOutputStream zos) throws IOException 227 { 228 for (String id : _archiverEP.getExtensionsIds()) 229 { 230 Archiver archiver = _archiverEP.getExtension(id); 231 232 getLogger().info("Processing archiver {} ({}) for export", id, archiver.getClass().getName()); 233 234 archiver.export(zos); 235 } 236 } 237 238 private ImportReport _partialImport(String archiverId, Path zipPath, Collection<String> partialImports, Merger merger) throws IOException 239 { 240 Archiver archiver = _archiverEP.getExtension(archiverId); 241 Collection<String> managedPartialImports = archiver.managedPartialImports(partialImports); 242 if (managedPartialImports.isEmpty()) 243 { 244 getLogger().info("Archiver '{}' ({}) not processed, given partial imports ({}) do not match", archiverId, archiver.getClass().getName(), partialImports); 245 return new ImportReport(); 246 } 247 else 248 { 249 getLogger().info("Processing archiver '{}' ({}) for import, with matching partial imports {}", archiverId, archiver.getClass().getName(), managedPartialImports); 250 return archiver.partialImport(zipPath, managedPartialImports, merger, merger.deleteBeforePartialImport()); 251 } 252 } 253 254 /** 255 * Imports all data from the given ZIP archive 256 * @param input the ZIP archive 257 * @param merger the {@link Merger} 258 * @return The {@link ImportReport} 259 * @throws IOException if an I/O error occurs 260 */ 261 public ImportReport importAll(File input, Merger merger) throws IOException 262 { 263 Path zipPath = input.toPath(); 264 // Import all declared partial imports 265 Collection<String> allPartialImports = _partialImports(zipPath) 266 .map(PartialImport::getKey) 267 .collect(Collectors.toList()); 268 return partialImport(input, allPartialImports, merger); 269 } 270 271 /** 272 * Imports partial data from the given ZIP archive 273 * @param input the ZIP archive 274 * @param partialImports the partial imports 275 * @param merger the {@link Merger} 276 * @return The {@link ImportReport} 277 * @throws IOException if an I/O error occurs 278 */ 279 public ImportReport partialImport(File input, Collection<String> partialImports, Merger merger) throws IOException 280 { 281 List<ImportReport> reports = new ArrayList<>(); 282 283 Path zipPath = input.toPath(); 284 for (String id : _archiverEP.getExtensionsIds()) 285 { 286 ImportReport partialImportResult = _partialImport(id, zipPath, partialImports, merger); 287 reports.add(partialImportResult); 288 } 289 290 return ImportReport.union(reports); 291 } 292}