| 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.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.Objects; |
| 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; |
| 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.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 Logger log = Logger.getLogger(BookmarkResource.class); |
| |
| private static DateTimeFormatter htmlDateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; |
| |
| private static DateTimeFormatter humanDateFormatter = |
| DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT); |
| |
| private static JsonProvider jsonProvider = JsonProvider.provider(); |
| |
| @ConfigProperty(name = "mulkcms.bookmarks.default-max-results") |
| int defaultMaxResults; |
| |
| @ResourcePath("benki/bookmarks/bookmarkList.html") |
| @Inject |
| Template bookmarkList; |
| |
| @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 q = selectBookmarks(null, cursor, maxResults); |
| |
| return bookmarkList |
| .data("bookmarks", q.bookmarks) |
| .data("feedUri", "/bookmarks/feed") |
| .data("authenticated", !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 q = selectBookmarks(owner, cursor, maxResults); |
| |
| return bookmarkList |
| .data("bookmarks", q.bookmarks) |
| .data("feedUri", String.format("/bookmarks/~%s/feed", ownerName)) |
| .data("authenticated", !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("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 = selectBookmarks(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); |
| } |
| |
| @POST |
| @Transactional |
| @Authenticated |
| public Response postBookmark( |
| @FormParam("uri") URI uri, |
| @FormParam("title") @NotEmpty String title, |
| @FormParam("description") String description, |
| @FormParam("visibility") @NotNull @Pattern(regexp = "public|semiprivate|private") |
| String visibility) |
| throws URISyntaxException { |
| |
| var userName = identity.getPrincipal().getName(); |
| var user = User.findByNickname(userName); |
| |
| var bookmark = new Bookmark(); |
| bookmark.uri = uri.toString(); |
| bookmark.title = title; |
| bookmark.tags = Set.of(); |
| bookmark.description = description != null ? description : ""; |
| bookmark.owner = user; |
| bookmark.date = OffsetDateTime.now(); |
| |
| if (visibility.equals("public")) { |
| Role world = Role.find("from Role r join r.tags tag where tag = 'world'").singleResult(); |
| bookmark.targets = Set.of(world); |
| } else if (visibility.equals("semiprivate")) { |
| bookmark.targets = Set.copyOf(user.defaultTargets); |
| } else if (!visibility.equals("private")) { |
| throw new BadRequestException(String.format("invalid visibility ā%sā", visibility)); |
| } |
| |
| bookmark.persistAndFlush(); |
| |
| return Response.seeOther(new URI("/bookmarks")).build(); |
| } |
| |
| @GET |
| @Path("page-info") |
| @Authenticated |
| @Produces(APPLICATION_JSON) |
| public JsonObject getPageInfo(@QueryParam("uri") URI uri) throws IOException { |
| 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); |
| } |
| |
| private static class BookmarkPage { |
| @CheckForNull Integer prevCursor; |
| @CheckForNull Integer cursor; |
| @CheckForNull Integer nextCursor; |
| List<Bookmark> bookmarks; |
| |
| public BookmarkPage( |
| @CheckForNull Integer c0, |
| @CheckForNull Integer c1, |
| @CheckForNull Integer c2, |
| List<Bookmark> resultList) { |
| this.prevCursor = c0; |
| this.cursor = c1; |
| this.nextCursor = c2; |
| this.bookmarks = resultList; |
| } |
| } |
| |
| private List<Bookmark> selectBookmarks(@CheckForNull User owner) { |
| return selectBookmarks(owner, null, null).bookmarks; |
| } |
| |
| private BookmarkPage selectBookmarks( |
| @CheckForNull User owner, @CheckForNull Integer cursor, @CheckForNull Integer count) { |
| |
| if (cursor != null) { |
| Objects.requireNonNull(count); |
| } |
| |
| var cb = entityManager.unwrap(Session.class).getCriteriaBuilder(); |
| |
| var forwardCriteria = Bookmark.findViewable(identity, owner, cursor, cb, true); |
| var forwardQuery = entityManager.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.findViewable(identity, owner, cursor, cb, false); |
| var backwardQuery = entityManager.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 BookmarkPage(prevCursor, cursor, nextCursor, forwardResults); |
| } |
| } |