add fever-compatible api

This commit is contained in:
Athou
2023-08-17 17:25:27 +02:00
parent b356be3e6f
commit d7b0d572c1
9 changed files with 491 additions and 10 deletions

View File

@@ -42,6 +42,7 @@ import com.commafeed.frontend.resource.FeedREST;
import com.commafeed.frontend.resource.PubSubHubbubCallbackREST;
import com.commafeed.frontend.resource.ServerREST;
import com.commafeed.frontend.resource.UserREST;
import com.commafeed.frontend.resource.fever.FeverREST;
import com.commafeed.frontend.servlet.AnalyticsServlet;
import com.commafeed.frontend.servlet.CustomCssServlet;
import com.commafeed.frontend.servlet.CustomJsServlet;
@@ -163,6 +164,7 @@ public class CommaFeedApplication extends Application<CommaFeedConfiguration> {
environment.jersey().register(injector.getInstance(PubSubHubbubCallbackREST.class));
environment.jersey().register(injector.getInstance(ServerREST.class));
environment.jersey().register(injector.getInstance(UserREST.class));
environment.jersey().register(injector.getInstance(FeverREST.class));
// Servlets
environment.servlets().addServlet("next", injector.getInstance(NextUnreadServlet.class)).addMapping("/next");

View File

@@ -117,7 +117,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
}
private JPAQuery<FeedEntry> buildQuery(User user, FeedSubscription sub, boolean unreadOnly, List<FeedEntryKeyword> keywords,
Date newerThan, int offset, int limit, ReadingOrder order, FeedEntryStatus last, String tag) {
Date newerThan, int offset, int limit, ReadingOrder order, FeedEntryStatus last, String tag, Long minEntryId, Long maxEntryId) {
JPAQuery<FeedEntry> query = query().selectFrom(entry).where(entry.feed.eq(sub.getFeed()));
@@ -159,6 +159,14 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
query.where(entry.inserted.goe(newerThan));
}
if (minEntryId != null) {
query.where(entry.id.gt(minEntryId));
}
if (maxEntryId != null) {
query.where(entry.id.lt(maxEntryId));
}
if (last != null) {
if (order == ReadingOrder.desc) {
query.where(entry.updated.gt(last.getEntryUpdated()));
@@ -189,7 +197,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
public List<FeedEntryStatus> findBySubscriptions(User user, List<FeedSubscription> subs, boolean unreadOnly,
List<FeedEntryKeyword> keywords, Date newerThan, int offset, int limit, ReadingOrder order, boolean includeContent,
boolean onlyIds, String tag) {
boolean onlyIds, String tag, Long minEntryId, Long maxEntryId) {
int capacity = offset + limit;
Comparator<FeedEntryStatus> comparator = order == ReadingOrder.desc ? STATUS_COMPARATOR_DESC : STATUS_COMPARATOR_ASC;
@@ -197,7 +205,8 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
FixedSizeSortedSet<FeedEntryStatus> set = new FixedSizeSortedSet<>(capacity, comparator);
for (FeedSubscription sub : subs) {
FeedEntryStatus last = (order != null && set.isFull()) ? set.last() : null;
JPAQuery<FeedEntry> query = buildQuery(user, sub, unreadOnly, keywords, newerThan, -1, capacity, order, last, tag);
JPAQuery<FeedEntry> query = buildQuery(user, sub, unreadOnly, keywords, newerThan, -1, capacity, order, last, tag, minEntryId,
maxEntryId);
List<Tuple> tuples = query.select(entry.id, entry.updated, status.id, entry.content.title).fetch();
for (Tuple tuple : tuples) {
@@ -250,7 +259,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
public UnreadCount getUnreadCount(User user, FeedSubscription subscription) {
UnreadCount uc = null;
JPAQuery<FeedEntry> query = buildQuery(user, subscription, true, null, null, -1, -1, null, null, null);
JPAQuery<FeedEntry> query = buildQuery(user, subscription, true, null, null, -1, -1, null, null, null, null, null);
List<Tuple> tuples = query.select(entry.count(), entry.updated.max()).fetch();
for (Tuple tuple : tuples) {
Long count = tuple.get(entry.count());

View File

@@ -111,7 +111,7 @@ public class FeedEntryService {
public void markSubscriptionEntries(User user, List<FeedSubscription> subscriptions, Date olderThan, List<FeedEntryKeyword> keywords) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, keywords, null, -1, -1, null,
false, false, null);
false, false, null, null, null);
markList(statuses, olderThan);
cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0]));
cache.invalidateUserRootCategory(user);

View File

@@ -82,6 +82,28 @@ public class UserService {
return Optional.empty();
}
/**
* try to log in with given fever api key
*/
public Optional<User> login(long userId, String feverApiKey) {
if (feverApiKey == null) {
return Optional.empty();
}
User user = userDAO.findById(userId);
if (user == null || user.isDisabled() || user.getApiKey() == null) {
return Optional.empty();
}
String computedFeverApiKey = DigestUtils.md5Hex(user.getName() + ":" + user.getApiKey());
if (!computedFeverApiKey.equals(feverApiKey)) {
return Optional.empty();
}
performPostLoginActivities(user);
return Optional.of(user);
}
/**
* should triggers after successful login
*/

View File

@@ -141,7 +141,7 @@ public class CategoryREST {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
offset, limit + 1, order, true, onlyIds, tag);
offset, limit + 1, order, true, onlyIds, tag, null, null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));
@@ -160,7 +160,7 @@ public class CategoryREST {
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
offset, limit + 1, order, true, onlyIds, tag);
offset, limit + 1, order, true, onlyIds, tag, null, null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));

View File

@@ -172,7 +172,7 @@ public class FeedREST {
entries.setFeedLink(subscription.getFeed().getLink());
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, Collections.singletonList(subscription), unreadOnly,
entryKeywords, newerThanDate, offset, limit + 1, order, true, onlyIds, null);
entryKeywords, newerThanDate, offset, limit + 1, order, true, onlyIds, null, null, null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.getApplicationSettings().getImageProxyEnabled()));

View File

@@ -0,0 +1,284 @@
package com.commafeed.frontend.resource.fever;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.UriInfo;
import com.codahale.metrics.annotation.Timed;
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.favicon.AbstractFaviconFetcher.Favicon;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntry;
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.FeedService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverFavicon;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeed;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeedGroup;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverGroup;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverItem;
import io.dropwizard.hibernate.UnitOfWork;
import lombok.RequiredArgsConstructor;
/**
* Fever-compatible API
*
* <ul>
* <li>url: http://localhost:8082/rest/fever/user/${userId}</li>
* <li>login: username</li>
* <li>password: api key</li>
* </ul>
*
* See https://feedafever.com/api
*/
@Path("/fever")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class FeverREST {
private static final int UNREAD_ITEM_IDS_BATCH_SIZE = 1000;
private static final int SAVED_ITEM_IDS_BATCH_SIZE = 1000;
private static final int ITEMS_BATCH_SIZE = 200;
private final UserService userService;
private final FeedEntryService feedEntryService;
private final FeedService feedService;
private final FeedEntryDAO feedEntryDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
@Path("/user/{userId}")
@POST
@UnitOfWork
@Timed
public FeverResponse handle(@Context UriInfo uri, @PathParam("userId") Long userId, MultivaluedMap<String, String> form) {
MultivaluedMap<String, String> query = uri.getQueryParameters();
User user = auth(userId, form.getFirst("api_key")).orElse(null);
if (user == null) {
FeverResponse resp = new FeverResponse();
resp.setAuth(false);
return resp;
}
FeverResponse resp = new FeverResponse();
resp.setAuth(true);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findAll(user);
resp.setLastRefreshedOnTime(buildLastRefreshedOnTime(subscriptions));
if (query.containsKey("groups") || query.containsKey("feeds")) {
resp.setFeedsGroups(buildFeedsGroups(subscriptions));
if (query.containsKey("groups")) {
List<FeedCategory> categories = feedCategoryDAO.findAll(user);
resp.setGroups(buildGroups(categories));
}
if (query.containsKey("feeds")) {
resp.setFeeds(buildFeeds(subscriptions));
}
}
if (query.containsKey("unread_item_ids")) {
resp.setUnreadItemIds(buildUnreadItemIds(user, subscriptions));
}
if (query.containsKey("saved_item_ids")) {
resp.setSavedItemIds(buildSavedItemIds(user));
}
if (query.containsKey("items")) {
if (query.containsKey("with_ids")) {
String withIds = query.getFirst("with_ids");
List<String> entryIds = Stream.of(withIds.split(",")).collect(Collectors.toList());
resp.setItems(buildItems(user, subscriptions, entryIds));
} else {
Long sinceId = query.containsKey("since_id") ? Long.valueOf(query.getFirst("since_id")) : null;
Long maxId = query.containsKey("max_id") ? Long.valueOf(query.getFirst("max_id")) : null;
resp.setItems(buildItems(user, subscriptions, sinceId, maxId));
}
}
if (query.containsKey("favicons")) {
resp.setFavicons(buildFavicons(subscriptions));
}
if (query.containsKey("links")) {
resp.setLinks(Collections.emptyList());
}
if (form.containsKey("mark") && form.containsKey("id") && form.containsKey("as")) {
long id = Long.parseLong(form.getFirst("id"));
String before = form.getFirst("before");
Date olderThan = before == null ? null : Date.from(Instant.ofEpochSecond(Long.parseLong(before)));
mark(user, form.getFirst("mark"), id, form.getFirst("as"), olderThan);
}
return resp;
}
private Optional<User> auth(Long userId, String feverApiKey) {
return userService.login(userId, feverApiKey);
}
private long buildLastRefreshedOnTime(List<FeedSubscription> subscriptions) {
return subscriptions.stream()
.map(FeedSubscription::getFeed)
.map(Feed::getLastUpdated)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.map(d -> d.toInstant().getEpochSecond())
.orElse(0L);
}
private List<FeverFeedGroup> buildFeedsGroups(List<FeedSubscription> subscriptions) {
return subscriptions.stream()
.collect(Collectors.groupingBy(s -> s.getCategory() == null ? 0 : s.getCategory().getId()))
.entrySet()
.stream()
.map(e -> {
FeverFeedGroup fg = new FeverFeedGroup();
fg.setGroupId(e.getKey());
fg.setFeedIds(e.getValue().stream().map(FeedSubscription::getId).collect(Collectors.toList()));
return fg;
})
.collect(Collectors.toList());
}
private List<FeverGroup> buildGroups(List<FeedCategory> categories) {
return categories.stream().map(c -> {
FeverGroup g = new FeverGroup();
g.setId(c.getId());
g.setTitle(c.getName());
return g;
}).collect(Collectors.toList());
}
private List<FeverFeed> buildFeeds(List<FeedSubscription> subscriptions) {
return subscriptions.stream().map(s -> {
FeverFeed f = new FeverFeed();
f.setId(s.getId());
f.setFaviconId(s.getId());
f.setTitle(s.getTitle());
f.setUrl(s.getFeed().getUrl());
f.setSiteUrl(s.getFeed().getLink());
f.setSpark(false);
f.setLastUpdatedOnTime(s.getFeed().getLastUpdated() == null ? 0 : s.getFeed().getLastUpdated().toInstant().getEpochSecond());
return f;
}).collect(Collectors.toList());
}
private List<Long> buildUnreadItemIds(User user, List<FeedSubscription> subscriptions) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0,
UNREAD_ITEM_IDS_BATCH_SIZE, ReadingOrder.desc, false, true, null, null, null);
return statuses.stream().map(s -> s.getEntry().getId()).collect(Collectors.toList());
}
private List<Long> buildSavedItemIds(User user) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findStarred(user, null, 0, SAVED_ITEM_IDS_BATCH_SIZE, ReadingOrder.desc, false);
return statuses.stream().map(s -> s.getEntry().getId()).collect(Collectors.toList());
}
private List<FeverItem> buildItems(User user, List<FeedSubscription> subscriptions, List<String> entryIds) {
List<FeverItem> items = new ArrayList<>();
Map<Long, FeedSubscription> subscriptionsByFeedId = subscriptions.stream()
.collect(Collectors.toMap(s -> s.getFeed().getId(), s -> s));
for (String entryId : entryIds) {
FeedEntry entry = feedEntryDAO.findById(Long.parseLong(entryId));
FeedSubscription sub = subscriptionsByFeedId.get(entry.getFeed().getId());
if (sub != null) {
FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry);
items.add(mapStatus(status));
}
}
return items;
}
private List<FeverItem> buildItems(User user, List<FeedSubscription> subscriptions, Long sinceId, Long maxId) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, false, null, null, 0, ITEMS_BATCH_SIZE,
ReadingOrder.desc, false, false, null, sinceId, maxId);
return statuses.stream().map(this::mapStatus).collect(Collectors.toList());
}
private FeverItem mapStatus(FeedEntryStatus s) {
FeverItem i = new FeverItem();
i.setId(s.getEntry().getId());
i.setFeedId(s.getSubscription().getId());
i.setTitle(s.getEntry().getContent().getTitle());
i.setAuthor(s.getEntry().getContent().getAuthor());
i.setHtml(s.getEntry().getContent().getContent());
i.setUrl(s.getEntry().getUrl());
i.setSaved(s.isStarred());
i.setRead(s.isRead());
i.setCreatedOnTime(s.getEntryUpdated().toInstant().getEpochSecond());
return i;
}
private List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) {
return subscriptions.stream().map(s -> {
Favicon favicon = feedService.fetchFavicon(s.getFeed());
FeverFavicon f = new FeverFavicon();
f.setId(s.getFeed().getId());
f.setData(String.format("data:%s;base64,%s", favicon.getMediaType(), Base64.getEncoder().encodeToString(favicon.getIcon())));
return f;
}).collect(Collectors.toList());
}
private void mark(User user, String source, long id, String action, Date olderThan) {
if ("item".equals(source)) {
if ("read".equals(action) || "unread".equals(action)) {
feedEntryService.markEntry(user, id, "read".equals(action));
} else if ("saved".equals(action) || "unsaved".equals(action)) {
FeedEntry entry = feedEntryDAO.findById(id);
FeedSubscription sub = feedSubscriptionDAO.findByFeed(user, entry.getFeed());
feedEntryService.starEntry(user, id, sub.getId(), "saved".equals(action));
}
} else if ("feed".equals(source)) {
FeedSubscription subscription = feedSubscriptionDAO.findById(user, id);
feedEntryService.markSubscriptionEntries(user, Collections.singletonList(subscription), olderThan, null);
} else if ("group".equals(source)) {
FeedCategory parent = feedCategoryDAO.findById(user, id);
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user, categories);
feedEntryService.markSubscriptionEntries(user, subscriptions, olderThan, null);
}
}
}

View File

@@ -0,0 +1,164 @@
package com.commafeed.frontend.resource.fever;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonFormat.Shape;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import lombok.Data;
@JsonInclude(Include.NON_NULL)
@Data
public class FeverResponse {
@JsonProperty("api_version")
private int apiVersion = 3;
@JsonProperty("auth")
@JsonFormat(shape = Shape.NUMBER)
private boolean auth;
@JsonProperty("last_refreshed_on_time")
private long lastRefreshedOnTime;
@JsonProperty("groups")
private List<FeverGroup> groups;
@JsonProperty("feeds")
private List<FeverFeed> feeds;
@JsonProperty("feeds_groups")
private List<FeverFeedGroup> feedsGroups;
@JsonProperty("unread_item_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
private List<Long> unreadItemIds;
@JsonProperty("saved_item_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
private List<Long> savedItemIds;
@JsonProperty("items")
private List<FeverItem> items;
@JsonProperty("favicons")
private List<FeverFavicon> favicons;
@JsonProperty("links")
private List<FeverLink> links;
@Data
public static class FeverGroup {
@JsonProperty("id")
private long id;
@JsonProperty("title")
private String title;
}
@Data
public static class FeverFeed {
@JsonProperty("id")
private long id;
@JsonProperty("favicon_id")
private long faviconId;
@JsonProperty("title")
private String title;
@JsonProperty("url")
private String url;
@JsonProperty("site_url")
private String siteUrl;
@JsonProperty("is_spark")
@JsonFormat(shape = Shape.NUMBER)
private boolean spark;
@JsonProperty("last_updated_on_time")
private long lastUpdatedOnTime;
}
@Data
public static class FeverFeedGroup {
@JsonProperty("group_id")
private long groupId;
@JsonProperty("feed_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
private List<Long> feedIds;
}
@Data
public static class FeverItem {
@JsonProperty("id")
private long id;
@JsonProperty("feed_id")
private long feedId;
@JsonProperty("title")
private String title;
@JsonProperty("author")
private String author;
@JsonProperty("html")
private String html;
@JsonProperty("url")
private String url;
@JsonProperty("is_saved")
@JsonFormat(shape = Shape.NUMBER)
private boolean saved;
@JsonProperty("is_read")
@JsonFormat(shape = Shape.NUMBER)
private boolean read;
@JsonProperty("created_on_time")
private long createdOnTime;
}
@Data
public static class FeverFavicon {
@JsonProperty("id")
private long id;
@JsonProperty("data")
private String data;
}
@Data
public static class FeverLink {
}
public static class LongListToCommaSeparatedStringSerializer extends JsonSerializer<List<Long>> {
@Override
public void serialize(List<Long> input, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
String output = input.stream().map(String::valueOf).collect(Collectors.joining(","));
jsonGenerator.writeObject(output);
}
}
}

View File

@@ -69,7 +69,7 @@ public class NextUnreadServlet extends HttpServlet {
if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user.get());
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subs, true, null, null, 0, 1, order,
true, false, null);
true, false, null, null, null);
s = Iterables.getFirst(statuses, null);
} else {
FeedCategory category = feedCategoryDAO.findById(user.get(), Long.valueOf(categoryId));
@@ -77,7 +77,7 @@ public class NextUnreadServlet extends HttpServlet {
List<FeedCategory> children = feedCategoryDAO.findAllChildrenCategories(user.get(), category);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user.get(), children);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subscriptions, true, null, null, 0,
1, order, true, false, null);
1, order, true, false, null, null, null);
s = Iterables.getFirst(statuses, null);
}
}