ArticleServiceImpl.java

/*
 * Copyright 2024 Global Crop Diversity Trust
 *
 * 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.genesys.server.service.impl;

import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.auditlog.service.ClassPKService;
import org.genesys.blocks.model.ClassPK;
import org.genesys.blocks.model.EntityId;
import org.genesys.blocks.security.service.CustomAclService;
import org.genesys.filerepository.FolderNotEmptyException;
import org.genesys.filerepository.InvalidRepositoryFileDataException;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.genesys.filerepository.NoSuchRepositoryFileException;
import org.genesys.filerepository.model.RepositoryFile;
import org.genesys.filerepository.model.RepositoryFolder;
import org.genesys.filerepository.service.RepositoryService;
import org.genesys.server.api.v2.MultiOp;
import org.genesys.server.component.security.AsAdminInvoker;
import org.genesys.server.component.security.SecurityUtils;
import org.genesys.server.exception.InvalidApiUsageException;
import org.genesys.server.exception.NotFoundElement;
import org.genesys.server.exception.SearchException;
import org.genesys.server.model.UserRole;
import org.genesys.server.model.impl.Article;
import org.genesys.server.model.impl.ArticleLang;
import org.genesys.server.model.impl.QArticle;
import org.genesys.server.persistence.ArticleLangRepository;
import org.genesys.server.persistence.ArticleRepository;
import org.genesys.server.service.ArticleService;
import org.genesys.server.service.ArticleTranslationService;
import org.genesys.server.service.ArticleTranslationService.TranslatedArticle;
import org.genesys.server.service.HtmlSanitizer;
import org.genesys.server.service.TranslatorService;
import org.genesys.server.service.TranslatorService.FormattedText;
import org.genesys.server.service.TranslatorService.TextFormat;
import org.genesys.server.service.filter.ArticleFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

import static org.genesys.server.service.ArticleTranslationService.TranslatedArticle;

@Service
@Transactional(readOnly = true)
@Validated
public class ArticleServiceImpl
	extends FilteredTranslatedCRUDServiceImpl<Article, ArticleLang, TranslatedArticle, ArticleFilter, ArticleRepository>
	implements ArticleService {

	@Autowired
	private SecurityUtils securityUtils;

	@Autowired
	private ClassPKService classPkService;

	@Autowired
	private HtmlSanitizer htmlSanitizer;

	@Autowired
	protected RepositoryService repositoryService;

	@Autowired
	private CustomAclService aclService;

	@Autowired
	protected AsAdminInvoker asAdminInvoker;

	@Autowired(required = false)
	private TranslatorService translatorService;

	@Component(value = "ArticleTranslationSupport")
	protected static class ArticleTranslationSupport
		extends BaseTranslationSupport<Article, ArticleLang, TranslatedArticle, ArticleFilter, ArticleLangRepository>
		implements ArticleTranslationService {

		@Override
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER') or hasPermission(#target.entity, 'ADMINISTRATION')")
		@CacheEvict(value = "contentcache", allEntries = true)
		public ArticleLang update(ArticleLang updated, ArticleLang target) {
			return super.update(updated, target);
		}

		@Override
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER') or hasPermission(#entity.entity, 'ADMINISTRATION')")
		@CacheEvict(value = "contentcache", allEntries = true)
		public ArticleLang remove(ArticleLang entity) {
			return super.remove(entity);
		}

		@Override
		@CacheEvict(value = "contentcache", allEntries = true)
		public MultiOp<ArticleLang> remove(List<ArticleLang> deletes) {
			return super.remove(deletes);
		}

	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	public Page<Article> list(ArticleFilter filter, Pageable page) throws SearchException {
		return super.list(filter, page);
	}

	@Override
	@Cacheable(value = "contentcache", key = "'globalarticle-' + #slug + '-' + #locale")
	public TranslatedArticle getGlobalArticle(String slug, Locale locale) {
		return getArticle(Article.class, null, slug, locale);
	}

	@Override
	@Cacheable(value = "contentcache", key = "'article-' + #entity.class.name + '-' + #entity.id + '-' + #slug + '-' + #locale")
	public TranslatedArticle getArticle(EntityId entity, String slug, Locale locale) {
		return getArticle(entity.getClass(), entity.getId(), slug, locale);
	}

	@Override
	public TranslatedArticle getArticleBySlugAndLang(String slug, String langTag) {
		QArticle qArticle = QArticle.article;
		var inst = Instant.now();
		
		Article article;
		
		if (securityUtils.hasRole(UserRole.ADMINISTRATOR) || securityUtils.hasRole(UserRole.CONTENTMANAGER)) {
			article = repository.findArticleBySlugAndTargetIdAndClassPk(slug, 0L, getClassPk(Article.class));
		} else {
			article = repository.findOne(qArticle.publishDate.before(inst)
				.andAnyOf(qArticle.expirationDate.isNull(), qArticle.expirationDate.after(inst))
				.and(qArticle.slug.eq(slug)).and(qArticle.targetId.eq(0L))).orElse(null);
		}
		return getTranslation(article, langTag);
	}

	@Override
	public TranslatedArticle getArticleBySlugLangTargetIdClassPk(String slug, String langTag, Long targetId, String classPKShortName) {
		ClassPK classPK = classPkService.findByShortName(classPKShortName);
		var article = repository.findArticleBySlugAndTargetIdAndClassPk(slug, targetId != null ? targetId : 0L, classPK);
		return getTranslation(article, langTag);
	}

	@Override
	@Cacheable(value = "contentcache", key = "'article-' + #clazz.name + '-' + #targetId + '-' + #slug + '-' + #locale")
	public TranslatedArticle getArticle(Class<?> clazz, Long targetId, String slug, Locale locale) {
		var langTag = locale.toLanguageTag();
		Article article = repository.findByClassPkAndTargetIdAndSlug(getClassPk(clazz), targetId != null ? targetId : 0L, slug);
		if (article == null) {
			// throw new NotFoundElement("Article not found");
			return null;
		}
		var translation = getTranslation(article, langTag);
		if (translation == null) {
			// Article contains original text fields
			translation = new TranslatedArticle(article, null);
		}
		return translation;
	}

//	@Override
//	@Transactional
//	@CacheEvict(value = "contentcache", allEntries = true)
//	public Article createArticle(Class<?> clazz, Long targetId, String slug, Locale locale, String title, String summary, String body, boolean isTemplate, Instant publishDate, Instant expirationDate) throws CRMException {
//		Article article = getArticle(clazz, targetId, slug, locale, false);
//
//		if (article == null) {
//			article = new Article();
//			if (!getDefaultLocale().equals(locale)) {
//				Article sourceArticle = getArticle(clazz, targetId, slug, locale, true);
//				if (sourceArticle == null) {
//					throw new CRMException("Article " + slug + " for " + clazz.getName() + " with targetId= " + targetId + " must exist in original language!");
//				}
//				article.setTemplate(sourceArticle.isTemplate());
//			} else {
//				article.setTemplate(isTemplate);
//			}
//			article.setClassPk(classPkService.getClassPk(clazz));
//			article.setTargetId(targetId);
//			article.setLang(locale.toLanguageTag());
//		}
//
//		article.setPublishDate(publishDate == null ? Instant.now() : publishDate);
//		article.setExpirationDate(expirationDate);
//		updateArticleContent(article, slug, title, summary, body);
//
//		return articleRepository.save(article);
//	}

	private void updateArticleContent(final Article article, String slug, String title, String summary, String body) {
		article.setSlug(slug);
		article.setTitle(htmlSanitizer.sanitize(title));
		article.setSummary(htmlSanitizer.sanitize(summary));

		if (article.isTemplate()) {
			article.setBody(body);
		} else {
			article.setBody(htmlSanitizer.sanitize(body));
		}
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@CacheEvict(value = "contentcache", allEntries = true)
	public Article remove(Article entity) {
		assert (entity.getId() != null);
		final Article article = repository.findById(entity.getId()).orElse(null);

		if (article == null) {
			throw new NotFoundElement("Article not found by id=" + entity.getId());
		}

		final Path repositoryPath = getArticleFolderPath(article);
		try {
			if (!repositoryService.hasPath(repositoryPath)) {
				// No related repository folder, so just delete article
				repository.delete(article);
				return article;
			}

			try {
				// Remove all files related to article
				for (RepositoryFile file : repositoryService.getFiles(repositoryPath, Sort.unsorted())) {
					repositoryService.removeFile(file);
				}
			} catch (NoSuchRepositoryFileException | IOException e) {
				LOG.warn("Could not delete file from Article {}", article.getId());
			}

			// Delete article
			repository.delete(article);
			// Delete folder
			repositoryService.deleteFolder(repositoryPath);
		} catch (InvalidRepositoryPathException e) {
			// Must be OK
			LOG.warn("Invalid repository path: {}", e.getMessage());
		} catch (FolderNotEmptyException e) {
			LOG.warn("Could not remove news folder: {}", e.getMessage(), e);
		}
		return article;
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@CacheEvict(value = "contentcache", allEntries = true)
	public void deleteArticle(final Long id, final Integer version) {
		final Article article = repository.findById(id).orElse(null);

		if (article == null) {
			throw new NotFoundElement("Article not found by id=" + id);
		}

		if (version != null && !article.getVersion().equals(version)) {
			LOG.warn("Articles versions don't match anymore");
			throw new ConcurrencyFailureException("Object version changed to " + article.getVersion() + ", you provided " + version);
		}

		final Path repositoryPath = getArticleFolderPath(article);
		try {
			if (!repositoryService.hasPath(repositoryPath)) {
				// No related repository folder, so just delete article
				repository.delete(article);
				return;
			}

			try {
				// Remove all files related to article
				for (RepositoryFile file : repositoryService.getFiles(repositoryPath, Sort.unsorted())) {
					repositoryService.removeFile(file);
				}
			} catch (NoSuchRepositoryFileException | IOException e) {
				LOG.warn("Could not delete file from Article {}", article.getId());
			}

			// Delete article
			repository.delete(article);
			// Delete folder
			repositoryService.deleteFolder(repositoryPath);
		} catch (InvalidRepositoryPathException e) {
			// Must be OK
			LOG.warn("Invalid repository path: {}", e.getMessage());
		} catch (FolderNotEmptyException e) {
			LOG.warn("Could not remove news folder: {}", e.getMessage(), e);
		}
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@Transactional
	@CacheEvict(value = "contentcache", allEntries = true)
	public void sanitizeAll() {
		LOG.info("Sanitizing articles");

		Page<Article> articles;
		int page = 0;
		do {
			articles = repository.findAll(new ArticleFilter().buildPredicate(), PageRequest.of(page++, 10));

			for (final Article a : articles) {
				a.setBody(htmlSanitizer.sanitize(a.getBody()));
			}

			repository.saveAll(articles.getContent());

		} while (articles.hasNext());
	}

//	@Override
//	@Transactional
//	public Article createGlobalArticle(String slug, Locale locale, String title, String summary, String body, boolean isTemplate, Instant publishDate, Instant expirationDate) throws CRMException {
//		Article primaryArticle = getGlobalArticle(slug, locale, true);
//
//		if (primaryArticle == null && !getDefaultLocale().getLanguage().equals(locale.toLanguageTag())) {
//			throw new CRMException("Global articles must first be created in primary language=" + getDefaultLocale().getLanguage());
//		}
//
//		if (primaryArticle != null && primaryArticle.getLang().equals(locale.toLanguageTag())) {
//			throw new CRMException(String.format("Global article with slug='%s' and language='%s' already exists.", slug, locale.toLanguageTag()));
//		}
//
//		Article article = new Article();
//		if (primaryArticle == null) {
//			article.setTemplate(isTemplate);
//			article.setClassPk(ensureClassPK(Article.class));
//			article.setTargetId(null);
//		} else {
//			article.setTemplate(primaryArticle.isTemplate());
//			article.setClassPk(primaryArticle.getClassPk());
//			article.setTargetId(primaryArticle.getTargetId());
//		}
//
//		article.setLang(locale.toLanguageTag());
//		article.setPublishDate(publishDate == null ? Instant.now() : publishDate);
//		article.setExpirationDate(expirationDate);
//		updateArticleContent(article, slug, title, summary, body);
//
//		return articleRepository.save(article);
//	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER') or hasPermission(#article, 'ADMINISTRATION')")
	public RepositoryFile uploadArticleFile(Article article, final MultipartFile file) throws IOException, InvalidRepositoryPathException, InvalidRepositoryFileDataException {
		if (file == null)
			throw new InvalidRepositoryPathException("Nothing to upload. File must be provided.");
		if (article == null || article.getId() == null)
			throw new InvalidApiUsageException("An existing Article must be provided.");

		// Reload
		article = get(article.getId());

		// Ensure article folder
		var articleFolder = ensureArticleFolder(article);

		String originalFilename = UUID.randomUUID().toString().concat("_").concat(file.getOriginalFilename());
		LOG.info("Upload file {} to path {}", originalFilename, articleFolder.getFolderPath());
		var savedFile = repositoryService.addFile(articleFolder.getFolderPath(), originalFilename, file.getContentType(), file.getInputStream(), null);

		if (savedFile != null) {
			// Relax permissions on descriptor image: allow USERS and ANONYMOUS to read the image
			aclService.makePubliclyReadable(savedFile, true);
		}

		return savedFile;
	}

	private RepositoryFolder ensureArticleFolder(final Article article) {
		try {
			final Path repositoryPath = getArticleFolderPath(article);
			return asAdminInvoker.invoke(() -> {
				// Ensure target folder exists for the Article
				return repositoryService.ensureFolder(repositoryPath);
			});
		} catch (Exception e) {
			LOG.warn("Could not create folder: {}", e.getMessage());
			throw new InvalidApiUsageException("Could not create folder", e);
		}
	}

	private TranslatedArticle getTranslation(Article article, String langTag) {
		if (article == null) {
			return null;
		}
		var lang = translationSupport.getLang(article, langTag);
		return new TranslatedArticle(article, lang);
	}
	
	private Path getArticleFolderPath(final Article article) {
		assert (article != null);
		assert (article.getId() != null);

		return Paths.get("/content", "articles", article.getId().toString()).toAbsolutePath();
	}

	@Override
	public ClassPK getClassPk(Class<?> clazz) {
		return classPkService.getClassPk(clazz);
	}

	@Override
	@CacheEvict(value = "contentcache", allEntries = true)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER') or hasPermission(#updated, 'ADMINISTRATION')")
	public Article update(Article updated) {
		return super.update(updated);
	}

	@Override
	@CacheEvict(value = "contentcache", allEntries = true)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER') or hasPermission(#target, 'ADMINISTRATION')")
	public Article updateFast(Article updated, Article target) {
		target.apply(updated);
		updateArticleContent(target, target.getSlug(), target.getTitle(), target.getSummary(), target.getBody());
		return repository.save(target);
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER') or hasPermission(#target, 'ADMINISTRATION')")
	@CacheEvict(value = "contentcache", allEntries = true)
	public Article update(Article updated, Article target) {
		return updateFast(updated, target);
	}

	@Override
	public ArticleLang machineTranslate(Article original, String targetLanguage) throws TranslatorService.TranslatorException {
		if (Objects.equals(original.getOriginalLanguageTag(), targetLanguage)) {
			throw new InvalidApiUsageException("Source and target language are the same");
		}

		var mt = new ArticleLang();
		mt.setMachineTranslated(true);
		mt.setLanguageTag(targetLanguage);
		mt.setEntity(original);

		if (translatorService == null) return mt;

		var builder = TranslatorService.TranslationStructuredRequest.builder()
			.targetLang(targetLanguage);

			// Translations to other languages use the English version (either original or translated)
		if (! Objects.equals(Locale.ENGLISH.getLanguage(), targetLanguage) && ! Objects.equals(Locale.ENGLISH.getLanguage(), original.getOriginalLanguageTag())) {
			var enTranslation = translationSupport.getLang(original, Locale.ENGLISH.getLanguage());
			if (enTranslation == null) {
				throw new InvalidApiUsageException("English text is not available.");
			}

			builder
				.sourceLang(enTranslation.getLanguageTag())
				// .context(enTranslation.getSummary())
				.texts(Map.of(
					"title", new FormattedText(TextFormat.markdown, enTranslation.getTitle()),
					"summary", new FormattedText(TextFormat.html, original.getSummary()),
					"body", new FormattedText(TextFormat.markdown, enTranslation.getBody())
				));

		} else {
			// Translations to English use the original texts
			builder
				.sourceLang(original.getOriginalLanguageTag())
				// .context(original.getSummary())
				.texts(Map.of(
					"title", new FormattedText(TextFormat.html, original.getTitle()),
					"summary", new FormattedText(TextFormat.html, original.getSummary()),
					"body", new FormattedText(TextFormat.html, original.getBody())
				));
		}

		var translations = translatorService.translate(builder.build());

		if (StringUtils.isNotBlank(original.getTitle())) {
			mt.setTitle(translations.getTexts().get("title"));
		}
		if (StringUtils.isNotBlank(original.getSummary())) {
			mt.setSummary(translations.getTexts().get("summary"));
		}
		if (StringUtils.isNotBlank(original.getBody())) {
			mt.setBody(translations.getTexts().get("body"));
		}
		return mt;
	}

	@Override
	public Article create(Article source) {
		return createFast(source);
	}

	@Override
	public Article createFast(Article source) {
		Article article = new Article();
		article.apply(source);

		if (article.getPublishDate() == null) {
			article.setPublishDate(Instant.now());
		}
		if (article.getClassPk() == null) {
			article.setClassPk(getClassPk(Article.class));
		}

		updateArticleContent(article, article.getSlug(), article.getTitle(), article.getSummary(), article.getBody());

		return repository.save(article);
	}
}