ContextService.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.rdf4j.repository.RepositoryException;
import org.eclipse.rdf4j.rio.RDFWriter;
import org.eclipse.rdf4j.rio.trig.TriGWriter;
import org.entrystore.Context;
import org.entrystore.ContextManager;
import org.entrystore.Entry;
import org.entrystore.PrincipalManager;
import org.entrystore.User;
import org.entrystore.impl.DataImpl;
import org.entrystore.impl.EntryNamesContext;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.repository.util.FileOperations;
import org.entrystore.repository.util.URISplit;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.EntityNotFoundException;
import org.entrystore.rest.springboot.model.exception.ForbiddenException;
import org.entrystore.rest.springboot.model.exception.InternalServerErrorException;
import org.entrystore.rest.springboot.util.GraphUtil;
import org.entrystore.rest.springboot.util.HttpUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

@Slf4j
@Service
@RequiredArgsConstructor
public class ContextService {

	private final RepositoryManagerImpl repositoryManager;
	private final ReservedNamesService reservedNames;
	private final PrincipalManager principalManager;

	@Value("${entrystore.data.folder:#{null}}")
	private String dataFolder;


	/**
	 * Retrieves the {@link Context} associated with the given context ID or throws an exception if no such context exists.
	 *
	 * @param contextId the ID of the context to retrieve
	 * @return the context associated with the given ID
	 * @throws EntityNotFoundException if the context with the specified ID is not found
	 */
	public Context getContextOrThrow(String contextId) {
		Context context = getContext(contextId);
		if (context == null) {
			throw new EntityNotFoundException("Context with id '" + contextId + "' does not exist");
		}
		return context;
	}

	/**
	 * Lists the IDs of a context's entries; admin only.
	 *
	 * <p>The existence check runs before the admin check, so a missing context is 404 for every caller. Clients
	 * rely on this to test whether a context ID or name is free (as in 5.x).
	 *
	 * @throws EntityNotFoundException if the context does not exist
	 * @throws ForbiddenException if the context exists and the caller is not an admin
	 */
	public List<String> getContextEntries(String contextId, boolean deletedEntries, String entryName) {

		Context context = getContextOrThrow(contextId);
		checkMayListEntries();

		if (deletedEntries) {

			return context.getDeletedEntries().keySet()
					.stream()
					.map(URI::toString)
					.map(URISplit::getLastSegment)
					.collect(Collectors.toList());

		} else if (context instanceof EntryNamesContext namesContext && entryName != null) {

			Entry matchedEntry = namesContext.getEntryByName(entryName);
			if (matchedEntry != null) {
				return List.of(matchedEntry.getId());
			} else {
				return Collections.emptyList();
			}
		}

		return context.getEntries()
				.stream()
				.map(uri -> {
					Entry entry = context.getByEntryURI(uri);
					if (entry == null) {
						log.warn("No entry found for this referenced URI: {}", uri);
						return null;
					}
					return entry.getId();
				})
				.filter(Objects::nonNull)
				.toList();
	}


	/**
	 * Lets only admins list a context's entry IDs: the listing skips every entry's ACL, so it would reveal
	 * private entries. Callers check that the context exists first (see {@link #getContextEntries}).
	 *
	 * @throws ForbiddenException if the caller is not an admin
	 */
	public void checkMayListEntries() {
		if (!principalManager.currentUserIsAdminOrAdminGroup()) {
			throw new ForbiddenException("Listing the entries of a context requires admin privileges");
		}
	}

	public Context getContext(String contextId) {

		ContextManager cm = repositoryManager.getContextManager();

		if (cm != null && contextId != null) {
			// Verify contextId against in-memory reservedNames on each context fetch, to minimise repository queries
			if (reservedNames.isReservedName(contextId.toLowerCase())) {
				log.error("Context ID is a reserved term and must not be used: \"{}\". This error is likely to be caused by an error in the REST routing.", contextId);
			} else {
				return cm.getContext(contextId);
			}
		}
		return null;
	}

	public File exportContextToAZipFile(Context context, boolean metadataOnly, String rdfFormat) {

		Class<? extends RDFWriter> writer = GraphUtil.getRDFWriterClassForMediaType(rdfFormat);
		if (writer == null) {
			if (rdfFormat != null) {
				log.warn("No RDF writer for requested export format '{}', falling back to TriG",
						HttpUtil.sanitizeForLog(rdfFormat));
			}
			writer = TriGWriter.class;
		}

		return getExport(context, metadataOnly, writer);
	}

	public void importContextDataFromFile(Context context, InputStream input) {
		File tmpFile = null;
		try {
			tmpFile = File.createTempFile("entrystore_context_import", ".zip");
			if (input != null) {
				try {
					FileOperations.copyFile(input, Files.newOutputStream(tmpFile.toPath()));
				} catch (IOException e) {
					throw new InternalServerErrorException("Exception copying the data to a temporary file for context import", e);
				}

				try {
					repositoryManager.getContextManager().importContext(context.getEntry(), tmpFile);
				} catch (org.entrystore.repository.RepositoryException e) {
					throw new BadRequestException("Failed to import context data from the uploaded file", e);
				}

			} else {
				throw new BadRequestException("Unable to import file, received null data");
			}
		} catch (IOException e) {
			throw new BadRequestException("Failed to import data", e);
		} finally {
			if (tmpFile != null) {
				tmpFile.delete();
			}
		}
	}

	private File getExport(Context context, boolean metadataOnly, Class<? extends RDFWriter> writer) {

		String contextId = context.getEntry().getId();
		ContextManager contextManager = repositoryManager.getContextManager();
		String tmpPrefix = "entrystore_context_" + contextId + "_export_";
		File tmpExport = null;
		File tmpTriples = null;
		File tmpProperties = null;
		boolean success = false;
		try {
			Set<URI> users = new HashSet<>();

			// TODO: refactor to generate the exports in-memory only and return by the endpoint, instead of current:
			//  in-memory to disk, then disk to endpoint

			// create temp files
			tmpExport = File.createTempFile(tmpPrefix, ".zip");
			tmpExport.deleteOnExit();
			tmpTriples = File.createTempFile(tmpPrefix + "triples_", ".rdf");
			tmpTriples.deleteOnExit();
			tmpProperties = File.createTempFile(tmpPrefix + "info_", ".properties");
			tmpProperties.deleteOnExit();

			// write context's triples to a rdf file
			log.info("Exporting triples of context {}", context.getURI());
			contextManager.exportContext(context.getEntry(), tmpTriples, users, metadataOnly, writer);

			// write export properties to a property file
			Properties exportProps = new Properties();
			exportProps.put("contextEntryURI", context.getEntry().getEntryURI().toString());
			exportProps.put("contextResourceURI", context.getEntry().getResourceURI().toString());
			exportProps.put("contextMetadataURI", context.getEntry().getLocalMetadataURI().toString());
			exportProps.put("contextRelationURI", context.getEntry().getRelationURI().toString());
			exportProps.put("baseURI", repositoryManager.getRepositoryURL().toString());
			exportProps.put("exportDate", new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(new Date()));
			exportProps.put("exportingUser", principalManager.getAuthenticatedUserURI().toString());
			if (!users.isEmpty()) {
				StringBuilder userList = new StringBuilder();
				for (URI uri : users) {
					String userID = URISplit.getLastSegment(uri.toString());
					userList.append(userID);
					User u = principalManager.getUser(uri);
					if (u != null) {
						String alias = u.getName();
						if (alias != null) {
							userList.append(":").append(alias);
						}
					}
					userList.append(",");
				}
				userList.deleteCharAt(userList.length() - 1);
				exportProps.put("containedUsers", userList.toString());
			}
			try (OutputStream fos = Files.newOutputStream(tmpProperties.toPath())) {
				exportProps.store(fos, "EntryStore export information");
			}

			// create zip stream
			try (ZipOutputStream zipOS = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(tmpExport.toPath())))) {
				addZipEntry(zipOS, "triples.rdf", tmpTriples);
				addZipEntry(zipOS, "export.properties", tmpProperties);

				// add resource files to zip file
				if (dataFolder != null) {
					File contextPathFile = new File(dataFolder);
					File contextFolder = new File(contextPathFile, contextId);
					File[] contextFiles = contextFolder.listFiles();
					if (contextFiles != null) {
						addDataFiles(zipOS, contextFiles);
					} else {
						log.warn("The data path of context {} is not a folder: {}", contextId, contextFolder);
					}
				} else {
					log.error("No EntryStore data folder configured");
				}
			}

			// return the zip file
			success = true;
			return tmpExport;

		} catch (IOException | RepositoryException ex) {
			throw new InternalServerErrorException("Exception generating context export for contextId '" + contextId + "'", ex);
		} finally {
			if (tmpTriples != null) {
				tmpTriples.delete();
			}
			if (tmpProperties != null) {
				tmpProperties.delete();
			}
			if (!success && tmpExport != null) {
				tmpExport.delete();
			}
		}
	}

	/**
	 * Adds the files of a context's data folder under {@code resources/}, leaving out staging files of uploads in
	 * progress and files that are gone by the time they are read, such as a digest an upload is replacing.
	 */
	static void addDataFiles(ZipOutputStream zipOS, File[] dataFiles) throws IOException {
		for (File dataFile : dataFiles) {
			if (DataImpl.isStagingFile(dataFile.getName())) {
				continue;
			}
			try {
				addZipEntry(zipOS, "resources/" + dataFile.getName(), dataFile);
			} catch (NoSuchFileException e) {
				log.debug("Not exporting {}, which was removed while the export ran", dataFile);
			}
		}
	}

	/**
	 * Writes {@code source} into the zip stream as a DEFLATED entry named {@code name}, carrying the
	 * file's modification time. Sizes and CRC are computed by the stream and written in the entry's
	 * data descriptor. The source is opened before the entry is written, so that a source that is gone
	 * leaves no empty entry behind.
	 */
	static void addZipEntry(ZipOutputStream zipOS, String name, File source) throws IOException {
		try (InputStream is = new BufferedInputStream(Files.newInputStream(source.toPath()), 8192)) {
			ZipEntry zipEntry = new ZipEntry(name);
			zipEntry.setTime(source.lastModified());
			zipEntry.setMethod(ZipEntry.DEFLATED);
			zipOS.putNextEntry(zipEntry);
			is.transferTo(zipOS);
		}
	}

}