package io.kaif.model.article;
import java.net.MalformedURLException;
import java.net.URL;
import java.sql.Date;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import javax.validation.UnexpectedTypeException;
import org.springframework.web.util.HtmlUtils;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import io.kaif.flake.FlakeId;
import io.kaif.kmark.KmarkProcessor;
import io.kaif.model.account.Account;
import io.kaif.model.account.Authorization;
import io.kaif.model.zone.Zone;
import io.kaif.web.v1.dto.V1ArticleDto;
import io.kaif.web.v1.dto.V1ArticleType;
public class Article {
// note that checking string length should use unescaped one
// database are allow more room for escaped string
public static final int TITLE_MIN = 3;
public static final int TITLE_MAX = 128;
public static final int URL_MAX = 512;
public static final int CONTENT_MIN = 10;
public static final int CONTENT_MAX = 4096;
//p{L} is unicode letter
public static final String URL_PATTERN = "^(https?|ftp)://[\\p{L}\\w\\-]+\\.[\\p{L}\\w\\-]+.*";
public static final Duration DELETE_LIMIT = Duration.ofMinutes(10);
public static Article createSpeak(Zone zone,
String zoneAliasName,
FlakeId articleId,
Account author,
String title,
String content,
Instant now) {
Preconditions.checkArgument(isValidTitle(title));
Preconditions.checkArgument(isValidContent(content));
String safeTitle = HtmlUtils.htmlEscape(title);
return new Article(zone,
zoneAliasName,
articleId,
safeTitle,
null,
content,
ArticleContentType.MARK_DOWN,
now,
author.getAccountId(),
author.getUsername(),
false,
0,
0,
0);
}
private static boolean isValidContent(String content) {
return content != null && content.length() <= CONTENT_MAX && content.length() >= CONTENT_MIN;
}
public static Article createExternalLink(Zone zone,
String zoneAliasName,
FlakeId articleId,
Account author,
String title,
String link,
Instant now) {
Preconditions.checkArgument(isValidTitle(title));
Preconditions.checkArgument(isValidLink(link));
String safeTitle = HtmlUtils.htmlEscape(title);
String safeLink = HtmlUtils.htmlEscape(link);
return new Article(zone,
zoneAliasName,
articleId,
safeTitle,
safeLink,
null,
ArticleContentType.NONE,
now,
author.getAccountId(),
author.getUsername(),
false,
0,
0,
0);
}
private static boolean isValidLink(String link) {
return link != null && link.length() <= URL_MAX && validateUrl(link);
}
private static boolean validateUrl(String url) {
try {
new URL(url);
return true;
} catch (MalformedURLException e) {
return false;
}
}
private static boolean isValidTitle(String title) {
return title != null && title.length() <= TITLE_MAX && title.length() >= TITLE_MIN;
}
public static String renderSpeakPreview(String content) {
return KmarkProcessor.process(content);
}
private final Zone zone;
private final String aliasName;
private final FlakeId articleId;
private final String title;
private final Instant createTime;
private final String link;
private final String content;
private final ArticleContentType contentType;
private final UUID authorId;
private final String authorName;
private final boolean deleted;
private final long upVote;
//article downVote count is preserved, not used
private final long downVote;
private final long debateCount;
Article(Zone zone,
String aliasName,
FlakeId articleId,
String title,
String link,
String content,
ArticleContentType contentType,
Instant createTime,
UUID authorId,
String authorName,
boolean deleted,
long upVote,
long downVote,
long debateCount) {
this.zone = zone;
this.aliasName = aliasName;
this.articleId = articleId;
this.title = title;
this.link = link;
this.content = content;
this.contentType = contentType;
this.createTime = createTime;
this.authorId = authorId;
this.authorName = authorName;
this.deleted = deleted;
this.upVote = upVote;
this.downVote = downVote;
this.debateCount = debateCount;
}
public Zone getZone() {
return zone;
}
public FlakeId getArticleId() {
return articleId;
}
public String getTitle() {
return title;
}
public Instant getCreateTime() {
return createTime;
}
public String getContent() {
return content;
}
public ArticleContentType getContentType() {
return contentType;
}
public UUID getAuthorId() {
return authorId;
}
public String getAuthorName() {
return authorName;
}
public boolean isDeleted() {
return deleted;
}
public long getUpVote() {
return upVote;
}
public long getDownVote() {
return downVote;
}
public long getDebateCount() {
return debateCount;
}
public String getLink() {
return link;
}
public String getAliasName() {
return aliasName;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Article article = (Article) o;
if (!articleId.equals(article.articleId)) {
return false;
}
return true;
}
@Override
public int hashCode() {
return articleId.hashCode();
}
@Override
public String toString() {
return "article:" +
"/z/" + zone +
"/article/" + articleId +
"/" + title;
}
public String getLinkHint() {
if (isExternalLink()) {
try {
return new URL(link).getHost();
} catch (MalformedURLException e) {
//this should never happened because constructor protected with design contract.
throw new UnexpectedTypeException("malformed url");
}
} else {
return "/z/" + zone;
}
}
public V1ArticleDto toV1Dto() {
return new V1ArticleDto(zone.value(),
aliasName,
articleId.toString(),
title,
link,
content,
isExternalLink() ? V1ArticleType.EXTERNAL_LINK : V1ArticleType.SPEAK,
Date.from(createTime),
authorName,
upVote,
debateCount,
deleted);
}
/**
* the method only allowed for article with content
*/
public String getRenderContent() {
switch (contentType) {
case NONE:
return "";
case MARK_DOWN:
return KmarkProcessor.process(content);
case MATOME:
}
throw new UnsupportedOperationException("could not render with type:" + contentType);
}
public boolean isExternalLink() {
return !Strings.isNullOrEmpty(link);
}
public boolean hasMarkDownContent() {
return contentType == ArticleContentType.MARK_DOWN;
}
public String getShortUrlPath() {
return String.format("/d/%s", getArticleId());
}
public boolean canDelete(Authorization authorization, Instant now) {
return authorization.belongToAccount(authorId) && createTime.plus(DELETE_LIMIT).isAfter(now);
}
@VisibleForTesting
public Article withDeleted() {
return new Article(zone,
aliasName,
articleId,
title,
link,
content,
contentType,
createTime,
authorId,
authorName,
true,
upVote,
downVote,
debateCount);
}
}