google reader api support

This commit is contained in:
Athou
2026-08-08 09:42:43 +02:00
parent 557d56ffc5
commit fd8bb60daa
35 changed files with 1925 additions and 59 deletions

View File

@@ -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;
}
}

View File

@@ -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) {}
}

View File

@@ -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/&lt;id&gt;" 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;
}
}
}

View File

@@ -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=&lt;token&gt;" 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);
}
}

View File

@@ -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);
}
}
}