normalize line endings

This commit is contained in:
Athou
2025-03-10 08:38:21 +01:00
parent ec4554c76e
commit fb7f041454
223 changed files with 18091 additions and 18093 deletions

View File

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

View File

@@ -1,50 +1,50 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "List of entries with some metadata")
@Data
@RegisterForReflection
public class Entries implements Serializable {
@Schema(description = "name of the feed or the category requested", requiredMode = RequiredMode.REQUIRED)
private String name;
@Schema(description = "error or warning message")
private String message;
@Schema(description = "times the server tried to refresh the feed and failed", requiredMode = RequiredMode.REQUIRED)
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 = "list generation timestamp", requiredMode = RequiredMode.REQUIRED)
private long timestamp;
@Schema(description = "if the query has more elements", requiredMode = RequiredMode.REQUIRED)
private boolean hasMore;
@Schema(description = "the requested offset")
private int offset;
@Schema(description = "the requested limit")
private int limit;
@Schema(description = "list of entries", requiredMode = RequiredMode.REQUIRED)
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",
requiredMode = RequiredMode.REQUIRED)
private boolean ignoredReadStatus;
}
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "List of entries with some metadata")
@Data
@RegisterForReflection
public class Entries implements Serializable {
@Schema(description = "name of the feed or the category requested", requiredMode = RequiredMode.REQUIRED)
private String name;
@Schema(description = "error or warning message")
private String message;
@Schema(description = "times the server tried to refresh the feed and failed", requiredMode = RequiredMode.REQUIRED)
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 = "list generation timestamp", requiredMode = RequiredMode.REQUIRED)
private long timestamp;
@Schema(description = "if the query has more elements", requiredMode = RequiredMode.REQUIRED)
private boolean hasMore;
@Schema(description = "the requested offset")
private int offset;
@Schema(description = "the requested limit")
private int limit;
@Schema(description = "list of entries", requiredMode = RequiredMode.REQUIRED)
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",
requiredMode = RequiredMode.REQUIRED)
private boolean ignoredReadStatus;
}

View File

@@ -1,174 +1,174 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
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 com.rometools.rome.feed.synd.SyndContentImpl;
import com.rometools.rome.feed.synd.SyndEnclosureImpl;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndEntryImpl;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Entry details")
@Data
@RegisterForReflection
public class Entry implements Serializable {
@Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED)
private String id;
@Schema(description = "entry guid", requiredMode = RequiredMode.REQUIRED)
private String guid;
@Schema(description = "entry title", requiredMode = RequiredMode.REQUIRED)
private String title;
@Schema(description = "entry content", requiredMode = RequiredMode.REQUIRED)
private String content;
@Schema(description = "comma-separated list of categories")
private String categories;
@Schema(description = "whether entry content and title are rtl", requiredMode = RequiredMode.REQUIRED)
private boolean rtl;
@Schema(description = "entry author")
private String author;
@Schema(description = "entry enclosure url, if any")
private String enclosureUrl;
@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 thumbnail url, if any")
private String mediaThumbnailUrl;
@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 publication date", type = "number", requiredMode = RequiredMode.REQUIRED)
private Instant date;
@Schema(description = "entry insertion date in the database", type = "number", requiredMode = RequiredMode.REQUIRED)
private Instant insertedDate;
@Schema(description = "feed id", requiredMode = RequiredMode.REQUIRED)
private String feedId;
@Schema(description = "feed name", requiredMode = RequiredMode.REQUIRED)
private String feedName;
@Schema(description = "this entry's feed url", requiredMode = RequiredMode.REQUIRED)
private String feedUrl;
@Schema(description = "this entry's website url", requiredMode = RequiredMode.REQUIRED)
private String feedLink;
@Schema(description = "The favicon url to use for this feed", requiredMode = RequiredMode.REQUIRED)
private String iconUrl;
@Schema(description = "entry url", requiredMode = RequiredMode.REQUIRED)
private String url;
@Schema(description = "read status", requiredMode = RequiredMode.REQUIRED)
private boolean read;
@Schema(description = "starred status", requiredMode = RequiredMode.REQUIRED)
private boolean starred;
@Schema(description = "whether the entry is still markable (old entry statuses are discarded)", requiredMode = RequiredMode.REQUIRED)
private boolean markable;
@Schema(description = "tags", requiredMode = RequiredMode.REQUIRED)
private List<String> tags;
public static Entry build(FeedEntryStatus status, boolean proxyImages) {
Entry entry = new Entry();
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());
if (content != null) {
entry.setRtl(content.isRTL());
entry.setTitle(content.getTitle());
entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent()) : content.getContent());
entry.setAuthor(content.getAuthor());
entry.setEnclosureType(content.getEnclosureType());
entry.setEnclosureUrl(proxyImages && StringUtils.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.setCategories(content.getCategories());
}
return entry;
}
public SyndEntry asRss() {
SyndEntry entry = new SyndEntryImpl();
entry.setUri(getGuid());
entry.setTitle(getTitle());
entry.setAuthor(getAuthor());
SyndContentImpl content = new SyndContentImpl();
content.setValue(getContent());
entry.setContents(Collections.singletonList(content));
if (getEnclosureUrl() != null) {
SyndEnclosureImpl enclosure = new SyndEnclosureImpl();
enclosure.setType(getEnclosureType());
enclosure.setUrl(getEnclosureUrl());
entry.setEnclosures(Collections.singletonList(enclosure));
}
entry.setLink(getUrl());
entry.setPublishedDate(getDate() == null ? null : Date.from(getDate()));
return entry;
}
}
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
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 com.rometools.rome.feed.synd.SyndContentImpl;
import com.rometools.rome.feed.synd.SyndEnclosureImpl;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndEntryImpl;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Entry details")
@Data
@RegisterForReflection
public class Entry implements Serializable {
@Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED)
private String id;
@Schema(description = "entry guid", requiredMode = RequiredMode.REQUIRED)
private String guid;
@Schema(description = "entry title", requiredMode = RequiredMode.REQUIRED)
private String title;
@Schema(description = "entry content", requiredMode = RequiredMode.REQUIRED)
private String content;
@Schema(description = "comma-separated list of categories")
private String categories;
@Schema(description = "whether entry content and title are rtl", requiredMode = RequiredMode.REQUIRED)
private boolean rtl;
@Schema(description = "entry author")
private String author;
@Schema(description = "entry enclosure url, if any")
private String enclosureUrl;
@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 thumbnail url, if any")
private String mediaThumbnailUrl;
@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 publication date", type = "number", requiredMode = RequiredMode.REQUIRED)
private Instant date;
@Schema(description = "entry insertion date in the database", type = "number", requiredMode = RequiredMode.REQUIRED)
private Instant insertedDate;
@Schema(description = "feed id", requiredMode = RequiredMode.REQUIRED)
private String feedId;
@Schema(description = "feed name", requiredMode = RequiredMode.REQUIRED)
private String feedName;
@Schema(description = "this entry's feed url", requiredMode = RequiredMode.REQUIRED)
private String feedUrl;
@Schema(description = "this entry's website url", requiredMode = RequiredMode.REQUIRED)
private String feedLink;
@Schema(description = "The favicon url to use for this feed", requiredMode = RequiredMode.REQUIRED)
private String iconUrl;
@Schema(description = "entry url", requiredMode = RequiredMode.REQUIRED)
private String url;
@Schema(description = "read status", requiredMode = RequiredMode.REQUIRED)
private boolean read;
@Schema(description = "starred status", requiredMode = RequiredMode.REQUIRED)
private boolean starred;
@Schema(description = "whether the entry is still markable (old entry statuses are discarded)", requiredMode = RequiredMode.REQUIRED)
private boolean markable;
@Schema(description = "tags", requiredMode = RequiredMode.REQUIRED)
private List<String> tags;
public static Entry build(FeedEntryStatus status, boolean proxyImages) {
Entry entry = new Entry();
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());
if (content != null) {
entry.setRtl(content.isRTL());
entry.setTitle(content.getTitle());
entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent()) : content.getContent());
entry.setAuthor(content.getAuthor());
entry.setEnclosureType(content.getEnclosureType());
entry.setEnclosureUrl(proxyImages && StringUtils.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.setCategories(content.getCategories());
}
return entry;
}
public SyndEntry asRss() {
SyndEntry entry = new SyndEntryImpl();
entry.setUri(getGuid());
entry.setTitle(getTitle());
entry.setAuthor(getAuthor());
SyndContentImpl content = new SyndContentImpl();
content.setValue(getContent());
entry.setContents(Collections.singletonList(content));
if (getEnclosureUrl() != null) {
SyndEnclosureImpl enclosure = new SyndEnclosureImpl();
enclosure.setType(getEnclosureType());
enclosure.setUrl(getEnclosureUrl());
entry.setEnclosures(Collections.singletonList(enclosure));
}
entry.setLink(getUrl());
entry.setPublishedDate(getDate() == null ? null : Date.from(getDate()));
return entry;
}
}

View File

@@ -1,22 +1,22 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Feed details")
@Data
@RegisterForReflection
public class FeedInfo implements Serializable {
@Schema(description = "url", requiredMode = RequiredMode.REQUIRED)
private String url;
@Schema(description = "title", requiredMode = RequiredMode.REQUIRED)
private String title;
}
package com.commafeed.frontend.model;
import java.io.Serializable;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Feed details")
@Data
@RegisterForReflection
public class FeedInfo implements Serializable {
@Schema(description = "url", requiredMode = RequiredMode.REQUIRED)
private String url;
@Schema(description = "title", requiredMode = RequiredMode.REQUIRED)
private String title;
}

View File

@@ -1,49 +1,49 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Server infos")
@Data
@RegisterForReflection
public class ServerInfo implements Serializable {
@Schema
private String announcement;
@Schema(requiredMode = RequiredMode.REQUIRED)
private String version;
@Schema(requiredMode = RequiredMode.REQUIRED)
private String gitCommit;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean allowRegistrations;
@Schema
private String googleAnalyticsCode;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean smtpEnabled;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean demoAccountEnabled;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean websocketEnabled;
@Schema(requiredMode = RequiredMode.REQUIRED)
private long websocketPingInterval;
@Schema(requiredMode = RequiredMode.REQUIRED)
private long treeReloadInterval;
@Schema(requiredMode = RequiredMode.REQUIRED)
private long forceRefreshCooldownDuration;
}
package com.commafeed.frontend.model;
import java.io.Serializable;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Server infos")
@Data
@RegisterForReflection
public class ServerInfo implements Serializable {
@Schema
private String announcement;
@Schema(requiredMode = RequiredMode.REQUIRED)
private String version;
@Schema(requiredMode = RequiredMode.REQUIRED)
private String gitCommit;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean allowRegistrations;
@Schema
private String googleAnalyticsCode;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean smtpEnabled;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean demoAccountEnabled;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean websocketEnabled;
@Schema(requiredMode = RequiredMode.REQUIRED)
private long websocketPingInterval;
@Schema(requiredMode = RequiredMode.REQUIRED)
private long treeReloadInterval;
@Schema(requiredMode = RequiredMode.REQUIRED)
private long forceRefreshCooldownDuration;
}

View File

@@ -1,112 +1,112 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User settings")
@Data
@RegisterForReflection
public class Settings implements Serializable {
@Schema(description = "user's preferred language, english if none", requiredMode = RequiredMode.REQUIRED)
private String language;
@Schema(
description = "user reads all entries or unread entries only",
allowableValues = "all,unread",
requiredMode = RequiredMode.REQUIRED)
private String readingMode;
@Schema(
description = "user reads entries in ascending or descending order",
allowableValues = "asc,desc",
requiredMode = RequiredMode.REQUIRED)
private String readingOrder;
@Schema(description = "user wants category and feeds with no unread entries shown", requiredMode = RequiredMode.REQUIRED)
private boolean showRead;
@Schema(description = "In expanded view, scroll through entries mark them as read", requiredMode = RequiredMode.REQUIRED)
private boolean scrollMarks;
@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 preferred scroll speed when navigating between entries", requiredMode = RequiredMode.REQUIRED)
private int scrollSpeed;
@Schema(
description = "whether to scroll to the selected entry",
allowableValues = "always,never,if_needed",
requiredMode = RequiredMode.REQUIRED)
private String scrollMode;
@Schema(description = "number of entries to keep above the selected entry when scrolling", requiredMode = RequiredMode.REQUIRED)
private int entriesToKeepOnTopWhenScrolling;
@Schema(
description = "whether to show the star icon in the header of entries",
allowableValues = "always,never,on_desktop,on_mobile",
requiredMode = RequiredMode.REQUIRED)
private String starIconDisplayMode;
@Schema(
description = "whether to show the external link icon in the header of entries",
allowableValues = "always,never,on_desktop,on_mobile",
requiredMode = RequiredMode.REQUIRED)
private String externalLinkIconDisplayMode;
@Schema(description = "ask for confirmation when marking all entries as read", requiredMode = RequiredMode.REQUIRED)
private boolean markAllAsReadConfirmation;
@Schema(description = "show commafeed's own context menu on right click", requiredMode = RequiredMode.REQUIRED)
private boolean customContextMenu;
@Schema(description = "on mobile, show action buttons at the bottom of the screen", requiredMode = RequiredMode.REQUIRED)
private boolean mobileFooter;
@Schema(description = "show unread count in the title", requiredMode = RequiredMode.REQUIRED)
private boolean unreadCountTitle;
@Schema(description = "show unread count in the favicon", requiredMode = RequiredMode.REQUIRED)
private boolean unreadCountFavicon;
@Schema(description = "sharing settings", requiredMode = RequiredMode.REQUIRED)
private SharingSettings sharingSettings = new SharingSettings();
@Schema(description = "User sharing settings")
@Data
public static class SharingSettings implements Serializable {
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean email;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean gmail;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean facebook;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean twitter;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean tumblr;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean pocket;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean instapaper;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean buffer;
}
}
package com.commafeed.frontend.model;
import java.io.Serializable;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User settings")
@Data
@RegisterForReflection
public class Settings implements Serializable {
@Schema(description = "user's preferred language, english if none", requiredMode = RequiredMode.REQUIRED)
private String language;
@Schema(
description = "user reads all entries or unread entries only",
allowableValues = "all,unread",
requiredMode = RequiredMode.REQUIRED)
private String readingMode;
@Schema(
description = "user reads entries in ascending or descending order",
allowableValues = "asc,desc",
requiredMode = RequiredMode.REQUIRED)
private String readingOrder;
@Schema(description = "user wants category and feeds with no unread entries shown", requiredMode = RequiredMode.REQUIRED)
private boolean showRead;
@Schema(description = "In expanded view, scroll through entries mark them as read", requiredMode = RequiredMode.REQUIRED)
private boolean scrollMarks;
@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 preferred scroll speed when navigating between entries", requiredMode = RequiredMode.REQUIRED)
private int scrollSpeed;
@Schema(
description = "whether to scroll to the selected entry",
allowableValues = "always,never,if_needed",
requiredMode = RequiredMode.REQUIRED)
private String scrollMode;
@Schema(description = "number of entries to keep above the selected entry when scrolling", requiredMode = RequiredMode.REQUIRED)
private int entriesToKeepOnTopWhenScrolling;
@Schema(
description = "whether to show the star icon in the header of entries",
allowableValues = "always,never,on_desktop,on_mobile",
requiredMode = RequiredMode.REQUIRED)
private String starIconDisplayMode;
@Schema(
description = "whether to show the external link icon in the header of entries",
allowableValues = "always,never,on_desktop,on_mobile",
requiredMode = RequiredMode.REQUIRED)
private String externalLinkIconDisplayMode;
@Schema(description = "ask for confirmation when marking all entries as read", requiredMode = RequiredMode.REQUIRED)
private boolean markAllAsReadConfirmation;
@Schema(description = "show commafeed's own context menu on right click", requiredMode = RequiredMode.REQUIRED)
private boolean customContextMenu;
@Schema(description = "on mobile, show action buttons at the bottom of the screen", requiredMode = RequiredMode.REQUIRED)
private boolean mobileFooter;
@Schema(description = "show unread count in the title", requiredMode = RequiredMode.REQUIRED)
private boolean unreadCountTitle;
@Schema(description = "show unread count in the favicon", requiredMode = RequiredMode.REQUIRED)
private boolean unreadCountFavicon;
@Schema(description = "sharing settings", requiredMode = RequiredMode.REQUIRED)
private SharingSettings sharingSettings = new SharingSettings();
@Schema(description = "User sharing settings")
@Data
public static class SharingSettings implements Serializable {
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean email;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean gmail;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean facebook;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean twitter;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean tumblr;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean pocket;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean instapaper;
@Schema(requiredMode = RequiredMode.REQUIRED)
private boolean buffer;
}
}

View File

@@ -1,86 +1,86 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
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 io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User information")
@Data
@RegisterForReflection
public class Subscription implements Serializable {
@Schema(description = "subscription id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@Schema(description = "subscription name", requiredMode = RequiredMode.REQUIRED)
private String name;
@Schema(description = "error message while fetching the feed")
private String message;
@Schema(description = "error count", requiredMode = RequiredMode.REQUIRED)
private int errorCount;
@Schema(description = "last time the feed was refreshed", type = "number")
private Instant lastRefresh;
@Schema(description = "next time the feed refresh is planned, null if refresh is already queued", type = "number")
private Instant nextRefresh;
@Schema(description = "this subscription's feed url", requiredMode = RequiredMode.REQUIRED)
private String feedUrl;
@Schema(description = "this subscription's website url", requiredMode = RequiredMode.REQUIRED)
private String feedLink;
@Schema(description = "The favicon url to use for this feed", requiredMode = RequiredMode.REQUIRED)
private String iconUrl;
@Schema(description = "unread count", requiredMode = RequiredMode.REQUIRED)
private long unread;
@Schema(description = "category id")
private String categoryId;
@Schema(description = "position of the subscription's in the list")
private int position;
@Schema(description = "date of the newest item", type = "number")
private Instant newestItemTime;
@Schema(description = "JEXL string evaluated on new entries to mark them as read if they do not match")
private String filter;
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());
return sub;
}
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
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 io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User information")
@Data
@RegisterForReflection
public class Subscription implements Serializable {
@Schema(description = "subscription id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@Schema(description = "subscription name", requiredMode = RequiredMode.REQUIRED)
private String name;
@Schema(description = "error message while fetching the feed")
private String message;
@Schema(description = "error count", requiredMode = RequiredMode.REQUIRED)
private int errorCount;
@Schema(description = "last time the feed was refreshed", type = "number")
private Instant lastRefresh;
@Schema(description = "next time the feed refresh is planned, null if refresh is already queued", type = "number")
private Instant nextRefresh;
@Schema(description = "this subscription's feed url", requiredMode = RequiredMode.REQUIRED)
private String feedUrl;
@Schema(description = "this subscription's website url", requiredMode = RequiredMode.REQUIRED)
private String feedLink;
@Schema(description = "The favicon url to use for this feed", requiredMode = RequiredMode.REQUIRED)
private String iconUrl;
@Schema(description = "unread count", requiredMode = RequiredMode.REQUIRED)
private long unread;
@Schema(description = "category id")
private String categoryId;
@Schema(description = "position of the subscription's in the list")
private int position;
@Schema(description = "date of the newest item", type = "number")
private Instant newestItemTime;
@Schema(description = "JEXL string evaluated on new entries to mark them as read if they do not match")
private String filter;
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());
return sub;
}
}

View File

@@ -1,34 +1,34 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Unread count")
@Data
@RegisterForReflection
public class UnreadCount implements Serializable {
@Schema
private long feedId;
@Schema
private long unreadCount;
@Schema(type = "number")
private Instant newestItemTime;
public UnreadCount() {
}
public UnreadCount(long feedId, long unreadCount, Instant newestItemTime) {
this.feedId = feedId;
this.unreadCount = unreadCount;
this.newestItemTime = newestItemTime;
}
}
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Unread count")
@Data
@RegisterForReflection
public class UnreadCount implements Serializable {
@Schema
private long feedId;
@Schema
private long unreadCount;
@Schema(type = "number")
private Instant newestItemTime;
public UnreadCount() {
}
public UnreadCount(long feedId, long unreadCount, Instant newestItemTime) {
this.feedId = feedId;
this.unreadCount = unreadCount;
this.newestItemTime = newestItemTime;
}
}

View File

@@ -1,47 +1,47 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User information")
@Data
@RegisterForReflection
public class UserModel implements Serializable {
@Schema(description = "user id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@Schema(description = "user name", requiredMode = RequiredMode.REQUIRED)
private String name;
@Schema(description = "user email, if any")
private String email;
@Schema(description = "api key")
private String apiKey;
@Schema(description = "user password, never returned by the api")
private String password;
@Schema(description = "account status", requiredMode = RequiredMode.REQUIRED)
private boolean enabled;
@Schema(description = "account creation date", type = "number")
private Instant created;
@Schema(description = "last login date", type = "number")
private Instant lastLogin;
@Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED)
private boolean admin;
@Schema(description = "user last force refresh", type = "number")
private Instant lastForceRefresh;
}
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.time.Instant;
import io.quarkus.runtime.annotations.RegisterForReflection;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "User information")
@Data
@RegisterForReflection
public class UserModel implements Serializable {
@Schema(description = "user id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@Schema(description = "user name", requiredMode = RequiredMode.REQUIRED)
private String name;
@Schema(description = "user email, if any")
private String email;
@Schema(description = "api key")
private String apiKey;
@Schema(description = "user password, never returned by the api")
private String password;
@Schema(description = "account status", requiredMode = RequiredMode.REQUIRED)
private boolean enabled;
@Schema(description = "account creation date", type = "number")
private Instant created;
@Schema(description = "last login date", type = "number")
private Instant lastLogin;
@Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED)
private boolean admin;
@Schema(description = "user last force refresh", type = "number")
private Instant lastForceRefresh;
}

View File

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

View File

@@ -1,31 +1,31 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Save User information")
@Data
public class AdminSaveUserRequest implements Serializable {
@Schema(description = "user id")
private Long id;
@Schema(description = "user name", requiredMode = RequiredMode.REQUIRED)
private String name;
@Schema(description = "user email, if any")
private String email;
@Schema(description = "user password")
private String password;
@Schema(description = "account status", requiredMode = RequiredMode.REQUIRED)
private boolean enabled;
@Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED)
private boolean admin;
}
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Save User information")
@Data
public class AdminSaveUserRequest implements Serializable {
@Schema(description = "user id")
private Long id;
@Schema(description = "user name", requiredMode = RequiredMode.REQUIRED)
private String name;
@Schema(description = "user email, if any")
private String email;
@Schema(description = "user password")
private String password;
@Schema(description = "account status", requiredMode = RequiredMode.REQUIRED)
private boolean enabled;
@Schema(description = "user is admin", requiredMode = RequiredMode.REQUIRED)
private boolean admin;
}

View File

@@ -1,30 +1,30 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.Size;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Category modification request")
@Data
public class CategoryModificationRequest implements Serializable {
@Schema(description = "id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@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;
}
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.Size;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Category modification request")
@Data
public class CategoryModificationRequest implements Serializable {
@Schema(description = "id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@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;
}

View File

@@ -1,20 +1,20 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Mark Request")
@Data
public class CollapseRequest implements Serializable {
@Schema(description = "category id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@Schema(description = "collapse", requiredMode = RequiredMode.REQUIRED)
private boolean collapse;
}
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Mark Request")
@Data
public class CollapseRequest implements Serializable {
@Schema(description = "category id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@Schema(description = "collapse", requiredMode = RequiredMode.REQUIRED)
private boolean collapse;
}

View File

@@ -1,22 +1,22 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Feed information request")
@Data
public class FeedInfoRequest implements Serializable {
@Schema(description = "feed url", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@Size(max = 4096)
private String url;
}
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Feed information request")
@Data
public class FeedInfoRequest implements Serializable {
@Schema(description = "feed url", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@Size(max = 4096)
private String url;
}

View File

@@ -1,34 +1,34 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.Size;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Feed modification request")
@Data
public class FeedModificationRequest implements Serializable {
@Schema(description = "id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@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 display position, null if not changed")
private Integer position;
@Schema(description = "JEXL string evaluated on new entries to mark them as read if they do not match")
@Size(max = 4096)
private String filter;
}
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.Size;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Feed modification request")
@Data
public class FeedModificationRequest implements Serializable {
@Schema(description = "id", requiredMode = RequiredMode.REQUIRED)
private Long id;
@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 display position, null if not changed")
private Integer position;
@Schema(description = "JEXL string evaluated on new entries to mark them as read if they do not match")
@Size(max = 4096)
private String filter;
}

View File

@@ -1,17 +1,17 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema
@Data
public class IDRequest implements Serializable {
@Schema(requiredMode = RequiredMode.REQUIRED)
private Long id;
}
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema
@Data
public class IDRequest implements Serializable {
@Schema(requiredMode = RequiredMode.REQUIRED)
private Long id;
}

View File

@@ -1,45 +1,45 @@
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 io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Mark Request")
@Data
public class MarkRequest implements Serializable {
@Schema(description = "entry id, category id, 'all' or 'starred'", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@Size(max = 128)
private String id;
@Schema(description = "mark as read or unread", requiredMode = RequiredMode.REQUIRED)
private boolean read;
@Schema(description = "mark only entries older than this", requiredMode = RequiredMode.NOT_REQUIRED)
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",
requiredMode = RequiredMode.NOT_REQUIRED)
private Long insertedBefore;
@Schema(
description = "only mark read if a feed has these keywords in the title or rss content",
requiredMode = RequiredMode.NOT_REQUIRED)
@Size(max = 128)
private String keywords;
@Schema(
description = "if marking a category or 'all', exclude those subscriptions from the marking",
requiredMode = RequiredMode.NOT_REQUIRED)
private List<Long> excludedSubscriptions;
}
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 io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Mark Request")
@Data
public class MarkRequest implements Serializable {
@Schema(description = "entry id, category id, 'all' or 'starred'", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@Size(max = 128)
private String id;
@Schema(description = "mark as read or unread", requiredMode = RequiredMode.REQUIRED)
private boolean read;
@Schema(description = "mark only entries older than this", requiredMode = RequiredMode.NOT_REQUIRED)
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",
requiredMode = RequiredMode.NOT_REQUIRED)
private Long insertedBefore;
@Schema(
description = "only mark read if a feed has these keywords in the title or rss content",
requiredMode = RequiredMode.NOT_REQUIRED)
@Size(max = 128)
private String keywords;
@Schema(
description = "if marking a category or 'all', exclude those subscriptions from the marking",
requiredMode = RequiredMode.NOT_REQUIRED)
private List<Long> excludedSubscriptions;
}

View File

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

View File

@@ -1,23 +1,23 @@
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 io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Data
@Schema
public class PasswordResetRequest implements Serializable {
@Schema(description = "email address for password recovery", requiredMode = RequiredMode.REQUIRED)
@Email
@NotEmpty
@Size(max = 255)
private String email;
}
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 io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Data
@Schema
public class PasswordResetRequest implements Serializable {
@Schema(description = "email address for password recovery", requiredMode = RequiredMode.REQUIRED)
@Email
@NotEmpty
@Size(max = 255)
private String email;
}

View File

@@ -1,34 +1,34 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import com.commafeed.security.password.ValidPassword;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Profile modification request")
@Data
public class ProfileModificationRequest implements Serializable {
@Schema(description = "current user password, required to change profile data", requiredMode = RequiredMode.REQUIRED)
@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 password of the user, if specified")
@ValidPassword
private String newPassword;
@Schema(description = "generate a new api key")
private boolean newApiKey;
}
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import com.commafeed.security.password.ValidPassword;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Profile modification request")
@Data
public class ProfileModificationRequest implements Serializable {
@Schema(description = "current user password, required to change profile data", requiredMode = RequiredMode.REQUIRED)
@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 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,36 @@
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 com.commafeed.security.password.ValidPassword;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Data
@Schema
public class RegistrationRequest implements Serializable {
@Schema(description = "username, between 3 and 32 characters", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@Size(min = 3, max = 32)
private String name;
@Schema(description = "password, minimum 6 characters", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@ValidPassword
private String password;
@Schema(description = "email address for password recovery", requiredMode = RequiredMode.REQUIRED)
@Email
@NotEmpty
@Size(max = 255)
private String email;
}
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 com.commafeed.security.password.ValidPassword;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Data
@Schema
public class RegistrationRequest implements Serializable {
@Schema(description = "username, between 3 and 32 characters", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@Size(min = 3, max = 32)
private String name;
@Schema(description = "password, minimum 6 characters", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@ValidPassword
private String password;
@Schema(description = "email address for password recovery", requiredMode = RequiredMode.REQUIRED)
@Email
@NotEmpty
@Size(max = 255)
private String email;
}

View File

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

View File

@@ -1,31 +1,31 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Subscription request")
@Data
public class SubscribeRequest implements Serializable {
@Schema(description = "url of the feed", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@Size(max = 4096)
private String url;
@Schema(description = "name of the feed for the user", requiredMode = RequiredMode.REQUIRED)
@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;
}
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Subscription request")
@Data
public class SubscribeRequest implements Serializable {
@Schema(description = "url of the feed", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
@Size(max = 4096)
private String url;
@Schema(description = "name of the feed for the user", requiredMode = RequiredMode.REQUIRED)
@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;
}

View File

@@ -1,21 +1,21 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Tag Request")
@Data
public class TagRequest implements Serializable {
@Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED)
private Long entryId;
@Schema(description = "tags", requiredMode = RequiredMode.REQUIRED)
private List<String> tags;
}
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import lombok.Data;
@SuppressWarnings("serial")
@Schema(description = "Tag Request")
@Data
public class TagRequest implements Serializable {
@Schema(description = "entry id", requiredMode = RequiredMode.REQUIRED)
private Long entryId;
@Schema(description = "tags", requiredMode = RequiredMode.REQUIRED)
private List<String> tags;
}

View File

@@ -1,199 +1,199 @@
package com.commafeed.frontend.resource;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import jakarta.annotation.security.RolesAllowed;
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.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedApplication;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserRoleDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.service.PasswordEncryptionService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.AdminSaveUserRequest;
import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import com.google.common.collect.Sets;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@Path("/rest/admin")
@RolesAllowed(Roles.ADMIN)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@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;
@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(@Parameter(required = true) AdminSaveUserRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
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 {
userService.register(req.getName(), req.getPassword(), req.getEmail(), roles, true);
} 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());
userDAO.saveOrUpdate(u);
Set<Role> roles = userRoleDAO.findRoles(u);
if (req.isAdmin() && !roles.contains(Role.ADMIN)) {
userRoleDAO.saveOrUpdate(new UserRole(u, Role.ADMIN));
} else if (!req.isAdmin() && roles.contains(Role.ADMIN)) {
if (CommaFeedApplication.USERNAME_ADMIN.equals(u.getName())) {
return Response.status(Status.FORBIDDEN).entity("You cannot remove the admin role from the admin user.").build();
}
for (UserRole userRole : userRoleDAO.findAll(u)) {
if (userRole.getRole() == Role.ADMIN) {
userRoleDAO.delete(userRole);
}
}
}
}
return Response.ok().build();
}
@Path("/user/get/{id}")
@GET
@Transactional
@Operation(
summary = "Get user information",
description = "Get user information",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) })
public Response 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 Response.ok(userModel).build();
}
@Path("/user/getAll")
@GET
@Transactional
@Operation(
summary = "Get all users",
description = "Get all users",
responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserModel.class)))) })
public Response adminGetUsers() {
Map<Long, UserModel> users = new HashMap<>();
for (UserRole role : userRoleDAO.findAll()) {
User u = role.getUser();
Long key = u.getId();
UserModel userModel = users.get(key);
if (userModel == null) {
userModel = new UserModel();
userModel.setId(u.getId());
userModel.setName(u.getName());
userModel.setEmail(u.getEmail());
userModel.setEnabled(!u.isDisabled());
userModel.setCreated(u.getCreated());
userModel.setLastLogin(u.getLastLogin());
users.put(key, userModel);
}
if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true);
}
}
return Response.ok(users.values()).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();
}
}
package com.commafeed.frontend.resource;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import jakarta.annotation.security.RolesAllowed;
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.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.MetricRegistry;
import com.commafeed.CommaFeedApplication;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserRoleDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.service.PasswordEncryptionService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.AdminSaveUserRequest;
import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import com.google.common.collect.Sets;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@Path("/rest/admin")
@RolesAllowed(Roles.ADMIN)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@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;
@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(@Parameter(required = true) AdminSaveUserRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
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 {
userService.register(req.getName(), req.getPassword(), req.getEmail(), roles, true);
} 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());
userDAO.saveOrUpdate(u);
Set<Role> roles = userRoleDAO.findRoles(u);
if (req.isAdmin() && !roles.contains(Role.ADMIN)) {
userRoleDAO.saveOrUpdate(new UserRole(u, Role.ADMIN));
} else if (!req.isAdmin() && roles.contains(Role.ADMIN)) {
if (CommaFeedApplication.USERNAME_ADMIN.equals(u.getName())) {
return Response.status(Status.FORBIDDEN).entity("You cannot remove the admin role from the admin user.").build();
}
for (UserRole userRole : userRoleDAO.findAll(u)) {
if (userRole.getRole() == Role.ADMIN) {
userRoleDAO.delete(userRole);
}
}
}
}
return Response.ok().build();
}
@Path("/user/get/{id}")
@GET
@Transactional
@Operation(
summary = "Get user information",
description = "Get user information",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) })
public Response 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 Response.ok(userModel).build();
}
@Path("/user/getAll")
@GET
@Transactional
@Operation(
summary = "Get all users",
description = "Get all users",
responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserModel.class)))) })
public Response adminGetUsers() {
Map<Long, UserModel> users = new HashMap<>();
for (UserRole role : userRoleDAO.findAll()) {
User u = role.getUser();
Long key = u.getId();
UserModel userModel = users.get(key);
if (userModel == null) {
userModel = new UserModel();
userModel.setId(u.getId());
userModel.setName(u.getName());
userModel.setEmail(u.getEmail());
userModel.setEnabled(!u.isDisabled());
userModel.setCreated(u.getCreated());
userModel.setLastLogin(u.getLastLogin());
users.put(key, userModel);
}
if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true);
}
}
return Response.ok(users.values()).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();
}
}

View File

@@ -1,465 +1,465 @@
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 com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.feed.FeedEntryKeyword;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedSubscriptionService;
import com.commafeed.frontend.model.Category;
import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.Subscription;
import com.commafeed.frontend.model.UnreadCount;
import com.commafeed.frontend.model.request.AddCategoryRequest;
import com.commafeed.frontend.model.request.CategoryModificationRequest;
import com.commafeed.frontend.model.request.CollapseRequest;
import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/rest/category")
@RolesAllowed(Roles.USER)
@Slf4j
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@Tag(name = "Feed categories")
public class CategoryREST {
public static final String ALL = "all";
public static final String STARRED = "starred";
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",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) })
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, 3 characters minimum") @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);
keywords = StringUtils.trimToNull(keywords);
Preconditions.checkArgument(keywords == null || StringUtils.length(keywords) >= 3);
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
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;
}
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();
}
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);
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, 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();
}
}
boolean hasMore = entries.getEntries().size() > limit;
if (hasMore) {
entries.setHasMore(true);
entries.getEntries().remove(entries.getEntries().size() - 1);
}
entries.setTimestamp(System.currentTimeMillis());
entries.setIgnoredReadStatus(STARRED.equals(id) || keywords != null || tag != null);
FeedUtils.removeUnwantedFromSearch(entries.getEntries(), entryKeywords);
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, 3 characters minimum") @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();
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(Entry::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();
}
@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);
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()));
}
}
@Path("/add")
@POST
@Transactional
@Operation(
summary = "Add a category",
description = "Add a new feed category",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) })
public Response addCategory(@Valid @Parameter(required = true) AddCategoryRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
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.saveOrUpdate(cat);
return Response.ok(cat.getId()).build();
}
@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());
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);
}
feedSubscriptionDAO.saveOrUpdate(subs);
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.saveOrUpdate(categories);
feedCategoryDAO.delete(cat);
return Response.ok().build();
} else {
return Response.status(Status.NOT_FOUND).build();
}
}
@POST
@Path("/modify")
@Transactional
@Operation(summary = "Rename a category", description = "Rename 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());
if (StringUtils.isNotBlank(req.getName())) {
category.setName(req.getName());
}
FeedCategory parent = null;
if (req.getParentId() != null && !CategoryREST.ALL.equals(req.getParentId())
&& !StringUtils.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()));
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);
}
feedCategoryDAO.saveOrUpdate(categories);
} else {
feedCategoryDAO.saveOrUpdate(category);
}
feedCategoryDAO.saveOrUpdate(category);
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());
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());
feedCategoryDAO.saveOrUpdate(category);
return Response.ok().build();
}
@GET
@Path("/unreadCount")
@Transactional
@Operation(
summary = "Get unread count for feed subscriptions",
responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UnreadCount.class)))) })
public Response getUnreadCount() {
User user = authenticationContext.getCurrentUser();
Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
return Response.ok(Lists.newArrayList(unreadCount.values())).build();
}
@GET
@Path("/get")
@Transactional
@Operation(
summary = "Get root category",
description = "Get all categories and subscriptions of the user",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Category.class))) })
public Response getRootCategory() {
User user = authenticationContext.getCurrentUser();
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");
return Response.ok(root).build();
}
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(Comparator.comparing(Category::getPosition).thenComparing(Category::getName));
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(Comparator.comparing(Subscription::getPosition).thenComparing(Subscription::getName));
return category;
}
}
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 com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.feed.FeedEntryKeyword;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedSubscriptionService;
import com.commafeed.frontend.model.Category;
import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.Subscription;
import com.commafeed.frontend.model.UnreadCount;
import com.commafeed.frontend.model.request.AddCategoryRequest;
import com.commafeed.frontend.model.request.CategoryModificationRequest;
import com.commafeed.frontend.model.request.CollapseRequest;
import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/rest/category")
@RolesAllowed(Roles.USER)
@Slf4j
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@Tag(name = "Feed categories")
public class CategoryREST {
public static final String ALL = "all";
public static final String STARRED = "starred";
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",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Entries.class))) })
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, 3 characters minimum") @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);
keywords = StringUtils.trimToNull(keywords);
Preconditions.checkArgument(keywords == null || StringUtils.length(keywords) >= 3);
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
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;
}
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();
}
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);
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, 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();
}
}
boolean hasMore = entries.getEntries().size() > limit;
if (hasMore) {
entries.setHasMore(true);
entries.getEntries().remove(entries.getEntries().size() - 1);
}
entries.setTimestamp(System.currentTimeMillis());
entries.setIgnoredReadStatus(STARRED.equals(id) || keywords != null || tag != null);
FeedUtils.removeUnwantedFromSearch(entries.getEntries(), entryKeywords);
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, 3 characters minimum") @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();
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(Entry::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();
}
@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);
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()));
}
}
@Path("/add")
@POST
@Transactional
@Operation(
summary = "Add a category",
description = "Add a new feed category",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Long.class))) })
public Response addCategory(@Valid @Parameter(required = true) AddCategoryRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
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.saveOrUpdate(cat);
return Response.ok(cat.getId()).build();
}
@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());
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);
}
feedSubscriptionDAO.saveOrUpdate(subs);
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.saveOrUpdate(categories);
feedCategoryDAO.delete(cat);
return Response.ok().build();
} else {
return Response.status(Status.NOT_FOUND).build();
}
}
@POST
@Path("/modify")
@Transactional
@Operation(summary = "Rename a category", description = "Rename 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());
if (StringUtils.isNotBlank(req.getName())) {
category.setName(req.getName());
}
FeedCategory parent = null;
if (req.getParentId() != null && !CategoryREST.ALL.equals(req.getParentId())
&& !StringUtils.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()));
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);
}
feedCategoryDAO.saveOrUpdate(categories);
} else {
feedCategoryDAO.saveOrUpdate(category);
}
feedCategoryDAO.saveOrUpdate(category);
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());
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());
feedCategoryDAO.saveOrUpdate(category);
return Response.ok().build();
}
@GET
@Path("/unreadCount")
@Transactional
@Operation(
summary = "Get unread count for feed subscriptions",
responses = { @ApiResponse(content = @Content(array = @ArraySchema(schema = @Schema(implementation = UnreadCount.class)))) })
public Response getUnreadCount() {
User user = authenticationContext.getCurrentUser();
Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
return Response.ok(Lists.newArrayList(unreadCount.values())).build();
}
@GET
@Path("/get")
@Transactional
@Operation(
summary = "Get root category",
description = "Get all categories and subscriptions of the user",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Category.class))) })
public Response getRootCategory() {
User user = authenticationContext.getCurrentUser();
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");
return Response.ok(root).build();
}
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(Comparator.comparing(Category::getPosition).thenComparing(Category::getName));
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(Comparator.comparing(Subscription::getPosition).thenComparing(Subscription::getName));
return category;
}
}

View File

@@ -1,117 +1,117 @@
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 com.commafeed.backend.dao.FeedEntryTagDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedEntryTagService;
import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.frontend.model.request.MultipleMarkRequest;
import com.commafeed.frontend.model.request.StarRequest;
import com.commafeed.frontend.model.request.TagRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@Path("/rest/entry")
@RolesAllowed(Roles.USER)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@Tag(name = "Feed entries")
public class EntryREST {
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());
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());
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();
}
@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());
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("/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();
}
}
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 com.commafeed.backend.dao.FeedEntryTagDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedEntryTagService;
import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.frontend.model.request.MultipleMarkRequest;
import com.commafeed.frontend.model.request.StarRequest;
import com.commafeed.frontend.model.request.TagRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@Path("/rest/entry")
@RolesAllowed(Roles.USER)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@Tag(name = "Feed entries")
public class EntryREST {
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());
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());
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();
}
@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());
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("/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();
}
}

View File

@@ -1,16 +1,16 @@
package com.commafeed.frontend.resource;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.servers.Server;
@OpenAPIDefinition(
info = @Info(title = "CommaFeed API"),
servers = { @Server(description = "CommaFeed API", url = "/") },
security = { @SecurityRequirement(name = "basicAuth") })
@SecurityScheme(name = "basicAuth", type = SecuritySchemeType.HTTP, scheme = "basic")
public class OpenAPI {
}
package com.commafeed.frontend.resource;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.servers.Server;
@OpenAPIDefinition(
info = @Info(title = "CommaFeed API"),
servers = { @Server(description = "CommaFeed API", url = "/") },
security = { @SecurityRequirement(name = "basicAuth") })
@SecurityScheme(name = "basicAuth", type = SecuritySchemeType.HTTP, scheme = "basic")
public class OpenAPI {
}

View File

@@ -1,88 +1,88 @@
package com.commafeed.frontend.resource;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
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 com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedVersion;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.frontend.model.ServerInfo;
import com.commafeed.security.Roles;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@Path("/rest/server")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@Tag(name = "Server")
public class ServerREST {
private final HttpGetter httpGetter;
private final CommaFeedConfiguration config;
private final CommaFeedVersion version;
@Path("/get")
@GET
@PermitAll
@Transactional
@Operation(
summary = "Get server infos",
description = "Get server infos",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = ServerInfo.class))) })
public Response 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.setGoogleAnalyticsCode(config.googleAnalyticsTrackingCode().orElse(null));
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());
return Response.ok(infos).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 = FeedUtils.imageProxyDecoder(url);
try {
HttpResult result = httpGetter.get(url);
return Response.ok(result.getContent()).build();
} catch (Exception e) {
return Response.status(Status.SERVICE_UNAVAILABLE).entity(e.getMessage()).build();
}
}
}
package com.commafeed.frontend.resource;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
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 com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedVersion;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.frontend.model.ServerInfo;
import com.commafeed.security.Roles;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@Path("/rest/server")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@Tag(name = "Server")
public class ServerREST {
private final HttpGetter httpGetter;
private final CommaFeedConfiguration config;
private final CommaFeedVersion version;
@Path("/get")
@GET
@PermitAll
@Transactional
@Operation(
summary = "Get server infos",
description = "Get server infos",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = ServerInfo.class))) })
public Response 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.setGoogleAnalyticsCode(config.googleAnalyticsTrackingCode().orElse(null));
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());
return Response.ok(infos).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 = FeedUtils.imageProxyDecoder(url);
try {
HttpResult result = httpGetter.get(url);
return Response.ok(result.getContent()).build();
} catch (Exception e) {
return Response.status(Status.SERVICE_UNAVAILABLE).entity(e.getMessage()).build();
}
}
}

View File

@@ -1,369 +1,369 @@
package com.commafeed.frontend.resource;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.Optional;
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.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.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.core5.net.URIBuilder;
import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.Digests;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserRoleDAO;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.backend.model.UserSettings.IconDisplayMode;
import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.model.UserSettings.ScrollMode;
import com.commafeed.backend.service.MailService;
import com.commafeed.backend.service.PasswordEncryptionService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.model.Settings;
import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.PasswordResetRequest;
import com.commafeed.frontend.model.request.ProfileModificationRequest;
import com.commafeed.frontend.model.request.RegistrationRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/rest/user")
@RolesAllowed(Roles.USER)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Slf4j
@RequiredArgsConstructor
@Singleton
@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 MailService mailService;
private final CommaFeedConfiguration config;
private final UriInfo uri;
@Path("/settings")
@GET
@Transactional
@Operation(
summary = "Retrieve user settings",
description = "Retrieve user settings",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Settings.class))) })
public Response getUserSettings() {
Settings s = new Settings();
User user = authenticationContext.getCurrentUser();
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings != null) {
s.setReadingMode(settings.getReadingMode().name());
s.setReadingOrder(settings.getReadingOrder().name());
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().setPocket(settings.isPocket());
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().name());
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(settings.getStarIconDisplayMode().name());
s.setExternalLinkIconDisplayMode(settings.getExternalLinkIconDisplayMode().name());
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());
s.setCustomContextMenu(settings.isCustomContextMenu());
s.setMobileFooter(settings.isMobileFooter());
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
} else {
s.setReadingMode(ReadingMode.unread.name());
s.setReadingOrder(ReadingOrder.desc.name());
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().setPocket(true);
s.getSharingSettings().setInstapaper(true);
s.getSharingSettings().setBuffer(true);
s.setScrollMarks(true);
s.setLanguage("en");
s.setScrollSpeed(400);
s.setScrollMode(ScrollMode.if_needed.name());
s.setEntriesToKeepOnTopWhenScrolling(0);
s.setStarIconDisplayMode(IconDisplayMode.on_desktop.name());
s.setExternalLinkIconDisplayMode(IconDisplayMode.on_desktop.name());
s.setMarkAllAsReadConfirmation(true);
s.setCustomContextMenu(true);
s.setMobileFooter(false);
s.setUnreadCountTitle(false);
s.setUnreadCountFavicon(true);
}
return Response.ok(s).build();
}
@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(ReadingMode.valueOf(settings.getReadingMode()));
s.setReadingOrder(ReadingOrder.valueOf(settings.getReadingOrder()));
s.setShowRead(settings.isShowRead());
s.setScrollMarks(settings.isScrollMarks());
s.setCustomCss(settings.getCustomCss());
s.setCustomJs(CommaFeedApplication.USERNAME_DEMO.equals(user.getName()) ? "" : settings.getCustomJs());
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setScrollMode(ScrollMode.valueOf(settings.getScrollMode()));
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(IconDisplayMode.valueOf(settings.getStarIconDisplayMode()));
s.setExternalLinkIconDisplayMode(IconDisplayMode.valueOf(settings.getExternalLinkIconDisplayMode()));
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());
s.setCustomContextMenu(settings.isCustomContextMenu());
s.setMobileFooter(settings.isMobileFooter());
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
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.setPocket(settings.getSharingSettings().isPocket());
s.setInstapaper(settings.getSharingSettings().isInstapaper());
s.setBuffer(settings.getSharingSettings().isBuffer());
userSettingsDAO.saveOrUpdate(s);
return Response.ok().build();
}
@Path("/profile")
@GET
@Transactional
@Operation(
summary = "Retrieve user's profile",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) })
public Response 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 Response.ok(userModel).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 (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).build();
}
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);
}
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));
}
userDAO.saveOrUpdate(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("/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();
}
try {
user.setRecoverPasswordToken(Digests.sha1Hex(UUID.randomUUID().toString()));
user.setRecoverPasswordTokenDate(Instant.now());
userDAO.saveOrUpdate(user);
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 Exception {
String publicUrl = FeedUtils.removeTrailingSlash(uri.getBaseUri().toString());
publicUrl += "/rest/user/passwordResetCallback";
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 Exception {
return new URIBuilder(publicUrl).addParameter("email", user.getEmail())
.addParameter("token", user.getRecoverPasswordToken())
.build()
.toURL()
.toString();
}
@Path("/passwordResetCallback")
@PermitAll
@GET
@Transactional
@Produces(MediaType.TEXT_HTML)
public Response passwordRecoveryCallback(@Parameter(required = true) @QueryParam("email") String email,
@Parameter(required = true) @QueryParam("token") String token) {
Preconditions.checkNotNull(email);
Preconditions.checkNotNull(token);
User user = userDAO.findByEmail(email);
if (user == null) {
return Response.status(Status.UNAUTHORIZED).entity("Email not found.").build();
}
if (user.getRecoverPasswordToken() == null || !user.getRecoverPasswordToken().equals(token)) {
return Response.status(Status.UNAUTHORIZED).entity("Invalid token.").build();
}
if (ChronoUnit.DAYS.between(user.getRecoverPasswordTokenDate(), Instant.now()) >= 2) {
return Response.status(Status.UNAUTHORIZED).entity("token expired.").build();
}
String passwd = RandomStringUtils.secure().nextAlphanumeric(10);
byte[] encryptedPassword = encryptionService.getEncryptedPassword(passwd, user.getSalt());
user.setPassword(encryptedPassword);
if (StringUtils.isNotBlank(user.getApiKey())) {
user.setApiKey(userService.generateApiKey(user));
}
user.setRecoverPasswordToken(null);
user.setRecoverPasswordTokenDate(null);
userDAO.saveOrUpdate(user);
String message = "Your new password is: " + passwd;
message += "<br />";
message += String.format("<a href=\"%s\">Back to Homepage</a>", uri.getBaseUri());
return Response.ok(message).build();
}
@Path("/profile/deleteAccount")
@POST
@Transactional
@Operation(summary = "Delete the user account")
public Response deleteUser() {
User user = authenticationContext.getCurrentUser();
if (CommaFeedApplication.USERNAME_ADMIN.equals(user.getName()) || CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).build();
}
userService.unregister(userDAO.findById(user.getId()));
return Response.ok().build();
}
}
package com.commafeed.frontend.resource;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
import java.util.Optional;
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.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.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.core5.net.URIBuilder;
import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.Digests;
import com.commafeed.backend.dao.UserDAO;
import com.commafeed.backend.dao.UserRoleDAO;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole;
import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.backend.model.UserSettings.IconDisplayMode;
import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.model.UserSettings.ScrollMode;
import com.commafeed.backend.service.MailService;
import com.commafeed.backend.service.PasswordEncryptionService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.model.Settings;
import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.PasswordResetRequest;
import com.commafeed.frontend.model.request.ProfileModificationRequest;
import com.commafeed.frontend.model.request.RegistrationRequest;
import com.commafeed.security.AuthenticationContext;
import com.commafeed.security.Roles;
import com.google.common.base.Preconditions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/rest/user")
@RolesAllowed(Roles.USER)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Slf4j
@RequiredArgsConstructor
@Singleton
@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 MailService mailService;
private final CommaFeedConfiguration config;
private final UriInfo uri;
@Path("/settings")
@GET
@Transactional
@Operation(
summary = "Retrieve user settings",
description = "Retrieve user settings",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = Settings.class))) })
public Response getUserSettings() {
Settings s = new Settings();
User user = authenticationContext.getCurrentUser();
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings != null) {
s.setReadingMode(settings.getReadingMode().name());
s.setReadingOrder(settings.getReadingOrder().name());
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().setPocket(settings.isPocket());
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().name());
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(settings.getStarIconDisplayMode().name());
s.setExternalLinkIconDisplayMode(settings.getExternalLinkIconDisplayMode().name());
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());
s.setCustomContextMenu(settings.isCustomContextMenu());
s.setMobileFooter(settings.isMobileFooter());
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
} else {
s.setReadingMode(ReadingMode.unread.name());
s.setReadingOrder(ReadingOrder.desc.name());
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().setPocket(true);
s.getSharingSettings().setInstapaper(true);
s.getSharingSettings().setBuffer(true);
s.setScrollMarks(true);
s.setLanguage("en");
s.setScrollSpeed(400);
s.setScrollMode(ScrollMode.if_needed.name());
s.setEntriesToKeepOnTopWhenScrolling(0);
s.setStarIconDisplayMode(IconDisplayMode.on_desktop.name());
s.setExternalLinkIconDisplayMode(IconDisplayMode.on_desktop.name());
s.setMarkAllAsReadConfirmation(true);
s.setCustomContextMenu(true);
s.setMobileFooter(false);
s.setUnreadCountTitle(false);
s.setUnreadCountFavicon(true);
}
return Response.ok(s).build();
}
@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(ReadingMode.valueOf(settings.getReadingMode()));
s.setReadingOrder(ReadingOrder.valueOf(settings.getReadingOrder()));
s.setShowRead(settings.isShowRead());
s.setScrollMarks(settings.isScrollMarks());
s.setCustomCss(settings.getCustomCss());
s.setCustomJs(CommaFeedApplication.USERNAME_DEMO.equals(user.getName()) ? "" : settings.getCustomJs());
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setScrollMode(ScrollMode.valueOf(settings.getScrollMode()));
s.setEntriesToKeepOnTopWhenScrolling(settings.getEntriesToKeepOnTopWhenScrolling());
s.setStarIconDisplayMode(IconDisplayMode.valueOf(settings.getStarIconDisplayMode()));
s.setExternalLinkIconDisplayMode(IconDisplayMode.valueOf(settings.getExternalLinkIconDisplayMode()));
s.setMarkAllAsReadConfirmation(settings.isMarkAllAsReadConfirmation());
s.setCustomContextMenu(settings.isCustomContextMenu());
s.setMobileFooter(settings.isMobileFooter());
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
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.setPocket(settings.getSharingSettings().isPocket());
s.setInstapaper(settings.getSharingSettings().isInstapaper());
s.setBuffer(settings.getSharingSettings().isBuffer());
userSettingsDAO.saveOrUpdate(s);
return Response.ok().build();
}
@Path("/profile")
@GET
@Transactional
@Operation(
summary = "Retrieve user's profile",
responses = { @ApiResponse(content = @Content(schema = @Schema(implementation = UserModel.class))) })
public Response 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 Response.ok(userModel).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 (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).build();
}
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);
}
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));
}
userDAO.saveOrUpdate(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("/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();
}
try {
user.setRecoverPasswordToken(Digests.sha1Hex(UUID.randomUUID().toString()));
user.setRecoverPasswordTokenDate(Instant.now());
userDAO.saveOrUpdate(user);
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 Exception {
String publicUrl = FeedUtils.removeTrailingSlash(uri.getBaseUri().toString());
publicUrl += "/rest/user/passwordResetCallback";
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 Exception {
return new URIBuilder(publicUrl).addParameter("email", user.getEmail())
.addParameter("token", user.getRecoverPasswordToken())
.build()
.toURL()
.toString();
}
@Path("/passwordResetCallback")
@PermitAll
@GET
@Transactional
@Produces(MediaType.TEXT_HTML)
public Response passwordRecoveryCallback(@Parameter(required = true) @QueryParam("email") String email,
@Parameter(required = true) @QueryParam("token") String token) {
Preconditions.checkNotNull(email);
Preconditions.checkNotNull(token);
User user = userDAO.findByEmail(email);
if (user == null) {
return Response.status(Status.UNAUTHORIZED).entity("Email not found.").build();
}
if (user.getRecoverPasswordToken() == null || !user.getRecoverPasswordToken().equals(token)) {
return Response.status(Status.UNAUTHORIZED).entity("Invalid token.").build();
}
if (ChronoUnit.DAYS.between(user.getRecoverPasswordTokenDate(), Instant.now()) >= 2) {
return Response.status(Status.UNAUTHORIZED).entity("token expired.").build();
}
String passwd = RandomStringUtils.secure().nextAlphanumeric(10);
byte[] encryptedPassword = encryptionService.getEncryptedPassword(passwd, user.getSalt());
user.setPassword(encryptedPassword);
if (StringUtils.isNotBlank(user.getApiKey())) {
user.setApiKey(userService.generateApiKey(user));
}
user.setRecoverPasswordToken(null);
user.setRecoverPasswordTokenDate(null);
userDAO.saveOrUpdate(user);
String message = "Your new password is: " + passwd;
message += "<br />";
message += String.format("<a href=\"%s\">Back to Homepage</a>", uri.getBaseUri());
return Response.ok(message).build();
}
@Path("/profile/deleteAccount")
@POST
@Transactional
@Operation(summary = "Delete the user account")
public Response deleteUser() {
User user = authenticationContext.getCurrentUser();
if (CommaFeedApplication.USERNAME_ADMIN.equals(user.getName()) || CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).build();
}
userService.unregister(userDAO.findById(user.getId()));
return Response.ok().build();
}
}

View File

@@ -1,331 +1,331 @@
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.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;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.favicon.Favicon;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverFavicon;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeed;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeedGroup;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverGroup;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverItem;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
/**
* Fever-compatible API
*
* <ul>
* <li>url: /rest/fever/user/${userId}</li>
* <li>login: username</li>
* <li>password: api key</li>
* </ul>
*
* See https://feedafever.com/api
*/
@Path("/rest/fever")
@PermitAll
@Produces(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@Hidden
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 final UserService userService;
private final FeedEntryService feedEntryService;
private final FeedService feedService;
private final FeedEntryDAO feedEntryDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
// expected Fever API
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path(PATH)
@POST
@Transactional
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.get(0)));
form.forEach((k, v) -> params.put(k, v.get(0)));
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
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.get(0)));
return handle(userId, params);
}
// workaround for some readers that use GET instead of POST
// e.g. Unread
@Path(PATH)
@GET
@Transactional
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.get(0)));
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
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.get(0)));
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;
}
FeverResponse resp = new FeverResponse();
resp.setAuth(true);
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")) {
List<FeedCategory> categories = feedCategoryDAO.findAll(user);
resp.setGroups(buildGroups(categories));
}
if (params.containsKey("feeds")) {
resp.setFeeds(buildFeeds(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("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 = params.containsKey("since_id") ? Long.valueOf(params.get("since_id")) : null;
Long maxId = params.containsKey("max_id") ? Long.valueOf(params.get("max_id")) : null;
resp.setItems(buildItems(user, subscriptions, sinceId, maxId));
}
}
if (params.containsKey("favicons")) {
resp.setFavicons(buildFavicons(subscriptions));
}
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);
}
return resp;
}
private Optional<User> auth(Long userId, String feverApiKey) {
return userService.login(userId, feverApiKey);
}
private long buildLastRefreshedOnTime(List<FeedSubscription> subscriptions) {
return subscriptions.stream()
.map(FeedSubscription::getFeed)
.map(Feed::getLastUpdated)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.map(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<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<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, 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<>();
Map<Long, FeedSubscription> subscriptionsByFeedId = subscriptions.stream()
.collect(Collectors.toMap(s -> s.getFeed().getId(), s -> s));
for (String entryId : entryIds) {
FeedEntry entry = feedEntryDAO.findById(Long.parseLong(entryId));
FeedSubscription sub = subscriptionsByFeedId.get(entry.getFeed().getId());
if (sub != null) {
FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry);
items.add(mapStatus(status));
}
}
return items;
}
private List<FeverItem> buildItems(User user, List<FeedSubscription> subscriptions, Long sinceId, Long maxId) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, false, null, null, 0, ITEMS_BATCH_SIZE,
ReadingOrder.desc, false, 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 List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) {
return subscriptions.stream().map(s -> {
Favicon favicon = feedService.fetchFavicon(s.getFeed());
FeverFavicon f = new FeverFavicon();
f.setId(s.getFeed().getId());
f.setData(String.format("data:%s;base64,%s", favicon.getMediaType(), Base64.getEncoder().encodeToString(favicon.getIcon())));
return f;
}).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);
}
}
}
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.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;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.favicon.Favicon;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverFavicon;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeed;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverFeedGroup;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverGroup;
import com.commafeed.frontend.resource.fever.FeverResponse.FeverItem;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
/**
* Fever-compatible API
*
* <ul>
* <li>url: /rest/fever/user/${userId}</li>
* <li>login: username</li>
* <li>password: api key</li>
* </ul>
*
* See https://feedafever.com/api
*/
@Path("/rest/fever")
@PermitAll
@Produces(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
@Singleton
@Hidden
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 final UserService userService;
private final FeedEntryService feedEntryService;
private final FeedService feedService;
private final FeedEntryDAO feedEntryDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
// expected Fever API
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path(PATH)
@POST
@Transactional
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.get(0)));
form.forEach((k, v) -> params.put(k, v.get(0)));
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
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.get(0)));
return handle(userId, params);
}
// workaround for some readers that use GET instead of POST
// e.g. Unread
@Path(PATH)
@GET
@Transactional
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.get(0)));
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
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.get(0)));
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;
}
FeverResponse resp = new FeverResponse();
resp.setAuth(true);
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")) {
List<FeedCategory> categories = feedCategoryDAO.findAll(user);
resp.setGroups(buildGroups(categories));
}
if (params.containsKey("feeds")) {
resp.setFeeds(buildFeeds(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("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 = params.containsKey("since_id") ? Long.valueOf(params.get("since_id")) : null;
Long maxId = params.containsKey("max_id") ? Long.valueOf(params.get("max_id")) : null;
resp.setItems(buildItems(user, subscriptions, sinceId, maxId));
}
}
if (params.containsKey("favicons")) {
resp.setFavicons(buildFavicons(subscriptions));
}
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);
}
return resp;
}
private Optional<User> auth(Long userId, String feverApiKey) {
return userService.login(userId, feverApiKey);
}
private long buildLastRefreshedOnTime(List<FeedSubscription> subscriptions) {
return subscriptions.stream()
.map(FeedSubscription::getFeed)
.map(Feed::getLastUpdated)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.map(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<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<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, 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<>();
Map<Long, FeedSubscription> subscriptionsByFeedId = subscriptions.stream()
.collect(Collectors.toMap(s -> s.getFeed().getId(), s -> s));
for (String entryId : entryIds) {
FeedEntry entry = feedEntryDAO.findById(Long.parseLong(entryId));
FeedSubscription sub = subscriptionsByFeedId.get(entry.getFeed().getId());
if (sub != null) {
FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry);
items.add(mapStatus(status));
}
}
return items;
}
private List<FeverItem> buildItems(User user, List<FeedSubscription> subscriptions, Long sinceId, Long maxId) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, false, null, null, 0, ITEMS_BATCH_SIZE,
ReadingOrder.desc, false, 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 List<FeverFavicon> buildFavicons(List<FeedSubscription> subscriptions) {
return subscriptions.stream().map(s -> {
Favicon favicon = feedService.fetchFavicon(s.getFeed());
FeverFavicon f = new FeverFavicon();
f.setId(s.getFeed().getId());
f.setData(String.format("data:%s;base64,%s", favicon.getMediaType(), Base64.getEncoder().encodeToString(favicon.getIcon())));
return f;
}).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,178 +1,178 @@
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;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
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 lombok.Data;
@JsonInclude(Include.NON_NULL)
@Data
public class FeverResponse {
@JsonProperty("api_version")
private int apiVersion = 3;
@JsonProperty("auth")
@JsonFormat(shape = Shape.NUMBER)
private boolean auth;
@JsonProperty("last_refreshed_on_time")
private Long lastRefreshedOnTime;
@JsonProperty("groups")
private List<FeverGroup> groups;
@JsonProperty("feeds")
private List<FeverFeed> feeds;
@JsonProperty("feeds_groups")
private List<FeverFeedGroup> feedsGroups;
@JsonProperty("unread_item_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@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("items")
private List<FeverItem> items;
@JsonProperty("favicons")
private List<FeverFavicon> favicons;
@JsonProperty("links")
private List<FeverLink> links;
@Data
public static class FeverGroup {
@JsonProperty("id")
private long id;
@JsonProperty("title")
private String title;
}
@Data
public static class FeverFeed {
@JsonProperty("id")
private long id;
@JsonProperty("favicon_id")
private long faviconId;
@JsonProperty("title")
private String title;
@JsonProperty("url")
private String url;
@JsonProperty("site_url")
private String siteUrl;
@JsonProperty("is_spark")
@JsonFormat(shape = Shape.NUMBER)
private boolean spark;
@JsonProperty("last_updated_on_time")
private long lastUpdatedOnTime;
}
@Data
public static class FeverFeedGroup {
@JsonProperty("group_id")
private long groupId;
@JsonProperty("feed_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class)
private List<Long> feedIds;
}
@Data
public static class FeverItem {
@JsonProperty("id")
private long id;
@JsonProperty("feed_id")
private long feedId;
@JsonProperty("title")
private String title;
@JsonProperty("author")
private String author;
@JsonProperty("html")
private String html;
@JsonProperty("url")
private String url;
@JsonProperty("is_saved")
@JsonFormat(shape = Shape.NUMBER)
private boolean saved;
@JsonProperty("is_read")
@JsonFormat(shape = Shape.NUMBER)
private boolean read;
@JsonProperty("created_on_time")
private long createdOnTime;
}
@Data
public static class FeverFavicon {
@JsonProperty("id")
private long id;
@JsonProperty("data")
private String data;
}
@Data
public static class FeverLink {
}
public static class LongListToCommaSeparatedStringSerializer extends JsonSerializer<List<Long>> {
@Override
public void serialize(List<Long> input, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
String output = input.stream().map(String::valueOf).collect(Collectors.joining(","));
jsonGenerator.writeObject(output);
}
}
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();
}
}
}
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;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
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 lombok.Data;
@JsonInclude(Include.NON_NULL)
@Data
public class FeverResponse {
@JsonProperty("api_version")
private int apiVersion = 3;
@JsonProperty("auth")
@JsonFormat(shape = Shape.NUMBER)
private boolean auth;
@JsonProperty("last_refreshed_on_time")
private Long lastRefreshedOnTime;
@JsonProperty("groups")
private List<FeverGroup> groups;
@JsonProperty("feeds")
private List<FeverFeed> feeds;
@JsonProperty("feeds_groups")
private List<FeverFeedGroup> feedsGroups;
@JsonProperty("unread_item_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@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("items")
private List<FeverItem> items;
@JsonProperty("favicons")
private List<FeverFavicon> favicons;
@JsonProperty("links")
private List<FeverLink> links;
@Data
public static class FeverGroup {
@JsonProperty("id")
private long id;
@JsonProperty("title")
private String title;
}
@Data
public static class FeverFeed {
@JsonProperty("id")
private long id;
@JsonProperty("favicon_id")
private long faviconId;
@JsonProperty("title")
private String title;
@JsonProperty("url")
private String url;
@JsonProperty("site_url")
private String siteUrl;
@JsonProperty("is_spark")
@JsonFormat(shape = Shape.NUMBER)
private boolean spark;
@JsonProperty("last_updated_on_time")
private long lastUpdatedOnTime;
}
@Data
public static class FeverFeedGroup {
@JsonProperty("group_id")
private long groupId;
@JsonProperty("feed_ids")
@JsonSerialize(using = LongListToCommaSeparatedStringSerializer.class)
@JsonDeserialize(using = CommaSeparatedStringToLongListDeserializer.class)
private List<Long> feedIds;
}
@Data
public static class FeverItem {
@JsonProperty("id")
private long id;
@JsonProperty("feed_id")
private long feedId;
@JsonProperty("title")
private String title;
@JsonProperty("author")
private String author;
@JsonProperty("html")
private String html;
@JsonProperty("url")
private String url;
@JsonProperty("is_saved")
@JsonFormat(shape = Shape.NUMBER)
private boolean saved;
@JsonProperty("is_read")
@JsonFormat(shape = Shape.NUMBER)
private boolean read;
@JsonProperty("created_on_time")
private long createdOnTime;
}
@Data
public static class FeverFavicon {
@JsonProperty("id")
private long id;
@JsonProperty("data")
private String data;
}
@Data
public static class FeverLink {
}
public static class LongListToCommaSeparatedStringSerializer extends JsonSerializer<List<Long>> {
@Override
public void serialize(List<Long> input, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
String output = input.stream().map(String::valueOf).collect(Collectors.joining(","));
jsonGenerator.writeObject(output);
}
}
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,41 +1,41 @@
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 com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.security.AuthenticationContext;
import lombok.RequiredArgsConstructor;
@Path("/custom_css.css")
@Produces("text/css")
@RequiredArgsConstructor
@Singleton
public class CustomCssServlet {
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
@GET
@Transactional
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings == null) {
return "";
}
return settings.getCustomCss();
}
}
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 com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.security.AuthenticationContext;
import lombok.RequiredArgsConstructor;
@Path("/custom_css.css")
@Produces("text/css")
@RequiredArgsConstructor
@Singleton
public class CustomCssServlet {
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
@GET
@Transactional
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings == null) {
return "";
}
return settings.getCustomCss();
}
}

View File

@@ -1,41 +1,41 @@
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 com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.security.AuthenticationContext;
import lombok.RequiredArgsConstructor;
@Path("/custom_js.js")
@Produces("application/javascript")
@RequiredArgsConstructor
@Singleton
public class CustomJsServlet {
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
@GET
@Transactional
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings == null) {
return "";
}
return settings.getCustomJs();
}
}
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 com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.security.AuthenticationContext;
import lombok.RequiredArgsConstructor;
@Path("/custom_js.js")
@Produces("application/javascript")
@RequiredArgsConstructor
@Singleton
public class CustomJsServlet {
private final AuthenticationContext authenticationContext;
private final UserSettingsDAO userSettingsDAO;
@GET
@Transactional
public String get() {
User user = authenticationContext.getCurrentUser();
if (user == null) {
return "";
}
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings == null) {
return "";
}
return settings.getCustomJs();
}
}

View File

@@ -1,34 +1,34 @@
package com.commafeed.frontend.servlet;
import java.time.Instant;
import java.util.Date;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.ws.rs.GET;
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.config.inject.ConfigProperty;
@Path("/logout")
@PermitAll
@Singleton
public class LogoutServlet {
private final UriInfo uri;
private final String cookieName;
public LogoutServlet(UriInfo uri, @ConfigProperty(name = "quarkus.http.auth.form.cookie-name") String cookieName) {
this.uri = uri;
this.cookieName = cookieName;
}
@GET
public Response get() {
NewCookie removeCookie = new NewCookie.Builder(cookieName).maxAge(0).expiry(Date.from(Instant.EPOCH)).path("/").build();
return Response.temporaryRedirect(uri.getBaseUri()).cookie(removeCookie).build();
}
}
package com.commafeed.frontend.servlet;
import java.time.Instant;
import java.util.Date;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.ws.rs.GET;
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.config.inject.ConfigProperty;
@Path("/logout")
@PermitAll
@Singleton
public class LogoutServlet {
private final UriInfo uri;
private final String cookieName;
public LogoutServlet(UriInfo uri, @ConfigProperty(name = "quarkus.http.auth.form.cookie-name") String cookieName) {
this.uri = uri;
this.cookieName = cookieName;
}
@GET
public Response get() {
NewCookie removeCookie = new NewCookie.Builder(cookieName).maxAge(0).expiry(Date.from(Instant.EPOCH)).path("/").build();
return Response.temporaryRedirect(uri.getBaseUri()).cookie(removeCookie).build();
}
}

View File

@@ -1,75 +1,75 @@
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 com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.frontend.resource.CategoryREST;
import com.commafeed.security.AuthenticationContext;
import com.google.common.collect.Iterables;
import lombok.RequiredArgsConstructor;
@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;
@GET
@Transactional
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 = Iterables.getFirst(statuses, 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 = Iterables.getFirst(statuses, 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();
}
}
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 com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.frontend.resource.CategoryREST;
import com.commafeed.security.AuthenticationContext;
import com.google.common.collect.Iterables;
import lombok.RequiredArgsConstructor;
@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;
@GET
@Transactional
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 = Iterables.getFirst(statuses, 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 = Iterables.getFirst(statuses, 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();
}
}

View File

@@ -1,34 +1,34 @@
package com.commafeed.frontend.servlet;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.apache.hc.core5.http.HttpStatus;
import com.commafeed.CommaFeedConfiguration;
import lombok.RequiredArgsConstructor;
@Path("/robots.txt")
@PermitAll
@Produces(MediaType.TEXT_PLAIN)
@RequiredArgsConstructor
@Singleton
public class RobotsTxtDisallowAllServlet {
private final CommaFeedConfiguration config;
@GET
public Response get() {
if (config.hideFromWebCrawlers()) {
return Response.ok("User-agent: *\nDisallow: /").build();
} else {
return Response.status(HttpStatus.SC_NOT_FOUND).build();
}
}
package com.commafeed.frontend.servlet;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Singleton;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.apache.hc.core5.http.HttpStatus;
import com.commafeed.CommaFeedConfiguration;
import lombok.RequiredArgsConstructor;
@Path("/robots.txt")
@PermitAll
@Produces(MediaType.TEXT_PLAIN)
@RequiredArgsConstructor
@Singleton
public class RobotsTxtDisallowAllServlet {
private final CommaFeedConfiguration config;
@GET
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,64 +1,64 @@
package com.commafeed.frontend.ws;
import java.io.IOException;
import jakarta.inject.Singleton;
import jakarta.websocket.CloseReason;
import jakarta.websocket.CloseReason.CloseCodes;
import jakarta.websocket.OnClose;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Singleton
@ServerEndpoint("/ws")
@RequiredArgsConstructor
public class WebSocketEndpoint {
private final AuthenticationContext authenticationContext;
private final CommaFeedConfiguration config;
private final WebSocketSessions sessions;
@OnOpen
public void onOpen(Session session) {
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);
}
@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) {
try {
session.close(new CloseReason(CloseCodes.VIOLATED_POLICY, "unauthorized"));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
package com.commafeed.frontend.ws;
import java.io.IOException;
import jakarta.inject.Singleton;
import jakarta.websocket.CloseReason;
import jakarta.websocket.CloseReason.CloseCodes;
import jakarta.websocket.OnClose;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Singleton
@ServerEndpoint("/ws")
@RequiredArgsConstructor
public class WebSocketEndpoint {
private final AuthenticationContext authenticationContext;
private final CommaFeedConfiguration config;
private final WebSocketSessions sessions;
@OnOpen
public void onOpen(Session session) {
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);
}
@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) {
try {
session.close(new CloseReason(CloseCodes.VIOLATED_POLICY, "unauthorized"));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -1,14 +1,14 @@
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);
}
}
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);
}
}

View File

@@ -1,49 +1,49 @@
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 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<>();
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 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);
}
}
}
}
}
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 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<>();
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 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);
}
}
}
}
}