| package eu.mulk.mulkcms2.benki.posts; |
| |
| import static jakarta.ws.rs.core.MediaType.APPLICATION_ATOM_XML; |
| import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; |
| import static jakarta.ws.rs.core.MediaType.TEXT_HTML; |
| import static jakarta.ws.rs.core.MediaType.TEXT_PLAIN; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| |
| import com.blazebit.persistence.CriteriaBuilderFactory; |
| 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.lazychat.LazychatMessage; |
| import eu.mulk.mulkcms2.benki.login.LoginRoles; |
| import eu.mulk.mulkcms2.benki.posts.Post.PostPage; |
| import eu.mulk.mulkcms2.benki.posts.Post.Scope; |
| import eu.mulk.mulkcms2.benki.users.User; |
| import io.quarkus.mailer.MailTemplate.MailTemplateInstance; |
| import io.quarkus.qute.CheckedTemplate; |
| import io.quarkus.qute.TemplateInstance; |
| import io.quarkus.security.identity.SecurityIdentity; |
| import io.smallrye.mutiny.Uni; |
| import jakarta.annotation.Nullable; |
| import jakarta.inject.Inject; |
| import jakarta.json.spi.JsonProvider; |
| import jakarta.persistence.EntityManager; |
| import jakarta.persistence.PersistenceContext; |
| import jakarta.transaction.Transactional; |
| import jakarta.validation.constraints.NotEmpty; |
| import jakarta.ws.rs.BadRequestException; |
| import jakarta.ws.rs.ForbiddenException; |
| import jakarta.ws.rs.FormParam; |
| import jakarta.ws.rs.GET; |
| import jakarta.ws.rs.POST; |
| import jakarta.ws.rs.Path; |
| import jakarta.ws.rs.PathParam; |
| import jakarta.ws.rs.Produces; |
| import jakarta.ws.rs.QueryParam; |
| import jakarta.ws.rs.core.Context; |
| import jakarta.ws.rs.core.Response; |
| import jakarta.ws.rs.core.Response.Status; |
| import jakarta.ws.rs.core.UriBuilder; |
| import jakarta.ws.rs.core.UriInfo; |
| import java.math.BigInteger; |
| import java.net.URI; |
| import java.net.URLEncoder; |
| import java.security.MessageDigest; |
| import java.security.NoSuchAlgorithmException; |
| import java.security.SecureRandom; |
| import java.time.Instant; |
| import java.time.OffsetDateTime; |
| import java.time.ZoneOffset; |
| 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.concurrent.ExecutionException; |
| import java.util.concurrent.TimeoutException; |
| import java.util.stream.Collectors; |
| import javax.annotation.CheckForNull; |
| import org.eclipse.microprofile.config.inject.ConfigProperty; |
| import org.hibernate.Session; |
| import org.jsoup.Jsoup; |
| |
| public abstract class PostResource { |
| |
| private static final String hashcashDigestAlgorithm = "SHA-256"; |
| |
| 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; |
| |
| @ConfigProperty(name = "quarkus.mailer.from") |
| String mailSenderAddress; |
| |
| @CheckedTemplate |
| static class Templates { |
| |
| public static native <P extends Post> TemplateInstance postList( |
| List<Post.Day<P>> postDays, |
| @CheckForNull String feedUri, |
| String pageTitle, |
| Boolean showBookmarkForm, |
| Boolean showLazychatForm, |
| Boolean showCommentBox, |
| Boolean hasPreviousPage, |
| Boolean hasNextPage, |
| @CheckForNull Integer previousCursor, |
| @CheckForNull Integer nextCursor, |
| @CheckForNull Integer pageSize, |
| @CheckForNull String searchQuery); |
| |
| public static native MailTemplateInstance commentNotificationMail( |
| int postId, LazychatMessage comment); |
| } |
| |
| @Inject protected SecurityIdentity identity; |
| |
| @Context protected UriInfo uri; |
| |
| @Inject |
| @ConfigProperty(name = "mulkcms.tag-base") |
| String tagBase; |
| |
| @PersistenceContext protected EntityManager entityManager; |
| |
| @Inject protected CriteriaBuilderFactory criteriaBuilderFactory; |
| |
| 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 q = |
| Post.findViewable( |
| postFilter, |
| entityManager, |
| criteriaBuilderFactory, |
| 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 Templates.postList( |
| q.days(), |
| feedUri, |
| pageTitle, |
| showBookmarkForm(), |
| showLazychatForm(), |
| false, |
| q.prevCursor != null, |
| q.nextCursor != null, |
| q.prevCursor, |
| q.nextCursor, |
| maxResults, |
| 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 q = |
| Post.findViewable( |
| postFilter, |
| entityManager, |
| criteriaBuilderFactory, |
| 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 Templates.postList( |
| q.days(), |
| feedUri, |
| pageTitle, |
| showBookmarkForm(), |
| showLazychatForm(), |
| false, |
| q.prevCursor != null, |
| q.nextCursor != null, |
| q.prevCursor, |
| q.nextCursor, |
| maxResults, |
| null); |
| } |
| |
| @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 Templates.postList( |
| new PostPage<>(null, null, null, List.of(post)).days(), |
| null, |
| String.format("Post #%d", id), |
| false, |
| false, |
| true, |
| false, |
| false, |
| null, |
| null, |
| null, |
| 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); |
| } |
| |
| @POST |
| @Produces(TEXT_PLAIN) |
| @Path("{id}/comments") |
| @Transactional |
| public Uni<Response> postComment( |
| @PathParam("id") int postId, |
| @FormParam("message") @NotEmpty String message, |
| @FormParam("hashcash-salt") long hashcashSalt) |
| throws NoSuchAlgorithmException, ExecutionException, InterruptedException, TimeoutException { |
| var hashcashDigest = MessageDigest.getInstance(hashcashDigestAlgorithm); |
| hashcashDigest.update("Hashcash-Salt: ".getBytes(UTF_8)); |
| hashcashDigest.update(String.valueOf(hashcashSalt).getBytes(UTF_8)); |
| hashcashDigest.update("\n\n".getBytes(UTF_8)); |
| |
| for (byte b : message.getBytes(UTF_8)) { |
| if (b == '\r') { |
| // Skip CR characters. The JavaScript side does not include them in its computation. |
| continue; |
| } |
| hashcashDigest.update(b); |
| } |
| var hashcash = hashcashDigest.digest(); |
| |
| if (hashcash[0] != 0 || hashcash[1] != 0) { |
| throw new BadRequestException( |
| "invalid hashcash", |
| Response.status(Status.BAD_REQUEST).entity("invalid hashcash").build()); |
| } |
| |
| Post post = Post.findById(postId); |
| |
| var comment = new LazychatMessage(); |
| comment.date = OffsetDateTime.now(); |
| comment.scope = Scope.comment; |
| comment.referees = List.of(post); |
| comment.setContent(message); |
| assignPostTargets(post.getVisibility(), post.owner, comment); |
| comment.persist(); |
| |
| var currentUser = getCurrentUser(); |
| if (currentUser != null) { |
| comment.owner = currentUser; |
| } |
| |
| var admins = User.findAdmins(); |
| |
| var mailText = Templates.commentNotificationMail(postId, comment); |
| var sendJob = |
| mailText |
| .subject(String.format("MulkCMS comment #%d", comment.id)) |
| .to(mailSenderAddress) |
| .bcc(admins.stream().map(x -> x.email).toArray(String[]::new)) |
| .send(); |
| return sendJob |
| .onItem() |
| .transform( |
| unused -> Response.seeOther(UriBuilder.fromUri("/posts/{id}").build(postId)).build()); |
| } |
| |
| 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, criteriaBuilderFactory, 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() |
| .filter(Post::isTopLevel) |
| .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); |
| } |
| |
| private boolean showBookmarkForm() { |
| switch (postFilter) { |
| case ALL: |
| case BOOKMARKS_ONLY: |
| return identity.hasRole(LoginRoles.EDITOR); |
| case LAZYCHAT_MESSAGES_ONLY: |
| return false; |
| default: |
| throw new IllegalStateException(); |
| } |
| } |
| |
| private boolean showLazychatForm() { |
| switch (postFilter) { |
| case ALL: |
| case LAZYCHAT_MESSAGES_ONLY: |
| return identity.hasRole(LoginRoles.EDITOR); |
| 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()); |
| } |
| } |