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}