package io.kaif.service.impl; import static java.util.stream.Collectors.*; import java.net.URI; import java.net.URISyntaxException; import java.time.Clock; import java.time.Instant; import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.function.BiFunction; import javax.annotation.Nullable; import org.apache.http.NameValuePair; import org.apache.http.client.utils.URIBuilder; import org.apache.http.client.utils.URLEncodedUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.Cacheable; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Charsets; import io.kaif.flake.FlakeId; import io.kaif.model.account.Account; import io.kaif.model.account.AccountDao; import io.kaif.model.account.Authority; import io.kaif.model.account.Authorization; import io.kaif.model.article.Article; import io.kaif.model.article.ArticleDao; import io.kaif.model.debate.Debate; import io.kaif.model.debate.DebateDao; import io.kaif.model.debate.DebateTree; import io.kaif.model.zone.Zone; import io.kaif.model.zone.ZoneDao; import io.kaif.model.zone.ZoneInfo; import io.kaif.service.ArticleService; import io.kaif.service.FeedService; import io.kaif.web.support.AccessDeniedException; @Service @Transactional public class ArticleServiceImpl implements ArticleService { private static final Logger editLog = LoggerFactory.getLogger("EDIT"); static final int PAGE_SIZE = 25; @Autowired private AccountDao accountDao; @Autowired private ArticleDao articleDao; @Autowired private ZoneDao zoneDao; @Autowired private DebateDao debateDao; @Autowired private FeedService feedService; private Clock clock = Clock.systemDefaultZone(); @VisibleForTesting void setClock(Clock clock) { this.clock = clock; } @Override public Article createExternalLink(Authorization authorization, Zone zone, String title, String link) { return createArticle(authorization, zone, (zoneInfo, author) -> articleDao.createExternalLink(zoneInfo, author, title, link, canonicalizeUrl(link), Instant.now(clock))); } private Article createArticle(Authorization authorization, Zone zone, BiFunction<ZoneInfo, Account, Article> articleCreator) { //creating article should not use cache ZoneInfo zoneInfo = zoneDao.loadZoneWithoutCache(zone); Account author = accountDao.strongVerifyAccount(authorization) .filter(zoneInfo::canWriteArticle) .orElseThrow(() -> new AccessDeniedException("no write to create article at zone:" + zone)); Article article = articleCreator.apply(zoneInfo, author); if (zoneInfo.getWriteAuthority() == Authority.CITIZEN) { accountDao.increaseArticleCount(author); } return article; } @Override public boolean canCreateArticle(Zone zone, Authorization auth) { ZoneInfo zoneInfo = zoneDao.loadZoneWithCache(zone); return accountDao.strongVerifyAccount(auth).filter(zoneInfo::canWriteArticle).isPresent(); } @Override public Article createSpeak(Authorization authorization, Zone zone, String title, String content) { return createArticle(authorization, zone, (zoneInfo, author) -> articleDao.createSpeak(zoneInfo, author, title, content, Instant.now(clock))); } @Override public Optional<Article> findArticle(FlakeId articleId) { return articleDao.findArticle(articleId); } @Override public List<Article> listLatestZoneArticles(Zone zone, @Nullable FlakeId startArticleId) { return articleDao.listZoneArticlesDesc(zone, startArticleId, PAGE_SIZE); } @Override public Article loadArticle(FlakeId articleId) throws EmptyResultDataAccessException { return articleDao.loadArticleWithoutCache(articleId); } @Override public String loadEditableDebateContent(FlakeId debateId, Authorization editor) { Debate debate = debateDao.loadDebateWithoutCache(debateId); accountDao.strongVerifyAccount(editor) .filter(debate::canEdit) .orElseThrow(() -> new AccessDeniedException("no permission to edit debate:" + debate.getDebateId())); return debate.getEscapeContent(); } @Override public String updateDebateContent(FlakeId debateId, Authorization editorAuth, String content) { Debate debate = debateDao.loadDebateWithoutCache(debateId); accountDao.strongVerifyAccount(editorAuth) .filter(debate::canEdit) .orElseThrow(() -> new AccessDeniedException("no permission to edit debate:" + debateId)); debateDao.updateContent(debateId, content, Instant.now(clock)); editLog.info("user(id:{}) update debate's(id:{}) content:{}", editorAuth.authenticatedId(), debateId.value(), debate.getContent()); return debateDao.loadDebateWithoutCache(debateId).getRenderContent(); } @Override public Debate debate(FlakeId articleId, @Nullable FlakeId parentDebateId, Authorization debaterAuth, String content) { //creating debate should not use cache Article article = articleDao.loadArticleWithoutCache(articleId); ZoneInfo zoneInfo = zoneDao.loadZoneWithoutCache(article.getZone()); Account debater = accountDao.strongVerifyAccount(debaterAuth) .filter(zoneInfo::canDebate) .orElseThrow(() -> new AccessDeniedException("no write to debate at zone:" + article.getZone())); Debate parent = Optional.ofNullable(parentDebateId).flatMap(debateDao::findDebate).orElse(null); Debate debate = debateDao.create(article, parent, content, debater, Instant.now(clock)); //may improve later to make it async, but async has transaction problem articleDao.increaseDebateCount(article); if (zoneInfo.getDebateAuthority() == Authority.CITIZEN) { accountDao.increaseDebateCount(debater); } if (!debate.getReplyToAccountId().equals(debater.getAccountId())) { feedService.createReplyFeed(debate.getDebateId(), debate.getReplyToAccountId()); } return debate; } @Override public DebateTree listBestDebates(FlakeId articleId, @Nullable FlakeId parentDebateId) { //TODO cache //TODO paging return debateDao.listDebateTreeByArticle(articleId, parentDebateId); } @Override public List<Article> listHotZoneArticles(Zone zone, FlakeId startArticleId) { //TODO cache return articleDao.listZoneHotArticles(zone, startArticleId, PAGE_SIZE); } @Override @Cacheable(value = "rssHotArticles") public List<Article> listRssHotZoneArticlesWithCache(Zone zone) { return listHotZoneArticles(zone, null); } @Override @Cacheable(value = "rssHotArticles") public List<Article> listRssTopArticlesWithCache() { return listTopArticles(null); } @Override public List<Article> listLatestArticles(@Nullable FlakeId startArticleId) { return articleDao.listArticlesDesc(startArticleId, PAGE_SIZE); } @Override public List<Article> listTopArticles(@Nullable FlakeId startArticleId) { //TODO cache return articleDao.listHotArticlesExcludeHidden(startArticleId, PAGE_SIZE); } @Override public Debate loadDebateWithoutCache(FlakeId debateId) { return debateDao.loadDebateWithoutCache(debateId); } @Override public Debate loadDebateWithCache(FlakeId debateId) { return debateDao.loadDebateWithCache(debateId); } @Override public List<Debate> listReplyToDebates(Authorization authorization, @Nullable FlakeId startDebateId) { return debateDao.listLatestDebateByReplyTo(authorization.authenticatedId(), startDebateId, PAGE_SIZE); } @Override public List<Debate> listLatestDebates(@Nullable FlakeId startDebateId) { return debateDao.listDebatesByTimeDesc(startDebateId, PAGE_SIZE); } @Override public List<Debate> listLatestZoneDebates(Zone zone, @Nullable FlakeId startDebateId) { return debateDao.listZoneDebatesByTimeDesc(zone, startDebateId, PAGE_SIZE); } @Override public List<Article> listArticlesByDebatesWithCache(List<FlakeId> debateIds) { return articleDao.listArticlesByDebatesWithCache(debateIds); } @Override public List<Debate> listDebatesByIdWithCache(List<FlakeId> debateIds) { return debateDao.listDebatesByIdWithCache(debateIds); } @Override public List<Article> listArticlesByAuthor(String username, @Nullable FlakeId startArticleId) { // we don't use join because we may use cache to optimize later Account author = accountDao.loadByUsername(username); return articleDao.listArticlesByAuthor(author.getAccountId(), startArticleId, PAGE_SIZE); } @Override public List<Debate> listDebatesByDebater(String username, @Nullable FlakeId startDebateId) { // we don't use join because we may use cache to optimize later Account debater = accountDao.loadByUsername(username); return debateDao.listDebatesByDebater(debater.getAccountId(), startDebateId, PAGE_SIZE); } @Override public boolean isExternalLinkExist(Zone zone, String externalLink) { return articleDao.isExternalLinkExist(zone, canonicalizeUrl(externalLink)); } @VisibleForTesting String canonicalizeUrl(String url) { //TODO visit target web page and get header: // <link rel="canonical" href="https://blog.example.com/dresses/" /> String cleaned = url.replaceAll("[\r\n \t]*", ""); try { URI uri = new URI(cleaned); List<NameValuePair> params = URLEncodedUtils.parse(uri, Charsets.UTF_8); List<NameValuePair> cleanedParams = params.stream() .filter(pair -> !pair.getName().startsWith("utm_")) .sorted(Comparator.comparing(NameValuePair::getName) .thenComparing(NameValuePair::getValue)) .collect(toList()); URIBuilder uriBuilder = new URIBuilder(uri); if (cleanedParams.isEmpty()) { uriBuilder.clearParameters(); } else { //set empty list will cause builder always append `?` uriBuilder.setParameters(cleanedParams); } return uriBuilder.build().toString(); } catch (URISyntaxException e) { //ignore } return cleaned; } @Override public List<Article> listArticlesByExternalLink(Zone zone, String externalLink) { return articleDao.listArticlesByExternalLink(zone, canonicalizeUrl(externalLink), 3); } @Override public void deleteArticle(Authorization authorization, FlakeId articleId) { Article target = articleDao.findArticle(articleId).filter(article -> { return canDeleteArticle(authorization, article); }).orElseThrow(() -> new AccessDeniedException("not allow delete article: " + articleId)); articleDao.markAsDeleted(target); } private boolean canDeleteArticle(Authorization authorization, Article article) { return article.canDelete(authorization, Instant.now(clock)) || zoneDao.isZoneAdmin(article.getZone(), authorization.authenticatedId()); } @Override public boolean canDeleteArticle(String username, FlakeId articleId) { return accountDao.findByUsername(username) .flatMap(account -> articleDao.findArticle(articleId) .filter(article -> canDeleteArticle(account, article))) .isPresent(); } }