use google java formatter to avoid downloading eclipse during build

This commit is contained in:
Athou
2026-07-05 10:18:30 +02:00
parent f4dc279ec9
commit 1bd9b71215
200 changed files with 13923 additions and 12754 deletions

View File

@@ -1,13 +1,11 @@
package com.commafeed.frontend.model;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import io.quarkus.runtime.annotations.RegisterForReflection;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Entry details")
@@ -15,27 +13,27 @@ import lombok.Data;
@RegisterForReflection
public class Category implements Serializable {
@Schema(description = "category id", required = true)
private String id;
@Schema(description = "category id", required = true)
private String id;
@Schema(description = "parent category id")
private String parentId;
@Schema(description = "parent category id")
private String parentId;
@Schema(description = "parent category name")
private String parentName;
@Schema(description = "parent category name")
private String parentName;
@Schema(description = "category id", required = true)
private String name;
@Schema(description = "category id", required = true)
private String name;
@Schema(description = "category children categories", required = true)
private List<Category> children = new ArrayList<>();
@Schema(description = "category children categories", required = true)
private List<Category> children = new ArrayList<>();
@Schema(description = "category feeds", required = true)
private List<Subscription> feeds = new ArrayList<>();
@Schema(description = "category feeds", required = true)
private List<Subscription> feeds = new ArrayList<>();
@Schema(description = "whether the category is expanded or collapsed", required = true)
private boolean expanded;
@Schema(description = "whether the category is expanded or collapsed", required = true)
private boolean expanded;
@Schema(description = "position of the category in the list", required = true)
private int position;
}
@Schema(description = "position of the category in the list", required = true)
private int position;
}

View File

@@ -1,13 +1,11 @@
package com.commafeed.frontend.model;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import io.quarkus.runtime.annotations.RegisterForReflection;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "List of entries with some metadata")
@@ -15,36 +13,38 @@ import lombok.Data;
@RegisterForReflection
public class Entries implements Serializable {
@Schema(description = "name of the feed or the category requested", required = true)
private String name;
@Schema(description = "name of the feed or the category requested", required = true)
private String name;
@Schema(description = "error or warning message")
private String message;
@Schema(description = "error or warning message")
private String message;
@Schema(description = "times the server tried to refresh the feed and failed", required = true)
private int errorCount;
@Schema(description = "times the server tried to refresh the feed and failed", required = true)
private int errorCount;
@Schema(description = "URL of the website, extracted from the feed, only filled if querying for feed entries, not category entries")
private String feedLink;
@Schema(
description =
"URL of the website, extracted from the feed, only filled if querying for feed entries, not category entries")
private String feedLink;
@Schema(description = "list generation timestamp", required = true)
private long timestamp;
@Schema(description = "list generation timestamp", required = true)
private long timestamp;
@Schema(description = "if the query has more elements", required = true)
private boolean hasMore;
@Schema(description = "if the query has more elements", required = true)
private boolean hasMore;
@Schema(description = "the requested offset")
private int offset;
@Schema(description = "the requested offset")
private int offset;
@Schema(description = "the requested limit")
private int limit;
@Schema(description = "the requested limit")
private int limit;
@Schema(description = "list of entries", required = true)
private List<Entry> entries = new ArrayList<>();
@Schema(
description = "if true, the unread flag was ignored in the request, all entries are returned regardless of their read status",
required = true)
private boolean ignoredReadStatus;
@Schema(description = "list of entries", required = true)
private List<Entry> entries = new ArrayList<>();
@Schema(
description =
"if true, the unread flag was ignored in the request, all entries are returned regardless of their read status",
required = true)
private boolean ignoredReadStatus;
}

View File

@@ -1,22 +1,19 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import org.apache.commons.lang3.Strings;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.FeedSubscription;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import lombok.Data;
import org.apache.commons.lang3.Strings;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Entry details")
@@ -24,123 +21,134 @@ import lombok.Data;
@RegisterForReflection
public class Entry implements Serializable {
@Schema(description = "entry id", required = true)
private String id;
@Schema(description = "entry id", required = true)
private String id;
@Schema(description = "entry guid", required = true)
private String guid;
@Schema(description = "entry guid", required = true)
private String guid;
@Schema(description = "entry title", required = true)
private String title;
@Schema(description = "entry title", required = true)
private String title;
@Schema(description = "entry content", required = true)
private String content;
@Schema(description = "entry content", required = true)
private String content;
@Schema(description = "comma-separated list of categories")
private String categories;
@Schema(description = "comma-separated list of categories")
private String categories;
@Schema(description = "whether entry content and title are rtl", required = true)
private boolean rtl;
@Schema(description = "whether entry content and title are rtl", required = true)
private boolean rtl;
@Schema(description = "entry author")
private String author;
@Schema(description = "entry author")
private String author;
@Schema(description = "entry enclosure url, if any")
private String enclosureUrl;
@Schema(description = "entry enclosure url, if any")
private String enclosureUrl;
@Schema(description = "entry enclosure mime type, if any")
private String enclosureType;
@Schema(description = "entry enclosure mime type, if any")
private String enclosureType;
@Schema(description = "entry media description, if any")
private String mediaDescription;
@Schema(description = "entry media description, if any")
private String mediaDescription;
@Schema(description = "entry media thumbnail url, if any")
private String mediaThumbnailUrl;
@Schema(description = "entry media thumbnail url, if any")
private String mediaThumbnailUrl;
@Schema(description = "entry media thumbnail width, if any")
private Integer mediaThumbnailWidth;
@Schema(description = "entry media thumbnail width, if any")
private Integer mediaThumbnailWidth;
@Schema(description = "entry media thumbnail height, if any")
private Integer mediaThumbnailHeight;
@Schema(description = "entry media thumbnail height, if any")
private Integer mediaThumbnailHeight;
@Schema(description = "entry publication date", type = SchemaType.INTEGER, required = true)
private Instant date;
@Schema(description = "entry publication date", type = SchemaType.INTEGER, required = true)
private Instant date;
@Schema(description = "entry insertion date in the database", type = SchemaType.INTEGER, required = true)
private Instant insertedDate;
@Schema(
description = "entry insertion date in the database",
type = SchemaType.INTEGER,
required = true)
private Instant insertedDate;
@Schema(description = "feed id", required = true)
private String feedId;
@Schema(description = "feed id", required = true)
private String feedId;
@Schema(description = "feed name", required = true)
private String feedName;
@Schema(description = "feed name", required = true)
private String feedName;
@Schema(description = "this entry's feed url", required = true)
private String feedUrl;
@Schema(description = "this entry's feed url", required = true)
private String feedUrl;
@Schema(description = "this entry's website url", required = true)
private String feedLink;
@Schema(description = "this entry's website url", required = true)
private String feedLink;
@Schema(description = "The favicon url to use for this feed", required = true)
private String iconUrl;
@Schema(description = "The favicon url to use for this feed", required = true)
private String iconUrl;
@Schema(description = "entry url", required = true)
private String url;
@Schema(description = "entry url", required = true)
private String url;
@Schema(description = "read status", required = true)
private boolean read;
@Schema(description = "read status", required = true)
private boolean read;
@Schema(description = "starred status", required = true)
private boolean starred;
@Schema(description = "starred status", required = true)
private boolean starred;
@Schema(description = "whether the entry is still markable (old entry statuses are discarded)", required = true)
private boolean markable;
@Schema(
description = "whether the entry is still markable (old entry statuses are discarded)",
required = true)
private boolean markable;
@Schema(description = "tags", required = true)
private List<String> tags;
@Schema(description = "tags", required = true)
private List<String> tags;
public static Entry build(FeedEntryStatus status, boolean proxyImages) {
Entry entry = new Entry();
public static Entry build(FeedEntryStatus status, boolean proxyImages) {
Entry entry = new Entry();
FeedEntry feedEntry = status.getEntry();
FeedSubscription sub = status.getSubscription();
FeedEntryContent content = feedEntry.getContent();
FeedEntry feedEntry = status.getEntry();
FeedSubscription sub = status.getSubscription();
FeedEntryContent content = feedEntry.getContent();
entry.setId(String.valueOf(feedEntry.getId()));
entry.setGuid(feedEntry.getGuid());
entry.setRead(status.isRead());
entry.setStarred(status.isStarred());
entry.setMarkable(status.isMarkable());
entry.setDate(feedEntry.getPublished());
entry.setInsertedDate(feedEntry.getInserted());
entry.setUrl(feedEntry.getUrl());
entry.setFeedName(sub.getTitle());
entry.setFeedId(String.valueOf(sub.getId()));
entry.setFeedUrl(sub.getFeed().getUrl());
entry.setFeedLink(sub.getFeed().getLink());
entry.setIconUrl(FeedUtils.getFaviconUrl(sub));
entry.setTags(status.getTags().stream().map(FeedEntryTag::getName).toList());
entry.setId(String.valueOf(feedEntry.getId()));
entry.setGuid(feedEntry.getGuid());
entry.setRead(status.isRead());
entry.setStarred(status.isStarred());
entry.setMarkable(status.isMarkable());
entry.setDate(feedEntry.getPublished());
entry.setInsertedDate(feedEntry.getInserted());
entry.setUrl(feedEntry.getUrl());
entry.setFeedName(sub.getTitle());
entry.setFeedId(String.valueOf(sub.getId()));
entry.setFeedUrl(sub.getFeed().getUrl());
entry.setFeedLink(sub.getFeed().getLink());
entry.setIconUrl(FeedUtils.getFaviconUrl(sub));
entry.setTags(status.getTags().stream().map(FeedEntryTag::getName).toList());
if (content != null) {
entry.setRtl(content.getDirection() == FeedEntryContent.Direction.RTL);
entry.setTitle(content.getTitle());
entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent()) : content.getContent());
entry.setAuthor(content.getAuthor());
if (content != null) {
entry.setRtl(content.getDirection() == FeedEntryContent.Direction.RTL);
entry.setTitle(content.getTitle());
entry.setContent(
proxyImages
? FeedUtils.proxyImages(content.getContent())
: content.getContent());
entry.setAuthor(content.getAuthor());
entry.setEnclosureType(content.getEnclosureType());
entry.setEnclosureUrl(proxyImages && Strings.CS.contains(content.getEnclosureType(), "image")
? FeedUtils.proxyImage(content.getEnclosureUrl())
: content.getEnclosureUrl());
entry.setEnclosureType(content.getEnclosureType());
entry.setEnclosureUrl(
proxyImages && Strings.CS.contains(content.getEnclosureType(), "image")
? FeedUtils.proxyImage(content.getEnclosureUrl())
: content.getEnclosureUrl());
entry.setMediaDescription(content.getMediaDescription());
entry.setMediaThumbnailUrl(proxyImages ? FeedUtils.proxyImage(content.getMediaThumbnailUrl()) : content.getMediaThumbnailUrl());
entry.setMediaThumbnailWidth(content.getMediaThumbnailWidth());
entry.setMediaThumbnailHeight(content.getMediaThumbnailHeight());
entry.setMediaDescription(content.getMediaDescription());
entry.setMediaThumbnailUrl(
proxyImages
? FeedUtils.proxyImage(content.getMediaThumbnailUrl())
: content.getMediaThumbnailUrl());
entry.setMediaThumbnailWidth(content.getMediaThumbnailWidth());
entry.setMediaThumbnailHeight(content.getMediaThumbnailHeight());
entry.setCategories(content.getCategories());
}
return entry;
}
entry.setCategories(content.getCategories());
}
return entry;
}
}

View File

@@ -1,11 +1,9 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Feed details")
@@ -13,10 +11,9 @@ import lombok.Data;
@RegisterForReflection
public class FeedInfo implements Serializable {
@Schema(description = "url", required = true)
private String url;
@Schema(description = "title", required = true)
private String title;
@Schema(description = "url", required = true)
private String url;
@Schema(description = "title", required = true)
private String title;
}

View File

@@ -1,11 +1,9 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Server infos")
@@ -13,46 +11,44 @@ import lombok.Data;
@RegisterForReflection
public class ServerInfo implements Serializable {
@Schema
private String announcement;
@Schema private String announcement;
@Schema(required = true)
private String version;
@Schema(required = true)
private String version;
@Schema(required = true)
private String gitCommit;
@Schema(required = true)
private String gitCommit;
@Schema(required = true)
private boolean allowRegistrations;
@Schema(required = true)
private boolean allowRegistrations;
@Schema(required = true)
private boolean emailAddressRequired;
@Schema(required = true)
private boolean emailAddressRequired;
@Schema(required = true)
private boolean smtpEnabled;
@Schema(required = true)
private boolean smtpEnabled;
@Schema(required = true)
private boolean demoAccountEnabled;
@Schema(required = true)
private boolean demoAccountEnabled;
@Schema(required = true)
private boolean websocketEnabled;
@Schema(required = true)
private boolean websocketEnabled;
@Schema(required = true)
private long websocketPingInterval;
@Schema(required = true)
private long websocketPingInterval;
@Schema(required = true)
private long treeReloadInterval;
@Schema(required = true)
private long treeReloadInterval;
@Schema(required = true)
private long forceRefreshCooldownDuration;
@Schema(required = true)
private long forceRefreshCooldownDuration;
@Schema(required = true)
private boolean initialSetupRequired;
@Schema(required = true)
private boolean initialSetupRequired;
@Schema(required = true)
private int minimumPasswordLength;
@Schema(required = true)
private boolean pushNotificationsEnabled;
@Schema(required = true)
private int minimumPasswordLength;
@Schema(required = true)
private boolean pushNotificationsEnabled;
}

View File

@@ -1,17 +1,14 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.commafeed.backend.model.UserSettings.IconDisplayMode;
import com.commafeed.backend.model.UserSettings.PushNotificationType;
import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.model.UserSettings.ScrollMode;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "User settings")
@@ -19,113 +16,128 @@ import lombok.Data;
@RegisterForReflection
public class Settings implements Serializable {
@Schema(description = "user's preferred language, english if none")
private String language;
@Schema(description = "user's preferred language, english if none")
private String language;
@Schema(description = "user reads all entries or unread entries only", required = true)
private ReadingMode readingMode;
@Schema(description = "user reads all entries or unread entries only", required = true)
private ReadingMode readingMode;
@Schema(description = "user reads entries in ascending or descending order", required = true)
private ReadingOrder readingOrder;
@Schema(description = "user reads entries in ascending or descending order", required = true)
private ReadingOrder readingOrder;
@Schema(description = "user wants category and feeds with no unread entries shown", required = true)
private boolean showRead;
@Schema(
description = "user wants category and feeds with no unread entries shown",
required = true)
private boolean showRead;
@Schema(description = "In expanded view, scroll through entries mark them as read", required = true)
private boolean scrollMarks;
@Schema(
description = "In expanded view, scroll through entries mark them as read",
required = true)
private boolean scrollMarks;
@Schema(description = "user's custom css for the website")
private String customCss;
@Schema(description = "user's custom css for the website")
private String customCss;
@Schema(description = "user's custom js for the website")
private String customJs;
@Schema(description = "user's custom js for the website")
private String customJs;
@Schema(description = "user's preferred scroll speed when navigating between entries", required = true)
private int scrollSpeed;
@Schema(
description = "user's preferred scroll speed when navigating between entries",
required = true)
private int scrollSpeed;
@Schema(description = "whether to scroll to the selected entry", required = true)
private ScrollMode scrollMode;
@Schema(description = "whether to scroll to the selected entry", required = true)
private ScrollMode scrollMode;
@Schema(description = "number of entries to keep above the selected entry when scrolling", required = true)
private int entriesToKeepOnTopWhenScrolling;
@Schema(
description = "number of entries to keep above the selected entry when scrolling",
required = true)
private int entriesToKeepOnTopWhenScrolling;
@Schema(description = "whether to show the star icon in the header of entries", required = true)
private IconDisplayMode starIconDisplayMode;
@Schema(description = "whether to show the star icon in the header of entries", required = true)
private IconDisplayMode starIconDisplayMode;
@Schema(description = "whether to show the external link icon in the header of entries", required = true)
private IconDisplayMode externalLinkIconDisplayMode;
@Schema(
description = "whether to show the external link icon in the header of entries",
required = true)
private IconDisplayMode externalLinkIconDisplayMode;
@Schema(description = "ask for confirmation when marking all entries as read", required = true)
private boolean markAllAsReadConfirmation;
@Schema(description = "ask for confirmation when marking all entries as read", required = true)
private boolean markAllAsReadConfirmation;
@Schema(description = "navigate to the next unread category or feed after marking all entries as read", required = true)
private boolean markAllAsReadNavigateToNextUnread;
@Schema(
description =
"navigate to the next unread category or feed after marking all entries as read",
required = true)
private boolean markAllAsReadNavigateToNextUnread;
@Schema(description = "show commafeed's own context menu on right click", required = true)
private boolean customContextMenu;
@Schema(description = "show commafeed's own context menu on right click", required = true)
private boolean customContextMenu;
@Schema(description = "on mobile, show action buttons at the bottom of the screen", required = true)
private boolean mobileFooter;
@Schema(
description = "on mobile, show action buttons at the bottom of the screen",
required = true)
private boolean mobileFooter;
@Schema(description = "show unread count in the title", required = true)
private boolean unreadCountTitle;
@Schema(description = "show unread count in the title", required = true)
private boolean unreadCountTitle;
@Schema(description = "show unread count in the favicon", required = true)
private boolean unreadCountFavicon;
@Schema(description = "show unread count in the favicon", required = true)
private boolean unreadCountFavicon;
@Schema(description = "disable pull to refresh", required = true)
private boolean disablePullToRefresh;
@Schema(description = "disable pull to refresh", required = true)
private boolean disablePullToRefresh;
@Schema(description = "primary theme color to use in the UI")
private String primaryColor;
@Schema(description = "primary theme color to use in the UI")
private String primaryColor;
@Schema(description = "sharing settings", required = true)
private SharingSettings sharingSettings = new SharingSettings();
@Schema(description = "sharing settings", required = true)
private SharingSettings sharingSettings = new SharingSettings();
@Schema(description = "push notification settings", required = true)
private PushNotificationSettings pushNotificationSettings = new PushNotificationSettings();
@Schema(description = "push notification settings", required = true)
private PushNotificationSettings pushNotificationSettings = new PushNotificationSettings();
@Schema(description = "User notification settings")
@Data
public static class PushNotificationSettings implements Serializable {
@Schema(description = "notification provider type")
private PushNotificationType type;
@Schema(description = "User notification settings")
@Data
public static class PushNotificationSettings implements Serializable {
@Schema(description = "notification provider type")
private PushNotificationType type;
@Schema(description = "server URL for ntfy or gotify")
private String serverUrl;
@Schema(description = "server URL for ntfy or gotify")
private String serverUrl;
@Schema(description = "user Id")
private String userId;
@Schema(description = "user Id")
private String userId;
@Schema(description = "user secret for authentication with the service")
private String userSecret;
@Schema(description = "user secret for authentication with the service")
private String userSecret;
@Schema(description = "topic")
private String topic;
}
@Schema(description = "topic")
private String topic;
}
@Schema(description = "User sharing settings")
@Data
public static class SharingSettings implements Serializable {
@Schema(required = true)
private boolean email;
@Schema(description = "User sharing settings")
@Data
public static class SharingSettings implements Serializable {
@Schema(required = true)
private boolean email;
@Schema(required = true)
private boolean gmail;
@Schema(required = true)
private boolean gmail;
@Schema(required = true)
private boolean facebook;
@Schema(required = true)
private boolean facebook;
@Schema(required = true)
private boolean twitter;
@Schema(required = true)
private boolean twitter;
@Schema(required = true)
private boolean tumblr;
@Schema(required = true)
private boolean tumblr;
@Schema(required = true)
private boolean instapaper;
@Schema(required = true)
private boolean instapaper;
@Schema(required = true)
private boolean buffer;
}
@Schema(required = true)
private boolean buffer;
}
}

View File

@@ -1,18 +1,15 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedSubscription;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.Serializable;
import java.time.Instant;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "User information")
@@ -20,80 +17,90 @@ import lombok.Data;
@RegisterForReflection
public class Subscription implements Serializable {
@Schema(description = "subscription id", required = true)
private Long id;
@Schema(description = "subscription id", required = true)
private Long id;
@Schema(description = "subscription name", required = true)
private String name;
@Schema(description = "subscription name", required = true)
private String name;
@Schema(description = "error message while fetching the feed")
private String message;
@Schema(description = "error message while fetching the feed")
private String message;
@Schema(description = "error count", required = true)
private int errorCount;
@Schema(description = "error count", required = true)
private int errorCount;
@Schema(description = "last time the feed was refreshed", type = SchemaType.INTEGER)
private Instant lastRefresh;
@Schema(description = "last time the feed was refreshed", type = SchemaType.INTEGER)
private Instant lastRefresh;
@Schema(description = "next time the feed refresh is planned, null if refresh is already queued", type = SchemaType.INTEGER)
private Instant nextRefresh;
@Schema(
description =
"next time the feed refresh is planned, null if refresh is already queued",
type = SchemaType.INTEGER)
private Instant nextRefresh;
@Schema(description = "this subscription's feed url", required = true)
private String feedUrl;
@Schema(description = "this subscription's feed url", required = true)
private String feedUrl;
@Schema(description = "this subscription's website url", required = true)
private String feedLink;
@Schema(description = "this subscription's website url", required = true)
private String feedLink;
@Schema(description = "The favicon url to use for this feed", required = true)
private String iconUrl;
@Schema(description = "The favicon url to use for this feed", required = true)
private String iconUrl;
@Schema(description = "unread count", required = true)
private long unread;
@Schema(description = "unread count", required = true)
private long unread;
@Schema(description = "category id")
private String categoryId;
@Schema(description = "category id")
private String categoryId;
@Schema(description = "position of the subscription's in the list")
private int position;
@Schema(description = "position of the subscription's in the list")
private int position;
@Schema(description = "date of the newest item", type = SchemaType.INTEGER)
private Instant newestItemTime;
@Schema(description = "date of the newest item", type = SchemaType.INTEGER)
private Instant newestItemTime;
@Schema(description = "CEL string evaluated on new entries to mark them as read if they do not match")
private String filter;
@Schema(
description =
"CEL string evaluated on new entries to mark them as read if they do not match")
private String filter;
@Schema(description = "JEXL legacy filter")
private String filterLegacy;
@Schema(description = "JEXL legacy filter")
private String filterLegacy;
@Schema(description = "whether to send push notifications for new entries of this feed", required = true)
private boolean pushNotificationsEnabled;
@Schema(
description = "whether to send push notifications for new entries of this feed",
required = true)
private boolean pushNotificationsEnabled;
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
@Schema(
description =
"automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
FeedCategory category = subscription.getCategory();
Feed feed = subscription.getFeed();
Subscription sub = new Subscription();
sub.setId(subscription.getId());
sub.setName(subscription.getTitle());
sub.setPosition(subscription.getPosition());
sub.setMessage(feed.getMessage());
sub.setErrorCount(feed.getErrorCount());
sub.setFeedUrl(feed.getUrl());
sub.setFeedLink(feed.getLink());
sub.setIconUrl(FeedUtils.getFaviconUrl(subscription));
sub.setLastRefresh(feed.getLastUpdated());
sub.setNextRefresh(
(feed.getDisabledUntil() != null && feed.getDisabledUntil().isBefore(Instant.now())) ? null : feed.getDisabledUntil());
sub.setUnread(unreadCount.getUnreadCount());
sub.setNewestItemTime(unreadCount.getNewestItemTime());
sub.setCategoryId(category == null ? null : String.valueOf(category.getId()));
sub.setFilter(subscription.getFilter());
sub.setFilterLegacy(subscription.getFilterLegacy());
sub.setPushNotificationsEnabled(subscription.isPushNotificationsEnabled());
sub.setAutoMarkAsReadAfterDays(subscription.getAutoMarkAsReadAfterDays());
return sub;
}
}
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
FeedCategory category = subscription.getCategory();
Feed feed = subscription.getFeed();
Subscription sub = new Subscription();
sub.setId(subscription.getId());
sub.setName(subscription.getTitle());
sub.setPosition(subscription.getPosition());
sub.setMessage(feed.getMessage());
sub.setErrorCount(feed.getErrorCount());
sub.setFeedUrl(feed.getUrl());
sub.setFeedLink(feed.getLink());
sub.setIconUrl(FeedUtils.getFaviconUrl(subscription));
sub.setLastRefresh(feed.getLastUpdated());
sub.setNextRefresh(
(feed.getDisabledUntil() != null && feed.getDisabledUntil().isBefore(Instant.now()))
? null
: feed.getDisabledUntil());
sub.setUnread(unreadCount.getUnreadCount());
sub.setNewestItemTime(unreadCount.getNewestItemTime());
sub.setCategoryId(category == null ? null : String.valueOf(category.getId()));
sub.setFilter(subscription.getFilter());
sub.setFilterLegacy(subscription.getFilterLegacy());
sub.setPushNotificationsEnabled(subscription.isPushNotificationsEnabled());
sub.setAutoMarkAsReadAfterDays(subscription.getAutoMarkAsReadAfterDays());
return sub;
}
}

View File

@@ -1,36 +1,30 @@
package com.commafeed.frontend.model;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.Serializable;
import java.time.Instant;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import io.quarkus.runtime.annotations.RegisterForReflection;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Unread count")
@Data
@RegisterForReflection
public class UnreadCount implements Serializable {
@Schema
private long feedId;
@Schema private long feedId;
@Schema
private long unreadCount;
@Schema private long unreadCount;
@Schema(type = SchemaType.INTEGER)
private Instant newestItemTime;
@Schema(type = SchemaType.INTEGER)
private Instant newestItemTime;
public UnreadCount() {
}
public UnreadCount(long feedId, long unreadCount, Instant newestItemTime) {
this.feedId = feedId;
this.unreadCount = unreadCount;
this.newestItemTime = newestItemTime;
}
public UnreadCount() {}
public UnreadCount(long feedId, long unreadCount, Instant newestItemTime) {
this.feedId = feedId;
this.unreadCount = unreadCount;
this.newestItemTime = newestItemTime;
}
}

View File

@@ -1,48 +1,45 @@
package com.commafeed.frontend.model;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.Serializable;
import java.time.Instant;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import io.quarkus.runtime.annotations.RegisterForReflection;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User information")
@Data
@RegisterForReflection
public class UserModel implements Serializable {
@Schema(description = "user id", required = true)
private Long id;
@Schema(description = "user id", required = true)
private Long id;
@Schema(description = "user name", required = true)
private String name;
@Schema(description = "user name", required = true)
private String name;
@Schema(description = "user email, if any")
private String email;
@Schema(description = "user email, if any")
private String email;
@Schema(description = "api key")
private String apiKey;
@Schema(description = "api key")
private String apiKey;
@Schema(description = "user password, never returned by the api")
private String password;
@Schema(description = "user password, never returned by the api")
private String password;
@Schema(description = "account status", required = true)
private boolean enabled;
@Schema(description = "account status", required = true)
private boolean enabled;
@Schema(description = "account creation date", type = SchemaType.INTEGER)
private Instant created;
@Schema(description = "account creation date", type = SchemaType.INTEGER)
private Instant created;
@Schema(description = "last login date", type = SchemaType.INTEGER)
private Instant lastLogin;
@Schema(description = "last login date", type = SchemaType.INTEGER)
private Instant lastLogin;
@Schema(description = "user is admin", required = true)
private boolean admin;
@Schema(description = "user last force refresh", type = SchemaType.INTEGER)
private Instant lastForceRefresh;
@Schema(description = "user is admin", required = true)
private boolean admin;
@Schema(description = "user last force refresh", type = SchemaType.INTEGER)
private Instant lastForceRefresh;
}

View File

@@ -1,26 +1,22 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Add Category Request")
@Data
public class AddCategoryRequest implements Serializable {
@Schema(description = "name", required = true)
@NotEmpty
@Size(max = 128)
private String name;
@Schema(description = "parent category id, if any")
@Size(max = 128)
private String parentId;
@Schema(description = "name", required = true)
@NotEmpty
@Size(max = 128)
private String name;
@Schema(description = "parent category id, if any")
@Size(max = 128)
private String parentId;
}

View File

@@ -1,34 +1,31 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.commafeed.security.password.ValidPassword;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Save User information")
@Data
public class AdminSaveUserRequest implements Serializable {
@Schema(description = "user id")
private Long id;
@Schema(description = "user id")
private Long id;
@Schema(description = "user name", required = true)
private String name;
@Schema(description = "user name", required = true)
private String name;
@Schema(description = "user email, if any")
private String email;
@Schema(description = "user email, if any")
private String email;
@Schema(description = "user password")
@ValidPassword
private String password;
@Schema(description = "user password")
@ValidPassword
private String password;
@Schema(description = "account status", required = true)
private boolean enabled;
@Schema(description = "account status", required = true)
private boolean enabled;
@Schema(description = "user is admin", required = true)
private boolean admin;
@Schema(description = "user is admin", required = true)
private boolean admin;
}

View File

@@ -1,30 +1,26 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Category modification request")
@Data
public class CategoryModificationRequest implements Serializable {
@Schema(description = "id", required = true)
private Long id;
@Schema(description = "id", required = true)
private Long id;
@Schema(description = "new name, null if not changed")
@Size(max = 128)
private String name;
@Schema(description = "new name, null if not changed")
@Size(max = 128)
private String name;
@Schema(description = "new parent category id")
@Size(max = 128)
private String parentId;
@Schema(description = "new display position, null if not changed")
private Integer position;
@Schema(description = "new parent category id")
@Size(max = 128)
private String parentId;
@Schema(description = "new display position, null if not changed")
private Integer position;
}

View File

@@ -1,20 +1,17 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Mark Request")
@Data
public class CollapseRequest implements Serializable {
@Schema(description = "category id", required = true)
private Long id;
@Schema(description = "collapse", required = true)
private boolean collapse;
@Schema(description = "category id", required = true)
private Long id;
@Schema(description = "collapse", required = true)
private boolean collapse;
}

View File

@@ -1,22 +1,18 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Feed information request")
@Data
public class FeedInfoRequest implements Serializable {
@Schema(description = "feed url", required = true)
@NotEmpty
@Size(max = 4096)
private String url;
@Schema(description = "feed url", required = true)
@NotEmpty
@Size(max = 4096)
private String url;
}

View File

@@ -1,40 +1,40 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Feed modification request")
@Data
public class FeedModificationRequest implements Serializable {
@Schema(description = "id", required = true)
private Long id;
@Schema(description = "id", required = true)
private Long id;
@Schema(description = "new name, null if not changed")
@Size(max = 128)
private String name;
@Schema(description = "new name, null if not changed")
@Size(max = 128)
private String name;
@Schema(description = "new parent category id")
@Size(max = 128)
private String categoryId;
@Schema(description = "new parent category id")
@Size(max = 128)
private String categoryId;
@Schema(description = "new display position, null if not changed")
private Integer position;
@Schema(description = "new display position, null if not changed")
private Integer position;
@Schema(description = "CEL string evaluated on new entries to mark them as read if they do not match")
@Size(max = 4096)
private String filter;
@Schema(
description =
"CEL string evaluated on new entries to mark them as read if they do not match")
@Size(max = 4096)
private String filter;
@Schema(description = "whether to send push notifications for new entries of this feed")
private boolean pushNotificationsEnabled;
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
@Schema(description = "whether to send push notifications for new entries of this feed")
private boolean pushNotificationsEnabled;
@Schema(
description =
"automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
}

View File

@@ -1,17 +1,14 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema
@Data
public class IDRequest implements Serializable {
@Schema(required = true)
private Long id;
@Schema(required = true)
private Long id;
}

View File

@@ -1,25 +1,22 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.commafeed.security.password.ValidPassword;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Initial admin account setup request")
@Data
public class InitialSetupRequest implements Serializable {
@Schema(description = "admin username", required = true)
private String name;
@Schema(description = "admin username", required = true)
private String name;
@Schema(description = "admin password", required = true)
@ValidPassword
private String password;
@Schema(description = "admin password", required = true)
@ValidPassword
private String password;
@Schema(description = "admin email")
private String email;
@Schema(description = "admin email")
private String email;
}

View File

@@ -1,40 +1,39 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import java.util.List;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import java.io.Serializable;
import java.util.List;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Mark Request")
@Data
public class MarkRequest implements Serializable {
@Schema(description = "entry id, category id, 'all' or 'starred'", required = true)
@NotEmpty
@Size(max = 128)
private String id;
@Schema(description = "entry id, category id, 'all' or 'starred'", required = true)
@NotEmpty
@Size(max = 128)
private String id;
@Schema(description = "mark as read or unread", required = true)
private boolean read;
@Schema(description = "mark as read or unread", required = true)
private boolean read;
@Schema(description = "mark only entries older than this")
private Long olderThan;
@Schema(description = "mark only entries older than this")
private Long olderThan;
@Schema(
description = "pass the timestamp you got from the entry list to avoid marking entries that may have been fetched in the mean time and never displayed")
private Long insertedBefore;
@Schema(
description =
"pass the timestamp you got from the entry list to avoid marking entries that may have been fetched in the mean time and never displayed")
private Long insertedBefore;
@Schema(description = "only mark read if a feed has these keywords in the title or rss content")
@Size(max = 128)
private String keywords;
@Schema(description = "if marking a category or 'all', exclude those subscriptions from the marking")
private List<Long> excludedSubscriptions;
@Schema(description = "only mark read if a feed has these keywords in the title or rss content")
@Size(max = 128)
private String keywords;
@Schema(
description =
"if marking a category or 'all', exclude those subscriptions from the marking")
private List<Long> excludedSubscriptions;
}

View File

@@ -1,20 +1,16 @@
package com.commafeed.frontend.model.request;
import jakarta.validation.Valid;
import java.io.Serializable;
import java.util.List;
import jakarta.validation.Valid;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Multiple Mark Request")
@Data
public class MultipleMarkRequest implements Serializable {
@Schema(description = "list of mark requests", required = true)
private List<@Valid MarkRequest> requests;
@Schema(description = "list of mark requests", required = true)
private List<@Valid MarkRequest> requests;
}

View File

@@ -1,34 +1,30 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import com.commafeed.security.password.ValidPassword;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.commafeed.security.password.ValidPassword;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Data
@Schema
public class PasswordResetConfirmationRequest implements Serializable {
@Schema(description = "email address for password recovery", required = true)
@Email
@NotEmpty
@Size(max = 255)
private String email;
@Schema(description = "email address for password recovery", required = true)
@Email
@NotEmpty
@Size(max = 255)
private String email;
@Schema(description = "password recovery token", required = true)
@NotEmpty
private String token;
@Schema(description = "password recovery token", required = true)
@NotEmpty
private String token;
@Schema(description = "new password", required = true)
@NotEmpty
@ValidPassword
private String password;
@Schema(description = "new password", required = true)
@NotEmpty
@ValidPassword
private String password;
}

View File

@@ -1,23 +1,20 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Data
@Schema
public class PasswordResetRequest implements Serializable {
@Schema(description = "email address for password recovery", required = true)
@Email
@NotEmpty
@Size(max = 255)
private String email;
@Schema(description = "email address for password recovery", required = true)
@Email
@NotEmpty
@Size(max = 255)
private String email;
}

View File

@@ -1,34 +1,29 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import com.commafeed.security.password.ValidPassword;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.commafeed.security.password.ValidPassword;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Profile modification request")
@Data
public class ProfileModificationRequest implements Serializable {
@Schema(description = "current user password, required to change profile data", required = true)
@NotEmpty
@Size(max = 128)
private String currentPassword;
@Schema(description = "current user password, required to change profile data", required = true)
@NotEmpty
@Size(max = 128)
private String currentPassword;
@Schema(description = "changes email of the user, if specified")
@Size(max = 255)
private String email;
@Schema(description = "changes email of the user, if specified")
@Size(max = 255)
private String email;
@Schema(description = "changes password of the user, if specified")
@ValidPassword
private String newPassword;
@Schema(description = "generate a new api key")
private boolean newApiKey;
@Schema(description = "changes password of the user, if specified")
@ValidPassword
private String newPassword;
@Schema(description = "generate a new api key")
private boolean newApiKey;
}

View File

@@ -1,36 +1,31 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import com.commafeed.security.password.ValidPassword;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import com.commafeed.security.password.ValidPassword;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Data
@Schema
public class RegistrationRequest implements Serializable {
@Schema(description = "username, between 3 and 32 characters", required = true)
@NotEmpty
@Size(min = 3, max = 32)
private String name;
@Schema(description = "username, between 3 and 32 characters", required = true)
@NotEmpty
@Size(min = 3, max = 32)
private String name;
@Schema(description = "password", required = true)
@NotEmpty
@ValidPassword
private String password;
@Schema(description = "email address for password recovery", required = true)
@Email
@NotEmpty
@Size(max = 255)
private String email;
@Schema(description = "password", required = true)
@NotEmpty
@ValidPassword
private String password;
@Schema(description = "email address for password recovery", required = true)
@Email
@NotEmpty
@Size(max = 255)
private String email;
}

View File

@@ -1,28 +1,24 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Star Request")
@Data
public class StarRequest implements Serializable {
@Schema(description = "id", required = true)
@NotEmpty
@Size(max = 128)
private String id;
@Schema(description = "id", required = true)
@NotEmpty
@Size(max = 128)
private String id;
@Schema(description = "feed id", required = true)
private Long feedId;
@Schema(description = "starred or not", required = true)
private boolean starred;
@Schema(description = "feed id", required = true)
private Long feedId;
@Schema(description = "starred or not", required = true)
private boolean starred;
}

View File

@@ -1,30 +1,27 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import java.io.Serializable;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Subscription request")
@Data
public class SubscribeRequest implements Serializable {
@Schema(description = "url of the feed", required = true)
@NotEmpty
@Size(max = 4096)
private String url;
@Schema(description = "url of the feed", required = true)
@NotEmpty
@Size(max = 4096)
private String url;
@Schema(description = "name of the feed for the user", required = true)
@NotEmpty
@Size(max = 128)
private String title;
@Schema(description = "name of the feed for the user", required = true)
@NotEmpty
@Size(max = 128)
private String title;
@Schema(description = "id of the user category to place the feed in")
@Size(max = 128)
private String categoryId;
@Schema(description = "id of the user category to place the feed in")
@Size(max = 128)
private String categoryId;
}

View File

@@ -2,20 +2,17 @@ package com.commafeed.frontend.model.request;
import java.io.Serializable;
import java.util.List;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import lombok.Data;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@SuppressWarnings("serial")
@Schema(description = "Tag Request")
@Data
public class TagRequest implements Serializable {
@Schema(description = "entry id", required = true)
private Long entryId;
@Schema(description = "tags", required = true)
private List<String> tags;
@Schema(description = "entry id", required = true)
private Long entryId;
@Schema(description = "tags", required = true)
private List<String> tags;
}

View File

@@ -1,30 +1,5 @@
package com.commafeed.frontend.resource;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
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.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import com.codahale.metrics.MetricRegistry;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserRoleDAO;
@@ -40,8 +15,29 @@ import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import com.google.common.collect.Sets;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
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.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Path("/rest/admin")
@RolesAllowed(Roles.ADMIN)
@@ -52,138 +48,156 @@ import lombok.RequiredArgsConstructor;
@Tag(name = "Admin")
public class AdminREST {
private final AuthenticationContext authenticationContext;
private final UserDAO userDAO;
private final UserRoleDAO userRoleDAO;
private final UserService userService;
private final PasswordEncryptionService encryptionService;
private final MetricRegistry metrics;
private final AuthenticationContext authenticationContext;
private final UserDAO userDAO;
private final UserRoleDAO userRoleDAO;
private final UserService userService;
private final PasswordEncryptionService encryptionService;
private final MetricRegistry metrics;
@Path("/user/save")
@POST
@Transactional
@Operation(
summary = "Save or update a user",
description = "Save or update a user. If the id is not specified, a new user will be created")
public Response adminSaveUser(@Valid @Parameter(required = true) AdminSaveUserRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
@Path("/user/save")
@POST
@Transactional
@Operation(
summary = "Save or update a user",
description =
"Save or update a user. If the id is not specified, a new user will be created")
public Response adminSaveUser(@Valid @Parameter(required = true) AdminSaveUserRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
Long id = req.getId();
if (id == null) {
Preconditions.checkNotNull(req.getPassword());
Long id = req.getId();
if (id == null) {
Preconditions.checkNotNull(req.getPassword());
Set<Role> roles = Sets.newHashSet(Role.USER);
if (req.isAdmin()) {
roles.add(Role.ADMIN);
}
try {
id = userService.register(req.getName(), req.getPassword(), req.getEmail(), roles, true).getId();
} catch (Exception e) {
return Response.status(Status.CONFLICT).entity(e.getMessage()).build();
}
} else {
User user = authenticationContext.getCurrentUser();
if (req.getId().equals(user.getId()) && !req.isEnabled()) {
return Response.status(Status.FORBIDDEN).entity("You cannot disable your own account.").build();
}
Set<Role> roles = Sets.newHashSet(Role.USER);
if (req.isAdmin()) {
roles.add(Role.ADMIN);
}
try {
id =
userService
.register(
req.getName(),
req.getPassword(),
req.getEmail(),
roles,
true)
.getId();
} catch (Exception e) {
return Response.status(Status.CONFLICT).entity(e.getMessage()).build();
}
} else {
User user = authenticationContext.getCurrentUser();
if (req.getId().equals(user.getId()) && !req.isEnabled()) {
return Response.status(Status.FORBIDDEN)
.entity("You cannot disable your own account.")
.build();
}
User u = userDAO.findById(id);
u.setName(req.getName());
if (StringUtils.isNotBlank(req.getPassword())) {
u.setPassword(encryptionService.getEncryptedPassword(req.getPassword(), u.getSalt()));
}
u.setEmail(req.getEmail());
u.setDisabled(!req.isEnabled());
User u = userDAO.findById(id);
u.setName(req.getName());
if (StringUtils.isNotBlank(req.getPassword())) {
u.setPassword(
encryptionService.getEncryptedPassword(req.getPassword(), u.getSalt()));
}
u.setEmail(req.getEmail());
u.setDisabled(!req.isEnabled());
Set<Role> roles = userRoleDAO.findRoles(u);
if (req.isAdmin() && !roles.contains(Role.ADMIN)) {
userRoleDAO.persist(new UserRole(u, Role.ADMIN));
} else if (!req.isAdmin() && roles.contains(Role.ADMIN)) {
if (userRoleDAO.countAdmins() == 1) {
return Response.status(Status.FORBIDDEN).entity("You cannot remove the admin role from the last admin user.").build();
}
for (UserRole userRole : userRoleDAO.findAll(u)) {
if (userRole.getRole() == Role.ADMIN) {
userRoleDAO.delete(userRole);
}
}
}
Set<Role> roles = userRoleDAO.findRoles(u);
if (req.isAdmin() && !roles.contains(Role.ADMIN)) {
userRoleDAO.persist(new UserRole(u, Role.ADMIN));
} else if (!req.isAdmin() && roles.contains(Role.ADMIN)) {
if (userRoleDAO.countAdmins() == 1) {
return Response.status(Status.FORBIDDEN)
.entity("You cannot remove the admin role from the last admin user.")
.build();
}
for (UserRole userRole : userRoleDAO.findAll(u)) {
if (userRole.getRole() == Role.ADMIN) {
userRoleDAO.delete(userRole);
}
}
}
}
return Response.ok(id).build();
}
}
return Response.ok(id).build();
@Path("/user/get/{id}")
@GET
@Transactional
@Operation(summary = "Get user information", description = "Get user information")
public UserModel adminGetUser(
@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
User u = userDAO.findById(id);
UserModel userModel = new UserModel();
userModel.setId(u.getId());
userModel.setName(u.getName());
userModel.setEmail(u.getEmail());
userModel.setEnabled(!u.isDisabled());
userModel.setAdmin(
userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN));
return userModel;
}
}
@Path("/user/getAll")
@GET
@Transactional
@Operation(summary = "Get all users", description = "Get all users")
public List<UserModel> adminGetUsers() {
Map<Long, UserModel> users = new HashMap<>();
for (UserRole role : userRoleDAO.findAll()) {
User u = role.getUser();
UserModel userModel =
users.computeIfAbsent(
u.getId(),
k -> {
UserModel um = new UserModel();
um.setId(u.getId());
um.setName(u.getName());
um.setEmail(u.getEmail());
um.setEnabled(!u.isDisabled());
um.setCreated(u.getCreated());
um.setLastLogin(u.getLastLogin());
return um;
});
@Path("/user/get/{id}")
@GET
@Transactional
@Operation(summary = "Get user information", description = "Get user information")
public UserModel adminGetUser(@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
User u = userDAO.findById(id);
UserModel userModel = new UserModel();
userModel.setId(u.getId());
userModel.setName(u.getName());
userModel.setEmail(u.getEmail());
userModel.setEnabled(!u.isDisabled());
userModel.setAdmin(userRoleDAO.findAll(u).stream().anyMatch(r -> r.getRole() == Role.ADMIN));
return userModel;
}
if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true);
}
}
return new ArrayList<>(users.values());
}
@Path("/user/getAll")
@GET
@Transactional
@Operation(summary = "Get all users", description = "Get all users")
public List<UserModel> adminGetUsers() {
Map<Long, UserModel> users = new HashMap<>();
for (UserRole role : userRoleDAO.findAll()) {
User u = role.getUser();
UserModel userModel = users.computeIfAbsent(u.getId(), k -> {
UserModel um = new UserModel();
um.setId(u.getId());
um.setName(u.getName());
um.setEmail(u.getEmail());
um.setEnabled(!u.isDisabled());
um.setCreated(u.getCreated());
um.setLastLogin(u.getLastLogin());
return um;
});
@Path("/user/delete")
@POST
@Transactional
@Operation(summary = "Delete a user", description = "Delete a user, and all his subscriptions")
public Response adminDeleteUser(@Parameter(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true);
}
}
return new ArrayList<>(users.values());
}
User u = userDAO.findById(req.getId());
if (u == null) {
return Response.status(Status.NOT_FOUND).build();
}
@Path("/user/delete")
@POST
@Transactional
@Operation(summary = "Delete a user", description = "Delete a user, and all his subscriptions")
public Response adminDeleteUser(@Parameter(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User u = userDAO.findById(req.getId());
if (u == null) {
return Response.status(Status.NOT_FOUND).build();
}
User user = authenticationContext.getCurrentUser();
if (user.getId().equals(u.getId())) {
return Response.status(Status.FORBIDDEN).entity("You cannot delete your own user.").build();
}
userService.unregister(u);
return Response.ok().build();
}
@Path("/metrics")
@GET
@Transactional
@Operation(summary = "Retrieve server metrics")
public Response getMetrics() {
return Response.ok(metrics).build();
}
User user = authenticationContext.getCurrentUser();
if (user.getId().equals(u.getId())) {
return Response.status(Status.FORBIDDEN)
.entity("You cannot delete your own user.")
.build();
}
userService.unregister(u);
return Response.ok().build();
}
@Path("/metrics")
@GET
@Transactional
@Operation(summary = "Retrieve server metrics")
public Response getMetrics() {
return Response.ok(metrics).build();
}
}

View File

@@ -1,41 +1,5 @@
package com.commafeed.frontend.resource;
import java.io.StringWriter;
import java.time.Instant;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriInfo;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
@@ -67,9 +31,41 @@ import com.google.common.collect.Lists;
import com.rometools.rome.feed.synd.SyndFeed;
import com.rometools.rome.feed.synd.SyndFeedImpl;
import com.rometools.rome.io.SyndFeedOutput;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriInfo;
import java.io.StringWriter;
import java.time.Instant;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Path("/rest/category")
@RolesAllowed(Roles.USER)
@@ -81,376 +77,472 @@ import lombok.extern.slf4j.Slf4j;
@Tag(name = "Feed categories")
public class CategoryREST {
public static final String ALL = "all";
public static final String STARRED = "starred";
public static final String ALL = "all";
public static final String STARRED = "starred";
private static final Comparator<Category> CATEGORY_COMPARATOR = Comparator.comparing(Category::getPosition)
.thenComparing(Category::getName);
private static final Comparator<Subscription> SUBSCRIPTION_COMPARATOR = Comparator.comparing(Subscription::getPosition)
.thenComparing(Subscription::getName);
private static final Comparator<Category> CATEGORY_COMPARATOR =
Comparator.comparing(Category::getPosition).thenComparing(Category::getName);
private static final Comparator<Subscription> SUBSCRIPTION_COMPARATOR =
Comparator.comparing(Subscription::getPosition).thenComparing(Subscription::getName);
private final AuthenticationContext authenticationContext;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryService feedEntryService;
private final FeedSubscriptionService feedSubscriptionService;
private final CommaFeedConfiguration config;
private final UriInfo uri;
private final AuthenticationContext authenticationContext;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryService feedEntryService;
private final FeedSubscriptionService feedSubscriptionService;
private final CommaFeedConfiguration config;
private final UriInfo uri;
@Path("/entries")
@GET
@Transactional
@Operation(summary = "Get category entries", description = "Get a list of category entries")
@APIResponse(
responseCode = "200",
content = { @Content(mediaType = "application/json", schema = @Schema(implementation = Entries.class)) })
@APIResponse(responseCode = "404", description = "category not found")
public Response getCategoryEntries(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id,
@Parameter(
description = "all entries or only unread ones",
required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@Parameter(
description = "search for keywords in either the title or the content of the entries, separated by spaces") @QueryParam("keywords") String keywords,
@Parameter(
description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
@Path("/entries")
@GET
@Transactional
@Operation(summary = "Get category entries", description = "Get a list of category entries")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Entries.class))
})
@APIResponse(responseCode = "404", description = "category not found")
public Response getCategoryEntries(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true)
@QueryParam("id")
String id,
@Parameter(description = "all entries or only unread ones", required = true)
@DefaultValue("unread")
@QueryParam("readType")
ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20")
@QueryParam("limit")
int limit,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc")
ReadingOrder order,
@Parameter(
description =
"search for keywords in either the title or the content of the entries, separated by spaces")
@QueryParam("keywords")
String keywords,
@Parameter(description = "comma-separated list of excluded subscription ids")
@QueryParam("excludedSubscriptionIds")
String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag")
String tag) {
Preconditions.checkNotNull(readType);
Preconditions.checkNotNull(readType);
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
List<FeedEntryKeyword> entryKeywords =
FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
limit = Math.min(limit, 1000);
limit = Math.max(0, limit);
limit = Math.min(limit, 1000);
limit = Math.max(0, limit);
Entries entries = new Entries();
entries.setOffset(offset);
entries.setLimit(limit);
boolean unreadOnly = readType == ReadingMode.UNREAD;
if (StringUtils.isBlank(id)) {
id = ALL;
}
Entries entries = new Entries();
entries.setOffset(offset);
entries.setLimit(limit);
boolean unreadOnly = readType == ReadingMode.UNREAD;
if (StringUtils.isBlank(id)) {
id = ALL;
}
Instant newerThanDate = newerThan == null ? null : Instant.ofEpochMilli(newerThan);
Instant newerThanDate = newerThan == null ? null : Instant.ofEpochMilli(newerThan);
List<Long> excludedIds = null;
if (StringUtils.isNotEmpty(excludedSubscriptionIds)) {
excludedIds = Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList();
}
List<Long> excludedIds = null;
if (StringUtils.isNotEmpty(excludedSubscriptionIds)) {
excludedIds =
Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).toList();
}
User user = authenticationContext.getCurrentUser();
if (ALL.equals(id)) {
entries.setName(Optional.ofNullable(tag).orElse("All"));
User user = authenticationContext.getCurrentUser();
if (ALL.equals(id)) {
entries.setName(Optional.ofNullable(tag).orElse("All"));
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
offset, limit + 1, order, true, tag, null, null);
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list =
feedEntryStatusDAO.findBySubscriptions(
user,
subs,
unreadOnly,
entryKeywords,
newerThanDate,
offset,
limit + 1,
order,
true,
tag,
null,
null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
} else if (STARRED.equals(id)) {
entries.setName("Starred");
List<FeedEntryStatus> starred = feedEntryStatusDAO.findStarred(user, entryKeywords, newerThanDate, offset, limit + 1, order,
true);
for (FeedEntryStatus status : starred) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
} else {
FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id));
if (parent != null) {
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
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, tag, null, null);
} else if (STARRED.equals(id)) {
entries.setName("Starred");
List<FeedEntryStatus> starred =
feedEntryStatusDAO.findStarred(
user, entryKeywords, newerThanDate, offset, limit + 1, order, true);
for (FeedEntryStatus status : starred) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
} else {
FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(id));
if (parent != null) {
List<FeedCategory> categories =
feedCategoryDAO.findAllChildrenCategories(user, parent);
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,
tag,
null,
null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
entries.setName(parent.getName());
} else {
return Response.status(Status.NOT_FOUND).entity("<message>category not found</message>").build();
}
}
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
entries.setName(parent.getName());
} else {
return Response.status(Status.NOT_FOUND)
.entity("<message>category not found</message>")
.build();
}
}
boolean hasMore = entries.getEntries().size() > limit;
if (hasMore) {
entries.setHasMore(true);
entries.getEntries().removeLast();
}
boolean hasMore = entries.getEntries().size() > limit;
if (hasMore) {
entries.setHasMore(true);
entries.getEntries().removeLast();
}
entries.setTimestamp(System.currentTimeMillis());
entries.setIgnoredReadStatus(STARRED.equals(id) || keywords != null || tag != null);
return Response.ok(entries).build();
}
entries.setTimestamp(System.currentTimeMillis());
entries.setIgnoredReadStatus(STARRED.equals(id) || keywords != null || tag != null);
return Response.ok(entries).build();
}
@Path("/entriesAsFeed")
@GET
@Transactional
@Operation(summary = "Get category entries as feed", description = "Get a feed of category entries")
@Produces(MediaType.APPLICATION_XML)
public Response getCategoryEntriesAsFeed(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id,
@Parameter(
description = "all entries or only unread ones",
required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@Parameter(
description = "search for keywords in either the title or the content of the entries, separated by spaces") @QueryParam("keywords") String keywords,
@Parameter(
description = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
@Path("/entriesAsFeed")
@GET
@Transactional
@Operation(
summary = "Get category entries as feed",
description = "Get a feed of category entries")
@Produces(MediaType.APPLICATION_XML)
public Response getCategoryEntriesAsFeed(
@Parameter(description = "id of the category, 'all' or 'starred'", required = true)
@QueryParam("id")
String id,
@Parameter(description = "all entries or only unread ones", required = true)
@DefaultValue("all")
@QueryParam("readType")
ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20")
@QueryParam("limit")
int limit,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc")
ReadingOrder order,
@Parameter(
description =
"search for keywords in either the title or the content of the entries, separated by spaces")
@QueryParam("keywords")
String keywords,
@Parameter(description = "comma-separated list of excluded subscription ids")
@QueryParam("excludedSubscriptionIds")
String excludedSubscriptionIds,
@Parameter(description = "keep only entries tagged with this tag") @QueryParam("tag")
String tag) {
Response response = getCategoryEntries(id, readType, newerThan, offset, limit, order, keywords, excludedSubscriptionIds, tag);
if (response.getStatus() != Status.OK.getStatusCode()) {
return response;
}
Entries entries = (Entries) response.getEntity();
Response response =
getCategoryEntries(
id,
readType,
newerThan,
offset,
limit,
order,
keywords,
excludedSubscriptionIds,
tag);
if (response.getStatus() != Status.OK.getStatusCode()) {
return response;
}
Entries entries = (Entries) response.getEntity();
SyndFeed feed = new SyndFeedImpl();
feed.setFeedType("rss_2.0");
feed.setTitle("CommaFeed - " + entries.getName());
feed.setDescription("CommaFeed - " + entries.getName());
feed.setLink(uri.getBaseUri().toString());
feed.setEntries(entries.getEntries().stream().map(FeedUtils::asRss).toList());
SyndFeed feed = new SyndFeedImpl();
feed.setFeedType("rss_2.0");
feed.setTitle("CommaFeed - " + entries.getName());
feed.setDescription("CommaFeed - " + entries.getName());
feed.setLink(uri.getBaseUri().toString());
feed.setEntries(entries.getEntries().stream().map(FeedUtils::asRss).toList());
SyndFeedOutput output = new SyndFeedOutput();
StringWriter writer = new StringWriter();
try {
output.output(feed, writer);
} catch (Exception e) {
writer.write("Could not get feed information");
log.error(e.getMessage(), e);
}
return Response.ok(writer.toString()).build();
}
SyndFeedOutput output = new SyndFeedOutput();
StringWriter writer = new StringWriter();
try {
output.output(feed, writer);
} catch (Exception e) {
writer.write("Could not get feed information");
log.error(e.getMessage(), e);
}
return Response.ok(writer.toString()).build();
}
@Path("/mark")
@POST
@Transactional
@Operation(summary = "Mark category entries", description = "Mark feed entries of this category as read")
public Response markCategoryEntries(@Valid @Parameter(description = "category id, or 'all'", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@Path("/mark")
@POST
@Transactional
@Operation(
summary = "Mark category entries",
description = "Mark feed entries of this category as read")
public Response markCategoryEntries(
@Valid @Parameter(description = "category id, or 'all'", required = true)
MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore());
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
Instant olderThan =
req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
Instant insertedBefore =
req.getInsertedBefore() == null
? null
: Instant.ofEpochMilli(req.getInsertedBefore());
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
User user = authenticationContext.getCurrentUser();
if (ALL.equals(req.getId())) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
} else if (STARRED.equals(req.getId())) {
feedEntryService.markStarredEntries(user, olderThan, insertedBefore);
} else {
FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(req.getId()));
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
}
return Response.ok().build();
}
User user = authenticationContext.getCurrentUser();
if (ALL.equals(req.getId())) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
feedEntryService.markSubscriptionEntries(
user, subs, olderThan, insertedBefore, entryKeywords);
} else if (STARRED.equals(req.getId())) {
feedEntryService.markStarredEntries(user, olderThan, insertedBefore);
} else {
FeedCategory parent = feedCategoryDAO.findById(user, Long.valueOf(req.getId()));
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, parent);
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(user, categories);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
feedEntryService.markSubscriptionEntries(
user, subs, olderThan, insertedBefore, entryKeywords);
}
return Response.ok().build();
}
private void removeExcludedSubscriptions(List<FeedSubscription> subs, List<Long> excludedIds) {
if (CollectionUtils.isNotEmpty(excludedIds)) {
subs.removeIf(sub -> excludedIds.contains(sub.getId()));
}
}
private void removeExcludedSubscriptions(List<FeedSubscription> subs, List<Long> excludedIds) {
if (CollectionUtils.isNotEmpty(excludedIds)) {
subs.removeIf(sub -> excludedIds.contains(sub.getId()));
}
}
@Path("/add")
@POST
@Transactional
@Operation(summary = "Add a category", description = "Add a new feed category")
public Long addCategory(@Valid @Parameter(required = true) AddCategoryRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
@Path("/add")
@POST
@Transactional
@Operation(summary = "Add a category", description = "Add a new feed category")
public Long addCategory(@Valid @Parameter(required = true) AddCategoryRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
User user = authenticationContext.getCurrentUser();
User user = authenticationContext.getCurrentUser();
FeedCategory cat = new FeedCategory();
cat.setName(req.getName());
cat.setUser(user);
cat.setPosition(0);
String parentId = req.getParentId();
if (parentId != null && !ALL.equals(parentId)) {
FeedCategory parent = new FeedCategory();
parent.setId(Long.valueOf(parentId));
cat.setParent(parent);
}
feedCategoryDAO.persist(cat);
return cat.getId();
}
FeedCategory cat = new FeedCategory();
cat.setName(req.getName());
cat.setUser(user);
cat.setPosition(0);
String parentId = req.getParentId();
if (parentId != null && !ALL.equals(parentId)) {
FeedCategory parent = new FeedCategory();
parent.setId(Long.valueOf(parentId));
cat.setParent(parent);
}
feedCategoryDAO.persist(cat);
return cat.getId();
}
@POST
@Path("/delete")
@Transactional
@Operation(summary = "Delete a category", description = "Delete an existing feed category")
public Response deleteCategory(@Parameter(required = true) IDRequest req) {
@POST
@Path("/delete")
@Transactional
@Operation(summary = "Delete a category", description = "Delete an existing feed category")
public Response deleteCategory(@Parameter(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
FeedCategory cat = feedCategoryDAO.findById(user, req.getId());
if (cat != null) {
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategory(user, cat);
for (FeedSubscription sub : subs) {
sub.setCategory(null);
}
User user = authenticationContext.getCurrentUser();
FeedCategory cat = feedCategoryDAO.findById(user, req.getId());
if (cat != null) {
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategory(user, cat);
for (FeedSubscription sub : subs) {
sub.setCategory(null);
}
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, cat);
for (FeedCategory child : categories) {
if (!child.getId().equals(cat.getId()) && child.getParent().getId().equals(cat.getId())) {
child.setParent(null);
}
}
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(user, cat);
for (FeedCategory child : categories) {
if (!child.getId().equals(cat.getId())
&& child.getParent().getId().equals(cat.getId())) {
child.setParent(null);
}
}
feedCategoryDAO.delete(cat);
return Response.ok().build();
} else {
return Response.status(Status.NOT_FOUND).build();
}
}
feedCategoryDAO.delete(cat);
return Response.ok().build();
} else {
return Response.status(Status.NOT_FOUND).build();
}
}
@POST
@Path("/modify")
@Transactional
@Operation(summary = "Modify a category", description = "Modify an existing feed category")
public Response modifyCategory(@Valid @Parameter(required = true) CategoryModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@POST
@Path("/modify")
@Transactional
@Operation(summary = "Modify a category", description = "Modify an existing feed category")
public Response modifyCategory(
@Valid @Parameter(required = true) CategoryModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
FeedCategory category = feedCategoryDAO.findById(user, req.getId());
User user = authenticationContext.getCurrentUser();
FeedCategory category = feedCategoryDAO.findById(user, req.getId());
if (StringUtils.isNotBlank(req.getName())) {
category.setName(req.getName());
}
if (StringUtils.isNotBlank(req.getName())) {
category.setName(req.getName());
}
FeedCategory parent = null;
if (req.getParentId() != null && !ALL.equals(req.getParentId())
&& !Strings.CS.equals(req.getParentId(), String.valueOf(req.getId()))) {
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId()));
}
category.setParent(parent);
FeedCategory parent = null;
if (req.getParentId() != null
&& !ALL.equals(req.getParentId())
&& !Strings.CS.equals(req.getParentId(), String.valueOf(req.getId()))) {
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getParentId()));
}
category.setParent(parent);
if (req.getPosition() != null) {
List<FeedCategory> categories = feedCategoryDAO.findByParent(user, parent);
categories.sort((o1, o2) -> ObjectUtils.compare(o1.getPosition(), o2.getPosition()));
if (req.getPosition() != null) {
List<FeedCategory> categories = feedCategoryDAO.findByParent(user, parent);
categories.sort((o1, o2) -> ObjectUtils.compare(o1.getPosition(), o2.getPosition()));
int existingIndex = -1;
for (int i = 0; i < categories.size(); i++) {
if (Objects.equals(categories.get(i).getId(), category.getId())) {
existingIndex = i;
}
}
if (existingIndex != -1) {
categories.remove(existingIndex);
}
int existingIndex = -1;
for (int i = 0; i < categories.size(); i++) {
if (Objects.equals(categories.get(i).getId(), category.getId())) {
existingIndex = i;
}
}
if (existingIndex != -1) {
categories.remove(existingIndex);
}
categories.add(Math.min(req.getPosition(), categories.size()), category);
for (int i = 0; i < categories.size(); i++) {
categories.get(i).setPosition(i);
}
}
categories.add(Math.min(req.getPosition(), categories.size()), category);
for (int i = 0; i < categories.size(); i++) {
categories.get(i).setPosition(i);
}
}
return Response.ok().build();
}
return Response.ok().build();
}
@POST
@Path("/collapse")
@Transactional
@Operation(summary = "Collapse a category", description = "Save collapsed or expanded status for a category")
public Response collapseCategory(@Parameter(required = true) CollapseRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@POST
@Path("/collapse")
@Transactional
@Operation(
summary = "Collapse a category",
description = "Save collapsed or expanded status for a category")
public Response collapseCategory(@Parameter(required = true) CollapseRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
FeedCategory category = feedCategoryDAO.findById(user, req.getId());
if (category == null) {
return Response.status(Status.NOT_FOUND).build();
}
category.setCollapsed(req.isCollapse());
User user = authenticationContext.getCurrentUser();
FeedCategory category = feedCategoryDAO.findById(user, req.getId());
if (category == null) {
return Response.status(Status.NOT_FOUND).build();
}
category.setCollapsed(req.isCollapse());
return Response.ok().build();
}
return Response.ok().build();
}
@GET
@Path("/unreadCount")
@Transactional
@Operation(summary = "Get unread count for feed subscriptions")
public List<UnreadCount> getUnreadCount() {
User user = authenticationContext.getCurrentUser();
Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
return Lists.newArrayList(unreadCount.values());
}
@GET
@Path("/unreadCount")
@Transactional
@Operation(summary = "Get unread count for feed subscriptions")
public List<UnreadCount> getUnreadCount() {
User user = authenticationContext.getCurrentUser();
Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
return Lists.newArrayList(unreadCount.values());
}
@GET
@Path("/get")
@Transactional
@Operation(summary = "Get root category", description = "Get all categories and subscriptions of the user")
public Category getRootCategory() {
User user = authenticationContext.getCurrentUser();
@GET
@Path("/get")
@Transactional
@Operation(
summary = "Get root category",
description = "Get all categories and subscriptions of the user")
public Category getRootCategory() {
User user = authenticationContext.getCurrentUser();
List<FeedCategory> categories = feedCategoryDAO.findAll(user);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findAll(user);
Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
List<FeedCategory> categories = feedCategoryDAO.findAll(user);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findAll(user);
Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
Category root = buildCategory(null, categories, subscriptions, unreadCount);
root.setId("all");
root.setName("All");
Category root = buildCategory(null, categories, subscriptions, unreadCount);
root.setId("all");
root.setName("All");
return root;
}
return root;
}
private Category buildCategory(Long id, List<FeedCategory> categories, List<FeedSubscription> subscriptions,
Map<Long, UnreadCount> unreadCount) {
Category category = new Category();
category.setId(String.valueOf(id));
category.setExpanded(true);
private Category buildCategory(
Long id,
List<FeedCategory> categories,
List<FeedSubscription> subscriptions,
Map<Long, UnreadCount> unreadCount) {
Category category = new Category();
category.setId(String.valueOf(id));
category.setExpanded(true);
for (FeedCategory c : categories) {
if (id == null && c.getParent() == null || c.getParent() != null && Objects.equals(c.getParent().getId(), id)) {
Category child = buildCategory(c.getId(), categories, subscriptions, unreadCount);
child.setId(String.valueOf(c.getId()));
child.setName(c.getName());
child.setPosition(c.getPosition());
if (c.getParent() != null && c.getParent().getId() != null) {
child.setParentId(String.valueOf(c.getParent().getId()));
child.setParentName(c.getParent().getName());
}
child.setExpanded(!c.isCollapsed());
category.getChildren().add(child);
}
}
category.getChildren().sort(CATEGORY_COMPARATOR);
for (FeedCategory c : categories) {
if (id == null && c.getParent() == null
|| c.getParent() != null && Objects.equals(c.getParent().getId(), id)) {
Category child = buildCategory(c.getId(), categories, subscriptions, unreadCount);
child.setId(String.valueOf(c.getId()));
child.setName(c.getName());
child.setPosition(c.getPosition());
if (c.getParent() != null && c.getParent().getId() != null) {
child.setParentId(String.valueOf(c.getParent().getId()));
child.setParentName(c.getParent().getName());
}
child.setExpanded(!c.isCollapsed());
category.getChildren().add(child);
}
}
category.getChildren().sort(CATEGORY_COMPARATOR);
for (FeedSubscription subscription : subscriptions) {
if (id == null && subscription.getCategory() == null
|| subscription.getCategory() != null && Objects.equals(subscription.getCategory().getId(), id)) {
UnreadCount uc = unreadCount.get(subscription.getId());
Subscription sub = Subscription.build(subscription, uc);
category.getFeeds().add(sub);
}
}
category.getFeeds().sort(SUBSCRIPTION_COMPARATOR);
return category;
}
for (FeedSubscription subscription : subscriptions) {
if (id == null && subscription.getCategory() == null
|| subscription.getCategory() != null
&& Objects.equals(subscription.getCategory().getId(), id)) {
UnreadCount uc = unreadCount.get(subscription.getId());
Subscription sub = Subscription.build(subscription, uc);
category.getFeeds().add(sub);
}
}
category.getFeeds().sort(SUBSCRIPTION_COMPARATOR);
return category;
}
}

View File

@@ -1,23 +1,5 @@
package com.commafeed.frontend.resource;
import java.util.List;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import com.commafeed.backend.dao.FeedEntryTagDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.service.FeedEntryService;
@@ -29,8 +11,22 @@ import com.commafeed.frontend.model.request.TagRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Path("/rest/entry")
@RolesAllowed(Roles.USER)
@@ -41,78 +37,87 @@ import lombok.RequiredArgsConstructor;
@Tag(name = "Feed entries")
public class EntryREST {
private final AuthenticationContext authenticationContext;
private final FeedEntryTagDAO feedEntryTagDAO;
private final FeedEntryService feedEntryService;
private final FeedEntryTagService feedEntryTagService;
private final AuthenticationContext authenticationContext;
private final FeedEntryTagDAO feedEntryTagDAO;
private final FeedEntryService feedEntryService;
private final FeedEntryTagService feedEntryTagService;
@Path("/mark")
@POST
@Transactional
@Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread")
public Response markEntry(@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@Path("/mark")
@POST
@Transactional
@Operation(summary = "Mark a feed entry", description = "Mark a feed entry as read/unread")
public Response markEntry(
@Valid @Parameter(description = "Mark Request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
feedEntryService.markEntry(user, Long.valueOf(req.getId()), req.isRead());
return Response.ok().build();
}
User user = authenticationContext.getCurrentUser();
feedEntryService.markEntry(user, Long.valueOf(req.getId()), req.isRead());
return Response.ok().build();
}
@Path("/markMultiple")
@POST
@Transactional
@Operation(summary = "Mark multiple feed entries", description = "Mark feed entries as read/unread")
public Response markEntries(@Valid @Parameter(description = "Multiple Mark Request", required = true) MultipleMarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getRequests());
@Path("/markMultiple")
@POST
@Transactional
@Operation(
summary = "Mark multiple feed entries",
description = "Mark feed entries as read/unread")
public Response markEntries(
@Valid @Parameter(description = "Multiple Mark Request", required = true)
MultipleMarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getRequests());
User user = authenticationContext.getCurrentUser();
for (MarkRequest r : req.getRequests()) {
Preconditions.checkNotNull(r.getId());
feedEntryService.markEntry(user, Long.valueOf(r.getId()), r.isRead());
}
User user = authenticationContext.getCurrentUser();
for (MarkRequest r : req.getRequests()) {
Preconditions.checkNotNull(r.getId());
feedEntryService.markEntry(user, Long.valueOf(r.getId()), r.isRead());
}
return Response.ok().build();
}
return Response.ok().build();
}
@Path("/star")
@POST
@Transactional
@Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread")
public Response starEntry(@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Preconditions.checkNotNull(req.getFeedId());
@Path("/star")
@POST
@Transactional
@Operation(summary = "Star a feed entry", description = "Mark a feed entry as read/unread")
public Response starEntry(
@Valid @Parameter(description = "Star Request", required = true) StarRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Preconditions.checkNotNull(req.getFeedId());
User user = authenticationContext.getCurrentUser();
feedEntryService.starEntry(user, Long.valueOf(req.getId()), req.getFeedId(), req.isStarred());
User user = authenticationContext.getCurrentUser();
feedEntryService.starEntry(
user, Long.valueOf(req.getId()), req.getFeedId(), req.isStarred());
return Response.ok().build();
}
return Response.ok().build();
}
@Path("/tags")
@GET
@Transactional
@Operation(summary = "Get list of tags for the user", description = "Get list of tags for the user")
public Response getTags() {
User user = authenticationContext.getCurrentUser();
List<String> tags = feedEntryTagDAO.findByUser(user);
return Response.ok(tags).build();
}
@Path("/tags")
@GET
@Transactional
@Operation(
summary = "Get list of tags for the user",
description = "Get list of tags for the user")
public Response getTags() {
User user = authenticationContext.getCurrentUser();
List<String> tags = feedEntryTagDAO.findByUser(user);
return Response.ok(tags).build();
}
@Path("/tag")
@POST
@Transactional
@Operation(summary = "Set feed entry tags")
public Response tagEntry(@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getEntryId());
@Path("/tag")
@POST
@Transactional
@Operation(summary = "Set feed entry tags")
public Response tagEntry(
@Valid @Parameter(description = "Tag Request", required = true) TagRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getEntryId());
User user = authenticationContext.getCurrentUser();
feedEntryTagService.updateTags(user, req.getEntryId(), req.getTags());
return Response.ok().build();
}
User user = authenticationContext.getCurrentUser();
feedEntryTagService.updateTags(user, req.getEntryId(), req.getTags());
return Response.ok().build();
}
}

View File

@@ -1,43 +1,5 @@
package com.commafeed.frontend.resource;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
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.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriInfo;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.SystemUtils;
import org.apache.hc.core5.http.HttpStatus;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.resteasy.reactive.Cache;
import org.jboss.resteasy.reactive.RestForm;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedConstants;
import com.commafeed.backend.dao.FeedCategoryDAO;
@@ -85,9 +47,43 @@ import com.rometools.rome.feed.synd.SyndFeedImpl;
import com.rometools.rome.io.FeedException;
import com.rometools.rome.io.SyndFeedOutput;
import com.rometools.rome.io.WireFeedOutput;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
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.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriInfo;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.SystemUtils;
import org.apache.hc.core5.http.HttpStatus;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.resteasy.reactive.Cache;
import org.jboss.resteasy.reactive.RestForm;
@Path("/rest/feed")
@RolesAllowed(Roles.USER)
@@ -99,422 +95,518 @@ import lombok.extern.slf4j.Slf4j;
@Tag(name = "Feeds")
public class FeedREST {
private static final FeedEntry TEST_ENTRY = initTestEntry();
private static final FeedEntry TEST_ENTRY = initTestEntry();
private final AuthenticationContext authenticationContext;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedFetcher feedFetcher;
private final FeedFaviconService feedFaviconService;
private final FeedEntryService feedEntryService;
private final FeedSubscriptionService feedSubscriptionService;
private final FeedEntryFilteringService feedEntryFilteringService;
private final OPMLImporter opmlImporter;
private final OPMLExporter opmlExporter;
private final CommaFeedConfiguration config;
private final UriInfo uri;
private final AuthenticationContext authenticationContext;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedFetcher feedFetcher;
private final FeedFaviconService feedFaviconService;
private final FeedEntryService feedEntryService;
private final FeedSubscriptionService feedSubscriptionService;
private final FeedEntryFilteringService feedEntryFilteringService;
private final OPMLImporter opmlImporter;
private final OPMLExporter opmlExporter;
private final CommaFeedConfiguration config;
private final UriInfo uri;
private static FeedEntry initTestEntry() {
FeedEntry entry = new FeedEntry();
entry.setUrl("https://github.com/Athou/commafeed");
private static FeedEntry initTestEntry() {
FeedEntry entry = new FeedEntry();
entry.setUrl("https://github.com/Athou/commafeed");
FeedEntryContent content = new FeedEntryContent();
content.setAuthor("Athou");
content.setTitle("Merge pull request #662 from Athou/dw8");
content.setContent("Merge pull request #662 from Athou/dw8");
entry.setContent(content);
return entry;
}
FeedEntryContent content = new FeedEntryContent();
content.setAuthor("Athou");
content.setTitle("Merge pull request #662 from Athou/dw8");
content.setContent("Merge pull request #662 from Athou/dw8");
entry.setContent(content);
return entry;
}
@Path("/entries")
@GET
@Transactional
@Operation(summary = "Get feed entries", description = "Get a list of feed entries")
@APIResponse(
responseCode = "200",
content = { @Content(mediaType = "application/json", schema = @Schema(implementation = Entries.class)) })
@APIResponse(responseCode = "404", description = "feed not found")
public Response getFeedEntries(@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id,
@Parameter(
description = "all entries or only unread ones",
required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter(
description = "search for keywords in either the title or the content of the entries, separated by spaces") @QueryParam("keywords") String keywords) {
@Path("/entries")
@GET
@Transactional
@Operation(summary = "Get feed entries", description = "Get a list of feed entries")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Entries.class))
})
@APIResponse(responseCode = "404", description = "feed not found")
public Response getFeedEntries(
@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id,
@Parameter(description = "all entries or only unread ones", required = true)
@DefaultValue("unread")
@QueryParam("readType")
ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20")
@QueryParam("limit")
int limit,
@Parameter(description = "ordering") @QueryParam("order") @DefaultValue("desc")
ReadingOrder order,
@Parameter(
description =
"search for keywords in either the title or the content of the entries, separated by spaces")
@QueryParam("keywords")
String keywords) {
Preconditions.checkNotNull(id);
Preconditions.checkNotNull(readType);
Preconditions.checkNotNull(id);
Preconditions.checkNotNull(readType);
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
List<FeedEntryKeyword> entryKeywords =
FeedEntryKeyword.fromQueryString(StringUtils.trimToNull(keywords));
limit = Math.min(limit, 1000);
limit = Math.max(0, limit);
limit = Math.min(limit, 1000);
limit = Math.max(0, limit);
Entries entries = new Entries();
entries.setOffset(offset);
entries.setLimit(limit);
Entries entries = new Entries();
entries.setOffset(offset);
entries.setLimit(limit);
boolean unreadOnly = readType == ReadingMode.UNREAD;
boolean unreadOnly = readType == ReadingMode.UNREAD;
Instant newerThanDate = newerThan == null ? null : Instant.ofEpochMilli(newerThan);
Instant newerThanDate = newerThan == null ? null : Instant.ofEpochMilli(newerThan);
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(id));
if (subscription != null) {
entries.setName(subscription.getTitle());
entries.setMessage(subscription.getFeed().getMessage());
entries.setErrorCount(subscription.getFeed().getErrorCount());
entries.setFeedLink(subscription.getFeed().getLink());
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(id));
if (subscription != null) {
entries.setName(subscription.getTitle());
entries.setMessage(subscription.getFeed().getMessage());
entries.setErrorCount(subscription.getFeed().getErrorCount());
entries.setFeedLink(subscription.getFeed().getLink());
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, Collections.singletonList(subscription), unreadOnly,
entryKeywords, newerThanDate, offset, limit + 1, order, true, null, null, null);
List<FeedEntryStatus> list =
feedEntryStatusDAO.findBySubscriptions(
user,
Collections.singletonList(subscription),
unreadOnly,
entryKeywords,
newerThanDate,
offset,
limit + 1,
order,
true,
null,
null,
null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
for (FeedEntryStatus status : list) {
entries.getEntries().add(Entry.build(status, config.imageProxyEnabled()));
}
boolean hasMore = entries.getEntries().size() > limit;
if (hasMore) {
entries.setHasMore(true);
entries.getEntries().removeLast();
}
} else {
return Response.status(Status.NOT_FOUND).entity("<message>feed not found</message>").build();
}
boolean hasMore = entries.getEntries().size() > limit;
if (hasMore) {
entries.setHasMore(true);
entries.getEntries().removeLast();
}
} else {
return Response.status(Status.NOT_FOUND)
.entity("<message>feed not found</message>")
.build();
}
entries.setTimestamp(System.currentTimeMillis());
entries.setIgnoredReadStatus(keywords != null);
return Response.ok(entries).build();
}
entries.setTimestamp(System.currentTimeMillis());
entries.setIgnoredReadStatus(keywords != null);
return Response.ok(entries).build();
}
@Path("/entriesAsFeed")
@GET
@Transactional
@Operation(summary = "Get feed entries as a feed", description = "Get a feed of feed entries")
@Produces(MediaType.APPLICATION_XML)
public Response getFeedEntriesAsFeed(@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id,
@Parameter(
description = "all entries or only unread ones",
required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc") ReadingOrder order, @Parameter(
description = "search for keywords in either the title or the content of the entries, separated by spaces") @QueryParam("keywords") String keywords) {
@Path("/entriesAsFeed")
@GET
@Transactional
@Operation(summary = "Get feed entries as a feed", description = "Get a feed of feed entries")
@Produces(MediaType.APPLICATION_XML)
public Response getFeedEntriesAsFeed(
@Parameter(description = "id of the feed", required = true) @QueryParam("id") String id,
@Parameter(description = "all entries or only unread ones", required = true)
@DefaultValue("all")
@QueryParam("readType")
ReadingMode readType,
@Parameter(description = "only entries newer than this") @QueryParam("newerThan")
Long newerThan,
@Parameter(description = "offset for paging") @DefaultValue("0") @QueryParam("offset")
int offset,
@Parameter(description = "limit for paging, default 20, maximum 1000")
@DefaultValue("20")
@QueryParam("limit")
int limit,
@Parameter(description = "date ordering") @QueryParam("order") @DefaultValue("desc")
ReadingOrder order,
@Parameter(
description =
"search for keywords in either the title or the content of the entries, separated by spaces")
@QueryParam("keywords")
String keywords) {
Response response = getFeedEntries(id, readType, newerThan, offset, limit, order, keywords);
if (response.getStatus() != Status.OK.getStatusCode()) {
return response;
}
Entries entries = (Entries) response.getEntity();
Response response = getFeedEntries(id, readType, newerThan, offset, limit, order, keywords);
if (response.getStatus() != Status.OK.getStatusCode()) {
return response;
}
Entries entries = (Entries) response.getEntity();
SyndFeed feed = new SyndFeedImpl();
feed.setFeedType("rss_2.0");
feed.setTitle("CommaFeed - " + entries.getName());
feed.setDescription("CommaFeed - " + entries.getName());
feed.setLink(uri.getBaseUri().toString());
feed.setEntries(entries.getEntries().stream().map(FeedUtils::asRss).toList());
SyndFeed feed = new SyndFeedImpl();
feed.setFeedType("rss_2.0");
feed.setTitle("CommaFeed - " + entries.getName());
feed.setDescription("CommaFeed - " + entries.getName());
feed.setLink(uri.getBaseUri().toString());
feed.setEntries(entries.getEntries().stream().map(FeedUtils::asRss).toList());
SyndFeedOutput output = new SyndFeedOutput();
StringWriter writer = new StringWriter();
try {
output.output(feed, writer);
} catch (Exception e) {
writer.write("Could not get feed information");
log.error(e.getMessage(), e);
}
return Response.ok(writer.toString()).build();
}
SyndFeedOutput output = new SyndFeedOutput();
StringWriter writer = new StringWriter();
try {
output.output(feed, writer);
} catch (Exception e) {
writer.write("Could not get feed information");
log.error(e.getMessage(), e);
}
return Response.ok(writer.toString()).build();
}
private FeedInfo fetchFeedInternal(String url) {
FeedInfo info;
url = StringUtils.trimToEmpty(url);
url = prependHttp(url);
try {
FeedFetcherResult feedFetcherResult = feedFetcher.fetch(url, true, null, null, null, null);
info = new FeedInfo();
info.setUrl(feedFetcherResult.urlAfterRedirect());
info.setTitle(feedFetcherResult.feed().title());
private FeedInfo fetchFeedInternal(String url) {
FeedInfo info;
url = StringUtils.trimToEmpty(url);
url = prependHttp(url);
try {
FeedFetcherResult feedFetcherResult =
feedFetcher.fetch(url, true, null, null, null, null);
info = new FeedInfo();
info.setUrl(feedFetcherResult.urlAfterRedirect());
info.setTitle(feedFetcherResult.feed().title());
} catch (Exception e) {
log.debug(e.getMessage(), e);
throw new WebApplicationException(e.getMessage(), Status.INTERNAL_SERVER_ERROR);
}
return info;
}
} catch (Exception e) {
log.debug(e.getMessage(), e);
throw new WebApplicationException(e.getMessage(), Status.INTERNAL_SERVER_ERROR);
}
return info;
}
@POST
@Path("/fetch")
@Transactional
@Operation(summary = "Fetch a feed", description = "Fetch a feed by its url")
@APIResponse(
responseCode = "200",
content = { @Content(mediaType = "application/json", schema = @Schema(implementation = FeedInfo.class)) })
@APIResponse(responseCode = "404", description = "feed not found")
public Response fetchFeed(@Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getUrl());
@POST
@Path("/fetch")
@Transactional
@Operation(summary = "Fetch a feed", description = "Fetch a feed by its url")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = FeedInfo.class))
})
@APIResponse(responseCode = "404", description = "feed not found")
public Response fetchFeed(
@Valid @Parameter(description = "feed url", required = true) FeedInfoRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getUrl());
FeedInfo info;
try {
info = fetchFeedInternal(req.getUrl());
} catch (Exception e) {
Throwable cause = Throwables.getRootCause(e);
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(cause.getMessage()).type(MediaType.TEXT_PLAIN).build();
}
return Response.ok(info).build();
}
FeedInfo info;
try {
info = fetchFeedInternal(req.getUrl());
} catch (Exception e) {
Throwable cause = Throwables.getRootCause(e);
return Response.status(Status.INTERNAL_SERVER_ERROR)
.entity(cause.getMessage())
.type(MediaType.TEXT_PLAIN)
.build();
}
return Response.ok(info).build();
}
@Path("/refreshAll")
@GET
@Transactional
@Operation(summary = "Queue all feeds of the user for refresh", description = "Manually add all feeds of the user to the refresh queue")
public Response queueAllForRefresh() {
User user = authenticationContext.getCurrentUser();
try {
feedSubscriptionService.refreshAll(user);
return Response.ok().build();
} catch (ForceFeedRefreshTooSoonException e) {
return Response.status(HttpStatus.SC_TOO_MANY_REQUESTS).build();
}
@Path("/refreshAll")
@GET
@Transactional
@Operation(
summary = "Queue all feeds of the user for refresh",
description = "Manually add all feeds of the user to the refresh queue")
public Response queueAllForRefresh() {
User user = authenticationContext.getCurrentUser();
try {
feedSubscriptionService.refreshAll(user);
return Response.ok().build();
} catch (ForceFeedRefreshTooSoonException e) {
return Response.status(HttpStatus.SC_TOO_MANY_REQUESTS).build();
}
}
}
@Path("/mark")
@POST
@Transactional
@Operation(
summary = "Mark feed entries",
description = "Mark feed entries as read (unread is not supported)")
public Response markFeedEntries(
@Valid @Parameter(description = "Mark request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@Path("/mark")
@POST
@Transactional
@Operation(summary = "Mark feed entries", description = "Mark feed entries as read (unread is not supported)")
public Response markFeedEntries(@Valid @Parameter(description = "Mark request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Instant olderThan =
req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
Instant insertedBefore =
req.getInsertedBefore() == null
? null
: Instant.ofEpochMilli(req.getInsertedBefore());
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
Instant olderThan = req.getOlderThan() == null ? null : Instant.ofEpochMilli(req.getOlderThan());
Instant insertedBefore = req.getInsertedBefore() == null ? null : Instant.ofEpochMilli(req.getInsertedBefore());
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription =
feedSubscriptionDAO.findById(user, Long.valueOf(req.getId()));
if (subscription != null) {
feedEntryService.markSubscriptionEntries(
user,
Collections.singletonList(subscription),
olderThan,
insertedBefore,
entryKeywords);
}
return Response.ok().build();
}
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(req.getId()));
if (subscription != null) {
feedEntryService.markSubscriptionEntries(user, Collections.singletonList(subscription), olderThan, insertedBefore,
entryKeywords);
}
return Response.ok().build();
}
@GET
@Path("/get/{id}")
@Transactional
@Operation(summary = "get feed")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Subscription.class))
})
@APIResponse(responseCode = "404", description = "feed not found")
public Response getFeed(
@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
@GET
@Path("/get/{id}")
@Transactional
@Operation(summary = "get feed")
@APIResponse(
responseCode = "200",
content = { @Content(mediaType = "application/json", schema = @Schema(implementation = Subscription.class)) })
@APIResponse(responseCode = "404", description = "feed not found")
public Response getFeed(@Parameter(description = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
User user = authenticationContext.getCurrentUser();
FeedSubscription sub = feedSubscriptionDAO.findById(user, id);
if (sub == null) {
return Response.status(Status.NOT_FOUND).build();
}
UnreadCount unreadCount = feedSubscriptionService.getUnreadCount(user).get(id);
return Response.ok(Subscription.build(sub, unreadCount)).build();
}
User user = authenticationContext.getCurrentUser();
FeedSubscription sub = feedSubscriptionDAO.findById(user, id);
if (sub == null) {
return Response.status(Status.NOT_FOUND).build();
}
UnreadCount unreadCount = feedSubscriptionService.getUnreadCount(user).get(id);
return Response.ok(Subscription.build(sub, unreadCount)).build();
}
@GET
@Path("/favicon/{id}")
@Cache(maxAge = 2592000)
@Operation(summary = "Fetch a feed's icon", description = "Fetch a feed's icon")
public Response getFeedFavicon(
@Parameter(description = "subscription id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
@GET
@Path("/favicon/{id}")
@Cache(maxAge = 2592000)
@Operation(summary = "Fetch a feed's icon", description = "Fetch a feed's icon")
public Response getFeedFavicon(@Parameter(description = "subscription id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, id);
if (subscription == null) {
return Response.status(Status.NOT_FOUND).build();
}
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, id);
if (subscription == null) {
return Response.status(Status.NOT_FOUND).build();
}
Feed feed = subscription.getFeed();
if (feed.getLastUpdated() == null) {
// the feed has never been fetched yet so the iconUrl field hasn't been updated yet, and
// the
// icon can't be fetched
// this can happen for newly subscribed feeds:
// the feed has been added in the tree in the web client and the favicon is being
// fetched,
// but the feed has not been fetched yet
// the client is configured to retry in that case
return Response.status(Status.SERVICE_UNAVAILABLE)
.entity("Feed has not been fetched yet, please retry in a bit")
.type(MediaType.TEXT_PLAIN)
.build();
}
Feed feed = subscription.getFeed();
if (feed.getLastUpdated() == null) {
// the feed has never been fetched yet so the iconUrl field hasn't been updated yet, and the icon can't be fetched
// this can happen for newly subscribed feeds:
// the feed has been added in the tree in the web client and the favicon is being fetched,
// but the feed has not been fetched yet
// the client is configured to retry in that case
return Response.status(Status.SERVICE_UNAVAILABLE)
.entity("Feed has not been fetched yet, please retry in a bit")
.type(MediaType.TEXT_PLAIN)
.build();
}
Favicon icon = feedFaviconService.fetchFavicon(feed);
return Response.ok(icon.icon(), icon.mediaType()).build();
}
Favicon icon = feedFaviconService.fetchFavicon(feed);
return Response.ok(icon.icon(), icon.mediaType()).build();
}
@POST
@Path("/subscribe")
@Transactional
@Operation(summary = "Subscribe to a feed", description = "Subscribe to a feed")
@APIResponse(
responseCode = "200",
content = {
@Content(
mediaType = "application/json",
schema = @Schema(implementation = Long.class))
})
public Response subscribe(
@Valid @Parameter(description = "subscription request", required = true)
SubscribeRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getTitle());
Preconditions.checkNotNull(req.getUrl());
@POST
@Path("/subscribe")
@Transactional
@Operation(summary = "Subscribe to a feed", description = "Subscribe to a feed")
@APIResponse(
responseCode = "200",
content = { @Content(mediaType = "application/json", schema = @Schema(implementation = Long.class)) })
public Response subscribe(@Valid @Parameter(description = "subscription request", required = true) SubscribeRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getTitle());
Preconditions.checkNotNull(req.getUrl());
try {
FeedCategory category = null;
if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) {
category = feedCategoryDAO.findById(Long.valueOf(req.getCategoryId()));
}
try {
FeedCategory category = null;
if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) {
category = feedCategoryDAO.findById(Long.valueOf(req.getCategoryId()));
}
FeedInfo info = fetchFeedInternal(prependHttp(req.getUrl()));
User user = authenticationContext.getCurrentUser();
long subscriptionId =
feedSubscriptionService.subscribe(
user, info.getUrl(), req.getTitle(), category, 0);
return Response.ok(subscriptionId).build();
} catch (Exception e) {
log.error("Failed to subscribe to URL {}: {}", req.getUrl(), e.getMessage(), e);
return Response.status(Status.SERVICE_UNAVAILABLE)
.entity("Failed to subscribe to URL " + req.getUrl() + ": " + e.getMessage())
.build();
}
}
FeedInfo info = fetchFeedInternal(prependHttp(req.getUrl()));
User user = authenticationContext.getCurrentUser();
long subscriptionId = feedSubscriptionService.subscribe(user, info.getUrl(), req.getTitle(), category, 0);
return Response.ok(subscriptionId).build();
} catch (Exception e) {
log.error("Failed to subscribe to URL {}: {}", req.getUrl(), e.getMessage(), e);
return Response.status(Status.SERVICE_UNAVAILABLE)
.entity("Failed to subscribe to URL " + req.getUrl() + ": " + e.getMessage())
.build();
}
}
@GET
@Path("/subscribe")
@Transactional
@Operation(summary = "Subscribe to a feed", description = "Subscribe to a feed")
public Response subscribeFromUrl(
@Parameter(description = "feed url", required = true) @QueryParam("url") String url) {
try {
Preconditions.checkNotNull(url);
FeedInfo info = fetchFeedInternal(prependHttp(url));
User user = authenticationContext.getCurrentUser();
feedSubscriptionService.subscribe(user, info.getUrl(), info.getTitle(), null, 0);
} catch (Exception e) {
log.info("Could not subscribe to url {} : {}", url, e.getMessage());
}
return Response.temporaryRedirect(uri.getBaseUri()).build();
}
@GET
@Path("/subscribe")
@Transactional
@Operation(summary = "Subscribe to a feed", description = "Subscribe to a feed")
public Response subscribeFromUrl(@Parameter(description = "feed url", required = true) @QueryParam("url") String url) {
try {
Preconditions.checkNotNull(url);
FeedInfo info = fetchFeedInternal(prependHttp(url));
User user = authenticationContext.getCurrentUser();
feedSubscriptionService.subscribe(user, info.getUrl(), info.getTitle(), null, 0);
} catch (Exception e) {
log.info("Could not subscribe to url {} : {}", url, e.getMessage());
}
return Response.temporaryRedirect(uri.getBaseUri()).build();
}
private String prependHttp(String url) {
if (!url.startsWith("http")) {
url = "http://" + url;
}
return url;
}
private String prependHttp(String url) {
if (!url.startsWith("http")) {
url = "http://" + url;
}
return url;
}
@POST
@Path("/unsubscribe")
@Transactional
@Operation(summary = "Unsubscribe from a feed", description = "Unsubscribe from a feed")
public Response unsubscribe(@Parameter(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@POST
@Path("/unsubscribe")
@Transactional
@Operation(summary = "Unsubscribe from a feed", description = "Unsubscribe from a feed")
public Response unsubscribe(@Parameter(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
User user = authenticationContext.getCurrentUser();
boolean deleted = feedSubscriptionService.unsubscribe(user, req.getId());
if (deleted) {
return Response.ok().build();
} else {
return Response.status(Status.NOT_FOUND).build();
}
}
User user = authenticationContext.getCurrentUser();
boolean deleted = feedSubscriptionService.unsubscribe(user, req.getId());
if (deleted) {
return Response.ok().build();
} else {
return Response.status(Status.NOT_FOUND).build();
}
}
@POST
@Path("/modify")
@Transactional
@Operation(summary = "Modify a subscription", description = "Modify a feed subscription")
public Response modifyFeed(
@Valid @Parameter(description = "subscription id", required = true)
FeedModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
@POST
@Path("/modify")
@Transactional
@Operation(summary = "Modify a subscription", description = "Modify a feed subscription")
public Response modifyFeed(@Valid @Parameter(description = "subscription id", required = true) FeedModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
try {
feedEntryFilteringService.filterMatchesEntry(req.getFilter(), TEST_ENTRY);
} catch (FeedEntryFilterException e) {
return Response.status(Status.BAD_REQUEST)
.entity(e.getMessage())
.type(MediaType.TEXT_PLAIN)
.build();
}
try {
feedEntryFilteringService.filterMatchesEntry(req.getFilter(), TEST_ENTRY);
} catch (FeedEntryFilterException e) {
return Response.status(Status.BAD_REQUEST).entity(e.getMessage()).type(MediaType.TEXT_PLAIN).build();
}
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, req.getId());
User user = authenticationContext.getCurrentUser();
FeedSubscription subscription = feedSubscriptionDAO.findById(user, req.getId());
subscription.setFilter(req.getFilter());
if (StringUtils.isNotBlank(subscription.getFilter())) {
// if the new filter is filled, remove the legacy filter
subscription.setFilterLegacy(null);
}
subscription.setFilter(req.getFilter());
if (StringUtils.isNotBlank(subscription.getFilter())) {
// if the new filter is filled, remove the legacy filter
subscription.setFilterLegacy(null);
}
subscription.setPushNotificationsEnabled(req.isPushNotificationsEnabled());
subscription.setAutoMarkAsReadAfterDays(req.getAutoMarkAsReadAfterDays());
subscription.setPushNotificationsEnabled(req.isPushNotificationsEnabled());
subscription.setAutoMarkAsReadAfterDays(req.getAutoMarkAsReadAfterDays());
if (StringUtils.isNotBlank(req.getName())) {
subscription.setTitle(req.getName());
}
if (StringUtils.isNotBlank(req.getName())) {
subscription.setTitle(req.getName());
}
FeedCategory parent = null;
if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) {
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getCategoryId()));
}
subscription.setCategory(parent);
FeedCategory parent = null;
if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) {
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getCategoryId()));
}
subscription.setCategory(parent);
if (req.getPosition() != null) {
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategory(user, parent);
subs.sort((o1, o2) -> ObjectUtils.compare(o1.getPosition(), o2.getPosition()));
if (req.getPosition() != null) {
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategory(user, parent);
subs.sort((o1, o2) -> ObjectUtils.compare(o1.getPosition(), o2.getPosition()));
int existingIndex = -1;
for (int i = 0; i < subs.size(); i++) {
if (Objects.equals(subs.get(i).getId(), subscription.getId())) {
existingIndex = i;
}
}
if (existingIndex != -1) {
subs.remove(existingIndex);
}
int existingIndex = -1;
for (int i = 0; i < subs.size(); i++) {
if (Objects.equals(subs.get(i).getId(), subscription.getId())) {
existingIndex = i;
}
}
if (existingIndex != -1) {
subs.remove(existingIndex);
}
subs.add(Math.min(req.getPosition(), subs.size()), subscription);
for (int i = 0; i < subs.size(); i++) {
subs.get(i).setPosition(i);
}
}
subs.add(Math.min(req.getPosition(), subs.size()), subscription);
for (int i = 0; i < subs.size(); i++) {
subs.get(i).setPosition(i);
}
}
return Response.ok().build();
}
return Response.ok().build();
}
@POST
@Path("/import")
@Transactional
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(
summary = "OPML import",
description = "Import an OPML file, posted as a FORM with the 'file' name")
public Response importOpml(
@Parameter(description = "ompl file", required = true) @RestForm("file") String opml) {
User user = authenticationContext.getCurrentUser();
if (CommaFeedConstants.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN)
.entity("Import is disabled for the demo account")
.build();
}
try {
// opml will be encoded in the default JVM encoding, bu we want UTF-8
opmlImporter.importOpml(
user,
new String(opml.getBytes(SystemUtils.FILE_ENCODING), StandardCharsets.UTF_8));
} catch (Exception e) {
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build();
}
return Response.ok().build();
}
@POST
@Path("/import")
@Transactional
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(summary = "OPML import", description = "Import an OPML file, posted as a FORM with the 'file' name")
public Response importOpml(@Parameter(description = "ompl file", required = true) @RestForm("file") String opml) {
User user = authenticationContext.getCurrentUser();
if (CommaFeedConstants.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).entity("Import is disabled for the demo account").build();
}
try {
// opml will be encoded in the default JVM encoding, bu we want UTF-8
opmlImporter.importOpml(user, new String(opml.getBytes(SystemUtils.FILE_ENCODING), StandardCharsets.UTF_8));
} catch (Exception e) {
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build();
}
return Response.ok().build();
}
@GET
@Path("/export")
@Transactional
@Produces(MediaType.APPLICATION_XML)
@Operation(summary = "OPML export", description = "Export an OPML file of the user's subscriptions")
public Response exportOpml() throws FeedException {
User user = authenticationContext.getCurrentUser();
Opml opml = opmlExporter.export(user);
WireFeedOutput output = new WireFeedOutput();
String opmlString = output.outputString(opml);
return Response.ok(opmlString).build();
}
@GET
@Path("/export")
@Transactional
@Produces(MediaType.APPLICATION_XML)
@Operation(
summary = "OPML export",
description = "Export an OPML file of the user's subscriptions")
public Response exportOpml() throws FeedException {
User user = authenticationContext.getCurrentUser();
Opml opml = opmlExporter.export(user);
WireFeedOutput output = new WireFeedOutput();
String opmlString = output.outputString(opml);
return Response.ok(opmlString).build();
}
}

View File

@@ -1,7 +1,6 @@
package com.commafeed.frontend.resource;
import jakarta.ws.rs.core.Application;
import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition;
import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType;
import org.eclipse.microprofile.openapi.annotations.info.Info;
@@ -10,9 +9,8 @@ import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme;
import org.eclipse.microprofile.openapi.annotations.servers.Server;
@OpenAPIDefinition(
info = @Info(title = "CommaFeed API", version = "1.0.0"),
servers = { @Server(description = "CommaFeed API", url = "/") },
security = { @SecurityRequirement(name = "basicAuth") })
info = @Info(title = "CommaFeed API", version = "1.0.0"),
servers = {@Server(description = "CommaFeed API", url = "/")},
security = {@SecurityRequirement(name = "basicAuth")})
@SecurityScheme(securitySchemeName = "basicAuth", type = SecuritySchemeType.HTTP, scheme = "basic")
public class OpenAPI extends Application {
}
public class OpenAPI extends Application {}

View File

@@ -1,5 +1,13 @@
package com.commafeed.frontend.resource;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedVersion;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.feed.ImageProxyUrl;
import com.commafeed.backend.service.db.DatabaseStartupService;
import com.commafeed.frontend.model.ServerInfo;
import com.commafeed.security.Roles;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
@@ -12,24 +20,12 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import lombok.RequiredArgsConstructor;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedVersion;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.feed.ImageProxyUrl;
import com.commafeed.backend.service.db.DatabaseStartupService;
import com.commafeed.frontend.model.ServerInfo;
import com.commafeed.security.Roles;
import lombok.RequiredArgsConstructor;
@Path("/rest/server")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@@ -37,52 +33,54 @@ import lombok.RequiredArgsConstructor;
@Tag(name = "Server")
public class ServerREST {
private final HttpGetter httpGetter;
private final CommaFeedConfiguration config;
private final CommaFeedVersion version;
private final DatabaseStartupService databaseStartupService;
private final HttpGetter httpGetter;
private final CommaFeedConfiguration config;
private final CommaFeedVersion version;
private final DatabaseStartupService databaseStartupService;
@Path("/get")
@GET
@PermitAll
@Transactional
@Operation(summary = "Get server infos", description = "Get server infos")
public ServerInfo getServerInfos() {
ServerInfo infos = new ServerInfo();
infos.setAnnouncement(config.announcement().orElse(null));
infos.setVersion(version.getVersion());
infos.setGitCommit(version.getGitCommit());
infos.setAllowRegistrations(config.users().allowRegistrations());
infos.setEmailAddressRequired(config.users().emailAddressRequired());
infos.setSmtpEnabled(config.passwordRecoveryEnabled());
infos.setDemoAccountEnabled(config.users().createDemoAccount());
infos.setWebsocketEnabled(config.websocket().enabled());
infos.setWebsocketPingInterval(config.websocket().pingInterval().toMillis());
infos.setTreeReloadInterval(config.websocket().treeReloadInterval().toMillis());
infos.setForceRefreshCooldownDuration(config.feedRefresh().forceRefreshCooldownDuration().toMillis());
infos.setInitialSetupRequired(databaseStartupService.isInitialSetupRequired());
infos.setMinimumPasswordLength(config.users().minimumPasswordLength());
infos.setPushNotificationsEnabled(config.pushNotifications().enabled());
return infos;
}
@Path("/get")
@GET
@PermitAll
@Transactional
@Operation(summary = "Get server infos", description = "Get server infos")
public ServerInfo getServerInfos() {
ServerInfo infos = new ServerInfo();
infos.setAnnouncement(config.announcement().orElse(null));
infos.setVersion(version.getVersion());
infos.setGitCommit(version.getGitCommit());
infos.setAllowRegistrations(config.users().allowRegistrations());
infos.setEmailAddressRequired(config.users().emailAddressRequired());
infos.setSmtpEnabled(config.passwordRecoveryEnabled());
infos.setDemoAccountEnabled(config.users().createDemoAccount());
infos.setWebsocketEnabled(config.websocket().enabled());
infos.setWebsocketPingInterval(config.websocket().pingInterval().toMillis());
infos.setTreeReloadInterval(config.websocket().treeReloadInterval().toMillis());
infos.setForceRefreshCooldownDuration(
config.feedRefresh().forceRefreshCooldownDuration().toMillis());
infos.setInitialSetupRequired(databaseStartupService.isInitialSetupRequired());
infos.setMinimumPasswordLength(config.users().minimumPasswordLength());
infos.setPushNotificationsEnabled(config.pushNotifications().enabled());
return infos;
}
@Path("/proxy")
@GET
@RolesAllowed(Roles.USER)
@Transactional
@Operation(summary = "proxy image")
@Produces("image/png")
public Response getProxiedImage(@Parameter(description = "image url", required = true) @QueryParam("u") String url) {
if (!config.imageProxyEnabled()) {
return Response.status(Status.FORBIDDEN).build();
}
@Path("/proxy")
@GET
@RolesAllowed(Roles.USER)
@Transactional
@Operation(summary = "proxy image")
@Produces("image/png")
public Response getProxiedImage(
@Parameter(description = "image url", required = true) @QueryParam("u") String url) {
if (!config.imageProxyEnabled()) {
return Response.status(Status.FORBIDDEN).build();
}
url = ImageProxyUrl.decode(url);
try {
HttpResult result = httpGetter.get(url);
return Response.ok(result.content()).build();
} catch (Exception e) {
return Response.status(Status.SERVICE_UNAVAILABLE).entity(e.getMessage()).build();
}
}
url = ImageProxyUrl.decode(url);
try {
HttpResult result = httpGetter.get(url);
return Response.ok(result.content()).build();
} catch (Exception e) {
return Response.status(Status.SERVICE_UNAVAILABLE).entity(e.getMessage()).build();
}
}
}

View File

@@ -1,36 +1,5 @@
package com.commafeed.frontend.resource;
import java.net.URISyntaxException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriInfo;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.core5.net.URIBuilder;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedConstants;
import com.commafeed.backend.Digests;
@@ -67,9 +36,36 @@ import com.commafeed.frontend.model.request.RegistrationRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriInfo;
import java.net.URISyntaxException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.core5.net.URIBuilder;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Path("/rest/user")
@RolesAllowed(Roles.USER)
@@ -81,371 +77,410 @@ import lombok.extern.slf4j.Slf4j;
@Tag(name = "Users")
public class UserREST {
private final AuthenticationContext authenticationContext;
private final UserDAO userDAO;
private final UserRoleDAO userRoleDAO;
private final UserSettingsDAO userSettingsDAO;
private final UserService userService;
private final PasswordEncryptionService encryptionService;
private final DatabaseStartupService databaseStartupService;
private final MailService mailService;
private final CommaFeedConfiguration config;
private final UriInfo uri;
private final PushNotificationService pushNotificationService;
private final AuthenticationContext authenticationContext;
private final UserDAO userDAO;
private final UserRoleDAO userRoleDAO;
private final UserSettingsDAO userSettingsDAO;
private final UserService userService;
private final PasswordEncryptionService encryptionService;
private final DatabaseStartupService databaseStartupService;
private final MailService mailService;
private final CommaFeedConfiguration config;
private final UriInfo uri;
private final PushNotificationService pushNotificationService;
@Path("/settings")
@GET
@Transactional
@Operation(summary = "Retrieve user settings", description = "Retrieve user settings")
public Settings getUserSettings() {
Settings s = new Settings();
@Path("/settings")
@GET
@Transactional
@Operation(summary = "Retrieve user settings", description = "Retrieve user settings")
public Settings getUserSettings() {
Settings s = new Settings();
User user = authenticationContext.getCurrentUser();
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings != null) {
s.setReadingMode(settings.getReadingMode());
s.setReadingOrder(settings.getReadingOrder());
s.setShowRead(settings.isShowRead());
User user = authenticationContext.getCurrentUser();
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings != null) {
s.setReadingMode(settings.getReadingMode());
s.setReadingOrder(settings.getReadingOrder());
s.setShowRead(settings.isShowRead());
s.getSharingSettings().setEmail(settings.isEmail());
s.getSharingSettings().setGmail(settings.isGmail());
s.getSharingSettings().setFacebook(settings.isFacebook());
s.getSharingSettings().setTwitter(settings.isTwitter());
s.getSharingSettings().setTumblr(settings.isTumblr());
s.getSharingSettings().setInstapaper(settings.isInstapaper());
s.getSharingSettings().setBuffer(settings.isBuffer());
s.getSharingSettings().setEmail(settings.isEmail());
s.getSharingSettings().setGmail(settings.isGmail());
s.getSharingSettings().setFacebook(settings.isFacebook());
s.getSharingSettings().setTwitter(settings.isTwitter());
s.getSharingSettings().setTumblr(settings.isTumblr());
s.getSharingSettings().setInstapaper(settings.isInstapaper());
s.getSharingSettings().setBuffer(settings.isBuffer());
s.setScrollMarks(settings.isScrollMarks());
s.setCustomCss(settings.getCustomCss());
s.setCustomJs(settings.getCustomJs());
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setScrollMode(settings.getScrollMode());
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(settings.getStarIconDisplayMode());
s.setExternalLinkIconDisplayMode(settings.getExternalLinkIconDisplayMode());
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());
s.setMarkAllAsReadNavigateToNextUnread(settings.isMarkAllAsReadNavigateToNextUnread());
s.setCustomContextMenu(settings.isCustomContextMenu());
s.setMobileFooter(settings.isMobileFooter());
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setPrimaryColor(settings.getPrimaryColor());
s.setScrollMarks(settings.isScrollMarks());
s.setCustomCss(settings.getCustomCss());
s.setCustomJs(settings.getCustomJs());
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setScrollMode(settings.getScrollMode());
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(settings.getStarIconDisplayMode());
s.setExternalLinkIconDisplayMode(settings.getExternalLinkIconDisplayMode());
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());
s.setMarkAllAsReadNavigateToNextUnread(settings.isMarkAllAsReadNavigateToNextUnread());
s.setCustomContextMenu(settings.isCustomContextMenu());
s.setMobileFooter(settings.isMobileFooter());
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setPrimaryColor(settings.getPrimaryColor());
if (settings.getPushNotifications() != null) {
s.getPushNotificationSettings().setType(settings.getPushNotifications().getType());
s.getPushNotificationSettings().setServerUrl(settings.getPushNotifications().getServerUrl());
s.getPushNotificationSettings().setUserId(settings.getPushNotifications().getUserId());
s.getPushNotificationSettings().setUserSecret(settings.getPushNotifications().getUserSecret());
s.getPushNotificationSettings().setTopic(settings.getPushNotifications().getTopic());
}
} else {
s.setReadingMode(ReadingMode.UNREAD);
s.setReadingOrder(ReadingOrder.DESC);
s.setShowRead(true);
if (settings.getPushNotifications() != null) {
s.getPushNotificationSettings().setType(settings.getPushNotifications().getType());
s.getPushNotificationSettings()
.setServerUrl(settings.getPushNotifications().getServerUrl());
s.getPushNotificationSettings()
.setUserId(settings.getPushNotifications().getUserId());
s.getPushNotificationSettings()
.setUserSecret(settings.getPushNotifications().getUserSecret());
s.getPushNotificationSettings()
.setTopic(settings.getPushNotifications().getTopic());
}
} else {
s.setReadingMode(ReadingMode.UNREAD);
s.setReadingOrder(ReadingOrder.DESC);
s.setShowRead(true);
s.getSharingSettings().setEmail(true);
s.getSharingSettings().setGmail(true);
s.getSharingSettings().setFacebook(true);
s.getSharingSettings().setTwitter(true);
s.getSharingSettings().setTumblr(true);
s.getSharingSettings().setInstapaper(true);
s.getSharingSettings().setBuffer(true);
s.getSharingSettings().setEmail(true);
s.getSharingSettings().setGmail(true);
s.getSharingSettings().setFacebook(true);
s.getSharingSettings().setTwitter(true);
s.getSharingSettings().setTumblr(true);
s.getSharingSettings().setInstapaper(true);
s.getSharingSettings().setBuffer(true);
s.setScrollMarks(true);
s.setScrollSpeed(400);
s.setScrollMode(ScrollMode.IF_NEEDED);
s.setEntriesToKeepOnTopWhenScrolling(1);
s.setStarIconDisplayMode(IconDisplayMode.ON_DESKTOP);
s.setExternalLinkIconDisplayMode(IconDisplayMode.ON_DESKTOP);
s.setMarkAllAsReadConfirmation(true);
s.setMarkAllAsReadNavigateToNextUnread(false);
s.setCustomContextMenu(true);
s.setMobileFooter(false);
s.setUnreadCountTitle(false);
s.setUnreadCountFavicon(true);
s.setDisablePullToRefresh(false);
}
return s;
}
s.setScrollMarks(true);
s.setScrollSpeed(400);
s.setScrollMode(ScrollMode.IF_NEEDED);
s.setEntriesToKeepOnTopWhenScrolling(1);
s.setStarIconDisplayMode(IconDisplayMode.ON_DESKTOP);
s.setExternalLinkIconDisplayMode(IconDisplayMode.ON_DESKTOP);
s.setMarkAllAsReadConfirmation(true);
s.setMarkAllAsReadNavigateToNextUnread(false);
s.setCustomContextMenu(true);
s.setMobileFooter(false);
s.setUnreadCountTitle(false);
s.setUnreadCountFavicon(true);
s.setDisablePullToRefresh(false);
}
return s;
}
@Path("/settings")
@POST
@Transactional
@Operation(summary = "Save user settings", description = "Save user settings")
public Response saveUserSettings(@Parameter(required = true) Settings settings) {
Preconditions.checkNotNull(settings);
@Path("/settings")
@POST
@Transactional
@Operation(summary = "Save user settings", description = "Save user settings")
public Response saveUserSettings(@Parameter(required = true) Settings settings) {
Preconditions.checkNotNull(settings);
User user = authenticationContext.getCurrentUser();
UserSettings s = userSettingsDAO.findByUser(user);
if (s == null) {
s = new UserSettings();
s.setUser(user);
}
s.setReadingMode(settings.getReadingMode());
s.setReadingOrder(settings.getReadingOrder());
s.setShowRead(settings.isShowRead());
s.setScrollMarks(settings.isScrollMarks());
s.setCustomCss(settings.getCustomCss());
s.setCustomJs(CommaFeedConstants.USERNAME_DEMO.equals(user.getName()) ? "" : settings.getCustomJs());
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setScrollMode(settings.getScrollMode());
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(settings.getStarIconDisplayMode());
s.setExternalLinkIconDisplayMode(settings.getExternalLinkIconDisplayMode());
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());
s.setMarkAllAsReadNavigateToNextUnread(settings.isMarkAllAsReadNavigateToNextUnread());
s.setCustomContextMenu(settings.isCustomContextMenu());
s.setMobileFooter(settings.isMobileFooter());
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setPrimaryColor(settings.getPrimaryColor());
User user = authenticationContext.getCurrentUser();
UserSettings s = userSettingsDAO.findByUser(user);
if (s == null) {
s = new UserSettings();
s.setUser(user);
}
s.setReadingMode(settings.getReadingMode());
s.setReadingOrder(settings.getReadingOrder());
s.setShowRead(settings.isShowRead());
s.setScrollMarks(settings.isScrollMarks());
s.setCustomCss(settings.getCustomCss());
s.setCustomJs(
CommaFeedConstants.USERNAME_DEMO.equals(user.getName())
? ""
: settings.getCustomJs());
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setScrollMode(settings.getScrollMode());
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(settings.getStarIconDisplayMode());
s.setExternalLinkIconDisplayMode(settings.getExternalLinkIconDisplayMode());
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());
s.setMarkAllAsReadNavigateToNextUnread(settings.isMarkAllAsReadNavigateToNextUnread());
s.setCustomContextMenu(settings.isCustomContextMenu());
s.setMobileFooter(settings.isMobileFooter());
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setPrimaryColor(settings.getPrimaryColor());
PushNotificationUserSettings ps = new PushNotificationUserSettings();
ps.setType(settings.getPushNotificationSettings().getType());
ps.setServerUrl(settings.getPushNotificationSettings().getServerUrl());
ps.setUserId(settings.getPushNotificationSettings().getUserId());
ps.setUserSecret(settings.getPushNotificationSettings().getUserSecret());
ps.setTopic(settings.getPushNotificationSettings().getTopic());
s.setPushNotifications(ps);
PushNotificationUserSettings ps = new PushNotificationUserSettings();
ps.setType(settings.getPushNotificationSettings().getType());
ps.setServerUrl(settings.getPushNotificationSettings().getServerUrl());
ps.setUserId(settings.getPushNotificationSettings().getUserId());
ps.setUserSecret(settings.getPushNotificationSettings().getUserSecret());
ps.setTopic(settings.getPushNotificationSettings().getTopic());
s.setPushNotifications(ps);
s.setEmail(settings.getSharingSettings().isEmail());
s.setGmail(settings.getSharingSettings().isGmail());
s.setFacebook(settings.getSharingSettings().isFacebook());
s.setTwitter(settings.getSharingSettings().isTwitter());
s.setTumblr(settings.getSharingSettings().isTumblr());
s.setInstapaper(settings.getSharingSettings().isInstapaper());
s.setBuffer(settings.getSharingSettings().isBuffer());
s.setEmail(settings.getSharingSettings().isEmail());
s.setGmail(settings.getSharingSettings().isGmail());
s.setFacebook(settings.getSharingSettings().isFacebook());
s.setTwitter(settings.getSharingSettings().isTwitter());
s.setTumblr(settings.getSharingSettings().isTumblr());
s.setInstapaper(settings.getSharingSettings().isInstapaper());
s.setBuffer(settings.getSharingSettings().isBuffer());
userSettingsDAO.merge(s);
return Response.ok().build();
userSettingsDAO.merge(s);
return Response.ok().build();
}
}
@Path("/pushNotificationTest")
@POST
@Transactional
@Operation(summary = "Send a test push notification")
public Response sendTestPushNotification(
@Parameter(required = true) PushNotificationSettings settings) {
FeedSubscription sub = new FeedSubscription();
sub.setTitle("CommaFeed Test Feed");
sub.setFeed(new Feed());
@Path("/pushNotificationTest")
@POST
@Transactional
@Operation(summary = "Send a test push notification")
public Response sendTestPushNotification(@Parameter(required = true) PushNotificationSettings settings) {
FeedSubscription sub = new FeedSubscription();
sub.setTitle("CommaFeed Test Feed");
sub.setFeed(new Feed());
FeedEntryContent content = new FeedEntryContent();
content.setTitle("Test Entry");
FeedEntryContent content = new FeedEntryContent();
content.setTitle("Test Entry");
FeedEntry entry = new FeedEntry();
entry.setContent(content);
FeedEntry entry = new FeedEntry();
entry.setContent(content);
PushNotificationUserSettings pushSettings = new PushNotificationUserSettings();
pushSettings.setType(settings.getType());
pushSettings.setServerUrl(settings.getServerUrl());
pushSettings.setUserId(settings.getUserId());
pushSettings.setUserSecret(settings.getUserSecret());
pushSettings.setTopic(settings.getTopic());
PushNotificationUserSettings pushSettings = new PushNotificationUserSettings();
pushSettings.setType(settings.getType());
pushSettings.setServerUrl(settings.getServerUrl());
pushSettings.setUserId(settings.getUserId());
pushSettings.setUserSecret(settings.getUserSecret());
pushSettings.setTopic(settings.getTopic());
try {
pushNotificationService.notify(pushSettings, sub, entry);
} catch (Exception e) {
return Response.status(Status.INTERNAL_SERVER_ERROR)
.entity(e.getCause().getMessage())
.type(MediaType.TEXT_PLAIN)
.build();
}
try {
pushNotificationService.notify(pushSettings, sub, entry);
} catch (Exception e) {
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getCause().getMessage()).type(MediaType.TEXT_PLAIN).build();
}
return Response.ok().build();
}
return Response.ok().build();
}
@Path("/profile")
@GET
@Transactional
@Operation(summary = "Retrieve user's profile")
public UserModel getUserProfile() {
User user = authenticationContext.getCurrentUser();
@Path("/profile")
@GET
@Transactional
@Operation(summary = "Retrieve user's profile")
public UserModel getUserProfile() {
User user = authenticationContext.getCurrentUser();
UserModel userModel = new UserModel();
userModel.setId(user.getId());
userModel.setName(user.getName());
userModel.setEmail(user.getEmail());
userModel.setEnabled(!user.isDisabled());
userModel.setApiKey(user.getApiKey());
userModel.setLastForceRefresh(user.getLastForceRefresh());
for (UserRole role : userRoleDAO.findAll(user)) {
if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true);
}
}
return userModel;
}
UserModel userModel = new UserModel();
userModel.setId(user.getId());
userModel.setName(user.getName());
userModel.setEmail(user.getEmail());
userModel.setEnabled(!user.isDisabled());
userModel.setApiKey(user.getApiKey());
userModel.setLastForceRefresh(user.getLastForceRefresh());
for (UserRole role : userRoleDAO.findAll(user)) {
if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true);
}
}
return userModel;
}
@Path("/profile")
@POST
@Transactional
@Operation(summary = "Save user's profile")
public Response saveUserProfile(
@Valid @Parameter(required = true) ProfileModificationRequest request) {
User user = authenticationContext.getCurrentUser();
if (CommaFeedConstants.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN)
.entity("the profile of the demo account cannot be modified")
.build();
}
@Path("/profile")
@POST
@Transactional
@Operation(summary = "Save user's profile")
public Response saveUserProfile(@Valid @Parameter(required = true) ProfileModificationRequest request) {
User user = authenticationContext.getCurrentUser();
if (CommaFeedConstants.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).entity("the profile of the demo account cannot be modified").build();
}
Optional<User> login = userService.login(user.getName(), request.getCurrentPassword());
if (login.isEmpty()) {
throw new BadRequestException("invalid password");
}
Optional<User> login = userService.login(user.getName(), request.getCurrentPassword());
if (login.isEmpty()) {
throw new BadRequestException("invalid password");
}
String email = StringUtils.trimToNull(request.getEmail());
if (StringUtils.isNotBlank(email)) {
User u = userDAO.findByEmail(email);
if (u != null && !user.getId().equals(u.getId())) {
throw new BadRequestException("email already taken");
}
user.setEmail(email);
}
String email = StringUtils.trimToNull(request.getEmail());
if (StringUtils.isNotBlank(email)) {
User u = userDAO.findByEmail(email);
if (u != null && !user.getId().equals(u.getId())) {
throw new BadRequestException("email already taken");
}
user.setEmail(email);
}
if (StringUtils.isNotBlank(request.getNewPassword())) {
byte[] password =
encryptionService.getEncryptedPassword(
request.getNewPassword(), user.getSalt());
user.setPassword(password);
user.setApiKey(userService.generateApiKey(user));
}
if (StringUtils.isNotBlank(request.getNewPassword())) {
byte[] password = encryptionService.getEncryptedPassword(request.getNewPassword(), user.getSalt());
user.setPassword(password);
user.setApiKey(userService.generateApiKey(user));
}
if (request.isNewApiKey()) {
user.setApiKey(userService.generateApiKey(user));
}
if (request.isNewApiKey()) {
user.setApiKey(userService.generateApiKey(user));
}
userDAO.merge(user);
return Response.ok().build();
}
userDAO.merge(user);
return Response.ok().build();
}
@Path("/register")
@PermitAll
@POST
@Transactional
@Operation(summary = "Register a new account")
public Response registerUser(@Valid @Parameter(required = true) RegistrationRequest req) {
try {
userService.register(
req.getName(),
req.getPassword(),
req.getEmail(),
Collections.singletonList(Role.USER));
return Response.ok().build();
} catch (final IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}
@Path("/register")
@PermitAll
@POST
@Transactional
@Operation(summary = "Register a new account")
public Response registerUser(@Valid @Parameter(required = true) RegistrationRequest req) {
try {
userService.register(req.getName(), req.getPassword(), req.getEmail(), Collections.singletonList(Role.USER));
return Response.ok().build();
} catch (final IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}
@Path("/initialSetup")
@PermitAll
@POST
@Transactional
@Operation(
summary = "Create the initial admin account",
description = "This endpoint is only available when no users exist in the database")
public Response initialSetup(@Valid @Parameter(required = true) InitialSetupRequest req) {
boolean initialSetupRequired = databaseStartupService.isInitialSetupRequired();
if (!initialSetupRequired) {
return Response.status(Status.BAD_REQUEST)
.entity("Initial setup has already been completed")
.build();
}
@Path("/initialSetup")
@PermitAll
@POST
@Transactional
@Operation(
summary = "Create the initial admin account",
description = "This endpoint is only available when no users exist in the database")
public Response initialSetup(@Valid @Parameter(required = true) InitialSetupRequest req) {
boolean initialSetupRequired = databaseStartupService.isInitialSetupRequired();
if (!initialSetupRequired) {
return Response.status(Status.BAD_REQUEST).entity("Initial setup has already been completed").build();
}
userService.register(
req.getName(),
req.getPassword(),
req.getEmail(),
List.of(Role.ADMIN, Role.USER),
true);
userService.register(req.getName(), req.getPassword(), req.getEmail(), List.of(Role.ADMIN, Role.USER), true);
if (config.users().createDemoAccount()) {
User demo = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO);
if (demo == null) {
userService.createDemoUser();
}
}
if (config.users().createDemoAccount()) {
User demo = userDAO.findByName(CommaFeedConstants.USERNAME_DEMO);
if (demo == null) {
userService.createDemoUser();
}
}
return Response.ok().build();
}
return Response.ok().build();
}
@Path("/passwordReset")
@PermitAll
@POST
@Transactional
@Operation(summary = "send a password reset email")
public Response sendPasswordReset(@Valid @Parameter(required = true) PasswordResetRequest req) {
if (!config.passwordRecoveryEnabled()) {
throw new IllegalArgumentException(
"Password recovery is not enabled on this CommaFeed instance");
}
@Path("/passwordReset")
@PermitAll
@POST
@Transactional
@Operation(summary = "send a password reset email")
public Response sendPasswordReset(@Valid @Parameter(required = true) PasswordResetRequest req) {
if (!config.passwordRecoveryEnabled()) {
throw new IllegalArgumentException("Password recovery is not enabled on this CommaFeed instance");
}
User user = userDAO.findByEmail(req.getEmail());
if (user == null) {
return Response.ok().build();
}
User user = userDAO.findByEmail(req.getEmail());
if (user == null) {
return Response.ok().build();
}
try {
user.setRecoverPasswordToken(Digests.sha1Hex(UUID.randomUUID().toString()));
user.setRecoverPasswordTokenDate(Instant.now());
try {
user.setRecoverPasswordToken(Digests.sha1Hex(UUID.randomUUID().toString()));
user.setRecoverPasswordTokenDate(Instant.now());
mailService.sendMail(user, "Password recovery", buildEmailContent(user));
return Response.ok().build();
} catch (Exception e) {
log.error(e.getMessage(), e);
return Response.status(Status.INTERNAL_SERVER_ERROR)
.entity("could not send email")
.type(MediaType.TEXT_PLAIN)
.build();
}
}
mailService.sendMail(user, "Password recovery", buildEmailContent(user));
return Response.ok().build();
} catch (Exception e) {
log.error(e.getMessage(), e);
return Response.status(Status.INTERNAL_SERVER_ERROR).entity("could not send email").type(MediaType.TEXT_PLAIN).build();
}
}
private String buildEmailContent(User user) throws URISyntaxException {
String publicUrl = Urls.removeTrailingSlash(uri.getBaseUri().toString());
return String.format(
"You asked for password recovery for account '%s', <a href='%s'>follow this link</a> to change your password. Ignore this if you didn't request a password recovery.",
user.getName(), callbackUrl(user, publicUrl));
}
private String buildEmailContent(User user) throws URISyntaxException {
String publicUrl = Urls.removeTrailingSlash(uri.getBaseUri().toString());
return String.format(
"You asked for password recovery for account '%s', <a href='%s'>follow this link</a> to change your password. Ignore this if you didn't request a password recovery.",
user.getName(), callbackUrl(user, publicUrl));
}
private String callbackUrl(User user, String publicUrl) throws URISyntaxException {
URIBuilder queryBuilder = new URIBuilder();
queryBuilder.addParameter("email", user.getEmail());
queryBuilder.addParameter("token", user.getRecoverPasswordToken());
String queryString = queryBuilder.build().getRawQuery();
return publicUrl + "/#/passwordReset?" + queryString;
}
private String callbackUrl(User user, String publicUrl) throws URISyntaxException {
URIBuilder queryBuilder = new URIBuilder();
queryBuilder.addParameter("email", user.getEmail());
queryBuilder.addParameter("token", user.getRecoverPasswordToken());
String queryString = queryBuilder.build().getRawQuery();
return publicUrl + "/#/passwordReset?" + queryString;
}
@Path("/passwordResetCallback")
@PermitAll
@POST
@Transactional
@Operation(summary = "confirm password reset with new password")
public Response passwordRecoveryCallback(
@Valid @Parameter(required = true) PasswordResetConfirmationRequest req) {
String email = req.getEmail();
String token = req.getToken();
String password = req.getPassword();
@Path("/passwordResetCallback")
@PermitAll
@POST
@Transactional
@Operation(summary = "confirm password reset with new password")
public Response passwordRecoveryCallback(@Valid @Parameter(required = true) PasswordResetConfirmationRequest req) {
String email = req.getEmail();
String token = req.getToken();
String password = req.getPassword();
Preconditions.checkNotNull(email);
Preconditions.checkNotNull(token);
Preconditions.checkNotNull(password);
Preconditions.checkNotNull(email);
Preconditions.checkNotNull(token);
Preconditions.checkNotNull(password);
User user = userDAO.findByEmail(email);
if (user == null
|| user.getRecoverPasswordToken() == null
|| !user.getRecoverPasswordToken().equals(token)) {
return Response.status(Status.UNAUTHORIZED)
.entity("Email not found or invalid token.")
.build();
}
if (ChronoUnit.MINUTES.between(user.getRecoverPasswordTokenDate(), Instant.now()) >= 30) {
return Response.status(Status.UNAUTHORIZED).entity("Token expired.").build();
}
User user = userDAO.findByEmail(email);
if (user == null || user.getRecoverPasswordToken() == null || !user.getRecoverPasswordToken().equals(token)) {
return Response.status(Status.UNAUTHORIZED).entity("Email not found or invalid token.").build();
}
if (ChronoUnit.MINUTES.between(user.getRecoverPasswordTokenDate(), Instant.now()) >= 30) {
return Response.status(Status.UNAUTHORIZED).entity("Token expired.").build();
}
byte[] encryptedPassword = encryptionService.getEncryptedPassword(password, user.getSalt());
user.setPassword(encryptedPassword);
if (StringUtils.isNotBlank(user.getApiKey())) {
user.setApiKey(userService.generateApiKey(user));
}
user.setRecoverPasswordToken(null);
user.setRecoverPasswordTokenDate(null);
byte[] encryptedPassword = encryptionService.getEncryptedPassword(password, user.getSalt());
user.setPassword(encryptedPassword);
if (StringUtils.isNotBlank(user.getApiKey())) {
user.setApiKey(userService.generateApiKey(user));
}
user.setRecoverPasswordToken(null);
user.setRecoverPasswordTokenDate(null);
return Response.ok().build();
}
return Response.ok().build();
}
@Path("/profile/deleteAccount")
@POST
@Transactional
@Operation(summary = "Delete the user account")
public Response deleteUser() {
User user = authenticationContext.getCurrentUser();
if (CommaFeedConstants.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN)
.entity("the demo account cannot be deleted")
.build();
}
@Path("/profile/deleteAccount")
@POST
@Transactional
@Operation(summary = "Delete the user account")
public Response deleteUser() {
User user = authenticationContext.getCurrentUser();
if (CommaFeedConstants.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).entity("the demo account cannot be deleted").build();
}
Set<Role> roles = userRoleDAO.findRoles(user);
if (roles.contains(Role.ADMIN) && userRoleDAO.countAdmins() == 1) {
return Response.status(Status.FORBIDDEN)
.entity("The last admin account cannot be deleted")
.build();
}
Set<Role> roles = userRoleDAO.findRoles(user);
if (roles.contains(Role.ADMIN) && userRoleDAO.countAdmins() == 1) {
return Response.status(Status.FORBIDDEN).entity("The last admin account cannot be deleted").build();
}
userService.unregister(userDAO.findById(user.getId()));
return Response.ok().build();
}
userService.unregister(userDAO.findById(user.getId()));
return Response.ok().build();
}
}

View File

@@ -1,37 +1,5 @@
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.HashMap;
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 jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
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.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.UriInfo;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.jboss.resteasy.reactive.server.multipart.FormValue;
import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
@@ -52,18 +20,46 @@ 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 jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
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.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.UriInfo;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
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 lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.jboss.resteasy.reactive.server.multipart.FormValue;
import org.jboss.resteasy.reactive.server.multipart.MultipartFormDataInput;
/**
* Fever-compatible API
*
*
* <ul>
* <li>url: /rest/fever/user/${userId}</li>
* <li>login: username</li>
* <li>password: api key</li>
* <li>url: /rest/fever/user/${userId}
* <li>login: username
* <li>password: api key
* </ul>
*
*
* See https://feedafever.com/api
*/
@Path("/rest/fever")
@@ -73,264 +69,336 @@ import lombok.RequiredArgsConstructor;
@Singleton
public class FeverREST {
private static final String PATH = "/user/{userId}{optionalTrailingFever : (/fever)?}{optionalTrailingSlash : (/)?}";
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 static final String PATH =
"/user/{userId}{optionalTrailingFever : (/fever)?}{optionalTrailingSlash : (/)?}";
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 FeedFaviconService feedFaviconService;
private final FeedEntryDAO feedEntryDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final UserService userService;
private final FeedEntryService feedEntryService;
private final FeedFaviconService feedFaviconService;
private final FeedEntryDAO feedEntryDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
// expected Fever API
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path(PATH)
@POST
@Transactional
@Operation(hidden = true)
public FeverResponse formUrlencoded(@Context UriInfo uri, @PathParam("userId") Long userId, 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()));
return handle(userId, params);
}
// expected Fever API
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path(PATH)
@POST
@Transactional
@Operation(hidden = true)
public FeverResponse formUrlencoded(
@Context UriInfo uri,
@PathParam("userId") Long userId,
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()));
return handle(userId, params);
}
// workaround for some readers that post data without any media type, and all params in the url
// e.g. FeedMe
@Path(PATH)
@POST
@Transactional
@Operation(hidden = true)
public FeverResponse noForm(@Context UriInfo uri, @PathParam("userId") Long userId) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
return handle(userId, params);
}
// workaround for some readers that post data without any media type, and all params in the url
// e.g. FeedMe
@Path(PATH)
@POST
@Transactional
@Operation(hidden = true)
public FeverResponse noForm(@Context UriInfo uri, @PathParam("userId") Long userId) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
return handle(userId, params);
}
// workaround for some readers that use GET instead of POST
// e.g. Unread
@Path(PATH)
@GET
@Transactional
@Operation(hidden = true)
public FeverResponse get(@Context UriInfo uri, @PathParam("userId") Long userId) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
return handle(userId, params);
}
// workaround for some readers that use GET instead of POST
// e.g. Unread
@Path(PATH)
@GET
@Transactional
@Operation(hidden = true)
public FeverResponse get(@Context UriInfo uri, @PathParam("userId") Long userId) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
return handle(userId, params);
}
// workaround for some readers that post data using MultiPart FormData instead of the classic POST
// e.g. Raven Reader
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Path(PATH)
@POST
@Transactional
@Operation(hidden = true)
public FeverResponse formData(@Context UriInfo uri, @PathParam("userId") Long userId, MultipartFormDataInput form) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
form.getValues().forEach((k, v) -> params.put(k, v.stream().map(FormValue::getValue).findFirst().orElse(null)));
return handle(userId, params);
}
// workaround for some readers that post data using MultiPart FormData instead of the classic
// POST
// e.g. Raven Reader
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Path(PATH)
@POST
@Transactional
@Operation(hidden = true)
public FeverResponse formData(
@Context UriInfo uri, @PathParam("userId") Long userId, MultipartFormDataInput form) {
Map<String, String> params = new HashMap<>();
uri.getQueryParameters().forEach((k, v) -> params.put(k, v.getFirst()));
form.getValues()
.forEach(
(k, v) ->
params.put(
k,
v.stream()
.map(FormValue::getValue)
.findFirst()
.orElse(null)));
return handle(userId, params);
}
public FeverResponse handle(long userId, Map<String, String> params) {
User user = auth(userId, params.get("api_key")).orElse(null);
if (user == null) {
FeverResponse resp = new FeverResponse();
resp.setAuth(false);
return resp;
}
public FeverResponse handle(long userId, Map<String, String> params) {
User user = auth(userId, params.get("api_key")).orElse(null);
if (user == null) {
FeverResponse resp = new FeverResponse();
resp.setAuth(false);
return resp;
}
FeverResponse resp = new FeverResponse();
resp.setAuth(true);
FeverResponse resp = new FeverResponse();
resp.setAuth(true);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findAll(user);
resp.setLastRefreshedOnTime(buildLastRefreshedOnTime(subscriptions));
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findAll(user);
resp.setLastRefreshedOnTime(buildLastRefreshedOnTime(subscriptions));
if (params.containsKey("groups") || params.containsKey("feeds")) {
resp.setFeedsGroups(buildFeedsGroups(subscriptions));
if (params.containsKey("groups") || params.containsKey("feeds")) {
resp.setFeedsGroups(buildFeedsGroups(subscriptions));
if (params.containsKey("groups")) {
List<FeedCategory> categories = feedCategoryDAO.findAll(user);
resp.setGroups(buildGroups(categories));
}
if (params.containsKey("groups")) {
List<FeedCategory> categories = feedCategoryDAO.findAll(user);
resp.setGroups(buildGroups(categories));
}
if (params.containsKey("feeds")) {
resp.setFeeds(buildFeeds(subscriptions));
}
}
if (params.containsKey("feeds")) {
resp.setFeeds(buildFeeds(subscriptions));
}
}
if (params.containsKey("unread_item_ids")) {
resp.setUnreadItemIds(buildUnreadItemIds(user, subscriptions));
}
if (params.containsKey("unread_item_ids")) {
resp.setUnreadItemIds(buildUnreadItemIds(user, subscriptions));
}
if (params.containsKey("saved_item_ids")) {
resp.setSavedItemIds(buildSavedItemIds(user));
}
if (params.containsKey("saved_item_ids")) {
resp.setSavedItemIds(buildSavedItemIds(user));
}
if (params.containsKey("items")) {
if (params.containsKey("with_ids")) {
String withIds = params.get("with_ids");
List<String> entryIds = Stream.of(withIds.split(",")).map(String::trim).toList();
resp.setItems(buildItems(user, subscriptions, entryIds));
} else {
Long sinceId = Optional.ofNullable(params.get("since_id")).filter(StringUtils::isNotBlank).map(Long::valueOf).orElse(null);
Long maxId = Optional.ofNullable(params.get("max_id")).filter(StringUtils::isNotBlank).map(Long::valueOf).orElse(null);
resp.setItems(buildItems(user, subscriptions, sinceId, maxId));
}
}
if (params.containsKey("items")) {
if (params.containsKey("with_ids")) {
String withIds = params.get("with_ids");
List<String> entryIds = Stream.of(withIds.split(",")).map(String::trim).toList();
resp.setItems(buildItems(user, subscriptions, entryIds));
} else {
Long sinceId =
Optional.ofNullable(params.get("since_id"))
.filter(StringUtils::isNotBlank)
.map(Long::valueOf)
.orElse(null);
Long maxId =
Optional.ofNullable(params.get("max_id"))
.filter(StringUtils::isNotBlank)
.map(Long::valueOf)
.orElse(null);
resp.setItems(buildItems(user, subscriptions, sinceId, maxId));
}
}
if (params.containsKey("favicons")) {
resp.setFavicons(buildFavicons(subscriptions));
}
if (params.containsKey("favicons")) {
resp.setFavicons(buildFavicons(subscriptions));
}
if (params.containsKey("links")) {
resp.setLinks(Collections.emptyList());
}
if (params.containsKey("links")) {
resp.setLinks(Collections.emptyList());
}
if (params.containsKey("mark") && params.containsKey("id") && params.containsKey("as")) {
long id = Long.parseLong(params.get("id"));
String before = params.get("before");
Instant insertedBefore = before == null ? null : Instant.ofEpochSecond(Long.parseLong(before));
mark(user, params.get("mark"), id, params.get("as"), insertedBefore);
}
if (params.containsKey("mark") && params.containsKey("id") && params.containsKey("as")) {
long id = Long.parseLong(params.get("id"));
String before = params.get("before");
Instant insertedBefore =
before == null ? null : Instant.ofEpochSecond(Long.parseLong(before));
mark(user, params.get("mark"), id, params.get("as"), insertedBefore);
}
return resp;
}
return resp;
}
private Optional<User> auth(Long userId, String feverApiKey) {
return userService.login(userId, feverApiKey);
}
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(Instant::getEpochSecond)
.orElse(0L);
}
private long buildLastRefreshedOnTime(List<FeedSubscription> subscriptions) {
return subscriptions.stream()
.map(FeedSubscription::getFeed)
.map(Feed::getLastUpdated)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.map(Instant::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).toList());
return fg;
})
.toList();
}
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).toList());
return fg;
})
.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;
}).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;
})
.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().getEpochSecond());
return f;
}).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().getEpochSecond());
return f;
})
.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, null, null, null);
return statuses.stream().map(s -> s.getEntry().getId()).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,
null,
null,
null);
return statuses.stream().map(s -> s.getEntry().getId()).toList();
}
private List<Long> buildSavedItemIds(User user) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findStarred(user, null, null, 0, SAVED_ITEM_IDS_BATCH_SIZE, ReadingOrder.DESC,
false);
return statuses.stream().map(s -> s.getEntry().getId()).toList();
}
private List<Long> buildSavedItemIds(User user) {
List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findStarred(
user, null, null, 0, SAVED_ITEM_IDS_BATCH_SIZE, ReadingOrder.DESC, false);
return statuses.stream().map(s -> s.getEntry().getId()).toList();
}
private List<FeverItem> buildItems(User user, List<FeedSubscription> subscriptions, List<String> entryIds) {
List<FeverItem> items = new ArrayList<>();
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));
}
}
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;
}
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, null, sinceId, maxId);
return statuses.stream().map(this::mapStatus).toList();
}
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,
null,
sinceId,
maxId);
return statuses.stream().map(this::mapStatus).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(Optional.ofNullable(s.getEntry().getContent().getContent()).orElse(""));
i.setUrl(s.getEntry().getUrl());
i.setSaved(s.isStarred());
i.setRead(s.isRead());
i.setCreatedOnTime(s.getEntryPublished().getEpochSecond());
return i;
}
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(Optional.ofNullable(s.getEntry().getContent().getContent()).orElse(""));
i.setUrl(s.getEntry().getUrl());
i.setSaved(s.isStarred());
i.setRead(s.isRead());
i.setCreatedOnTime(s.getEntryPublished().getEpochSecond());
return i;
}
private List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) {
return subscriptions.stream().map(s -> {
Favicon favicon = feedFaviconService.fetchFavicon(s.getFeed());
private List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) {
return subscriptions.stream()
.map(
s -> {
Favicon favicon = feedFaviconService.fetchFavicon(s.getFeed());
FeverFavicon f = new FeverFavicon();
f.setId(s.getFeed().getId());
f.setData(String.format("data:%s;base64,%s", favicon.mediaType(), Base64.getEncoder().encodeToString(favicon.icon())));
return f;
}).toList();
}
private void mark(User user, String source, long id, String action, Instant insertedBefore) {
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), null, insertedBefore, 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, null, insertedBefore, null);
}
}
FeverFavicon f = new FeverFavicon();
f.setId(s.getFeed().getId());
f.setData(
String.format(
"data:%s;base64,%s",
favicon.mediaType(),
Base64.getEncoder().encodeToString(favicon.icon())));
return f;
})
.toList();
}
private void mark(User user, String source, long id, String action, Instant insertedBefore) {
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), null, insertedBefore, 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, null, insertedBefore, null);
}
}
}

View File

@@ -1,10 +1,5 @@
package com.commafeed.frontend.resource.fever;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonFormat.Shape;
import com.fasterxml.jackson.annotation.JsonInclude;
@@ -18,161 +13,168 @@ import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.Data;
@JsonInclude(Include.NON_NULL)
@Data
public class FeverResponse {
@JsonProperty("api_version")
private int apiVersion = 3;
@JsonProperty("api_version")
private int apiVersion = 3;
@JsonProperty("auth")
@JsonFormat(shape = Shape.NUMBER)
private boolean auth;
@JsonProperty("auth")
@JsonFormat(shape = Shape.NUMBER)
private boolean auth;
@JsonProperty("last_refreshed_on_time")
private Long lastRefreshedOnTime;
@JsonProperty("last_refreshed_on_time")
private Long lastRefreshedOnTime;
@JsonProperty("groups")
private List<FeverGroup> groups;
@JsonProperty("groups")
private List<FeverGroup> groups;
@JsonProperty("feeds")
private List<FeverFeed> feeds;
@JsonProperty("feeds")
private List<FeverFeed> feeds;
@JsonProperty("feeds_groups")
private List<FeverFeedGroup> feedsGroups;
@JsonProperty("feeds_groups")
private List<FeverFeedGroup> feedsGroups;
@JsonProperty("unread_item_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class)
private List<Long> unreadItemIds;
@JsonProperty("unread_item_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class)
private List<Long> unreadItemIds;
@JsonProperty("saved_item_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class)
private List<Long> savedItemIds;
@JsonProperty("saved_item_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class)
private List<Long> savedItemIds;
@JsonProperty("items")
private List<FeverItem> items;
@JsonProperty("items")
private List<FeverItem> items;
@JsonProperty("favicons")
private List<FeverFavicon> favicons;
@JsonProperty("favicons")
private List<FeverFavicon> favicons;
@JsonProperty("links")
private List<FeverLink> links;
@JsonProperty("links")
private List<FeverLink> links;
@Data
public static class FeverGroup {
@Data
public static class FeverGroup {
@JsonProperty("id")
private long id;
@JsonProperty("id")
private long id;
@JsonProperty("title")
private String title;
}
@JsonProperty("title")
private String title;
}
@Data
public static class FeverFeed {
@Data
public static class FeverFeed {
@JsonProperty("id")
private long id;
@JsonProperty("id")
private long id;
@JsonProperty("favicon_id")
private long faviconId;
@JsonProperty("favicon_id")
private long faviconId;
@JsonProperty("title")
private String title;
@JsonProperty("title")
private String title;
@JsonProperty("url")
private String url;
@JsonProperty("url")
private String url;
@JsonProperty("site_url")
private String siteUrl;
@JsonProperty("site_url")
private String siteUrl;
@JsonProperty("is_spark")
@JsonFormat(shape = Shape.NUMBER)
private boolean spark;
@JsonProperty("is_spark")
@JsonFormat(shape = Shape.NUMBER)
private boolean spark;
@JsonProperty("last_updated_on_time")
private long lastUpdatedOnTime;
}
@JsonProperty("last_updated_on_time")
private long lastUpdatedOnTime;
}
@Data
public static class FeverFeedGroup {
@Data
public static class FeverFeedGroup {
@JsonProperty("group_id")
private long groupId;
@JsonProperty("group_id")
private long groupId;
@JsonProperty("feed_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class)
private List<Long> feedIds;
}
@JsonProperty("feed_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class)
private List<Long> feedIds;
}
@Data
public static class FeverItem {
@Data
public static class FeverItem {
@JsonProperty("id")
private long id;
@JsonProperty("id")
private long id;
@JsonProperty("feed_id")
private long feedId;
@JsonProperty("feed_id")
private long feedId;
@JsonProperty("title")
private String title;
@JsonProperty("title")
private String title;
@JsonProperty("author")
private String author;
@JsonProperty("author")
private String author;
@JsonProperty("html")
private String html;
@JsonProperty("html")
private String html;
@JsonProperty("url")
private String url;
@JsonProperty("url")
private String url;
@JsonProperty("is_saved")
@JsonFormat(shape = Shape.NUMBER)
private boolean saved;
@JsonProperty("is_saved")
@JsonFormat(shape = Shape.NUMBER)
private boolean saved;
@JsonProperty("is_read")
@JsonFormat(shape = Shape.NUMBER)
private boolean read;
@JsonProperty("is_read")
@JsonFormat(shape = Shape.NUMBER)
private boolean read;
@JsonProperty("created_on_time")
private long createdOnTime;
@JsonProperty("created_on_time")
private long createdOnTime;
}
}
@Data
public static class FeverFavicon {
@Data
public static class FeverFavicon {
@JsonProperty("id")
private long id;
@JsonProperty("id")
private long id;
@JsonProperty("data")
private String data;
}
@JsonProperty("data")
private String data;
}
@Data
public static class FeverLink {}
@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);
}
}
}
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);
}
}
public static class CommaSeparatedStringToLongListDeserializer extends JsonDeserializer<List<Long>> {
@Override
public List<Long> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
String value = ctxt.readValue(p, String.class);
return Stream.of(value.split(",")).map(Long::valueOf).toList();
}
}
public static class CommaSeparatedStringToLongListDeserializer
extends JsonDeserializer<List<Long>> {
@Override
public List<Long> deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
String value = ctxt.readValue(p, String.class);
return Stream.of(value.split(",")).map(Long::valueOf).toList();
}
}
}

View File

@@ -1,19 +1,16 @@
package com.commafeed.frontend.servlet;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import org.eclipse.microprofile.openapi.annotations.Operation;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.security.AuthenticationContext;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import lombok.RequiredArgsConstructor;
import org.eclipse.microprofile.openapi.annotations.Operation;
@Path("/custom_css.css")
@Produces("text/css")
@@ -21,24 +18,23 @@ import lombok.RequiredArgsConstructor;
@Singleton
public class CustomCssServlet {
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
@GET
@Transactional
@Operation(hidden = true)
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
@GET
@Transactional
@Operation(hidden = true)
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings == null) {
return "";
}
return settings.getCustomCss();
}
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings == null) {
return "";
}
return settings.getCustomCss();
}
}

View File

@@ -1,19 +1,16 @@
package com.commafeed.frontend.servlet;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import org.eclipse.microprofile.openapi.annotations.Operation;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.security.AuthenticationContext;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import lombok.RequiredArgsConstructor;
import org.eclipse.microprofile.openapi.annotations.Operation;
@Path("/custom_js.js")
@Produces("application/javascript")
@@ -21,24 +18,23 @@ import lombok.RequiredArgsConstructor;
@Singleton
public class CustomJsServlet {
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
@GET
@Transactional
@Operation(hidden = true)
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
@GET
@Transactional
@Operation(hidden = true)
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings == null) {
return "";
}
return settings.getCustomJs();
}
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings == null) {
return "";
}
return settings.getCustomJs();
}
}

View File

@@ -1,5 +1,6 @@
package com.commafeed.frontend.servlet;
import com.commafeed.security.CookieService;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.ws.rs.GET;
@@ -7,12 +8,8 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.eclipse.microprofile.openapi.annotations.Operation;
import com.commafeed.security.CookieService;
import lombok.RequiredArgsConstructor;
import org.eclipse.microprofile.openapi.annotations.Operation;
@RequiredArgsConstructor
@Path("/logout")
@@ -20,13 +17,13 @@ import lombok.RequiredArgsConstructor;
@Singleton
public class LogoutServlet {
private final UriInfo uri;
private final CookieService cookieService;
private final UriInfo uri;
private final CookieService cookieService;
@GET
@Operation(hidden = true)
public Response get() {
NewCookie removeCookie = cookieService.buildLogoutCookie();
return Response.temporaryRedirect(uri.getBaseUri()).cookie(removeCookie).build();
}
@GET
@Operation(hidden = true)
public Response get() {
NewCookie removeCookie = cookieService.buildLogoutCookie();
return Response.temporaryRedirect(uri.getBaseUri()).cookie(removeCookie).build();
}
}

View File

@@ -1,20 +1,5 @@
package com.commafeed.frontend.servlet;
import java.net.URI;
import java.util.List;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.microprofile.openapi.annotations.Operation;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
@@ -26,51 +11,79 @@ import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.frontend.resource.CategoryREST;
import com.commafeed.security.AuthenticationContext;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.microprofile.openapi.annotations.Operation;
@Path("/next")
@RequiredArgsConstructor
@Singleton
public class NextUnreadServlet {
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryService feedEntryService;
private final AuthenticationContext authenticationContext;
private final UriInfo uri;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryService feedEntryService;
private final AuthenticationContext authenticationContext;
private final UriInfo uri;
@GET
@Transactional
@Operation(hidden = true)
public Response get(@QueryParam("category") String categoryId, @QueryParam("order") @DefaultValue("desc") ReadingOrder order) {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return Response.temporaryRedirect(uri.getBaseUri()).build();
}
@GET
@Transactional
@Operation(hidden = true)
public Response get(
@QueryParam("category") String categoryId,
@QueryParam("order") @DefaultValue("desc") ReadingOrder order) {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return Response.temporaryRedirect(uri.getBaseUri()).build();
}
FeedEntryStatus s = null;
if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subs, true, null, null, 0, 1, order, true, null,
null, null);
s = statuses.stream().findFirst().orElse(null);
} else {
FeedCategory category = feedCategoryDAO.findById(user, Long.valueOf(categoryId));
if (category != null) {
List<FeedCategory> children = feedCategoryDAO.findAllChildrenCategories(user, category);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user, children);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0, 1, order,
true, null, null, null);
s = statuses.stream().findFirst().orElse(null);
}
}
if (s != null) {
feedEntryService.markEntry(user, s.getEntry().getId(), true);
}
FeedEntryStatus s = null;
if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findBySubscriptions(
user, subs, true, null, null, 0, 1, order, true, null, null, null);
s = statuses.stream().findFirst().orElse(null);
} else {
FeedCategory category = feedCategoryDAO.findById(user, Long.valueOf(categoryId));
if (category != null) {
List<FeedCategory> children =
feedCategoryDAO.findAllChildrenCategories(user, category);
List<FeedSubscription> subscriptions =
feedSubscriptionDAO.findByCategories(user, children);
List<FeedEntryStatus> statuses =
feedEntryStatusDAO.findBySubscriptions(
user,
subscriptions,
true,
null,
null,
0,
1,
order,
true,
null,
null,
null);
s = statuses.stream().findFirst().orElse(null);
}
}
if (s != null) {
feedEntryService.markEntry(user, s.getEntry().getId(), true);
}
String url = s == null ? uri.getBaseUri().toString() : s.getEntry().getUrl();
return Response.temporaryRedirect(URI.create(url)).build();
}
String url = s == null ? uri.getBaseUri().toString() : s.getEntry().getUrl();
return Response.temporaryRedirect(URI.create(url)).build();
}
}

View File

@@ -1,5 +1,6 @@
package com.commafeed.frontend.servlet;
import com.commafeed.CommaFeedConfiguration;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.ws.rs.GET;
@@ -7,14 +8,10 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.hc.core5.http.HttpStatus;
import org.eclipse.microprofile.openapi.annotations.Operation;
import com.commafeed.CommaFeedConfiguration;
import lombok.RequiredArgsConstructor;
@Path("/robots.txt")
@PermitAll
@Produces(MediaType.TEXT_PLAIN)
@@ -22,15 +19,15 @@ import lombok.RequiredArgsConstructor;
@Singleton
public class RobotsTxtDisallowAllServlet {
private final CommaFeedConfiguration config;
private final CommaFeedConfiguration config;
@GET
@Operation(hidden = true)
public Response get() {
if (config.hideFromWebCrawlers()) {
return Response.ok("User-agent: *\nDisallow: /").build();
} else {
return Response.status(HttpStatus.SC_NOT_FOUND).build();
}
}
}
@GET
@Operation(hidden = true)
public Response get() {
if (config.hideFromWebCrawlers()) {
return Response.ok("User-agent: *\nDisallow: /").build();
} else {
return Response.status(HttpStatus.SC_NOT_FOUND).build();
}
}
}

View File

@@ -1,7 +1,8 @@
package com.commafeed.frontend.ws;
import java.io.IOException;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.User;
import com.commafeed.security.AuthenticationContext;
import jakarta.inject.Singleton;
import jakarta.websocket.CloseReason;
import jakarta.websocket.CloseReason.CloseCodes;
@@ -10,11 +11,7 @@ import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.model.User;
import com.commafeed.security.AuthenticationContext;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -24,37 +21,36 @@ import lombok.extern.slf4j.Slf4j;
@RequiredArgsConstructor
public class WebSocketEndpoint {
private final AuthenticationContext authenticationContext;
private final CommaFeedConfiguration config;
private final WebSocketSessions sessions;
private final AuthenticationContext authenticationContext;
private final CommaFeedConfiguration config;
private final WebSocketSessions sessions;
@OnOpen
public void onOpen(Session session) throws IOException {
User user = authenticationContext.getCurrentUser();
if (user == null) {
reject(session);
return;
}
@OnOpen
public void onOpen(Session session) throws IOException {
User user = authenticationContext.getCurrentUser();
if (user == null) {
reject(session);
return;
}
log.debug("created websocket session for user '{}'", user.getName());
sessions.add(user.getId(), session);
session.setMaxIdleTimeout(config.websocket().pingInterval().toMillis() + 10000);
}
log.debug("created websocket session for user '{}'", user.getName());
sessions.add(user.getId(), session);
session.setMaxIdleTimeout(config.websocket().pingInterval().toMillis() + 10000);
}
@OnMessage
public void onMessage(String message, Session session) {
if ("ping".equals(message)) {
session.getAsyncRemote().sendText("pong");
}
}
@OnMessage
public void onMessage(String message, Session session) {
if ("ping".equals(message)) {
session.getAsyncRemote().sendText("pong");
}
}
@OnClose
public void onClose(Session session) {
sessions.remove(session);
}
private void reject(Session session) throws IOException {
session.close(new CloseReason(CloseCodes.VIOLATED_POLICY, "unauthorized"));
}
@OnClose
public void onClose(Session session) {
sessions.remove(session);
}
private void reject(Session session) throws IOException {
session.close(new CloseReason(CloseCodes.VIOLATED_POLICY, "unauthorized"));
}
}

View File

@@ -1,14 +1,12 @@
package com.commafeed.frontend.ws;
import com.commafeed.backend.model.FeedSubscription;
import lombok.experimental.UtilityClass;
@UtilityClass
public class WebSocketMessageBuilder {
public static String newFeedEntries(FeedSubscription subscription, long count) {
return String.format("%s:%s:%s", "new-feed-entries", subscription.getId(), count);
}
public static String newFeedEntries(FeedSubscription subscription, long count) {
return String.format("%s:%s:%s", "new-feed-entries", subscription.getId(), count);
}
}

View File

@@ -1,49 +1,52 @@
package com.commafeed.frontend.ws;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import jakarta.inject.Singleton;
import jakarta.websocket.Session;
import com.codahale.metrics.Gauge;
import com.codahale.metrics.MetricRegistry;
import com.commafeed.backend.model.User;
import jakarta.inject.Singleton;
import jakarta.websocket.Session;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.slf4j.Slf4j;
@Singleton
@Slf4j
public class WebSocketSessions {
// a user may have multiple sessions (two tabs, two devices, ...)
private final Map<Long, Set<Session>> sessions = new ConcurrentHashMap<>();
// a user may have multiple sessions (two tabs, two devices, ...)
private final Map<Long, Set<Session>> sessions = new ConcurrentHashMap<>();
public WebSocketSessions(MetricRegistry metrics) {
metrics.register(MetricRegistry.name(getClass(), "users"),
(Gauge<Long>) () -> sessions.values().stream().filter(v -> !v.isEmpty()).count());
metrics.register(MetricRegistry.name(getClass(), "sessions"),
(Gauge<Long>) () -> sessions.values().stream().mapToLong(Set::size).sum());
}
public WebSocketSessions(MetricRegistry metrics) {
metrics.register(
MetricRegistry.name(getClass(), "users"),
(Gauge<Long>) () -> sessions.values().stream().filter(v -> !v.isEmpty()).count());
metrics.register(
MetricRegistry.name(getClass(), "sessions"),
(Gauge<Long>) () -> sessions.values().stream().mapToLong(Set::size).sum());
}
public void add(Long userId, Session session) {
sessions.computeIfAbsent(userId, v -> ConcurrentHashMap.newKeySet()).add(session);
}
public void add(Long userId, Session session) {
sessions.computeIfAbsent(userId, v -> ConcurrentHashMap.newKeySet()).add(session);
}
public void remove(Session session) {
sessions.values().forEach(v -> v.remove(session));
}
public void remove(Session session) {
sessions.values().forEach(v -> v.remove(session));
}
public void sendMessage(User user, String text) {
Set<Session> userSessions = sessions.get(user.getId());
if (userSessions != null && !userSessions.isEmpty()) {
log.debug("sending '{}' to user {} via websocket ({} sessions)", text, user.getId(), userSessions.size());
for (Session userSession : userSessions) {
if (userSession.isOpen()) {
userSession.getAsyncRemote().sendText(text);
}
}
}
}
public void sendMessage(User user, String text) {
Set<Session> userSessions = sessions.get(user.getId());
if (userSessions != null && !userSessions.isEmpty()) {
log.debug(
"sending '{}' to user {} via websocket ({} sessions)",
text,
user.getId(),
userSessions.size());
for (Session userSession : userSessions) {
if (userSession.isOpen()) {
userSession.getAsyncRemote().sendText(text);
}
}
}
}
}