From 4940b29dbe78cc9ff8baea0ede775aee37745a33 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sun, 29 Mar 2020 18:41:07 +0200 Subject: Factor common parts of BookmarkResource and LazychatResource into PostResource. Change-Id: I6e5e123c67340e564c47448cf43b803f7d0cc809 --- .../eu/mulk/mulkcms2/benki/accesscontrol/Role.java | 2 +- .../eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java | 2 +- .../mulkcms2/benki/bookmarks/BookmarkResource.java | 227 ++---------------- .../java/eu/mulk/mulkcms2/benki/generic/Post.java | 208 ----------------- .../eu/mulk/mulkcms2/benki/generic/PostTarget.java | 30 --- .../mulk/mulkcms2/benki/generic/PostTargetPK.java | 59 ----- .../mulkcms2/benki/lazychat/LazychatMessage.java | 2 +- .../mulkcms2/benki/lazychat/LazychatResource.java | 105 +-------- .../java/eu/mulk/mulkcms2/benki/posts/Post.java | 225 ++++++++++++++++++ .../eu/mulk/mulkcms2/benki/posts/PostFilter.java | 7 + .../eu/mulk/mulkcms2/benki/posts/PostResource.java | 253 +++++++++++++++++++++ .../eu/mulk/mulkcms2/benki/posts/PostTarget.java | 30 +++ .../eu/mulk/mulkcms2/benki/posts/PostTargetPK.java | 59 +++++ .../java/eu/mulk/mulkcms2/benki/users/User.java | 2 +- 14 files changed, 599 insertions(+), 612 deletions(-) delete mode 100644 src/main/java/eu/mulk/mulkcms2/benki/generic/Post.java delete mode 100644 src/main/java/eu/mulk/mulkcms2/benki/generic/PostTarget.java delete mode 100644 src/main/java/eu/mulk/mulkcms2/benki/generic/PostTargetPK.java create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/posts/PostFilter.java create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/posts/PostTarget.java create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/posts/PostTargetPK.java (limited to 'src/main/java/eu') diff --git a/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java b/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java index f35fc6c..6298245 100644 --- a/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java +++ b/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/Role.java @@ -1,6 +1,6 @@ package eu.mulk.mulkcms2.benki.accesscontrol; -import eu.mulk.mulkcms2.benki.generic.PostTarget; +import eu.mulk.mulkcms2.benki.posts.PostTarget; import eu.mulk.mulkcms2.benki.users.User; import eu.mulk.mulkcms2.benki.users.UserRole; import io.quarkus.hibernate.orm.panache.PanacheEntityBase; diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java index ea62af3..736740a 100644 --- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java +++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/Bookmark.java @@ -1,6 +1,6 @@ package eu.mulk.mulkcms2.benki.bookmarks; -import eu.mulk.mulkcms2.benki.generic.Post; +import eu.mulk.mulkcms2.benki.posts.Post; import eu.mulk.mulkcms2.benki.users.User; import eu.mulk.mulkcms2.common.markdown.MarkdownConverter; import io.quarkus.security.identity.SecurityIdentity; diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java index 485a96e..f2e3067 100644 --- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java +++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java @@ -1,45 +1,24 @@ package eu.mulk.mulkcms2.benki.bookmarks; -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.Role; +import eu.mulk.mulkcms2.benki.posts.PostFilter; +import eu.mulk.mulkcms2.benki.posts.PostResource; 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.Authenticated; -import io.quarkus.security.identity.SecurityIdentity; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; -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.Comparator; -import java.util.Date; -import java.util.List; import java.util.Set; -import java.util.stream.Collectors; import javax.annotation.CheckForNull; -import javax.annotation.Nullable; import javax.inject.Inject; import javax.json.JsonObject; -import javax.json.spi.JsonProvider; -import javax.persistence.EntityManager; -import javax.persistence.PersistenceContext; import javax.transaction.Transactional; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; @@ -49,195 +28,20 @@ import javax.ws.rs.FormParam; import javax.ws.rs.GET; import javax.ws.rs.POST; 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.Response; -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; @Path("/bookmarks") -public class BookmarkResource { - - private static final Logger log = Logger.getLogger(BookmarkResource.class); - - private static final DateTimeFormatter htmlDateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; - - private static final DateTimeFormatter humanDateFormatter = - DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); - - private static final JsonProvider jsonProvider = JsonProvider.provider(); - - @ConfigProperty(name = "mulkcms.bookmarks.default-max-results") - int defaultMaxResults; - - @ResourcePath("benki/posts/postList.html") - @Inject - Template postList; +public class BookmarkResource extends PostResource { @ResourcePath("benki/bookmarks/newBookmark.html") @Inject Template newBookmark; - @Inject SecurityIdentity identity; - - @Context UriInfo uri; - - @Inject - @ConfigProperty(name = "mulkcms.tag-base") - String tagBase; - - @PersistenceContext EntityManager entityManager; - - @GET - @Produces(TEXT_HTML) - public TemplateInstance getIndex( - @QueryParam("i") @CheckForNull Integer cursor, - @QueryParam("n") @CheckForNull Integer maxResults) { - - maxResults = maxResults == null ? defaultMaxResults : maxResults; - - var session = entityManager.unwrap(Session.class); - var q = Bookmark.findViewable(session, identity, null, cursor, maxResults); - - return postList - .data("posts", q.posts) - .data("feedUri", "/bookmarks/feed") - .data("pageTitle", "Bookmarks") - .data("showBookmarkForm", !identity.isAnonymous()) - .data("showLazychatForm", false) - .data("hasPreviousPage", q.prevCursor != null) - .data("hasNextPage", q.nextCursor != null) - .data("previousCursor", q.prevCursor) - .data("nextCursor", q.nextCursor) - .data("pageSize", maxResults); - } - - @GET - @Path("~{ownerName}") - @Produces(TEXT_HTML) - public TemplateInstance getUserIndex( - @PathParam("ownerName") String ownerName, - @QueryParam("i") @CheckForNull Integer cursor, - @QueryParam("n") @CheckForNull Integer maxResults) { - - maxResults = maxResults == null ? defaultMaxResults : maxResults; - - var owner = User.findByNickname(ownerName); - var session = entityManager.unwrap(Session.class); - var q = Bookmark.findViewable(session, identity, owner, cursor, maxResults); - - return postList - .data("posts", q.posts) - .data("feedUri", String.format("/bookmarks/~%s/feed", ownerName)) - .data("pageTitle", "Bookmarks") - .data("showBookmarkForm", !identity.isAnonymous()) - .data("showLazychatForm", false) - .data("hasPreviousPage", q.prevCursor != null) - .data("hasNextPage", q.nextCursor != null) - .data("previousCursor", q.prevCursor) - .data("nextCursor", q.nextCursor) - .data("pageSize", maxResults); - } - - @GET - @Path("feed") - @Produces(APPLICATION_ATOM_XML) - public String getFeed() throws FeedException { - return makeFeed(null, null); - } - - @GET - @Path("~{ownerName}/feed") - @Produces(APPLICATION_ATOM_XML) - public String getUserFeed(@PathParam("ownerName") String ownerName) throws FeedException { - var owner = User.findByNickname(ownerName); - return makeFeed(owner, ownerName); - } - - private String makeFeed(@Nullable User owner, @Nullable String ownerName) throws FeedException { - var bookmarks = Bookmark.findViewable(entityManager.unwrap(Session.class), identity, owner); - var feed = new Feed("atom_1.0"); - - var feedSubId = owner == null ? "" : String.format("/%d", owner.id); - - feed.setTitle("Book Marx"); - feed.setId( - String.format( - "tag:%s,2019:marx%s:%s", - tagBase, - feedSubId, - identity.isAnonymous() ? "world" : identity.getPrincipal().getName())); - feed.setUpdated( - Date.from( - bookmarks.stream() - .map(x -> x.date) - .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 = owner == null ? "/bookmarks" : String.format("~%s/bookmarks", ownerName); - htmlAltLink.setHref(uri.resolve(URI.create(htmlAltPath)).toString()); - htmlAltLink.setRel("alternate"); - htmlAltLink.setType("text/html"); - feed.setAlternateLinks(List.of(htmlAltLink)); - - feed.setEntries( - bookmarks.stream() - .map( - bookmark -> { - var entry = new Entry(); - - entry.setId(String.format("tag:%s,2012:/marx/%d", tagBase, bookmark.id)); - entry.setPublished(Date.from(bookmark.date.toInstant())); - entry.setUpdated(Date.from(bookmark.date.toInstant())); - - var author = new SyndPersonImpl(); - author.setName(bookmark.owner.getFirstAndLastName()); - entry.setAuthors(List.of(author)); - - var title = new Content(); - title.setType("text"); - title.setValue(bookmark.title); - entry.setTitleEx(title); - - var summary = new Content(); - summary.setType("html"); - summary.setValue(bookmark.getDescriptionHtml()); - entry.setSummary(summary); - - var link = new Link(); - link.setHref(bookmark.uri); - link.setRel("alternate"); - entry.setAlternateLinks(List.of(link)); - - return entry; - }) - .collect(Collectors.toUnmodifiableList())); - - var wireFeedOutput = new WireFeedOutput(); - return wireFeedOutput.outputString(feed); - } - - @GET - @Authenticated - @Path("new") - @Produces(TEXT_HTML) - public TemplateInstance getNewBookmarkForm( - @QueryParam("uri") @CheckForNull String uri, - @QueryParam("title") @CheckForNull String title, - @QueryParam("description") @CheckForNull String description) { - return newBookmark.data("uri", uri).data("title", title).data("description", description); + public BookmarkResource() { + super(PostFilter.BOOKMARKS_ONLY, "Bookmarks"); } @POST @@ -276,6 +80,17 @@ public class BookmarkResource { return Response.seeOther(new URI("/bookmarks")).build(); } + @GET + @Authenticated + @Path("new") + @Produces(TEXT_HTML) + public TemplateInstance getNewBookmarkForm( + @QueryParam("uri") @CheckForNull String uri, + @QueryParam("title") @CheckForNull String title, + @QueryParam("description") @CheckForNull String description) { + return newBookmark.data("uri", uri).data("title", title).data("description", description); + } + @GET @Path("page-info") @Authenticated @@ -284,14 +99,4 @@ public class BookmarkResource { var document = Jsoup.connect(uri.toString()).get(); return jsonProvider.createObjectBuilder().add("title", document.title()).build(); } - - @TemplateExtension - static String humanDateTime(TemporalAccessor x) { - return humanDateFormatter.format(x); - } - - @TemplateExtension - static String htmlDateTime(TemporalAccessor x) { - return htmlDateFormatter.format(x); - } } diff --git a/src/main/java/eu/mulk/mulkcms2/benki/generic/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/generic/Post.java deleted file mode 100644 index 7d75bb4..0000000 --- a/src/main/java/eu/mulk/mulkcms2/benki/generic/Post.java +++ /dev/null @@ -1,208 +0,0 @@ -package eu.mulk.mulkcms2.benki.generic; - -import eu.mulk.mulkcms2.benki.accesscontrol.Role; -import eu.mulk.mulkcms2.benki.bookmarks.Bookmark; -import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage; -import eu.mulk.mulkcms2.benki.users.User; -import eu.mulk.mulkcms2.benki.users.User_; -import io.quarkus.hibernate.orm.panache.PanacheEntityBase; -import io.quarkus.security.identity.SecurityIdentity; -import java.time.OffsetDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import javax.annotation.CheckForNull; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.Inheritance; -import javax.persistence.InheritanceType; -import javax.persistence.JoinColumn; -import javax.persistence.JoinTable; -import javax.persistence.ManyToMany; -import javax.persistence.ManyToOne; -import javax.persistence.SequenceGenerator; -import javax.persistence.Table; -import javax.persistence.criteria.CriteriaBuilder; -import javax.persistence.criteria.CriteriaQuery; -import javax.persistence.criteria.From; -import javax.persistence.criteria.JoinType; -import javax.persistence.criteria.Predicate; -import org.hibernate.Session; -import org.jboss.logging.Logger; - -@Entity -@Table(name = "posts", schema = "benki") -@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) -public abstract class Post extends PanacheEntityBase { - - private static Logger log = Logger.getLogger(Post.class); - - @Id - @SequenceGenerator( - allocationSize = 1, - sequenceName = "posts_id_seq", - name = "posts_id_seq", - schema = "benki") - @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "posts_id_seq") - @Column(name = "id", nullable = false) - public Integer id; - - @Column(name = "date", nullable = true) - public OffsetDateTime date; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "owner", referencedColumnName = "id") - public User owner; - - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable( - name = "user_visible_posts", - schema = "benki", - joinColumns = @JoinColumn(name = "message"), - inverseJoinColumns = @JoinColumn(name = "user")) - public Set visibleTo; - - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable( - name = "post_targets", - schema = "benki", - joinColumns = @JoinColumn(name = "message"), - inverseJoinColumns = @JoinColumn(name = "target")) - public Set targets; - - public abstract boolean isBookmark(); - - public abstract boolean isLazychatMessage(); - - protected static CriteriaQuery queryViewable( - Class entityClass, - SecurityIdentity readerIdentity, - @CheckForNull User owner, - @CheckForNull Integer cursor, - CriteriaBuilder cb, - boolean forward) { - CriteriaQuery query = cb.createQuery(entityClass); - - var conditions = new ArrayList(); - - From post; - if (readerIdentity.isAnonymous()) { - post = query.from(entityClass); - var target = post.join(Post_.targets); - conditions.add(cb.equal(target, Role.getWorld())); - } else { - var userName = readerIdentity.getPrincipal().getName(); - var user = User.findByNickname(userName); - - var root = query.from(User.class); - conditions.add(cb.equal(root, user)); - if (entityClass.isAssignableFrom(Bookmark.class)) { - post = (From) root.join(User_.visibleBookmarks); - } else { - assert entityClass.isAssignableFrom(LazychatMessage.class) : entityClass; - post = (From) root.join(User_.visibleLazychatMessages); - } - } - - query.select(post); - post.fetch(Post_.owner, JoinType.LEFT); - - if (owner != null) { - conditions.add(cb.equal(post.get(Post_.owner), owner)); - } - - if (forward) { - query.orderBy(cb.desc(post.get(Post_.id))); - } else { - query.orderBy(cb.asc(post.get(Post_.id))); - } - - if (cursor != null) { - if (forward) { - conditions.add(cb.le(post.get(Post_.id), cursor)); - } else { - conditions.add(cb.gt(post.get(Post_.id), cursor)); - } - } - - query.where(conditions.toArray(new Predicate[0])); - - return query; - } - - public static class PostPage { - public @CheckForNull Integer prevCursor; - public @CheckForNull Integer cursor; - public @CheckForNull Integer nextCursor; - public List posts; - - private PostPage( - @CheckForNull Integer c0, - @CheckForNull Integer c1, - @CheckForNull Integer c2, - List resultList) { - this.prevCursor = c0; - this.cursor = c1; - this.nextCursor = c2; - this.posts = resultList; - } - } - - protected static List findViewable( - Class entityClass, Session session, SecurityIdentity viewer, @CheckForNull User owner) { - return findViewable(entityClass, session, viewer, owner, null, null).posts; - } - - protected static PostPage findViewable( - Class entityClass, - Session session, - SecurityIdentity viewer, - @CheckForNull User owner, - @CheckForNull Integer cursor, - @CheckForNull Integer count) { - - if (cursor != null) { - Objects.requireNonNull(count); - } - - var cb = session.getCriteriaBuilder(); - - var forwardCriteria = Bookmark.queryViewable(entityClass, viewer, owner, cursor, cb, true); - var forwardQuery = session.createQuery(forwardCriteria); - - if (count != null) { - forwardQuery.setMaxResults(count + 1); - } - - log.debug(forwardQuery.unwrap(org.hibernate.query.Query.class).getQueryString()); - - @CheckForNull Integer prevCursor = null; - @CheckForNull Integer nextCursor = null; - - if (cursor != null) { - // Look backwards as well so we can find the prevCursor. - var backwardCriteria = Bookmark.queryViewable(entityClass, viewer, owner, cursor, cb, false); - var backwardQuery = session.createQuery(backwardCriteria); - backwardQuery.setMaxResults(count); - var backwardResults = backwardQuery.getResultList(); - if (!backwardResults.isEmpty()) { - prevCursor = backwardResults.get(backwardResults.size() - 1).id; - } - } - - var forwardResults = forwardQuery.getResultList(); - if (count != null) { - if (forwardResults.size() == count + 1) { - nextCursor = forwardResults.get(count).id; - forwardResults.remove((int) count); - } - } - - return new PostPage(prevCursor, cursor, nextCursor, forwardResults); - } -} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTarget.java b/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTarget.java deleted file mode 100644 index 7073874..0000000 --- a/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTarget.java +++ /dev/null @@ -1,30 +0,0 @@ -package eu.mulk.mulkcms2.benki.generic; - -import eu.mulk.mulkcms2.benki.accesscontrol.Role; -import io.quarkus.hibernate.orm.panache.PanacheEntityBase; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.Id; -import javax.persistence.IdClass; -import javax.persistence.JoinColumn; -import javax.persistence.ManyToOne; -import javax.persistence.Table; - -@Entity -@Table(name = "post_targets", schema = "benki") -@IdClass(PostTargetPK.class) -public class PostTarget extends PanacheEntityBase { - - @Id - @Column(name = "message", nullable = false) - public int message; - - @Id - @Column(name = "target", nullable = false) - public int targetId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "target", referencedColumnName = "id", nullable = false) - public Role target; -} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTargetPK.java b/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTargetPK.java deleted file mode 100644 index 13c660d..0000000 --- a/src/main/java/eu/mulk/mulkcms2/benki/generic/PostTargetPK.java +++ /dev/null @@ -1,59 +0,0 @@ -package eu.mulk.mulkcms2.benki.generic; - -import java.io.Serializable; -import javax.persistence.Column; -import javax.persistence.Id; - -public class PostTargetPK implements Serializable { - - private int message; - private int targetId; - - @Column(name = "message", nullable = false) - @Id - public int getMessage() { - return message; - } - - public void setMessage(int message) { - this.message = message; - } - - @Column(name = "target", nullable = false) - @Id - public int getTargetId() { - return targetId; - } - - public void setTargetId(int targetId) { - this.targetId = targetId; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - - PostTargetPK that = (PostTargetPK) o; - - if (message != that.message) { - return false; - } - if (targetId != that.targetId) { - return false; - } - - return true; - } - - @Override - public int hashCode() { - int result = message; - result = 31 * result + targetId; - return result; - } -} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java index 4c7f6a0..5e00c60 100644 --- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java +++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatMessage.java @@ -1,6 +1,6 @@ package eu.mulk.mulkcms2.benki.lazychat; -import eu.mulk.mulkcms2.benki.generic.Post; +import eu.mulk.mulkcms2.benki.posts.Post; import eu.mulk.mulkcms2.benki.users.User; import eu.mulk.mulkcms2.common.markdown.MarkdownConverter; import io.quarkus.security.identity.SecurityIdentity; diff --git a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java index a74692b..8a4d2a3 100644 --- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java +++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java @@ -1,113 +1,28 @@ package eu.mulk.mulkcms2.benki.lazychat; -import static javax.ws.rs.core.MediaType.TEXT_HTML; - import eu.mulk.mulkcms2.benki.accesscontrol.Role; +import eu.mulk.mulkcms2.benki.posts.PostFilter; +import eu.mulk.mulkcms2.benki.posts.PostResource; 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.Authenticated; -import io.quarkus.security.identity.SecurityIdentity; import java.net.URI; import java.net.URISyntaxException; import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.time.temporal.TemporalAccessor; import java.util.Set; -import javax.annotation.CheckForNull; -import javax.inject.Inject; -import javax.json.spi.JsonProvider; -import javax.persistence.EntityManager; -import javax.persistence.PersistenceContext; import javax.transaction.Transactional; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; import javax.ws.rs.BadRequestException; import javax.ws.rs.FormParam; -import javax.ws.rs.GET; import javax.ws.rs.POST; 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.Response; -import org.eclipse.microprofile.config.inject.ConfigProperty; -import org.hibernate.Session; -import org.jboss.logging.Logger; @Path("/lazychat") -public class LazychatResource { - - private static final Logger log = Logger.getLogger(LazychatResource.class); - - private static final DateTimeFormatter htmlDateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; - - private static final DateTimeFormatter humanDateFormatter = - DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); - - private static final JsonProvider jsonProvider = JsonProvider.provider(); - - @ConfigProperty(name = "mulkcms.lazychat.default-max-results") - int defaultMaxResults; - - @ResourcePath("benki/posts/postList.html") - @Inject - Template postList; - - @Inject SecurityIdentity identity; - - @PersistenceContext EntityManager entityManager; - - @GET - @Produces(TEXT_HTML) - public TemplateInstance getIndex( - @QueryParam("i") @CheckForNull Integer cursor, - @QueryParam("n") @CheckForNull Integer maxResults) { - - maxResults = maxResults == null ? defaultMaxResults : maxResults; +public class LazychatResource extends PostResource { - var session = entityManager.unwrap(Session.class); - var q = LazychatMessage.findViewable(session, identity, null, cursor, maxResults); - - return postList - .data("posts", q.posts) - .data("pageTitle", "Lazy Chat") - .data("showBookmarkForm", false) - .data("showLazychatForm", !identity.isAnonymous()) - .data("hasPreviousPage", q.prevCursor != null) - .data("hasNextPage", q.nextCursor != null) - .data("previousCursor", q.prevCursor) - .data("nextCursor", q.nextCursor) - .data("pageSize", maxResults); - } - - @GET - @Path("~{ownerName}") - @Produces(TEXT_HTML) - public TemplateInstance getUserIndex( - @PathParam("ownerName") String ownerName, - @QueryParam("i") @CheckForNull Integer cursor, - @QueryParam("n") @CheckForNull Integer maxResults) { - - maxResults = maxResults == null ? defaultMaxResults : maxResults; - - var owner = User.findByNickname(ownerName); - var session = entityManager.unwrap(Session.class); - var q = LazychatMessage.findViewable(session, identity, owner, cursor, maxResults); - - return postList - .data("posts", q.posts) - .data("pageTitle", "Lazy Chat") - .data("showBookmarkForm", false) - .data("showLazychatForm", !identity.isAnonymous()) - .data("hasPreviousPage", q.prevCursor != null) - .data("hasNextPage", q.nextCursor != null) - .data("previousCursor", q.prevCursor) - .data("nextCursor", q.nextCursor) - .data("pageSize", maxResults); + public LazychatResource() { + super(PostFilter.LAZYCHAT_MESSAGES_ONLY, "Lazy Chat"); } @POST @@ -141,14 +56,4 @@ public class LazychatResource { return Response.seeOther(new URI("/lazychat")).build(); } - - @TemplateExtension - static String humanDateTime(TemporalAccessor x) { - return humanDateFormatter.format(x); - } - - @TemplateExtension - static String htmlDateTime(TemporalAccessor x) { - return htmlDateFormatter.format(x); - } } diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java new file mode 100644 index 0000000..fc9ba78 --- /dev/null +++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java @@ -0,0 +1,225 @@ +package eu.mulk.mulkcms2.benki.posts; + +import eu.mulk.mulkcms2.benki.accesscontrol.Role; +import eu.mulk.mulkcms2.benki.bookmarks.Bookmark; +import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage; +import eu.mulk.mulkcms2.benki.users.User; +import eu.mulk.mulkcms2.benki.users.User_; +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import io.quarkus.security.identity.SecurityIdentity; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import javax.annotation.CheckForNull; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Inheritance; +import javax.persistence.InheritanceType; +import javax.persistence.JoinColumn; +import javax.persistence.JoinTable; +import javax.persistence.ManyToMany; +import javax.persistence.ManyToOne; +import javax.persistence.SequenceGenerator; +import javax.persistence.Table; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.From; +import javax.persistence.criteria.JoinType; +import javax.persistence.criteria.Predicate; +import org.hibernate.Session; +import org.jboss.logging.Logger; + +@Entity +@Table(name = "posts", schema = "benki") +@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) +public abstract class Post extends PanacheEntityBase { + + private static Logger log = Logger.getLogger(Post.class); + + @Id + @SequenceGenerator( + allocationSize = 1, + sequenceName = "posts_id_seq", + name = "posts_id_seq", + schema = "benki") + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "posts_id_seq") + @Column(name = "id", nullable = false) + public Integer id; + + @Column(name = "date", nullable = true) + public OffsetDateTime date; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner", referencedColumnName = "id") + public User owner; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "user_visible_posts", + schema = "benki", + joinColumns = @JoinColumn(name = "message"), + inverseJoinColumns = @JoinColumn(name = "user")) + public Set visibleTo; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "post_targets", + schema = "benki", + joinColumns = @JoinColumn(name = "message"), + inverseJoinColumns = @JoinColumn(name = "target")) + public Set targets; + + public abstract boolean isBookmark(); + + public abstract boolean isLazychatMessage(); + + protected static CriteriaQuery queryViewable( + Class entityClass, + SecurityIdentity readerIdentity, + @CheckForNull User owner, + @CheckForNull Integer cursor, + CriteriaBuilder cb, + boolean forward) { + CriteriaQuery query = cb.createQuery(entityClass); + + var conditions = new ArrayList(); + + From post; + if (readerIdentity.isAnonymous()) { + post = query.from(entityClass); + var target = post.join(Post_.targets); + conditions.add(cb.equal(target, Role.getWorld())); + } else { + var userName = readerIdentity.getPrincipal().getName(); + var user = User.findByNickname(userName); + + var root = query.from(User.class); + conditions.add(cb.equal(root, user)); + if (entityClass.isAssignableFrom(Bookmark.class)) { + post = (From) root.join(User_.visibleBookmarks); + } else if (entityClass.isAssignableFrom(LazychatMessage.class)) { + post = (From) root.join(User_.visibleLazychatMessages); + } else { + post = (From) root.join(User_.visiblePosts); + } + } + + query.select(post); + post.fetch(Post_.owner, JoinType.LEFT); + + if (owner != null) { + conditions.add(cb.equal(post.get(Post_.owner), owner)); + } + + if (forward) { + query.orderBy(cb.desc(post.get(Post_.id))); + } else { + query.orderBy(cb.asc(post.get(Post_.id))); + } + + if (cursor != null) { + if (forward) { + conditions.add(cb.le(post.get(Post_.id), cursor)); + } else { + conditions.add(cb.gt(post.get(Post_.id), cursor)); + } + } + + query.where(conditions.toArray(new Predicate[0])); + + return query; + } + + public static class PostPage { + public @CheckForNull Integer prevCursor; + public @CheckForNull Integer cursor; + public @CheckForNull Integer nextCursor; + public List posts; + + private PostPage( + @CheckForNull Integer c0, + @CheckForNull Integer c1, + @CheckForNull Integer c2, + List resultList) { + this.prevCursor = c0; + this.cursor = c1; + this.nextCursor = c2; + this.posts = resultList; + } + } + + public static PostPage findViewable( + PostFilter postFilter, + Session session, + SecurityIdentity viewer, + @CheckForNull User owner, + @CheckForNull Integer cursor, + @CheckForNull Integer count) { + Class entityClass; + switch (postFilter) { + case BOOKMARKS_ONLY: + entityClass = Bookmark.class; + break; + case LAZYCHAT_MESSAGES_ONLY: + entityClass = LazychatMessage.class; + break; + default: + entityClass = Post.class; + } + return findViewable(entityClass, session, viewer, owner, cursor, count); + } + + protected static PostPage findViewable( + Class entityClass, + Session session, + SecurityIdentity viewer, + @CheckForNull User owner, + @CheckForNull Integer cursor, + @CheckForNull Integer count) { + + if (cursor != null) { + Objects.requireNonNull(count); + } + + var cb = session.getCriteriaBuilder(); + + var forwardCriteria = queryViewable(entityClass, viewer, owner, cursor, cb, true); + var forwardQuery = session.createQuery(forwardCriteria); + + if (count != null) { + forwardQuery.setMaxResults(count + 1); + } + + log.debug(forwardQuery.unwrap(org.hibernate.query.Query.class).getQueryString()); + + @CheckForNull Integer prevCursor = null; + @CheckForNull Integer nextCursor = null; + + if (cursor != null) { + // Look backwards as well so we can find the prevCursor. + var backwardCriteria = queryViewable(entityClass, viewer, owner, cursor, cb, false); + var backwardQuery = session.createQuery(backwardCriteria); + backwardQuery.setMaxResults(count); + var backwardResults = backwardQuery.getResultList(); + if (!backwardResults.isEmpty()) { + prevCursor = backwardResults.get(backwardResults.size() - 1).id; + } + } + + var forwardResults = (List) forwardQuery.getResultList(); + if (count != null) { + if (forwardResults.size() == count + 1) { + nextCursor = forwardResults.get(count).id; + forwardResults.remove((int) count); + } + } + + return new PostPage(prevCursor, cursor, nextCursor, forwardResults); + } +} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostFilter.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostFilter.java new file mode 100644 index 0000000..94069e3 --- /dev/null +++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostFilter.java @@ -0,0 +1,7 @@ +package eu.mulk.mulkcms2.benki.posts; + +public enum PostFilter { + BOOKMARKS_ONLY, + LAZYCHAT_MESSAGES_ONLY, + ALL, +} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java new file mode 100644 index 0000000..e08aaf1 --- /dev/null +++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java @@ -0,0 +1,253 @@ +package eu.mulk.mulkcms2.benki.posts; + +import static javax.ws.rs.core.MediaType.APPLICATION_ATOM_XML; +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.bookmarks.Bookmark; +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.net.URI; +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.Comparator; +import java.util.Date; +import java.util.List; +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.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; + +public abstract class PostResource { + + private static final Logger log = Logger.getLogger(PostResource.class); + + private static final DateTimeFormatter htmlDateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + private static final DateTimeFormatter humanDateFormatter = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); + + 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 EntityManager entityManager; + + private final PostFilter postFilter; + private final String pageTitle; + + public PostResource(PostFilter postFilter, String pageTitle) { + this.postFilter = postFilter; + this.pageTitle = pageTitle; + } + + @GET + @Produces(TEXT_HTML) + public TemplateInstance getIndex( + @QueryParam("i") @CheckForNull Integer cursor, + @QueryParam("n") @CheckForNull Integer maxResults) { + + maxResults = maxResults == null ? defaultMaxResults : maxResults; + + var session = entityManager.unwrap(Session.class); + var q = Post.findViewable(postFilter, session, identity, null, cursor, maxResults); + + return postList + .data("posts", q.posts) + .data("feedUri", "/bookmarks/feed") + .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); + } + + @GET + @Path("~{ownerName}") + @Produces(TEXT_HTML) + public TemplateInstance getUserIndex( + @PathParam("ownerName") String ownerName, + @QueryParam("i") @CheckForNull Integer cursor, + @QueryParam("n") @CheckForNull Integer maxResults) { + + maxResults = maxResults == null ? defaultMaxResults : maxResults; + + var owner = User.findByNickname(ownerName); + var session = entityManager.unwrap(Session.class); + var q = Post.findViewable(postFilter, session, identity, owner, cursor, maxResults); + + return postList + .data("posts", q.posts) + .data("feedUri", String.format("/bookmarks/~%s/feed", ownerName)) + .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); + } + + @GET + @Path("feed") + @Produces(APPLICATION_ATOM_XML) + public String getFeed() throws FeedException { + return makeFeed(null, null); + } + + @GET + @Path("~{ownerName}/feed") + @Produces(APPLICATION_ATOM_XML) + public String getUserFeed(@PathParam("ownerName") String ownerName) throws FeedException { + var owner = User.findByNickname(ownerName); + return makeFeed(owner, ownerName); + } + + private String makeFeed(@Nullable User owner, @Nullable String ownerName) throws FeedException { + var bookmarks = Bookmark.findViewable(entityManager.unwrap(Session.class), identity, owner); + var feed = new Feed("atom_1.0"); + + var feedSubId = owner == null ? "" : String.format("/%d", owner.id); + + feed.setTitle("Book Marx"); + feed.setId( + String.format( + "tag:%s,2019:marx%s:%s", + tagBase, + feedSubId, + identity.isAnonymous() ? "world" : identity.getPrincipal().getName())); + feed.setUpdated( + Date.from( + bookmarks.stream() + .map(x -> x.date) + .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 = owner == null ? "/bookmarks" : String.format("~%s/bookmarks", ownerName); + htmlAltLink.setHref(uri.resolve(URI.create(htmlAltPath)).toString()); + htmlAltLink.setRel("alternate"); + htmlAltLink.setType("text/html"); + feed.setAlternateLinks(List.of(htmlAltLink)); + + feed.setEntries( + bookmarks.stream() + .map( + bookmark -> { + var entry = new Entry(); + + entry.setId(String.format("tag:%s,2012:/marx/%d", tagBase, bookmark.id)); + entry.setPublished(Date.from(bookmark.date.toInstant())); + entry.setUpdated(Date.from(bookmark.date.toInstant())); + + var author = new SyndPersonImpl(); + author.setName(bookmark.owner.getFirstAndLastName()); + entry.setAuthors(List.of(author)); + + var title = new Content(); + title.setType("text"); + title.setValue(bookmark.title); + entry.setTitleEx(title); + + var summary = new Content(); + summary.setType("html"); + summary.setValue(bookmark.getDescriptionHtml()); + entry.setSummary(summary); + + var link = new Link(); + link.setHref(bookmark.uri); + link.setRel("alternate"); + entry.setAlternateLinks(List.of(link)); + + return entry; + }) + .collect(Collectors.toUnmodifiableList())); + + var wireFeedOutput = new WireFeedOutput(); + return wireFeedOutput.outputString(feed); + } + + @TemplateExtension + static String humanDateTime(TemporalAccessor x) { + return humanDateFormatter.format(x); + } + + @TemplateExtension + static String htmlDateTime(TemporalAccessor x) { + 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(); + } + } +} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTarget.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTarget.java new file mode 100644 index 0000000..112ca3e --- /dev/null +++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTarget.java @@ -0,0 +1,30 @@ +package eu.mulk.mulkcms2.benki.posts; + +import eu.mulk.mulkcms2.benki.accesscontrol.Role; +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; + +@Entity +@Table(name = "post_targets", schema = "benki") +@IdClass(PostTargetPK.class) +public class PostTarget extends PanacheEntityBase { + + @Id + @Column(name = "message", nullable = false) + public int message; + + @Id + @Column(name = "target", nullable = false) + public int targetId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "target", referencedColumnName = "id", nullable = false) + public Role target; +} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTargetPK.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTargetPK.java new file mode 100644 index 0000000..ecd5861 --- /dev/null +++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostTargetPK.java @@ -0,0 +1,59 @@ +package eu.mulk.mulkcms2.benki.posts; + +import java.io.Serializable; +import javax.persistence.Column; +import javax.persistence.Id; + +public class PostTargetPK implements Serializable { + + private int message; + private int targetId; + + @Column(name = "message", nullable = false) + @Id + public int getMessage() { + return message; + } + + public void setMessage(int message) { + this.message = message; + } + + @Column(name = "target", nullable = false) + @Id + public int getTargetId() { + return targetId; + } + + public void setTargetId(int targetId) { + this.targetId = targetId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + PostTargetPK that = (PostTargetPK) o; + + if (message != that.message) { + return false; + } + if (targetId != that.targetId) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = message; + result = 31 * result + targetId; + return result; + } +} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/users/User.java b/src/main/java/eu/mulk/mulkcms2/benki/users/User.java index 6587ec4..5879046 100644 --- a/src/main/java/eu/mulk/mulkcms2/benki/users/User.java +++ b/src/main/java/eu/mulk/mulkcms2/benki/users/User.java @@ -3,8 +3,8 @@ package eu.mulk.mulkcms2.benki.users; import eu.mulk.mulkcms2.benki.accesscontrol.PageKey; import eu.mulk.mulkcms2.benki.accesscontrol.Role; import eu.mulk.mulkcms2.benki.bookmarks.Bookmark; -import eu.mulk.mulkcms2.benki.generic.Post; import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage; +import eu.mulk.mulkcms2.benki.posts.Post; import eu.mulk.mulkcms2.benki.wiki.WikiPageRevision; import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import java.util.Collection; -- cgit v1.2.3