KB49 Add private news feeds.

Change-Id: Ib9488351b9734795e02ddaeb26aa81eeb79c0b4d
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/PageKey.java b/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/PageKey.java
index 2553a11..05e4bb5 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/PageKey.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/accesscontrol/PageKey.java
@@ -26,6 +26,6 @@
   public BigInteger key;
 
   @ManyToOne(fetch = FetchType.LAZY)
-  @JoinColumn(name = "user", referencedColumnName = "id", nullable = false)
+  @JoinColumn(name = "\"user\"", referencedColumnName = "id", nullable = false)
   public User user;
 }
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 6dc76b0..495e511 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
@@ -16,6 +16,7 @@
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.security.NoSuchAlgorithmException;
 import java.time.OffsetDateTime;
 import java.util.Objects;
 import java.util.Set;
@@ -42,7 +43,7 @@
   @Inject
   Template newBookmark;
 
-  public BookmarkResource() {
+  public BookmarkResource() throws NoSuchAlgorithmException {
     super(PostFilter.BOOKMARKS_ONLY, "Bookmarks");
   }
 
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 4f9a9fb..cbdbe76 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/lazychat/LazychatResource.java
@@ -10,6 +10,7 @@
 import io.quarkus.security.Authenticated;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.security.NoSuchAlgorithmException;
 import java.time.OffsetDateTime;
 import java.util.Objects;
 import javax.transaction.Transactional;
@@ -27,7 +28,7 @@
 @Path("/lazychat")
 public class LazychatResource extends PostResource {
 
-  public LazychatResource() {
+  public LazychatResource() throws NoSuchAlgorithmException {
     super(PostFilter.LAZYCHAT_MESSAGES_ONLY, "Lazy Chat");
   }
 
@@ -76,7 +77,7 @@
       throw new NotFoundException();
     }
 
-    if (!Objects.equals(message.owner.id, user.id)) {
+    if (message.owner == null || !Objects.equals(message.owner.id, user.id)) {
       throw new ForbiddenException();
     }
 
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/AllPostsResource.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/AllPostsResource.java
index 47c644c..4047b9e 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/AllPostsResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/AllPostsResource.java
@@ -1,11 +1,12 @@
 package eu.mulk.mulkcms2.benki.posts;
 
+import java.security.NoSuchAlgorithmException;
 import javax.ws.rs.Path;
 
 @Path("/posts")
 public class AllPostsResource extends PostResource {
 
-  public AllPostsResource() {
+  public AllPostsResource() throws NoSuchAlgorithmException {
     super(PostFilter.ALL, "All Posts");
   }
 }
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
index fc0f76f..3a02e4e 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
@@ -6,7 +6,6 @@
 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;
@@ -60,6 +59,7 @@
 
   @ManyToOne(fetch = FetchType.LAZY)
   @JoinColumn(name = "owner", referencedColumnName = "id")
+  @CheckForNull
   @JsonbTransient
   public User owner;
 
@@ -108,7 +108,7 @@
 
   protected static <T extends Post> CriteriaQuery<T> queryViewable(
       Class<T> entityClass,
-      SecurityIdentity readerIdentity,
+      @CheckForNull User reader,
       @CheckForNull User owner,
       @CheckForNull Integer cursor,
       CriteriaBuilder cb,
@@ -118,16 +118,13 @@
     var conditions = new ArrayList<Predicate>();
 
     From<?, T> post;
-    if (readerIdentity.isAnonymous()) {
+    if (reader == null) {
       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));
+      conditions.add(cb.equal(root, reader));
       if (entityClass.isAssignableFrom(Post.class)) {
         post = (From<?, T>) root.join(User_.visiblePosts);
       } else if (entityClass.isAssignableFrom(Bookmark.class)) {
@@ -189,14 +186,14 @@
   }
 
   public static List<Post> findViewable(
-      PostFilter postFilter, Session session, SecurityIdentity viewer, @CheckForNull User owner) {
+      PostFilter postFilter, Session session, @CheckForNull User viewer, @CheckForNull User owner) {
     return findViewable(postFilter, session, viewer, owner, null, null).posts;
   }
 
   public static PostPage<Post> findViewable(
       PostFilter postFilter,
       Session session,
-      SecurityIdentity viewer,
+      @CheckForNull User viewer,
       @CheckForNull User owner,
       @CheckForNull Integer cursor,
       @CheckForNull Integer count) {
@@ -217,7 +214,7 @@
   protected static <T extends Post> PostPage<T> findViewable(
       Class<? extends T> entityClass,
       Session session,
-      SecurityIdentity viewer,
+      @CheckForNull User viewer,
       @CheckForNull User owner,
       @CheckForNull Integer cursor,
       @CheckForNull Integer count) {
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
index c718bbc..c100e55 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/PostResource.java
@@ -11,6 +11,7 @@
 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.users.User;
 import io.quarkus.qute.Template;
@@ -18,7 +19,10 @@
 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.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
 import java.time.Instant;
 import java.time.OffsetDateTime;
 import java.time.ZoneOffset;
@@ -29,6 +33,7 @@
 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;
@@ -37,6 +42,7 @@
 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;
@@ -59,6 +65,8 @@
   private static final DateTimeFormatter humanDateFormatter =
       DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
 
+  private static final int pageKeyBytes = 32;
+
   protected static final JsonProvider jsonProvider = JsonProvider.provider();
 
   @ConfigProperty(name = "mulkcms.posts.default-max-results")
@@ -78,12 +86,15 @@
 
   @PersistenceContext protected EntityManager entityManager;
 
+  private final SecureRandom secureRandom;
+
   private final PostFilter postFilter;
   private final String pageTitle;
 
-  public PostResource(PostFilter postFilter, String pageTitle) {
+  public PostResource(PostFilter postFilter, String pageTitle) throws NoSuchAlgorithmException {
     this.postFilter = postFilter;
     this.pageTitle = pageTitle;
+    secureRandom = SecureRandom.getInstanceStrong();
   }
 
   @GET
@@ -94,12 +105,19 @@
 
     maxResults = maxResults == null ? defaultMaxResults : maxResults;
 
+    @CheckForNull var reader = getCurrentUser();
     var session = entityManager.unwrap(Session.class);
-    var q = Post.findViewable(postFilter, session, identity, null, cursor, maxResults);
+    var q = Post.findViewable(postFilter, session, reader, null, cursor, maxResults);
+
+    var feedUri = "/posts/feed";
+    if (reader != null) {
+      var pageKey = ensurePageKey(reader, feedUri);
+      feedUri += "?page-key=" + pageKey.key.toString(36);
+    }
 
     return postList
         .data("posts", q.posts)
-        .data("feedUri", "/posts/feed")
+        .data("feedUri", feedUri)
         .data("pageTitle", pageTitle)
         .data("showBookmarkForm", showBookmarkForm())
         .data("showLazychatForm", showLazychatForm())
@@ -120,13 +138,20 @@
 
     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, identity, owner, cursor, maxResults);
+    var q = Post.findViewable(postFilter, session, reader, owner, cursor, maxResults);
+
+    var feedUri = String.format("/posts/~%s/feed", ownerName);
+    if (reader != null) {
+      var pageKey = ensurePageKey(reader, feedUri);
+      feedUri += "?page-key=" + pageKey.key.toString(36);
+    }
 
     return postList
         .data("posts", q.posts)
-        .data("feedUri", String.format("/posts/~%s/feed", ownerName))
+        .data("feedUri", feedUri)
         .data("pageTitle", pageTitle)
         .data("showBookmarkForm", showBookmarkForm())
         .data("showLazychatForm", showLazychatForm())
@@ -137,23 +162,63 @@
         .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
   @Path("feed")
   @Produces(APPLICATION_ATOM_XML)
-  public String getFeed() throws FeedException {
-    return makeFeed(null, null);
+  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)
-  public String getUserFeed(@PathParam("ownerName") String ownerName) throws FeedException {
+  public String getUserFeed(
+      @QueryParam("page-key") @CheckForNull String pageKeyBase36,
+      @PathParam("ownerName") String ownerName)
+      throws FeedException {
     var owner = User.findByNickname(ownerName);
-    return makeFeed(owner, ownerName);
+    @CheckForNull var pageKey = pageKeyBase36 == null ? null : new BigInteger(pageKeyBase36, 36);
+    return makeFeed(pageKey, ownerName, owner);
   }
 
-  private String makeFeed(@Nullable User owner, @Nullable String ownerName) throws FeedException {
-    var posts = Post.findViewable(postFilter, entityManager.unwrap(Session.class), identity, 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 posts = Post.findViewable(postFilter, entityManager.unwrap(Session.class), reader, owner);
     var feed = new Feed("atom_1.0");
 
     var feedSubId = owner == null ? "" : String.format("/%d", owner.id);
@@ -181,7 +246,7 @@
     feed.setOtherLinks(List.of(selfLink));
 
     var htmlAltLink = new Link();
-    var htmlAltPath = owner == null ? "/posts" : String.format("~%s/posts", ownerName);
+    var htmlAltPath = ownerName == null ? "/posts" : String.format("~%s/posts", ownerName);
     htmlAltLink.setHref(uri.resolve(URI.create(htmlAltPath)).toString());
     htmlAltLink.setRel("alternate");
     htmlAltLink.setType("text/html");
@@ -199,9 +264,11 @@
                     entry.setUpdated(Date.from(post.date.toInstant()));
                   }
 
-                  var author = new SyndPersonImpl();
-                  author.setName(post.owner.getFirstAndLastName());
-                  entry.setAuthors(List.of(author));
+                  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();
@@ -294,16 +361,6 @@
     }
   }
 
-  @CheckForNull
-  protected final User getCurrentUser() {
-    if (identity.isAnonymous()) {
-      return null;
-    }
-
-    var userName = identity.getPrincipal().getName();
-    return User.findByNickname(userName);
-  }
-
   protected final Post getPostIfVisible(int id) {
     @CheckForNull var user = getCurrentUser();
     var message = getSession().byId(Post.class).load(id);
@@ -315,6 +372,11 @@
     return message;
   }
 
+  @CheckForNull
+  protected final User getCurrentUser() {
+    return identity.isAnonymous() ? null : User.findByNickname(identity.getPrincipal().getName());
+  }
+
   @GET
   @Produces(APPLICATION_JSON)
   @Path("{id}")
diff --git a/src/main/resources/hibernate.properties b/src/main/resources/hibernate.properties
new file mode 100644
index 0000000..52cfc53
--- /dev/null
+++ b/src/main/resources/hibernate.properties
@@ -0,0 +1 @@
+hibernate.dialect = io.quarkus.hibernate.orm.runtime.dialect.QuarkusPostgreSQL10Dialect