blob: 59caceedd88c750d226ab2fc0f80b56bad60cac4 [file] [log] [blame]
package eu.mulk.mulkcms2.benki.posts;
import static java.nio.charset.StandardCharsets.UTF_8;
import static javax.ws.rs.core.MediaType.APPLICATION_ATOM_XML;
import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
import static javax.ws.rs.core.MediaType.TEXT_HTML;
import com.rometools.rome.feed.atom.Content;
import com.rometools.rome.feed.atom.Entry;
import com.rometools.rome.feed.atom.Feed;
import com.rometools.rome.feed.atom.Link;
import com.rometools.rome.feed.synd.SyndPersonImpl;
import com.rometools.rome.io.FeedException;
import com.rometools.rome.io.WireFeedOutput;
import eu.mulk.mulkcms2.benki.accesscontrol.PageKey;
import eu.mulk.mulkcms2.benki.accesscontrol.Role;
import eu.mulk.mulkcms2.benki.posts.Post.PostPage;
import eu.mulk.mulkcms2.benki.users.User;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateExtension;
import io.quarkus.qute.TemplateInstance;
import io.quarkus.qute.api.ResourcePath;
import io.quarkus.security.identity.SecurityIdentity;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLEncoder;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.time.temporal.TemporalAccessor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.json.spi.JsonProvider;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.transaction.Transactional;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.UriInfo;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.hibernate.Session;
import org.jboss.logging.Logger;
import org.jsoup.Jsoup;
public abstract class PostResource {
private static final Logger log = Logger.getLogger(PostResource.class);
private static final DateTimeFormatter htmlDateTimeFormatter =
DateTimeFormatter.ISO_OFFSET_DATE_TIME;
private static final DateTimeFormatter humanDateTimeFormatter =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
private static final DateTimeFormatter htmlDateFormatter = DateTimeFormatter.ISO_LOCAL_DATE;
private static final DateTimeFormatter humanDateFormatter =
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG);
private static final int pageKeyBytes = 32;
private static final int AUTOTITLE_WORDS = 10;
protected static final JsonProvider jsonProvider = JsonProvider.provider();
@ConfigProperty(name = "mulkcms.posts.default-max-results")
int defaultMaxResults;
@ResourcePath("benki/posts/postList.html")
@Inject
Template postList;
@Inject protected SecurityIdentity identity;
@Context protected UriInfo uri;
@Inject
@ConfigProperty(name = "mulkcms.tag-base")
String tagBase;
@PersistenceContext protected EntityManager entityManager;
private final SecureRandom secureRandom;
private final PostFilter postFilter;
private final String pageTitle;
public PostResource(PostFilter postFilter, String pageTitle) throws NoSuchAlgorithmException {
this.postFilter = postFilter;
this.pageTitle = pageTitle;
secureRandom = SecureRandom.getInstanceStrong();
}
@GET
@Produces(TEXT_HTML)
@Transactional
public TemplateInstance getIndex(
@QueryParam("i") @CheckForNull Integer cursor,
@QueryParam("n") @CheckForNull Integer maxResults,
@QueryParam("search-query") @CheckForNull String searchQuery) {
maxResults = maxResults == null ? defaultMaxResults : maxResults;
@CheckForNull var reader = getCurrentUser();
var session = entityManager.unwrap(Session.class);
var q = Post.findViewable(postFilter, session, reader, null, cursor, maxResults, searchQuery);
q.cacheDescriptions();
var feedUri = uri.getPath() + "/feed";
if (reader != null) {
var pageKey = ensurePageKey(reader, feedUri);
feedUri += "?page-key=" + pageKey.key.toString(36);
}
return postList
.data("postDays", q.days())
.data("feedUri", feedUri)
.data("pageTitle", pageTitle)
.data("showBookmarkForm", showBookmarkForm())
.data("showLazychatForm", showLazychatForm())
.data("hasPreviousPage", q.prevCursor != null)
.data("hasNextPage", q.nextCursor != null)
.data("previousCursor", q.prevCursor)
.data("nextCursor", q.nextCursor)
.data("pageSize", maxResults)
.data("searchQuery", searchQuery);
}
@GET
@Path("~{ownerName}")
@Produces(TEXT_HTML)
@Transactional
public TemplateInstance getUserIndex(
@PathParam("ownerName") String ownerName,
@QueryParam("i") @CheckForNull Integer cursor,
@QueryParam("n") @CheckForNull Integer maxResults) {
maxResults = maxResults == null ? defaultMaxResults : maxResults;
@CheckForNull var reader = getCurrentUser();
var owner = User.findByNickname(ownerName);
var session = entityManager.unwrap(Session.class);
var q = Post.findViewable(postFilter, session, reader, owner, cursor, maxResults, null);
q.cacheDescriptions();
var feedUri = uri.getPath() + "/feed";
if (reader != null) {
var pageKey = ensurePageKey(reader, feedUri);
feedUri += "?page-key=" + pageKey.key.toString(36);
}
return postList
.data("postDays", q.days())
.data("feedUri", feedUri)
.data("pageTitle", pageTitle)
.data("showBookmarkForm", showBookmarkForm())
.data("showLazychatForm", showLazychatForm())
.data("hasPreviousPage", q.prevCursor != null)
.data("hasNextPage", q.nextCursor != null)
.data("previousCursor", q.prevCursor)
.data("nextCursor", q.nextCursor)
.data("pageSize", maxResults);
}
@Transactional
protected final PageKey ensurePageKey(User reader, String pagePath) {
PageKey pageKey = PageKey.find("page = ?1 AND user = ?2", pagePath, reader).firstResult();
if (pageKey == null) {
pageKey = new PageKey();
byte[] keyBytes = new byte[pageKeyBytes];
secureRandom.nextBytes(keyBytes);
pageKey.key = new BigInteger(keyBytes);
pageKey.page = pagePath;
pageKey.user = reader;
pageKey.persist();
}
return pageKey;
}
@GET
@Produces(APPLICATION_JSON)
@Path("{id}")
public Post getPostJson(@PathParam("id") int id) {
return getPostIfVisible(id);
}
@GET
@Produces(TEXT_HTML)
@Path("{id}")
public TemplateInstance getPostHtml(@PathParam("id") int id) {
var post = getPostIfVisible(id);
return postList
.data("postDays", new PostPage<>(null, null, null, List.of(post)).days())
.data("pageTitle", String.format("Post #%d", id))
.data("showBookmarkForm", false)
.data("showLazychatForm", false)
.data("hasPreviousPage", false)
.data("hasNextPage", false)
.data("previousCursor", null)
.data("nextCursor", null)
.data("pageSize", null);
}
@GET
@Path("feed")
@Produces(APPLICATION_ATOM_XML)
@Transactional
public String getFeed(@QueryParam("page-key") @CheckForNull String pageKeyBase36)
throws FeedException {
@CheckForNull var pageKey = pageKeyBase36 == null ? null : new BigInteger(pageKeyBase36, 36);
return makeFeed(pageKey, null, null);
}
@GET
@Path("~{ownerName}/feed")
@Produces(APPLICATION_ATOM_XML)
@Transactional
public String getUserFeed(
@QueryParam("page-key") @CheckForNull String pageKeyBase36,
@PathParam("ownerName") String ownerName)
throws FeedException {
var owner = User.findByNickname(ownerName);
@CheckForNull var pageKey = pageKeyBase36 == null ? null : new BigInteger(pageKeyBase36, 36);
return makeFeed(pageKey, ownerName, owner);
}
private String makeFeed(
@CheckForNull BigInteger pageKey, @CheckForNull String ownerName, @CheckForNull User owner)
throws FeedException {
if (pageKey == null) {
return makeFeed(getCurrentUser(), owner, ownerName);
}
Optional<PageKey> pageKeyInfo =
PageKey.find("page = ?1 AND key = ?2", uri.getPath(), pageKey).singleResultOptional();
if (pageKeyInfo.isEmpty()) {
throw new ForbiddenException();
}
var pageKeyOwner = pageKeyInfo.get().user;
return makeFeed(pageKeyOwner, owner, ownerName);
}
private String makeFeed(
@CheckForNull User reader, @Nullable User owner, @Nullable String ownerName)
throws FeedException {
var q = Post.findViewable(postFilter, entityManager.unwrap(Session.class), reader, owner);
q.cacheDescriptions();
var posts = q.posts;
var feed = new Feed("atom_1.0");
var feedSubId = owner == null ? "" : String.format("/%d", owner.id);
feed.setTitle(String.format("Benki ā†’ %s", pageTitle));
feed.setId(
String.format(
"tag:%s,2019:%s:%s:%s",
URLEncoder.encode(tagBase, UTF_8),
URLEncoder.encode(pageTitle, UTF_8),
URLEncoder.encode(feedSubId, UTF_8),
URLEncoder.encode(
identity.isAnonymous() ? "world" : identity.getPrincipal().getName(), UTF_8)));
feed.setUpdated(
Date.from(
posts.stream()
.map(x -> x.date)
.filter(Objects::nonNull)
.max(Comparator.comparing(x -> x))
.orElse(OffsetDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC))
.toInstant()));
var selfLink = new Link();
selfLink.setHref(uri.getRequestUri().toString());
selfLink.setRel("self");
feed.setOtherLinks(List.of(selfLink));
var htmlAltLink = new Link();
var htmlAltPath =
ownerName == null
? "/posts"
: String.format("~%s/posts", URLEncoder.encode(ownerName, UTF_8));
htmlAltLink.setHref(uri.resolve(URI.create(htmlAltPath)).toString());
htmlAltLink.setRel("alternate");
htmlAltLink.setType("text/html");
feed.setAlternateLinks(List.of(htmlAltLink));
feed.setEntries(
posts.stream()
.map(
post -> {
var entry = new Entry();
entry.setId(
String.format(
"tag:%s,2012:/marx/%d", URLEncoder.encode(tagBase, UTF_8), post.id));
if (post.date != null) {
entry.setPublished(Date.from(post.date.toInstant()));
entry.setUpdated(Date.from(post.date.toInstant()));
}
if (post.owner != null) {
var author = new SyndPersonImpl();
author.setName(post.owner.getFirstAndLastName());
entry.setAuthors(List.of(author));
}
if (post.getTitle() != null) {
var title = new Content();
title.setType("text");
title.setValue(post.getTitle());
entry.setTitleEx(title);
} else if (post.getDescriptionHtml() != null) {
var title = new Content();
title.setType("text");
var words =
Jsoup.parse(post.getDescriptionHtml()).text().split("\\s", AUTOTITLE_WORDS);
var titleWords = Arrays.asList(words).subList(0, words.length - 1);
title.setValue(String.join(" ", titleWords) + " ...");
entry.setTitleEx(title);
}
if (post.getDescriptionHtml() != null) {
var description = new Content();
description.setType("html");
description.setValue(post.getDescriptionHtml());
if (post.getUri() != null) {
entry.setSummary(description);
} else {
entry.setContents(List.of(description));
}
}
var alternateLinks = new ArrayList<Link>();
if (post.getUri() != null) {
var postUriLink = new Link();
postUriLink.setRel("alternate");
postUriLink.setHref(post.getUri());
alternateLinks.add(postUriLink);
} else {
var postSelfHref =
uri.resolve(URI.create(String.format("/posts/%d", post.id))).toString();
var postSelfLink = new Link();
postSelfLink.setRel("alternate");
postSelfLink.setHref(postSelfHref);
alternateLinks.add(postSelfLink);
}
entry.setAlternateLinks(alternateLinks);
return entry;
})
.collect(Collectors.toUnmodifiableList()));
var wireFeedOutput = new WireFeedOutput();
return wireFeedOutput.outputString(feed);
}
@TemplateExtension
@CheckForNull
static String humanDateTime(@CheckForNull TemporalAccessor x) {
if (x == null) {
return null;
}
return humanDateTimeFormatter.format(x);
}
@TemplateExtension
@CheckForNull
static String htmlDateTime(@CheckForNull TemporalAccessor x) {
if (x == null) {
return null;
}
return htmlDateTimeFormatter.format(x);
}
@TemplateExtension
@CheckForNull
static String humanDate(@CheckForNull TemporalAccessor x) {
if (x == null) {
return null;
}
return humanDateFormatter.format(x);
}
@TemplateExtension
@CheckForNull
static String htmlDate(@CheckForNull TemporalAccessor x) {
if (x == null) {
return null;
}
return htmlDateFormatter.format(x);
}
private boolean showBookmarkForm() {
switch (postFilter) {
case ALL:
case BOOKMARKS_ONLY:
return !identity.isAnonymous();
case LAZYCHAT_MESSAGES_ONLY:
return false;
default:
throw new IllegalStateException();
}
}
private boolean showLazychatForm() {
switch (postFilter) {
case ALL:
case LAZYCHAT_MESSAGES_ONLY:
return !identity.isAnonymous();
case BOOKMARKS_ONLY:
return false;
default:
throw new IllegalStateException();
}
}
protected final Session getSession() {
return entityManager.unwrap(Session.class);
}
protected static void assignPostTargets(Post.Visibility visibility, User user, Post post) {
switch (visibility) {
case PUBLIC:
post.targets = Set.of(Role.getWorld());
break;
case SEMIPRIVATE:
post.targets = Set.copyOf(user.defaultTargets);
break;
case PRIVATE:
post.targets = Set.of();
break;
default:
throw new BadRequestException(String.format("invalid visibility ā€œ%sā€", visibility));
}
}
protected final Post getPostIfVisible(int id) {
@CheckForNull var user = getCurrentUser();
var message = getSession().byId(Post.class).load(id);
if (!message.isVisibleTo(user)) {
throw new ForbiddenException();
}
return message;
}
@CheckForNull
protected final User getCurrentUser() {
return identity.isAnonymous() ? null : User.findByNickname(identity.getPrincipal().getName());
}
}