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);
}
}