mirror of
https://github.com/Athou/commafeed.git
synced 2026-09-20 19:51:09 +00:00
google reader api support
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
package com.commafeed.frontend.resource.googlereader;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@JsonInclude(Include.NON_NULL)
|
||||
public class GoogleReaderModel {
|
||||
|
||||
private GoogleReaderModel() {}
|
||||
|
||||
@Data
|
||||
public static class UserInfo {
|
||||
|
||||
@JsonProperty("userId")
|
||||
private String userId;
|
||||
|
||||
@JsonProperty("userName")
|
||||
private String userName;
|
||||
|
||||
@JsonProperty("userProfileId")
|
||||
private String userProfileId;
|
||||
|
||||
@JsonProperty("userEmail")
|
||||
private String userEmail;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SubscriptionList {
|
||||
|
||||
@JsonProperty("subscriptions")
|
||||
private List<Subscription> subscriptions = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Subscription {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("title")
|
||||
private String title;
|
||||
|
||||
@JsonProperty("categories")
|
||||
private List<Category> categories = new ArrayList<>();
|
||||
|
||||
@JsonProperty("url")
|
||||
private String url;
|
||||
|
||||
@JsonProperty("htmlUrl")
|
||||
private String htmlUrl;
|
||||
|
||||
@JsonProperty("iconUrl")
|
||||
private String iconUrl;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Category {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("label")
|
||||
private String label;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TagList {
|
||||
|
||||
@JsonProperty("tags")
|
||||
private List<Tag> tags = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Tag {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class StreamContents {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("title")
|
||||
private String title;
|
||||
|
||||
@JsonProperty("updated")
|
||||
private long updated;
|
||||
|
||||
@JsonProperty("items")
|
||||
private List<StreamItem> items = new ArrayList<>();
|
||||
|
||||
@JsonProperty("continuation")
|
||||
private String continuation;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class StreamItem {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("crawlTimeMsec")
|
||||
private String crawlTimeMsec;
|
||||
|
||||
@JsonProperty("timestampUsec")
|
||||
private String timestampUsec;
|
||||
|
||||
@JsonProperty("published")
|
||||
private long published;
|
||||
|
||||
@JsonProperty("updated")
|
||||
private long updated;
|
||||
|
||||
@JsonProperty("title")
|
||||
private String title;
|
||||
|
||||
@JsonProperty("author")
|
||||
private String author;
|
||||
|
||||
@JsonProperty("categories")
|
||||
private List<String> categories = new ArrayList<>();
|
||||
|
||||
@JsonProperty("canonical")
|
||||
private List<HrefEntry> canonical = new ArrayList<>();
|
||||
|
||||
@JsonProperty("alternate")
|
||||
private List<HrefEntry> alternate = new ArrayList<>();
|
||||
|
||||
@JsonProperty("summary")
|
||||
private Content summary;
|
||||
|
||||
@JsonProperty("origin")
|
||||
private Origin origin;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class HrefEntry {
|
||||
|
||||
@JsonProperty("href")
|
||||
private String href;
|
||||
|
||||
@JsonProperty("type")
|
||||
private String type;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Content {
|
||||
|
||||
@JsonProperty("content")
|
||||
private String content;
|
||||
|
||||
@JsonProperty("direction")
|
||||
private String direction;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Origin {
|
||||
|
||||
@JsonProperty("streamId")
|
||||
private String streamId;
|
||||
|
||||
@JsonProperty("title")
|
||||
private String title;
|
||||
|
||||
@JsonProperty("htmlUrl")
|
||||
private String htmlUrl;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ItemRefs {
|
||||
|
||||
@JsonProperty("itemRefs")
|
||||
private List<ItemRef> itemRefs = new ArrayList<>();
|
||||
|
||||
@JsonProperty("continuation")
|
||||
private String continuation;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ItemRef {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class UnreadCounts {
|
||||
|
||||
@JsonProperty("max")
|
||||
private long max;
|
||||
|
||||
@JsonProperty("unreadcounts")
|
||||
private List<UnreadCountEntry> unreadCounts = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class UnreadCountEntry {
|
||||
|
||||
@JsonProperty("id")
|
||||
private String id;
|
||||
|
||||
@JsonProperty("count")
|
||||
private long count;
|
||||
|
||||
@JsonProperty("newestItemTimestampUsec")
|
||||
private String newestItemTimestampUsec;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
package com.commafeed.frontend.resource.googlereader;
|
||||
|
||||
import com.commafeed.backend.Digests;
|
||||
import com.commafeed.backend.dao.FeedCategoryDAO;
|
||||
import com.commafeed.backend.dao.FeedEntryDAO;
|
||||
import com.commafeed.backend.dao.FeedEntryStatusDAO;
|
||||
import com.commafeed.backend.dao.FeedSubscriptionDAO;
|
||||
import com.commafeed.backend.feed.FeedUtils;
|
||||
import com.commafeed.backend.model.FeedCategory;
|
||||
import com.commafeed.backend.model.FeedEntry;
|
||||
import com.commafeed.backend.model.FeedEntryContent;
|
||||
import com.commafeed.backend.model.FeedEntryStatus;
|
||||
import com.commafeed.backend.model.FeedSubscription;
|
||||
import com.commafeed.backend.model.User;
|
||||
import com.commafeed.backend.model.UserSettings.ReadingOrder;
|
||||
import com.commafeed.backend.service.FeedEntryService;
|
||||
import com.commafeed.backend.service.FeedSubscriptionService;
|
||||
import com.commafeed.backend.service.UserService;
|
||||
import com.commafeed.frontend.model.UnreadCount;
|
||||
import com.commafeed.security.AuthenticationContext;
|
||||
import com.commafeed.security.Roles;
|
||||
import com.commafeed.security.mechanism.GoogleReaderAuthenticationMechanism;
|
||||
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Singleton;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
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.MediaType;
|
||||
import jakarta.ws.rs.core.MultivaluedMap;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Google Reader compatible API
|
||||
*
|
||||
* <ul>
|
||||
* <li>url: /rest/googlereader
|
||||
* <li>login: username
|
||||
* <li>password: api key
|
||||
* </ul>
|
||||
*/
|
||||
@Path(GoogleReaderAuthenticationMechanism.PATH_PREFIX)
|
||||
@RolesAllowed(Roles.USER)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@RequiredArgsConstructor
|
||||
@Singleton
|
||||
public class GoogleReaderREST {
|
||||
|
||||
private static final int DEFAULT_ITEM_COUNT = 20;
|
||||
private static final int MAX_ITEM_COUNT = 1000;
|
||||
// cap at which clients should display "count+" instead of the exact unread count
|
||||
private static final int MAX_UNREAD_COUNT = 10000;
|
||||
|
||||
private final AuthenticationContext authenticationContext;
|
||||
private final UserService userService;
|
||||
private final FeedSubscriptionService feedSubscriptionService;
|
||||
private final FeedEntryService feedEntryService;
|
||||
private final FeedSubscriptionDAO feedSubscriptionDAO;
|
||||
private final FeedCategoryDAO feedCategoryDAO;
|
||||
private final FeedEntryDAO feedEntryDAO;
|
||||
private final FeedEntryStatusDAO feedEntryStatusDAO;
|
||||
|
||||
@GET
|
||||
@PermitAll
|
||||
@Path("/")
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Operation(hidden = true)
|
||||
public Response welcome() {
|
||||
return Response.ok("This is the Google Reader API endpoint.").build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@PermitAll
|
||||
@Path("/accounts/ClientLogin")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response clientLogin(@Context UriInfo uri, MultivaluedMap<String, String> form) {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
|
||||
form.forEach((k, v) -> params.put(k, v.getFirst()));
|
||||
|
||||
String password = params.get("Passwd");
|
||||
User user = StringUtils.isBlank(password) ? null : userService.login(password).orElse(null);
|
||||
if (user == null) {
|
||||
return Response.status(Status.FORBIDDEN).entity("Error=BadAuthentication\n").build();
|
||||
}
|
||||
|
||||
String token = user.getName() + "/" + user.getApiKey();
|
||||
String body = "SID=" + token + "\nLSID=" + token + "\nAuth=" + token + "\n";
|
||||
return Response.ok(body).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/token")
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response token() {
|
||||
return Response.ok(computeEditToken(currentUser())).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/user-info")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.UserInfo userInfo() {
|
||||
User user = currentUser();
|
||||
GoogleReaderModel.UserInfo info = new GoogleReaderModel.UserInfo();
|
||||
info.setUserId(String.valueOf(user.getId()));
|
||||
info.setUserName(user.getName());
|
||||
info.setUserProfileId(String.valueOf(user.getId()));
|
||||
info.setUserEmail(user.getEmail());
|
||||
return info;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/subscription/list")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.SubscriptionList subscriptionList() {
|
||||
User user = currentUser();
|
||||
GoogleReaderModel.SubscriptionList resp = new GoogleReaderModel.SubscriptionList();
|
||||
resp.setSubscriptions(
|
||||
feedSubscriptionDAO.findAll(user).stream().map(this::mapSubscription).toList());
|
||||
return resp;
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/reader/api/0/subscription/edit")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response subscriptionEdit(
|
||||
@FormParam("ac") String action,
|
||||
@FormParam("s") String streamId,
|
||||
@FormParam("t") String title,
|
||||
@FormParam("a") String addLabel,
|
||||
@FormParam("r") String removeLabel,
|
||||
@FormParam("T") String token) {
|
||||
User user = currentUser();
|
||||
if (!validToken(user, token)) {
|
||||
return Response.status(Status.UNAUTHORIZED).build();
|
||||
}
|
||||
if (StringUtils.isBlank(action) || StringUtils.isBlank(streamId)) {
|
||||
return Response.status(Status.BAD_REQUEST).build();
|
||||
}
|
||||
|
||||
if ("subscribe".equals(action)) {
|
||||
String url =
|
||||
streamId.startsWith(GoogleReaderStreamId.FEED_PREFIX)
|
||||
? streamId.substring(GoogleReaderStreamId.FEED_PREFIX.length())
|
||||
: streamId;
|
||||
FeedCategory category = resolveLabelCategory(user, addLabel, true);
|
||||
String subTitle = StringUtils.defaultIfBlank(title, url);
|
||||
feedSubscriptionService.subscribe(user, url, subTitle, category, 0);
|
||||
} else {
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(streamId);
|
||||
Long subId = parsed.feedSubscriptionId();
|
||||
if (subId == null) {
|
||||
return Response.status(Status.BAD_REQUEST).build();
|
||||
}
|
||||
|
||||
if ("unsubscribe".equals(action)) {
|
||||
feedSubscriptionService.unsubscribe(user, subId);
|
||||
} else if ("edit".equals(action)) {
|
||||
FeedSubscription sub = feedSubscriptionDAO.findById(user, subId);
|
||||
if (sub != null) {
|
||||
if (StringUtils.isNotBlank(title)) {
|
||||
sub.setTitle(title);
|
||||
}
|
||||
if (StringUtils.isNotBlank(addLabel)) {
|
||||
sub.setCategory(resolveLabelCategory(user, addLabel, true));
|
||||
}
|
||||
if (StringUtils.isNotBlank(removeLabel)) {
|
||||
sub.setCategory(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.ok("OK").build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/tag/list")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.TagList tagList() {
|
||||
User user = currentUser();
|
||||
List<GoogleReaderModel.Tag> tags = new ArrayList<>();
|
||||
feedCategoryDAO
|
||||
.findAll(user)
|
||||
.forEach(
|
||||
c ->
|
||||
tags.add(
|
||||
new GoogleReaderModel.Tag(
|
||||
GoogleReaderStreamId.label(c.getName()))));
|
||||
tags.add(new GoogleReaderModel.Tag(GoogleReaderStreamId.STARRED));
|
||||
|
||||
GoogleReaderModel.TagList resp = new GoogleReaderModel.TagList();
|
||||
resp.setTags(tags);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/unread-count")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.UnreadCounts unreadCounts() {
|
||||
User user = currentUser();
|
||||
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
|
||||
Map<Long, UnreadCount> unreadCounts = feedSubscriptionService.getUnreadCount(user);
|
||||
|
||||
List<GoogleReaderModel.UnreadCountEntry> entries = new ArrayList<>();
|
||||
Map<Long, Long> unreadByCategory = new HashMap<>();
|
||||
long totalUnread = 0;
|
||||
|
||||
for (FeedSubscription sub : subs) {
|
||||
UnreadCount uc = unreadCounts.get(sub.getId());
|
||||
|
||||
GoogleReaderModel.UnreadCountEntry entry = new GoogleReaderModel.UnreadCountEntry();
|
||||
entry.setId(GoogleReaderStreamId.feed(sub.getId()));
|
||||
entry.setCount(uc.getUnreadCount());
|
||||
entry.setNewestItemTimestampUsec(toUsec(uc.getNewestItemTime()));
|
||||
entries.add(entry);
|
||||
|
||||
totalUnread += uc.getUnreadCount();
|
||||
|
||||
long categoryId = sub.getCategory() == null ? 0 : sub.getCategory().getId();
|
||||
unreadByCategory.merge(categoryId, uc.getUnreadCount(), Long::sum);
|
||||
}
|
||||
|
||||
Map<Long, FeedCategory> categoriesById =
|
||||
feedCategoryDAO.findAll(user).stream()
|
||||
.collect(Collectors.toMap(FeedCategory::getId, c -> c));
|
||||
unreadByCategory.forEach(
|
||||
(categoryId, count) -> {
|
||||
if (categoryId == 0) {
|
||||
return;
|
||||
}
|
||||
FeedCategory category = categoriesById.get(categoryId);
|
||||
if (category == null) {
|
||||
return;
|
||||
}
|
||||
GoogleReaderModel.UnreadCountEntry entry =
|
||||
new GoogleReaderModel.UnreadCountEntry();
|
||||
entry.setId(GoogleReaderStreamId.label(category.getName()));
|
||||
entry.setCount(count);
|
||||
entries.add(entry);
|
||||
});
|
||||
|
||||
GoogleReaderModel.UnreadCountEntry readingList = new GoogleReaderModel.UnreadCountEntry();
|
||||
readingList.setId(GoogleReaderStreamId.READING_LIST);
|
||||
readingList.setCount(totalUnread);
|
||||
entries.add(readingList);
|
||||
|
||||
GoogleReaderModel.UnreadCounts resp = new GoogleReaderModel.UnreadCounts();
|
||||
resp.setMax(MAX_UNREAD_COUNT);
|
||||
resp.setUnreadCounts(entries);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/stream/contents/{streamId : .+}")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.StreamContents streamContentsPath(
|
||||
@PathParam("streamId") String streamId,
|
||||
@QueryParam("n") Integer n,
|
||||
@QueryParam("c") String continuation,
|
||||
@QueryParam("r") String order,
|
||||
@QueryParam("xt") List<String> excludeTargets) {
|
||||
return buildStreamContents(streamId, n, continuation, order, excludeTargets);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/stream/contents")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.StreamContents streamContentsQuery(
|
||||
@QueryParam("s") String streamId,
|
||||
@QueryParam("n") Integer n,
|
||||
@QueryParam("c") String continuation,
|
||||
@QueryParam("r") String order,
|
||||
@QueryParam("xt") List<String> excludeTargets) {
|
||||
return buildStreamContents(streamId, n, continuation, order, excludeTargets);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/stream/items/ids")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.ItemRefs streamItemIds(
|
||||
@QueryParam("s") String streamId,
|
||||
@QueryParam("n") Integer n,
|
||||
@QueryParam("c") String continuation,
|
||||
@QueryParam("r") String order,
|
||||
@QueryParam("xt") List<String> excludeTargets) {
|
||||
User user = currentUser();
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(streamId);
|
||||
int limit = clampLimit(n);
|
||||
ReadingOrder readingOrder = "o".equals(order) ? ReadingOrder.ASC : ReadingOrder.DESC;
|
||||
boolean unreadOnly = isUnreadOnly(excludeTargets);
|
||||
|
||||
PagedStatuses paged =
|
||||
fetchStatuses(user, parsed, limit, continuation, readingOrder, unreadOnly);
|
||||
|
||||
GoogleReaderModel.ItemRefs resp = new GoogleReaderModel.ItemRefs();
|
||||
resp.setItemRefs(
|
||||
paged.statuses().stream()
|
||||
.map(
|
||||
s ->
|
||||
new GoogleReaderModel.ItemRef(
|
||||
String.valueOf(s.getEntry().getId())))
|
||||
.toList());
|
||||
resp.setContinuation(paged.continuation());
|
||||
return resp;
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/reader/api/0/stream/items/contents")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.StreamContents streamItemsContentsPost(
|
||||
@FormParam("i") List<String> ids) {
|
||||
return buildStreamItemsContents(ids);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/reader/api/0/stream/items/contents")
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public GoogleReaderModel.StreamContents streamItemsContentsGet(
|
||||
@QueryParam("i") List<String> ids) {
|
||||
return buildStreamItemsContents(ids);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/reader/api/0/edit-tag")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response editTag(
|
||||
@FormParam("i") List<String> ids,
|
||||
@FormParam("a") List<String> add,
|
||||
@FormParam("r") List<String> remove,
|
||||
@FormParam("T") String token) {
|
||||
User user = currentUser();
|
||||
if (!validToken(user, token)) {
|
||||
return Response.status(Status.UNAUTHORIZED).build();
|
||||
}
|
||||
if (ids == null) {
|
||||
return Response.ok("OK").build();
|
||||
}
|
||||
|
||||
boolean markRead = containsTag(add, GoogleReaderStreamId.READ);
|
||||
boolean markUnread = containsTag(remove, GoogleReaderStreamId.READ);
|
||||
boolean star = containsTag(add, GoogleReaderStreamId.STARRED);
|
||||
boolean unstar = containsTag(remove, GoogleReaderStreamId.STARRED);
|
||||
|
||||
for (String rawId : ids) {
|
||||
Long entryId = parseItemId(rawId);
|
||||
if (entryId == null) {
|
||||
continue;
|
||||
}
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
if (entry == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (markRead) {
|
||||
feedEntryService.markEntry(user, entryId, true);
|
||||
}
|
||||
if (markUnread) {
|
||||
feedEntryService.markEntry(user, entryId, false);
|
||||
}
|
||||
if (star || unstar) {
|
||||
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
|
||||
if (sub != null) {
|
||||
if (star) {
|
||||
feedEntryService.starEntry(user, entryId, sub.getId(), true);
|
||||
}
|
||||
if (unstar) {
|
||||
feedEntryService.starEntry(user, entryId, sub.getId(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.ok("OK").build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/reader/api/0/mark-all-as-read")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
@Transactional
|
||||
@Operation(hidden = true)
|
||||
public Response markAllAsRead(
|
||||
@FormParam("s") String streamId,
|
||||
@FormParam("ts") Long ts,
|
||||
@FormParam("T") String token) {
|
||||
User user = currentUser();
|
||||
if (!validToken(user, token)) {
|
||||
return Response.status(Status.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(streamId);
|
||||
Instant olderThan = (ts != null && ts > 0) ? Instant.ofEpochSecond(ts / 1_000_000) : null;
|
||||
|
||||
switch (parsed.type()) {
|
||||
case STARRED -> feedEntryService.markStarredEntries(user, olderThan, null);
|
||||
case FEED -> {
|
||||
Long subId = parsed.feedSubscriptionId();
|
||||
FeedSubscription sub =
|
||||
subId == null ? null : feedSubscriptionDAO.findById(user, subId);
|
||||
if (sub != null) {
|
||||
feedEntryService.markSubscriptionEntries(
|
||||
user, List.of(sub), olderThan, null, null);
|
||||
}
|
||||
}
|
||||
case LABEL -> {
|
||||
FeedCategory category = findCategoryByName(user, parsed.value());
|
||||
if (category != null) {
|
||||
List<FeedCategory> categories =
|
||||
feedCategoryDAO.findAllChildrenCategories(user, category);
|
||||
List<FeedSubscription> subs =
|
||||
feedSubscriptionDAO.findByCategories(user, categories);
|
||||
feedEntryService.markSubscriptionEntries(user, subs, olderThan, null, null);
|
||||
}
|
||||
}
|
||||
default ->
|
||||
feedEntryService.markSubscriptionEntries(
|
||||
user, feedSubscriptionDAO.findAll(user), olderThan, null, null);
|
||||
}
|
||||
|
||||
return Response.ok("OK").build();
|
||||
}
|
||||
|
||||
private GoogleReaderModel.StreamContents buildStreamContents(
|
||||
String rawStreamId,
|
||||
Integer n,
|
||||
String continuation,
|
||||
String order,
|
||||
List<String> excludeTargets) {
|
||||
User user = currentUser();
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(rawStreamId);
|
||||
int limit = clampLimit(n);
|
||||
ReadingOrder readingOrder = "o".equals(order) ? ReadingOrder.ASC : ReadingOrder.DESC;
|
||||
boolean unreadOnly = isUnreadOnly(excludeTargets);
|
||||
|
||||
PagedStatuses paged =
|
||||
fetchStatuses(user, parsed, limit, continuation, readingOrder, unreadOnly);
|
||||
|
||||
GoogleReaderModel.StreamContents resp = new GoogleReaderModel.StreamContents();
|
||||
resp.setId(streamIdString(parsed));
|
||||
resp.setTitle(streamTitle(user, parsed));
|
||||
resp.setUpdated(Instant.now().getEpochSecond());
|
||||
resp.setItems(paged.statuses().stream().map(this::mapItem).toList());
|
||||
resp.setContinuation(paged.continuation());
|
||||
return resp;
|
||||
}
|
||||
|
||||
private GoogleReaderModel.StreamContents buildStreamItemsContents(List<String> ids) {
|
||||
User user = currentUser();
|
||||
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
|
||||
Map<Long, FeedSubscription> subsByFeedId =
|
||||
subs.stream()
|
||||
.collect(Collectors.toMap(s -> s.getFeed().getId(), s -> s, (a, b) -> a));
|
||||
|
||||
List<GoogleReaderModel.StreamItem> items = new ArrayList<>();
|
||||
if (ids != null) {
|
||||
for (String rawId : ids) {
|
||||
Long entryId = parseItemId(rawId);
|
||||
if (entryId == null) {
|
||||
continue;
|
||||
}
|
||||
FeedEntry entry = feedEntryDAO.findById(entryId);
|
||||
if (entry == null) {
|
||||
continue;
|
||||
}
|
||||
FeedSubscription sub = subsByFeedId.get(entry.getFeed().getId());
|
||||
if (sub == null) {
|
||||
continue;
|
||||
}
|
||||
FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry);
|
||||
items.add(mapItem(status));
|
||||
}
|
||||
}
|
||||
|
||||
GoogleReaderModel.StreamContents resp = new GoogleReaderModel.StreamContents();
|
||||
resp.setId(GoogleReaderStreamId.READING_LIST);
|
||||
resp.setUpdated(Instant.now().getEpochSecond());
|
||||
resp.setItems(items);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private PagedStatuses fetchStatuses(
|
||||
User user,
|
||||
GoogleReaderStreamId streamId,
|
||||
int limit,
|
||||
String continuation,
|
||||
ReadingOrder order,
|
||||
boolean unreadOnly) {
|
||||
Long minEntryId = null;
|
||||
Long maxEntryId = null;
|
||||
int offset = 0;
|
||||
|
||||
if (streamId.type() == GoogleReaderStreamId.Type.STARRED) {
|
||||
offset = parseOffset(continuation);
|
||||
} else if (StringUtils.isNotBlank(continuation)) {
|
||||
try {
|
||||
long cursor = Long.parseLong(continuation);
|
||||
if (order == ReadingOrder.ASC) {
|
||||
minEntryId = cursor;
|
||||
} else {
|
||||
maxEntryId = cursor;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// invalid continuation token, ignore and start from the beginning
|
||||
}
|
||||
}
|
||||
|
||||
List<FeedEntryStatus> statuses;
|
||||
int fetchedCount;
|
||||
switch (streamId.type()) {
|
||||
case STARRED -> {
|
||||
List<FeedEntryStatus> starred =
|
||||
feedEntryStatusDAO.findStarred(
|
||||
user, null, null, offset, limit, order, true);
|
||||
fetchedCount = starred.size();
|
||||
statuses =
|
||||
unreadOnly ? starred.stream().filter(s -> !s.isRead()).toList() : starred;
|
||||
}
|
||||
case FEED -> {
|
||||
Long subId = streamId.feedSubscriptionId();
|
||||
FeedSubscription sub =
|
||||
subId == null ? null : feedSubscriptionDAO.findById(user, subId);
|
||||
List<FeedSubscription> subs = sub == null ? List.of() : List.of(sub);
|
||||
statuses =
|
||||
feedEntryStatusDAO.findBySubscriptions(
|
||||
user,
|
||||
subs,
|
||||
unreadOnly,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
limit,
|
||||
order,
|
||||
true,
|
||||
null,
|
||||
minEntryId,
|
||||
maxEntryId);
|
||||
fetchedCount = statuses.size();
|
||||
}
|
||||
case LABEL -> {
|
||||
FeedCategory category = findCategoryByName(user, streamId.value());
|
||||
List<FeedSubscription> subs =
|
||||
category == null
|
||||
? List.of()
|
||||
: feedSubscriptionDAO.findByCategories(
|
||||
user,
|
||||
feedCategoryDAO.findAllChildrenCategories(user, category));
|
||||
statuses =
|
||||
feedEntryStatusDAO.findBySubscriptions(
|
||||
user,
|
||||
subs,
|
||||
unreadOnly,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
limit,
|
||||
order,
|
||||
true,
|
||||
null,
|
||||
minEntryId,
|
||||
maxEntryId);
|
||||
fetchedCount = statuses.size();
|
||||
}
|
||||
default -> {
|
||||
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
|
||||
statuses =
|
||||
feedEntryStatusDAO.findBySubscriptions(
|
||||
user,
|
||||
subs,
|
||||
unreadOnly,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
limit,
|
||||
order,
|
||||
true,
|
||||
null,
|
||||
minEntryId,
|
||||
maxEntryId);
|
||||
fetchedCount = statuses.size();
|
||||
}
|
||||
}
|
||||
|
||||
String nextContinuation = null;
|
||||
if (fetchedCount >= limit && fetchedCount > 0) {
|
||||
if (streamId.type() == GoogleReaderStreamId.Type.STARRED) {
|
||||
nextContinuation = String.valueOf(offset + limit);
|
||||
} else if (!statuses.isEmpty()) {
|
||||
nextContinuation = String.valueOf(statuses.getLast().getEntry().getId());
|
||||
}
|
||||
}
|
||||
|
||||
return new PagedStatuses(statuses, nextContinuation);
|
||||
}
|
||||
|
||||
private GoogleReaderModel.StreamItem mapItem(FeedEntryStatus status) {
|
||||
FeedEntry entry = status.getEntry();
|
||||
FeedSubscription sub = status.getSubscription();
|
||||
|
||||
GoogleReaderModel.StreamItem item = new GoogleReaderModel.StreamItem();
|
||||
item.setId(formatItemId(entry.getId()));
|
||||
|
||||
Instant published = entry.getPublished() != null ? entry.getPublished() : Instant.EPOCH;
|
||||
item.setPublished(published.getEpochSecond());
|
||||
item.setUpdated(published.getEpochSecond());
|
||||
item.setCrawlTimeMsec(String.valueOf(entry.getInserted().toEpochMilli()));
|
||||
item.setTimestampUsec(String.valueOf(published.toEpochMilli() * 1000));
|
||||
item.setTitle(entry.getContent().getTitle());
|
||||
item.setAuthor(entry.getContent().getAuthor());
|
||||
|
||||
if (StringUtils.isNotBlank(entry.getUrl())) {
|
||||
item.setCanonical(List.of(new GoogleReaderModel.HrefEntry(entry.getUrl(), null)));
|
||||
item.setAlternate(
|
||||
List.of(new GoogleReaderModel.HrefEntry(entry.getUrl(), "text/html")));
|
||||
}
|
||||
item.setSummary(
|
||||
new GoogleReaderModel.Content(
|
||||
Optional.ofNullable(entry.getContent().getContent()).orElse(""),
|
||||
entry.getContent().getDirection() == FeedEntryContent.Direction.RTL
|
||||
? "rtl"
|
||||
: "ltr"));
|
||||
|
||||
List<String> categories = new ArrayList<>();
|
||||
categories.add(GoogleReaderStreamId.READING_LIST);
|
||||
if (status.isRead()) {
|
||||
categories.add(GoogleReaderStreamId.READ);
|
||||
}
|
||||
if (status.isStarred()) {
|
||||
categories.add(GoogleReaderStreamId.STARRED);
|
||||
}
|
||||
if (sub != null && sub.getCategory() != null) {
|
||||
categories.add(GoogleReaderStreamId.label(sub.getCategory().getName()));
|
||||
}
|
||||
item.setCategories(categories);
|
||||
|
||||
if (sub != null) {
|
||||
GoogleReaderModel.Origin origin = new GoogleReaderModel.Origin();
|
||||
origin.setStreamId(GoogleReaderStreamId.feed(sub.getId()));
|
||||
origin.setTitle(sub.getTitle());
|
||||
origin.setHtmlUrl(sub.getFeed().getLink());
|
||||
item.setOrigin(origin);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private GoogleReaderModel.Subscription mapSubscription(FeedSubscription sub) {
|
||||
GoogleReaderModel.Subscription s = new GoogleReaderModel.Subscription();
|
||||
s.setId(GoogleReaderStreamId.feed(sub.getId()));
|
||||
s.setTitle(sub.getTitle());
|
||||
s.setUrl(sub.getFeed().getUrl());
|
||||
s.setHtmlUrl(sub.getFeed().getLink());
|
||||
s.setIconUrl(sub.getFeed().getIconUrl());
|
||||
if (sub.getCategory() != null) {
|
||||
s.getCategories()
|
||||
.add(
|
||||
new GoogleReaderModel.Category(
|
||||
GoogleReaderStreamId.label(sub.getCategory().getName()),
|
||||
sub.getCategory().getName()));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private FeedCategory resolveLabelCategory(
|
||||
User user, String labelParam, boolean createIfMissing) {
|
||||
if (StringUtils.isBlank(labelParam)) {
|
||||
return null;
|
||||
}
|
||||
GoogleReaderStreamId parsed = GoogleReaderStreamId.parse(labelParam);
|
||||
if (parsed.type() != GoogleReaderStreamId.Type.LABEL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String name = parsed.value();
|
||||
FeedCategory category = findCategoryByName(user, name);
|
||||
if (category == null && createIfMissing) {
|
||||
category = new FeedCategory();
|
||||
category.setUser(user);
|
||||
category.setName(FeedUtils.truncate(name, 128));
|
||||
feedCategoryDAO.persist(category);
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
private FeedCategory findCategoryByName(User user, String name) {
|
||||
return feedCategoryDAO.findAll(user).stream()
|
||||
.filter(c -> c.getName().equals(name))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String streamIdString(GoogleReaderStreamId parsed) {
|
||||
return switch (parsed.type()) {
|
||||
case FEED -> GoogleReaderStreamId.FEED_PREFIX + parsed.value();
|
||||
case LABEL -> GoogleReaderStreamId.label(parsed.value());
|
||||
case STARRED -> GoogleReaderStreamId.STARRED;
|
||||
default -> GoogleReaderStreamId.READING_LIST;
|
||||
};
|
||||
}
|
||||
|
||||
private String streamTitle(User user, GoogleReaderStreamId parsed) {
|
||||
return switch (parsed.type()) {
|
||||
case FEED -> {
|
||||
Long subId = parsed.feedSubscriptionId();
|
||||
FeedSubscription sub =
|
||||
subId == null ? null : feedSubscriptionDAO.findById(user, subId);
|
||||
yield sub == null ? null : sub.getTitle();
|
||||
}
|
||||
case LABEL -> parsed.value();
|
||||
case STARRED -> "Starred items";
|
||||
default -> "reading list";
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isUnreadOnly(List<String> excludeTargets) {
|
||||
return excludeTargets != null
|
||||
&& excludeTargets.stream().anyMatch(t -> t.endsWith("/state/com.google/read"));
|
||||
}
|
||||
|
||||
private boolean containsTag(List<String> tags, String suffix) {
|
||||
return tags != null && tags.stream().anyMatch(t -> t.endsWith(suffix));
|
||||
}
|
||||
|
||||
private String formatItemId(long id) {
|
||||
// Google Reader's canonical long-form item id, e.g.
|
||||
// "tag:google.com,2005:reader/item/00000000148b9369", where the suffix is the item's
|
||||
// numeric id, represented as a zero-padded, 16-digit hex string
|
||||
return "tag:google.com,2005:reader/item/" + String.format("%016x", id);
|
||||
}
|
||||
|
||||
private Long parseItemId(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
int idx = raw.lastIndexOf('/');
|
||||
if (raw.startsWith("tag:google.com") && idx != -1) {
|
||||
try {
|
||||
return Long.parseUnsignedLong(raw.substring(idx + 1), 16);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(raw);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private int parseOffset(String continuation) {
|
||||
if (StringUtils.isBlank(continuation)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(continuation);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private int clampLimit(Integer n) {
|
||||
if (n == null || n <= 0) {
|
||||
return DEFAULT_ITEM_COUNT;
|
||||
}
|
||||
return Math.min(n, MAX_ITEM_COUNT);
|
||||
}
|
||||
|
||||
private String toUsec(Instant instant) {
|
||||
return instant == null ? "0" : String.valueOf(instant.toEpochMilli() * 1000);
|
||||
}
|
||||
|
||||
private boolean validToken(User user, String token) {
|
||||
// the edit token is a CSRF protection inherited from Google Reader's cookie-based
|
||||
// authentication model. Since this API is authenticated via an api key header instead of
|
||||
// cookies, that risk does not apply here. Some real-world clients (e.g. RSSGuard, unless
|
||||
// configured as "Reedah"/"Miniflux") never call the "token" endpoint and always submit a
|
||||
// blank token, so, blank tokens are accepted.
|
||||
return StringUtils.isBlank(token) || computeEditToken(user).equals(token);
|
||||
}
|
||||
|
||||
private String computeEditToken(User user) {
|
||||
return Digests.md5Hex(user.getApiKey() + ":" + user.getId());
|
||||
}
|
||||
|
||||
private User currentUser() {
|
||||
return authenticationContext.getCurrentUser();
|
||||
}
|
||||
|
||||
private record PagedStatuses(List<FeedEntryStatus> statuses, String continuation) {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.commafeed.frontend.resource.googlereader;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public record GoogleReaderStreamId(Type type, String value) {
|
||||
|
||||
public enum Type {
|
||||
ALL,
|
||||
FEED,
|
||||
LABEL,
|
||||
STARRED
|
||||
}
|
||||
|
||||
public static final String READING_LIST = "user/-/state/com.google/reading-list";
|
||||
public static final String STARRED = "user/-/state/com.google/starred";
|
||||
public static final String READ = "user/-/state/com.google/read";
|
||||
public static final String LABEL_PREFIX = "user/-/label/";
|
||||
public static final String FEED_PREFIX = "feed/";
|
||||
|
||||
public static final GoogleReaderStreamId ALL_STREAM = new GoogleReaderStreamId(Type.ALL, null);
|
||||
public static final GoogleReaderStreamId STARRED_STREAM =
|
||||
new GoogleReaderStreamId(Type.STARRED, null);
|
||||
|
||||
public static GoogleReaderStreamId parse(String raw) {
|
||||
if (StringUtils.isBlank(raw) || "reading-list".equals(raw) || READING_LIST.equals(raw)) {
|
||||
return ALL_STREAM;
|
||||
}
|
||||
if (STARRED.equals(raw)) {
|
||||
return STARRED_STREAM;
|
||||
}
|
||||
if (raw.startsWith(LABEL_PREFIX)) {
|
||||
return new GoogleReaderStreamId(Type.LABEL, raw.substring(LABEL_PREFIX.length()));
|
||||
}
|
||||
if (raw.startsWith(FEED_PREFIX)) {
|
||||
return new GoogleReaderStreamId(Type.FEED, raw.substring(FEED_PREFIX.length()));
|
||||
}
|
||||
// unrecognized stream id, default to the whole reading list
|
||||
return ALL_STREAM;
|
||||
}
|
||||
|
||||
public static String feed(long subscriptionId) {
|
||||
return FEED_PREFIX + subscriptionId;
|
||||
}
|
||||
|
||||
public static String label(String categoryName) {
|
||||
return LABEL_PREFIX + categoryName;
|
||||
}
|
||||
|
||||
/** extracts the subscription id out of a "feed/<id>" stream id, or null if invalid */
|
||||
public Long feedSubscriptionId() {
|
||||
if (type != Type.FEED) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.commafeed.security.mechanism;
|
||||
|
||||
import io.quarkus.security.credential.TokenCredential;
|
||||
import io.quarkus.security.identity.IdentityProviderManager;
|
||||
import io.quarkus.security.identity.SecurityIdentity;
|
||||
import io.quarkus.security.identity.request.AuthenticationRequest;
|
||||
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
|
||||
import io.quarkus.vertx.http.runtime.security.ChallengeData;
|
||||
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
|
||||
import io.smallrye.mutiny.Uni;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Authenticates requests to the Google Reader compatible API, using the "Authorization: GoogleLogin
|
||||
* auth=<token>" header set by Google Reader API clients.
|
||||
*
|
||||
* <p>This mechanism only applies to requests under {@link #PATH_PREFIX}, so that a stray
|
||||
* "Authorization: GoogleLogin ..." header sent to any other endpoint is not mistakenly accepted as
|
||||
* an api key authentication.
|
||||
*/
|
||||
@Singleton
|
||||
public class GoogleReaderAuthenticationMechanism implements HttpAuthenticationMechanism {
|
||||
|
||||
public static final String PATH_PREFIX = "/rest/googlereader";
|
||||
|
||||
private static final String AUTH_HEADER = "Authorization";
|
||||
private static final String AUTH_PREFIX = "GoogleLogin auth=";
|
||||
|
||||
@Override
|
||||
public Uni<SecurityIdentity> authenticate(
|
||||
RoutingContext context, IdentityProviderManager identityProviderManager) {
|
||||
if (!context.normalizedPath().startsWith(PATH_PREFIX)) {
|
||||
return Uni.createFrom().optional(Optional.empty());
|
||||
}
|
||||
|
||||
String header = context.request().getHeader(AUTH_HEADER);
|
||||
if (StringUtils.isBlank(header) || !header.startsWith(AUTH_PREFIX)) {
|
||||
return Uni.createFrom().optional(Optional.empty());
|
||||
}
|
||||
|
||||
String token = header.substring(AUTH_PREFIX.length()).trim();
|
||||
int slashIndex = token.lastIndexOf('/');
|
||||
String apiKey = slashIndex == -1 ? token : token.substring(slashIndex + 1);
|
||||
if (StringUtils.isBlank(apiKey)) {
|
||||
return Uni.createFrom().optional(Optional.empty());
|
||||
}
|
||||
|
||||
TokenCredential credential = new TokenCredential(apiKey, "apiKey");
|
||||
TokenAuthenticationRequest request = new TokenAuthenticationRequest(credential);
|
||||
return identityProviderManager.authenticate(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uni<ChallengeData> getChallenge(RoutingContext context) {
|
||||
return Uni.createFrom().optional(Optional.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Class<? extends AuthenticationRequest>> getCredentialTypes() {
|
||||
return Set.of(TokenAuthenticationRequest.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.commafeed.integration.rest;
|
||||
|
||||
import com.commafeed.TestConstants;
|
||||
import com.commafeed.frontend.model.Entry;
|
||||
import com.commafeed.frontend.model.UserModel;
|
||||
import com.commafeed.frontend.model.request.ProfileModificationRequest;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.ItemRefs;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.StreamContents;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.SubscriptionList;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.TagList;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.UnreadCounts;
|
||||
import com.commafeed.frontend.resource.googlereader.GoogleReaderModel.UserInfo;
|
||||
import com.commafeed.integration.BaseIT;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.http.ContentType;
|
||||
import io.restassured.response.Response;
|
||||
import io.restassured.specification.RequestSpecification;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
import org.apache.hc.core5.http.HttpStatus;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@QuarkusTest
|
||||
class GoogleReaderIT extends BaseIT {
|
||||
|
||||
private static final String BASE = "rest/googlereader";
|
||||
|
||||
@Getter private String apiKey;
|
||||
private GoogleReaderClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
initialSetup(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
|
||||
RestAssured.authentication =
|
||||
RestAssured.preemptive()
|
||||
.basic(TestConstants.ADMIN_USERNAME, TestConstants.ADMIN_PASSWORD);
|
||||
|
||||
// create api key
|
||||
ProfileModificationRequest req = new ProfileModificationRequest();
|
||||
req.setCurrentPassword(TestConstants.ADMIN_PASSWORD);
|
||||
req.setNewApiKey(true);
|
||||
RestAssured.given()
|
||||
.body(req)
|
||||
.contentType(ContentType.JSON)
|
||||
.post("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK);
|
||||
|
||||
// retrieve api key
|
||||
UserModel user =
|
||||
RestAssured.given()
|
||||
.get("rest/user/profile")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.as(UserModel.class);
|
||||
this.apiKey = user.getApiKey();
|
||||
this.client = new GoogleReaderClient(clientLogin(TestConstants.ADMIN_USERNAME, apiKey));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
RestAssured.reset();
|
||||
}
|
||||
|
||||
private String clientLogin(String email, String password) {
|
||||
String body =
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.formParam("Email", email)
|
||||
.formParam("Passwd", password)
|
||||
.post(BASE + "/accounts/ClientLogin")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_OK)
|
||||
.extract()
|
||||
.asString();
|
||||
|
||||
Map<String, String> values =
|
||||
Arrays.stream(body.split("\n"))
|
||||
.map(line -> line.split("=", 2))
|
||||
.collect(Collectors.toMap(kv -> kv[0], kv -> kv[1]));
|
||||
return values.get("Auth");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidClientLogin() {
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.formParam("Email", TestConstants.ADMIN_USERNAME)
|
||||
.formParam("Passwd", "invalid-key")
|
||||
.post(BASE + "/accounts/ClientLogin")
|
||||
.then()
|
||||
.statusCode(HttpStatus.SC_FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validClientLogin() {
|
||||
String token = clientLogin(TestConstants.ADMIN_USERNAME, apiKey);
|
||||
Assertions.assertNotNull(token);
|
||||
Assertions.assertTrue(token.endsWith("/" + apiKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidAuthToken() {
|
||||
Response response =
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.header("Authorization", "GoogleLogin auth=invalid/invalid-key")
|
||||
.get(BASE + "/reader/api/0/user-info");
|
||||
Assertions.assertEquals(HttpStatus.SC_UNAUTHORIZED, response.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authTokenOnlyAppliesToGoogleReaderEndpoints() {
|
||||
String token = clientLogin(TestConstants.ADMIN_USERNAME, apiKey);
|
||||
Response response =
|
||||
RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.header("Authorization", "GoogleLogin auth=" + token)
|
||||
.get("rest/user/profile");
|
||||
Assertions.assertNotEquals(HttpStatus.SC_OK, response.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userInfo() {
|
||||
UserInfo info = client.get(BASE + "/reader/api/0/user-info").as(UserInfo.class);
|
||||
Assertions.assertEquals(TestConstants.ADMIN_USERNAME, info.getUserName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscriptionList() {
|
||||
subscribe(getFeedUrl());
|
||||
SubscriptionList list =
|
||||
client.get(BASE + "/reader/api/0/subscription/list").as(SubscriptionList.class);
|
||||
Assertions.assertEquals(1, list.getSubscriptions().size());
|
||||
Assertions.assertEquals(
|
||||
"my title for this feed", list.getSubscriptions().getFirst().getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tagList() {
|
||||
createCategory("test-category");
|
||||
TagList tagList = client.get(BASE + "/reader/api/0/tag/list").as(TagList.class);
|
||||
Assertions.assertTrue(
|
||||
tagList.getTags().stream()
|
||||
.anyMatch(t -> t.getId().equals("user/-/label/test-category")));
|
||||
Assertions.assertTrue(
|
||||
tagList.getTags().stream()
|
||||
.anyMatch(t -> t.getId().equals("user/-/state/com.google/starred")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreadCount() {
|
||||
subscribeAndWaitForEntries(getFeedUrl());
|
||||
UnreadCounts counts =
|
||||
client.get(BASE + "/reader/api/0/unread-count").as(UnreadCounts.class);
|
||||
Assertions.assertTrue(
|
||||
counts.getUnreadCounts().stream()
|
||||
.anyMatch(
|
||||
c ->
|
||||
c.getId().equals("user/-/state/com.google/reading-list")
|
||||
&& c.getCount() == 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamContents() {
|
||||
subscribeAndWaitForEntries(getFeedUrl());
|
||||
StreamContents contents =
|
||||
client.get(BASE + "/reader/api/0/stream/contents/reading-list")
|
||||
.as(StreamContents.class);
|
||||
Assertions.assertEquals(2, contents.getItems().size());
|
||||
Assertions.assertEquals("Item 2", contents.getItems().getFirst().getTitle());
|
||||
Assertions.assertTrue(
|
||||
contents.getItems()
|
||||
.getFirst()
|
||||
.getId()
|
||||
.matches("tag:google\\.com,2005:reader/item/[0-9a-f]{16}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamItemIds() {
|
||||
subscribeAndWaitForEntries(getFeedUrl());
|
||||
ItemRefs refs =
|
||||
client.get(
|
||||
BASE
|
||||
+ "/reader/api/0/stream/items/ids?s=user/-/state/com.google/reading-list")
|
||||
.as(ItemRefs.class);
|
||||
Assertions.assertEquals(2, refs.getItemRefs().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editTagMarkRead() {
|
||||
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
|
||||
Entry entry = getFeedEntries(subscriptionId).getEntries().getFirst();
|
||||
|
||||
String token = client.get(BASE + "/reader/api/0/token").asString();
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/edit-tag",
|
||||
Map.of("i", entry.getId(), "a", "user/-/state/com.google/read", "T", token));
|
||||
|
||||
Assertions.assertEquals(
|
||||
1,
|
||||
getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count());
|
||||
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/edit-tag",
|
||||
Map.of("i", entry.getId(), "r", "user/-/state/com.google/read", "T", token));
|
||||
|
||||
Assertions.assertEquals(
|
||||
0,
|
||||
getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editTagMarkReadWithoutToken() {
|
||||
// some real-world Google Reader API clients never fetch the edit token and always submit
|
||||
// a blank one, this must still be accepted
|
||||
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
|
||||
Entry entry = getFeedEntries(subscriptionId).getEntries().getFirst();
|
||||
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/edit-tag",
|
||||
Map.of("i", entry.getId(), "a", "user/-/state/com.google/read"));
|
||||
|
||||
Assertions.assertEquals(
|
||||
1,
|
||||
getFeedEntries(subscriptionId).getEntries().stream().filter(Entry::isRead).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editTagStar() {
|
||||
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
|
||||
Entry entry = getFeedEntries(subscriptionId).getEntries().getFirst();
|
||||
|
||||
String token = client.get(BASE + "/reader/api/0/token").asString();
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/edit-tag",
|
||||
Map.of("i", entry.getId(), "a", "user/-/state/com.google/starred", "T", token));
|
||||
|
||||
Assertions.assertEquals(
|
||||
1,
|
||||
getFeedEntries(subscriptionId).getEntries().stream()
|
||||
.filter(Entry::isStarred)
|
||||
.count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllAsRead() {
|
||||
Long subscriptionId = subscribeAndWaitForEntries(getFeedUrl());
|
||||
|
||||
String token = client.get(BASE + "/reader/api/0/token").asString();
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/mark-all-as-read",
|
||||
Map.of("s", "user/-/state/com.google/reading-list", "T", token));
|
||||
|
||||
Assertions.assertTrue(
|
||||
getFeedEntries(subscriptionId).getEntries().stream().allMatch(Entry::isRead));
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeAndUnsubscribe() {
|
||||
String token = client.get(BASE + "/reader/api/0/token").asString();
|
||||
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/subscription/edit",
|
||||
Map.of(
|
||||
"ac",
|
||||
"subscribe",
|
||||
"s",
|
||||
"feed/" + getFeedUrl(),
|
||||
"t",
|
||||
"new subscription",
|
||||
"T",
|
||||
token));
|
||||
|
||||
SubscriptionList list =
|
||||
client.get(BASE + "/reader/api/0/subscription/list").as(SubscriptionList.class);
|
||||
Assertions.assertEquals(1, list.getSubscriptions().size());
|
||||
String feedStreamId = list.getSubscriptions().getFirst().getId();
|
||||
|
||||
client.postForm(
|
||||
BASE + "/reader/api/0/subscription/edit",
|
||||
Map.of("ac", "unsubscribe", "s", feedStreamId, "T", token));
|
||||
|
||||
list = client.get(BASE + "/reader/api/0/subscription/list").as(SubscriptionList.class);
|
||||
Assertions.assertTrue(list.getSubscriptions().isEmpty());
|
||||
}
|
||||
|
||||
private record GoogleReaderClient(String token) {
|
||||
|
||||
private RequestSpecification given() {
|
||||
return RestAssured.given()
|
||||
.auth()
|
||||
.none()
|
||||
.header("Authorization", "GoogleLogin auth=" + token);
|
||||
}
|
||||
|
||||
Response get(String path) {
|
||||
return given().get(path).then().statusCode(HttpStatus.SC_OK).extract().response();
|
||||
}
|
||||
|
||||
void postForm(String path, Map<String, ?> form) {
|
||||
RequestSpecification spec = given();
|
||||
form.forEach(spec::formParam);
|
||||
spec.post(path).then().statusCode(HttpStatus.SC_OK);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user