split client and server into maven modules

This commit is contained in:
Athou
2022-08-13 10:34:59 +02:00
parent 4c4868a2b6
commit ac7b6eeb21
277 changed files with 645 additions and 521 deletions

View File

@@ -0,0 +1,59 @@
package com.commafeed.frontend.auth;
import java.util.List;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import org.apache.commons.lang3.StringUtils;
import org.passay.CharacterRule;
import org.passay.EnglishCharacterData;
import org.passay.LengthRule;
import org.passay.PasswordData;
import org.passay.PasswordValidator;
import org.passay.RuleResult;
import org.passay.WhitespaceRule;
public class PasswordConstraintValidator implements ConstraintValidator<ValidPassword, String> {
@Override
public void initialize(ValidPassword constraintAnnotation) {
// nothing to do
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (StringUtils.isBlank(value)) {
return true;
}
PasswordValidator validator = buildPasswordValidator();
RuleResult result = validator.validate(new PasswordData(value));
if (result.isValid()) {
return true;
}
List<String> messages = validator.getMessages(result);
String message = String.join(System.lineSeparator(), messages);
context.buildConstraintViolationWithTemplate(message).addConstraintViolation().disableDefaultConstraintViolation();
return false;
}
private PasswordValidator buildPasswordValidator() {
return new PasswordValidator(
// length
new LengthRule(8, 128),
// 1 uppercase char
new CharacterRule(EnglishCharacterData.UpperCase, 1),
// 1 lowercase char
new CharacterRule(EnglishCharacterData.LowerCase, 1),
// 1 digit
new CharacterRule(EnglishCharacterData.Digit, 1),
// 1 special char
new CharacterRule(EnglishCharacterData.Special, 1),
// no whitespace
new WhitespaceRule());
}
}

View File

@@ -0,0 +1,22 @@
package com.commafeed.frontend.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import com.commafeed.backend.model.UserRole.Role;
@Inherited
@Target({ ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface SecurityCheck {
/**
* Roles needed.
*/
Role value() default Role.USER;
boolean apiKeyAllowed() default false;
}

View File

@@ -0,0 +1,99 @@
package com.commafeed.frontend.auth;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.glassfish.jersey.server.ContainerRequest;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserRole.Role;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.session.SessionHelper;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class SecurityCheckFactory implements Function<ContainerRequest, User> {
private static final String PREFIX = "Basic";
private final UserService userService;
private final HttpServletRequest request;
private final Role role;
private final boolean apiKeyAllowed;
@Override
public User apply(ContainerRequest req) {
Optional<User> user = apiKeyLogin();
if (!user.isPresent()) {
user = basicAuthenticationLogin();
}
if (!user.isPresent()) {
user = cookieSessionLogin(new SessionHelper(request));
}
if (user.isPresent()) {
Set<Role> roles = userService.getRoles(user.get());
if (roles.contains(role)) {
return user.get();
} else {
throw new WebApplicationException(Response.status(Response.Status.FORBIDDEN)
.entity("You don't have the required role to access this resource.")
.type(MediaType.TEXT_PLAIN_TYPE)
.build());
}
} else {
throw new WebApplicationException(Response.status(Response.Status.UNAUTHORIZED)
.entity("Credentials are required to access this resource.")
.type(MediaType.TEXT_PLAIN_TYPE)
.build());
}
}
Optional<User> cookieSessionLogin(SessionHelper sessionHelper) {
Optional<User> loggedInUser = sessionHelper.getLoggedInUser();
if (loggedInUser.isPresent()) {
userService.performPostLoginActivities(loggedInUser.get());
}
return loggedInUser;
}
private Optional<User> basicAuthenticationLogin() {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null) {
int space = header.indexOf(' ');
if (space > 0) {
String method = header.substring(0, space);
if (PREFIX.equalsIgnoreCase(method)) {
byte[] decodedBytes = Base64.getDecoder().decode(header.substring(space + 1));
String decoded = new String(decodedBytes, StandardCharsets.ISO_8859_1);
int i = decoded.indexOf(':');
if (i > 0) {
String username = decoded.substring(0, i);
String password = decoded.substring(i + 1);
return userService.login(username, password);
}
}
}
}
return Optional.empty();
}
private Optional<User> apiKeyLogin() {
String apiKey = request.getParameter("apiKey");
if (apiKey != null && apiKeyAllowed) {
return userService.login(apiKey);
}
return Optional.empty();
}
}

View File

@@ -0,0 +1,63 @@
package com.commafeed.frontend.auth;
import java.util.function.Function;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.http.HttpServletRequest;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
import org.glassfish.jersey.server.ContainerRequest;
import org.glassfish.jersey.server.internal.inject.AbstractValueParamProvider;
import org.glassfish.jersey.server.internal.inject.MultivaluedParameterExtractorProvider;
import org.glassfish.jersey.server.model.Parameter;
import org.glassfish.jersey.server.spi.internal.ValueParamProvider;
import com.commafeed.backend.model.User;
import com.commafeed.backend.service.UserService;
import lombok.RequiredArgsConstructor;
@Singleton
public class SecurityCheckFactoryProvider extends AbstractValueParamProvider {
private UserService userService;
private HttpServletRequest request;
@Inject
public SecurityCheckFactoryProvider(final MultivaluedParameterExtractorProvider extractorProvider, UserService userService,
HttpServletRequest request) {
super(() -> extractorProvider, Parameter.Source.UNKNOWN);
this.userService = userService;
this.request = request;
}
@Override
protected Function<ContainerRequest, ?> createValueProvider(Parameter parameter) {
final Class<?> classType = parameter.getRawType();
SecurityCheck securityCheck = parameter.getAnnotation(SecurityCheck.class);
if (securityCheck == null) {
return null;
}
if (!classType.isAssignableFrom(User.class)) {
return null;
}
return new SecurityCheckFactory(userService, request, securityCheck.value(), securityCheck.apiKeyAllowed());
}
@RequiredArgsConstructor
public static class Binder extends AbstractBinder {
private final UserService userService;
@Override
protected void configure() {
bind(SecurityCheckFactoryProvider.class).to(ValueParamProvider.class).in(Singleton.class);
bind(userService).to(UserService.class);
}
}
}

View File

@@ -0,0 +1,23 @@
package com.commafeed.frontend.auth;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
@Documented
@Constraint(validatedBy = PasswordConstraintValidator.class)
@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPassword {
String message() default "Invalid Password";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -0,0 +1,36 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Entry details")
@Data
public class Category implements Serializable {
@ApiModelProperty(value = "category id", required = true)
private String id;
@ApiModelProperty(value = "parent category id")
private String parentId;
@ApiModelProperty(value = "category id", required = true)
private String name;
@ApiModelProperty(value = "category children categories", required = true)
private List<Category> children = new ArrayList<>();
@ApiModelProperty(value = "category feeds", required = true)
private List<Subscription> feeds = new ArrayList<>();
@ApiModelProperty(value = "wether the category is expanded or collapsed", required = true)
private boolean expanded;
@ApiModelProperty(value = "position of the category in the list", required = true)
private Integer position;
}

View File

@@ -0,0 +1,48 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "List of entries with some metadata")
@Data
public class Entries implements Serializable {
@ApiModelProperty(value = "name of the feed or the category requested", required = true)
private String name;
@ApiModelProperty(value = "error or warning message")
private String message;
@ApiModelProperty(value = "times the server tried to refresh the feed and failed", required = true)
private int errorCount;
@ApiModelProperty(value = "URL of the website, extracted from the feed", required = true)
private String feedLink;
@ApiModelProperty(value = "list generation timestamp", required = true)
private long timestamp;
@ApiModelProperty(value = "if the query has more elements", required = true)
private boolean hasMore;
@ApiModelProperty(value = "the requested offset")
private int offset;
@ApiModelProperty(value = "the requested limit")
private int limit;
@ApiModelProperty(value = "list of entries", required = true)
private List<Entry> entries = new ArrayList<>();
@ApiModelProperty(
value = "if true, the unread flag was ignored in the request, all entries are returned regardless of their read status",
required = true)
private boolean ignoredReadStatus;
}

View File

@@ -0,0 +1,175 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
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.SyndContent;
import com.rometools.rome.feed.synd.SyndContentImpl;
import com.rometools.rome.feed.synd.SyndEnclosure;
import com.rometools.rome.feed.synd.SyndEnclosureImpl;
import com.rometools.rome.feed.synd.SyndEntry;
import com.rometools.rome.feed.synd.SyndEntryImpl;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Entry details")
@Data
public class Entry implements Serializable {
@ApiModelProperty(value = "entry id", required = true)
private String id;
@ApiModelProperty(value = "entry guid", required = true)
private String guid;
@ApiModelProperty(value = "entry title", required = true)
private String title;
@ApiModelProperty(value = "entry content", required = true)
private String content;
@ApiModelProperty(value = "comma-separated list of categories")
private String categories;
@ApiModelProperty(value = "wether entry content and title are rtl", required = true)
private boolean rtl;
@ApiModelProperty(value = "entry author")
private String author;
@ApiModelProperty(value = "entry enclosure url, if any")
private String enclosureUrl;
@ApiModelProperty(value = "entry enclosure mime type, if any")
private String enclosureType;
@ApiModelProperty(value = "entry media description, if any")
private String mediaDescription;
@ApiModelProperty(value = "entry media thumbnail url, if any")
private String mediaThumbnailUrl;
@ApiModelProperty(value = "entry media thumbnail width, if any")
private Integer mediaThumbnailWidth;
@ApiModelProperty(value = "entry media thumbnail height, if any")
private Integer mediaThumbnailHeight;
@ApiModelProperty(value = "entry publication date", dataType = "number", required = true)
private Date date;
@ApiModelProperty(value = "entry insertion date in the database", dataType = "number", required = true)
private Date insertedDate;
@ApiModelProperty(value = "feed id", required = true)
private String feedId;
@ApiModelProperty(value = "feed name", required = true)
private String feedName;
@ApiModelProperty(value = "this entry's feed url", required = true)
private String feedUrl;
@ApiModelProperty(value = "this entry's website url", required = true)
private String feedLink;
@ApiModelProperty(value = "The favicon url to use for this feed", required = true)
private String iconUrl;
@ApiModelProperty(value = "entry url", required = true)
private String url;
@ApiModelProperty(value = "read status", required = true)
private boolean read;
@ApiModelProperty(value = "starred status", required = true)
private boolean starred;
@ApiModelProperty(value = "wether the entry is still markable (old entry statuses are discarded)", required = true)
private boolean markable;
@ApiModelProperty(value = "tags", required = true)
private List<String> tags;
public static Entry build(FeedEntryStatus status, String publicUrl, 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.getUpdated());
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, publicUrl));
entry.setTags(status.getTags().stream().map(FeedEntryTag::getName).collect(Collectors.toList()));
if (content != null) {
entry.setRtl(FeedUtils.isRTL(feedEntry));
entry.setTitle(content.getTitle());
entry.setContent(proxyImages ? FeedUtils.proxyImages(content.getContent(), publicUrl) : content.getContent());
entry.setAuthor(content.getAuthor());
entry.setEnclosureType(content.getEnclosureType());
entry.setEnclosureUrl(proxyImages && StringUtils.contains(content.getEnclosureType(), "image")
? FeedUtils.proxyImage(content.getEnclosureUrl(), publicUrl)
: content.getEnclosureUrl());
entry.setMediaDescription(content.getMediaDescription());
entry.setMediaThumbnailUrl(
proxyImages ? FeedUtils.proxyImage(content.getMediaThumbnailUrl(), publicUrl) : 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(Arrays.<SyndContent> asList(content));
if (getEnclosureUrl() != null) {
SyndEnclosureImpl enclosure = new SyndEnclosureImpl();
enclosure.setType(getEnclosureType());
enclosure.setUrl(getEnclosureUrl());
entry.setEnclosures(Arrays.<SyndEnclosure> asList(enclosure));
}
entry.setLink(getUrl());
entry.setPublishedDate(getDate());
return entry;
}
}

View File

@@ -0,0 +1,20 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Feed details")
@Data
public class FeedInfo implements Serializable {
@ApiModelProperty(value = "url", required = true)
private String url;
@ApiModelProperty(value = "title", required = true)
private String title;
}

View File

@@ -0,0 +1,32 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Server infos")
@Data
public class ServerInfo implements Serializable {
@ApiModelProperty
private String announcement;
@ApiModelProperty(required = true)
private String version;
@ApiModelProperty(required = true)
private String gitCommit;
@ApiModelProperty(required = true)
private boolean allowRegistrations;
@ApiModelProperty
private String googleAnalyticsCode;
@ApiModelProperty(required = true)
private boolean smtpEnabled;
}

View File

@@ -0,0 +1,64 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "User settings")
@Data
public class Settings implements Serializable {
@ApiModelProperty(value = "user's preferred language, english if none", required = true)
private String language;
@ApiModelProperty(value = "user reads all entries or unread entries only", allowableValues = "all,unread", required = true)
private String readingMode;
@ApiModelProperty(value = "user reads entries in ascending or descending order", allowableValues = "asc,desc", required = true)
private String readingOrder;
@ApiModelProperty(value = "user viewing mode, either title-only or expande view", allowableValues = "title,expanded", required = true)
private String viewMode;
@ApiModelProperty(value = "user wants category and feeds with no unread entries shown", required = true)
private boolean showRead;
@ApiModelProperty(value = "In expanded view, scroll through entries mark them as read", required = true)
private boolean scrollMarks;
@ApiModelProperty(value = "user's selected theme")
private String theme;
@ApiModelProperty(value = "user's custom css for the website")
private String customCss;
@ApiModelProperty(value = "user's preferred scroll speed when navigating between entries", required = true)
private int scrollSpeed;
@ApiModelProperty(required = true)
private boolean email;
@ApiModelProperty(required = true)
private boolean gmail;
@ApiModelProperty(required = true)
private boolean facebook;
@ApiModelProperty(required = true)
private boolean twitter;
@ApiModelProperty(required = true)
private boolean tumblr;
@ApiModelProperty(required = true)
private boolean pocket;
@ApiModelProperty(required = true)
private boolean instapaper;
@ApiModelProperty(required = true)
private boolean buffer;
}

View File

@@ -0,0 +1,87 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.util.Date;
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.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "User information")
@Data
public class Subscription implements Serializable {
@ApiModelProperty(value = "subscription id", required = true)
private Long id;
@ApiModelProperty(value = "subscription name", required = true)
private String name;
@ApiModelProperty(value = "error message while fetching the feed", required = true)
private String message;
@ApiModelProperty(value = "error count", required = true)
private int errorCount;
@ApiModelProperty(value = "last time the feed was refreshed", dataType = "number", required = true)
private Date lastRefresh;
@ApiModelProperty(
value = "next time the feed refresh is planned, null if refresh is already queued",
dataType = "number",
required = true)
private Date nextRefresh;
@ApiModelProperty(value = "this subscription's feed url", required = true)
private String feedUrl;
@ApiModelProperty(value = "this subscription's website url", required = true)
private String feedLink;
@ApiModelProperty(value = "The favicon url to use for this feed", required = true)
private String iconUrl;
@ApiModelProperty(value = "unread count", required = true)
private long unread;
@ApiModelProperty(value = "category id")
private String categoryId;
@ApiModelProperty("position of the subscription's in the list")
private Integer position;
@ApiModelProperty(value = "date of the newest item", dataType = "number")
private Date newestItemTime;
@ApiModelProperty(value = "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, String publicUrl, UnreadCount unreadCount) {
Date now = new Date();
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, publicUrl));
sub.setLastRefresh(feed.getLastUpdated());
sub.setNextRefresh((feed.getDisabledUntil() != null && feed.getDisabledUntil().before(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

@@ -0,0 +1,33 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.util.Date;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Unread count")
@Data
public class UnreadCount implements Serializable {
@ApiModelProperty
private long feedId;
@ApiModelProperty
private long unreadCount;
@ApiModelProperty(dataType = "number")
private Date newestItemTime;
public UnreadCount() {
}
public UnreadCount(long feedId, long unreadCount, Date newestItemTime) {
this.feedId = feedId;
this.unreadCount = unreadCount;
this.newestItemTime = newestItemTime;
}
}

View File

@@ -0,0 +1,42 @@
package com.commafeed.frontend.model;
import java.io.Serializable;
import java.util.Date;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "User information")
@Data
public class UserModel implements Serializable {
@ApiModelProperty(value = "user id", required = true)
private Long id;
@ApiModelProperty(value = "user name", required = true)
private String name;
@ApiModelProperty("user email, if any")
private String email;
@ApiModelProperty("api key")
private String apiKey;
@ApiModelProperty(value = "user password, never returned by the api")
private String password;
@ApiModelProperty(value = "account status", required = true)
private boolean enabled;
@ApiModelProperty(value = "account creation date", dataType = "number", required = true)
private Date created;
@ApiModelProperty(value = "last login date", dataType = "number")
private Date lastLogin;
@ApiModelProperty(value = "user is admin", required = true)
private boolean admin;
}

View File

@@ -0,0 +1,26 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Add Category Request")
@Data
public class AddCategoryRequest implements Serializable {
@ApiModelProperty(value = "name", required = true)
@NotEmpty
@Size(max = 128)
private String name;
@ApiModelProperty(value = "parent category id, if any")
@Size(max = 128)
private String parentId;
}

View File

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

View File

@@ -0,0 +1,20 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Mark Request")
@Data
public class CollapseRequest implements Serializable {
@ApiModelProperty(value = "category id", required = true)
private Long id;
@ApiModelProperty(value = "collapse", required = true)
private boolean collapse;
}

View File

@@ -0,0 +1,22 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Feed information request")
@Data
public class FeedInfoRequest implements Serializable {
@ApiModelProperty(value = "feed url", required = true)
@NotEmpty
@Size(max = 4096)
private String url;
}

View File

@@ -0,0 +1,34 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import javax.validation.constraints.Size;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Feed modification request")
@Data
public class FeedModificationRequest implements Serializable {
@ApiModelProperty(value = "id", required = true)
private Long id;
@ApiModelProperty(value = "new name, null if not changed")
@Size(max = 128)
private String name;
@ApiModelProperty(value = "new parent category id")
@Size(max = 128)
private String categoryId;
@ApiModelProperty(value = "new display position, null if not changed")
private Integer position;
@ApiModelProperty(value = "JEXL string evaluated on new entries to mark them as read if they do not match")
@Size(max = 4096)
private String filter;
}

View File

@@ -0,0 +1,17 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel
@Data
public class IDRequest implements Serializable {
@ApiModelProperty(required = true)
private Long id;
}

View File

@@ -0,0 +1,25 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@Data
@ApiModel
public class LoginRequest implements Serializable {
@ApiModelProperty(value = "username", required = true)
@Size(min = 3, max = 32)
private String name;
@ApiModelProperty(value = "password", required = true)
@NotEmpty
@Size(max = 128)
private String password;
}

View File

@@ -0,0 +1,38 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import java.util.List;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Mark Request")
@Data
public class MarkRequest implements Serializable {
@ApiModelProperty(value = "entry id, category id, 'all' or 'starred'", required = true)
@NotEmpty
@Size(max = 128)
private String id;
@ApiModelProperty(value = "mark as read or unread", required = true)
private boolean read;
@ApiModelProperty(
value = "only entries older than this, pass the timestamp you got from the entry list to prevent marking an entry that was not retrieved",
required = false)
private Long olderThan;
@ApiModelProperty(value = "only mark read if a feed has these keywords in the title or rss content", required = false)
@Size(max = 128)
private String keywords;
@ApiModelProperty(value = "if marking a category or 'all', exclude those subscriptions from the marking", required = false)
private List<Long> excludedSubscriptions;
}

View File

@@ -0,0 +1,20 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import java.util.List;
import javax.validation.Valid;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Multiple Mark Request")
@Data
public class MultipleMarkRequest implements Serializable {
@ApiModelProperty(value = "list of mark requests", required = true)
private List<@Valid MarkRequest> requests;
}

View File

@@ -0,0 +1,23 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@Data
@ApiModel
public class PasswordResetRequest implements Serializable {
@ApiModelProperty(value = "email address for password recovery", required = true)
@Email
@NotEmpty
@Size(max = 255)
private String email;
}

View File

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

View File

@@ -0,0 +1,36 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import com.commafeed.frontend.auth.ValidPassword;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@Data
@ApiModel
public class RegistrationRequest implements Serializable {
@ApiModelProperty(value = "username, between 3 and 32 characters", required = true)
@NotEmpty
@Size(min = 3, max = 32)
private String name;
@ApiModelProperty(value = "password, minimum 6 characters", required = true)
@NotEmpty
@ValidPassword
private String password;
@ApiModelProperty(value = "email address for password recovery", required = true)
@Email
@NotEmpty
@Size(max = 255)
private String email;
}

View File

@@ -0,0 +1,28 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Star Request")
@Data
public class StarRequest implements Serializable {
@ApiModelProperty(value = "id", required = true)
@NotEmpty
@Size(max = 128)
private String id;
@ApiModelProperty(value = "feed id", required = true)
private Long feedId;
@ApiModelProperty(value = "starred or not", required = true)
private boolean starred;
}

View File

@@ -0,0 +1,31 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Subscription request")
@Data
public class SubscribeRequest implements Serializable {
@ApiModelProperty(value = "url of the feed", required = true)
@NotEmpty
@Size(max = 4096)
private String url;
@ApiModelProperty(value = "name of the feed for the user", required = true)
@NotEmpty
@Size(max = 128)
private String title;
@ApiModelProperty(value = "id of the user category to place the feed in")
@Size(max = 128)
private String categoryId;
}

View File

@@ -0,0 +1,21 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import java.util.List;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@SuppressWarnings("serial")
@ApiModel(description = "Tag Request")
@Data
public class TagRequest implements Serializable {
@ApiModelProperty(value = "entry id", required = true)
private Long entryId;
@ApiModelProperty(value = "tags", required = true)
private List<String> tags;
}

View File

@@ -0,0 +1,201 @@
package com.commafeed.frontend.resource;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.CommaFeedConfiguration.ApplicationSettings;
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.auth.SecurityCheck;
import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.IDRequest;
import com.google.common.base.Preconditions;
import com.google.common.collect.Sets;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
@Path("/admin")
@Api(value = "/admin")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class AdminREST {
private final UserDAO userDAO;
private final UserRoleDAO userRoleDAO;
private final UserService userService;
private final PasswordEncryptionService encryptionService;
private final CommaFeedConfiguration config;
private final MetricRegistry metrics;
@Path("/user/save")
@POST
@UnitOfWork
@ApiOperation(value = "Save or update a user", notes = "Save or update a user. If the id is not specified, a new user will be created")
@Timed
public Response adminSaveUser(@ApiParam(hidden = true) @SecurityCheck(Role.ADMIN) User user,
@ApiParam(required = true) UserModel userModel) {
Preconditions.checkNotNull(userModel);
Preconditions.checkNotNull(userModel.getName());
Long id = userModel.getId();
if (id == null) {
Preconditions.checkNotNull(userModel.getPassword());
Set<Role> roles = Sets.newHashSet(Role.USER);
if (userModel.isAdmin()) {
roles.add(Role.ADMIN);
}
try {
userService.register(userModel.getName(), userModel.getPassword(), userModel.getEmail(), roles, true);
} catch (Exception e) {
return Response.status(Status.CONFLICT).entity(e.getMessage()).build();
}
} else {
if (userModel.getId().equals(user.getId()) && !userModel.isEnabled()) {
return Response.status(Status.FORBIDDEN).entity("You cannot disable your own account.").build();
}
User u = userDAO.findById(id);
u.setName(userModel.getName());
if (StringUtils.isNotBlank(userModel.getPassword())) {
u.setPassword(encryptionService.getEncryptedPassword(userModel.getPassword(), u.getSalt()));
}
u.setEmail(userModel.getEmail());
u.setDisabled(!userModel.isEnabled());
userDAO.saveOrUpdate(u);
Set<Role> roles = userRoleDAO.findRoles(u);
if (userModel.isAdmin() && !roles.contains(Role.ADMIN)) {
userRoleDAO.saveOrUpdate(new UserRole(u, Role.ADMIN));
} else if (!userModel.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
@UnitOfWork
@ApiOperation(value = "Get user information", notes = "Get user information", response = UserModel.class)
@Timed
public Response adminGetUser(@ApiParam(hidden = true) @SecurityCheck(Role.ADMIN) User user,
@ApiParam(value = "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
@UnitOfWork
@ApiOperation(value = "Get all users", notes = "Get all users", response = UserModel.class, responseContainer = "List")
@Timed
public Response adminGetUsers(@ApiParam(hidden = true) @SecurityCheck(Role.ADMIN) User user) {
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
@UnitOfWork
@ApiOperation(value = "Delete a user", notes = "Delete a user, and all his subscriptions")
@Timed
public Response adminDeleteUser(@ApiParam(hidden = true) @SecurityCheck(Role.ADMIN) User user,
@ApiParam(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();
}
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("/settings")
@GET
@UnitOfWork
@ApiOperation(value = "Retrieve application settings", notes = "Retrieve application settings", response = ApplicationSettings.class)
@Timed
public Response getApplicationSettings(@ApiParam(hidden = true) @SecurityCheck(Role.ADMIN) User user) {
return Response.ok(config.getApplicationSettings()).build();
}
@Path("/metrics")
@GET
@UnitOfWork
@ApiOperation(value = "Retrieve server metrics")
@Timed
public Response getMetrics(@ApiParam(hidden = true) @SecurityCheck(Role.ADMIN) User user) {
return Response.ok(metrics).build();
}
}

View File

@@ -0,0 +1,492 @@
package com.commafeed.frontend.resource;
import java.io.StringWriter;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.cache.CacheService;
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.auth.SecurityCheck;
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.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.dropwizard.hibernate.UnitOfWork;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/category")
@Api(value = "/category")
@Slf4j
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class CategoryREST {
public static final String ALL = "all";
public static final String STARRED = "starred";
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryService feedEntryService;
private final FeedSubscriptionService feedSubscriptionService;
private final CacheService cache;
private final CommaFeedConfiguration config;
@Path("/entries")
@GET
@UnitOfWork
@ApiOperation(value = "Get category entries", notes = "Get a list of category entries", response = Entries.class)
@Timed
public Response getCategoryEntries(@ApiParam(hidden = true) @SecurityCheck User user,
@ApiParam(value = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id,
@ApiParam(
value = "all entries or only unread ones",
allowableValues = "all,unread",
required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType,
@ApiParam(value = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@ApiParam(value = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@ApiParam(value = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@ApiParam(
value = "ordering",
allowableValues = "asc,desc,abc,zyx") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@ApiParam(
value = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
@ApiParam(value = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds,
@ApiParam(
value = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
@ApiParam(value = "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;
}
Date newerThanDate = newerThan == null ? null : new Date(newerThan);
List<Long> excludedIds = null;
if (StringUtils.isNotEmpty(excludedSubscriptionIds)) {
excludedIds = Arrays.stream(excludedSubscriptionIds.split(",")).map(Long::valueOf).collect(Collectors.toList());
}
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, onlyIds, tag);
for (FeedEntryStatus status : list) {
entries.getEntries()
.add(Entry.build(status, config.getApplicationSettings().getPublicUrl(),
config.getApplicationSettings().getImageProxyEnabled()));
}
} else if (STARRED.equals(id)) {
entries.setName("Starred");
List<FeedEntryStatus> starred = feedEntryStatusDAO.findStarred(user, newerThanDate, offset, limit + 1, order, !onlyIds);
for (FeedEntryStatus status : starred) {
entries.getEntries()
.add(Entry.build(status, config.getApplicationSettings().getPublicUrl(),
config.getApplicationSettings().getImageProxyEnabled()));
}
} 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, onlyIds, tag);
for (FeedEntryStatus status : list) {
entries.getEntries()
.add(Entry.build(status, config.getApplicationSettings().getPublicUrl(),
config.getApplicationSettings().getImageProxyEnabled()));
}
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
@UnitOfWork
@ApiOperation(value = "Get category entries as feed", notes = "Get a feed of category entries")
@Produces(MediaType.APPLICATION_XML)
@Timed
public Response getCategoryEntriesAsFeed(@ApiParam(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user,
@ApiParam(value = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id,
@ApiParam(
value = "all entries or only unread ones",
allowableValues = "all,unread",
required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType,
@ApiParam(value = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@ApiParam(value = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@ApiParam(value = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@ApiParam(value = "date ordering", allowableValues = "asc,desc") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@ApiParam(
value = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
@ApiParam(value = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds,
@ApiParam(
value = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
@ApiParam(value = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
Response response = getCategoryEntries(user, id, readType, newerThan, offset, limit, order, keywords, onlyIds,
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(config.getApplicationSettings().getPublicUrl());
feed.setEntries(entries.getEntries().stream().map(Entry::asRss).collect(Collectors.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
@UnitOfWork
@ApiOperation(value = "Mark category entries", notes = "Mark feed entries of this category as read")
@Timed
public Response markCategoryEntries(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(value = "category id, or 'all'", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Date olderThan = req.getOlderThan() == null ? null : new Date(req.getOlderThan());
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
if (ALL.equals(req.getId())) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
feedEntryService.markSubscriptionEntries(user, subs, olderThan, entryKeywords);
} else if (STARRED.equals(req.getId())) {
feedEntryService.markStarredEntries(user, olderThan);
} 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, entryKeywords);
}
return Response.ok().build();
}
private void removeExcludedSubscriptions(List<FeedSubscription> subs, List<Long> excludedIds) {
if (CollectionUtils.isNotEmpty(excludedIds)) {
Iterator<FeedSubscription> it = subs.iterator();
while (it.hasNext()) {
FeedSubscription sub = it.next();
if (excludedIds.contains(sub.getId())) {
it.remove();
}
}
}
}
@Path("/add")
@POST
@UnitOfWork
@ApiOperation(value = "Add a category", notes = "Add a new feed category", response = Long.class)
@Timed
public Response addCategory(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(required = true) AddCategoryRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getName());
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);
cache.invalidateUserRootCategory(user);
return Response.ok(cat.getId()).build();
}
@POST
@Path("/delete")
@UnitOfWork
@ApiOperation(value = "Delete a category", notes = "Delete an existing feed category")
@Timed
public Response deleteCategory(@ApiParam(hidden = true) @SecurityCheck User user, @ApiParam(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
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);
cache.invalidateUserRootCategory(user);
return Response.ok().build();
} else {
return Response.status(Status.NOT_FOUND).build();
}
}
@POST
@Path("/modify")
@UnitOfWork
@ApiOperation(value = "Rename a category", notes = "Rename an existing feed category")
@Timed
public Response modifyCategory(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(required = true) CategoryModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
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);
Collections.sort(categories, new Comparator<FeedCategory>() {
@Override
public int compare(FeedCategory o1, FeedCategory o2) {
return 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);
cache.invalidateUserRootCategory(user);
return Response.ok().build();
}
@POST
@Path("/collapse")
@UnitOfWork
@ApiOperation(value = "Collapse a category", notes = "Save collapsed or expanded status for a category")
@Timed
public Response collapseCategory(@ApiParam(hidden = true) @SecurityCheck User user, @ApiParam(required = true) CollapseRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
FeedCategory category = feedCategoryDAO.findById(user, req.getId());
if (category == null) {
return Response.status(Status.NOT_FOUND).build();
}
category.setCollapsed(req.isCollapse());
feedCategoryDAO.saveOrUpdate(category);
cache.invalidateUserRootCategory(user);
return Response.ok().build();
}
@GET
@Path("/unreadCount")
@UnitOfWork
@ApiOperation(value = "Get unread count for feed subscriptions", response = UnreadCount.class, responseContainer = "List")
@Timed
public Response getUnreadCount(@ApiParam(hidden = true) @SecurityCheck User user) {
Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
return Response.ok(Lists.newArrayList(unreadCount.values())).build();
}
@GET
@Path("/get")
@UnitOfWork
@ApiOperation(value = "Get root category", notes = "Get all categories and subscriptions of the user", response = Category.class)
@Timed
public Response getRootCategory(@ApiParam(hidden = true) @SecurityCheck User user) {
Category root = cache.getUserRootCategory(user);
if (root == null) {
log.debug("tree cache miss for {}", user.getId());
List<FeedCategory> categories = feedCategoryDAO.findAll(user);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findAll(user);
Map<Long, UnreadCount> unreadCount = feedSubscriptionService.getUnreadCount(user);
root = buildCategory(null, categories, subscriptions, unreadCount);
root.setId("all");
root.setName("All");
cache.setUserRootCategory(user, root);
}
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.setExpanded(!c.isCollapsed());
category.getChildren().add(child);
}
}
Collections.sort(category.getChildren(), new Comparator<Category>() {
@Override
public int compare(Category o1, Category o2) {
return ObjectUtils.compare(o1.getPosition(), o2.getPosition());
}
});
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, config.getApplicationSettings().getPublicUrl(), uc);
category.getFeeds().add(sub);
}
}
Collections.sort(category.getFeeds(), new Comparator<Subscription>() {
@Override
public int compare(Subscription o1, Subscription o2) {
return ObjectUtils.compare(o1.getPosition(), o2.getPosition());
}
});
return category;
}
}

View File

@@ -0,0 +1,118 @@
package com.commafeed.frontend.resource;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import com.codahale.metrics.annotation.Timed;
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.auth.SecurityCheck;
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.google.common.base.Preconditions;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
@Path("/entry")
@Api(value = "/entry")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class EntryREST {
private final FeedEntryTagDAO feedEntryTagDAO;
private final FeedEntryService feedEntryService;
private final FeedEntryTagService feedEntryTagService;
@Path("/mark")
@POST
@UnitOfWork
@ApiOperation(value = "Mark a feed entry", notes = "Mark a feed entry as read/unread")
@Timed
public Response markEntry(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(value = "Mark Request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
feedEntryService.markEntry(user, Long.valueOf(req.getId()), req.isRead());
return Response.ok().build();
}
@Path("/markMultiple")
@POST
@UnitOfWork
@ApiOperation(value = "Mark multiple feed entries", notes = "Mark feed entries as read/unread")
@Timed
public Response markEntries(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(value = "Multiple Mark Request", required = true) MultipleMarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getRequests());
for (MarkRequest r : req.getRequests()) {
markEntry(user, r);
}
return Response.ok().build();
}
@Path("/star")
@POST
@UnitOfWork
@ApiOperation(value = "Mark a feed entry", notes = "Mark a feed entry as read/unread")
@Timed
public Response starEntry(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(value = "Star Request", required = true) StarRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Preconditions.checkNotNull(req.getFeedId());
feedEntryService.starEntry(user, Long.valueOf(req.getId()), req.getFeedId(), req.isStarred());
return Response.ok().build();
}
@Path("/tags")
@GET
@UnitOfWork
@ApiOperation(value = "Get list of tags for the user", notes = "Get list of tags for the user")
@Timed
public Response getTags(@ApiParam(hidden = true) @SecurityCheck User user) {
List<String> tags = feedEntryTagDAO.findByUser(user);
return Response.ok(tags).build();
}
@Path("/tag")
@POST
@UnitOfWork
@ApiOperation(value = "Mark a feed entry", notes = "Mark a feed entry as read/unread")
@Timed
public Response tagEntry(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(value = "Tag Request", required = true) TagRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getEntryId());
feedEntryTagService.updateTags(user, req.getEntryId(), req.getTags());
return Response.ok().build();
}
}

View File

@@ -0,0 +1,562 @@
package com.commafeed.frontend.resource;
import java.io.InputStream;
import java.io.StringWriter;
import java.net.URI;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.CacheControl;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.ResponseBuilder;
import javax.ws.rs.core.Response.Status;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.glassfish.jersey.media.multipart.FormDataParam;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.cache.CacheService;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.favicon.AbstractFaviconFetcher.Favicon;
import com.commafeed.backend.feed.FeedEntryKeyword;
import com.commafeed.backend.feed.FeedFetcher;
import com.commafeed.backend.feed.FeedQueues;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.feed.FetchedFeed;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.opml.OPMLExporter;
import com.commafeed.backend.opml.OPMLImporter;
import com.commafeed.backend.service.FeedEntryFilteringService;
import com.commafeed.backend.service.FeedEntryFilteringService.FeedEntryFilterException;
import com.commafeed.backend.service.FeedEntryService;
import com.commafeed.backend.service.FeedService;
import com.commafeed.backend.service.FeedSubscriptionService;
import com.commafeed.frontend.auth.SecurityCheck;
import com.commafeed.frontend.model.Entries;
import com.commafeed.frontend.model.Entry;
import com.commafeed.frontend.model.FeedInfo;
import com.commafeed.frontend.model.Subscription;
import com.commafeed.frontend.model.UnreadCount;
import com.commafeed.frontend.model.request.FeedInfoRequest;
import com.commafeed.frontend.model.request.FeedModificationRequest;
import com.commafeed.frontend.model.request.IDRequest;
import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.frontend.model.request.SubscribeRequest;
import com.google.common.base.Preconditions;
import com.google.common.base.Throwables;
import com.rometools.opml.feed.opml.Opml;
import com.rometools.rome.feed.synd.SyndFeed;
import com.rometools.rome.feed.synd.SyndFeedImpl;
import com.rometools.rome.io.SyndFeedOutput;
import com.rometools.rome.io.WireFeedOutput;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/feed")
@Api(value = "/feed")
@Slf4j
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class FeedREST {
private static final FeedEntry TEST_ENTRY = initTestEntry();
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedFetcher feedFetcher;
private final FeedService feedService;
private final FeedEntryService feedEntryService;
private final FeedSubscriptionService feedSubscriptionService;
private final FeedEntryFilteringService feedEntryFilteringService;
private final FeedQueues queues;
private final OPMLImporter opmlImporter;
private final OPMLExporter opmlExporter;
private final CacheService cache;
private final CommaFeedConfiguration config;
private static FeedEntry initTestEntry() {
FeedEntry entry = new FeedEntry();
entry.setUrl("https://github.com/Athou/commafeed");
FeedEntryContent content = new FeedEntryContent();
content.setAuthor("Athou");
content.setTitle("Merge pull request #662 from Athou/dw8");
content.setContent("Merge pull request #662 from Athou/dw8");
entry.setContent(content);
return entry;
}
@Path("/entries")
@GET
@UnitOfWork
@ApiOperation(value = "Get feed entries", notes = "Get a list of feed entries", response = Entries.class)
@Timed
public Response getFeedEntries(@ApiParam(hidden = true) @SecurityCheck User user,
@ApiParam(value = "id of the feed", required = true) @QueryParam("id") String id,
@ApiParam(
value = "all entries or only unread ones",
allowableValues = "all,unread",
required = true) @DefaultValue("unread") @QueryParam("readType") ReadingMode readType,
@ApiParam(value = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@ApiParam(value = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@ApiParam(value = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@ApiParam(
value = "ordering",
allowableValues = "asc,desc,abc,zyx") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@ApiParam(
value = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
@ApiParam(value = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds) {
Preconditions.checkNotNull(id);
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;
Date newerThanDate = newerThan == null ? null : new Date(newerThan);
FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(id));
if (subscription != null) {
entries.setName(subscription.getTitle());
entries.setMessage(subscription.getFeed().getMessage());
entries.setErrorCount(subscription.getFeed().getErrorCount());
entries.setFeedLink(subscription.getFeed().getLink());
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, Arrays.asList(subscription), unreadOnly,
entryKeywords, newerThanDate, offset, limit + 1, order, true, onlyIds, null);
for (FeedEntryStatus status : list) {
entries.getEntries()
.add(Entry.build(status, config.getApplicationSettings().getPublicUrl(),
config.getApplicationSettings().getImageProxyEnabled()));
}
boolean hasMore = entries.getEntries().size() > limit;
if (hasMore) {
entries.setHasMore(true);
entries.getEntries().remove(entries.getEntries().size() - 1);
}
} else {
return Response.status(Status.NOT_FOUND).entity("<message>feed not found</message>").build();
}
entries.setTimestamp(System.currentTimeMillis());
entries.setIgnoredReadStatus(keywords != null);
FeedUtils.removeUnwantedFromSearch(entries.getEntries(), entryKeywords);
return Response.ok(entries).build();
}
@Path("/entriesAsFeed")
@GET
@UnitOfWork
@ApiOperation(value = "Get feed entries as a feed", notes = "Get a feed of feed entries")
@Produces(MediaType.APPLICATION_XML)
@Timed
public Response getFeedEntriesAsFeed(@ApiParam(hidden = true) @SecurityCheck(apiKeyAllowed = true) User user,
@ApiParam(value = "id of the feed", required = true) @QueryParam("id") String id,
@ApiParam(
value = "all entries or only unread ones",
allowableValues = "all,unread",
required = true) @DefaultValue("all") @QueryParam("readType") ReadingMode readType,
@ApiParam(value = "only entries newer than this") @QueryParam("newerThan") Long newerThan,
@ApiParam(value = "offset for paging") @DefaultValue("0") @QueryParam("offset") int offset,
@ApiParam(value = "limit for paging, default 20, maximum 1000") @DefaultValue("20") @QueryParam("limit") int limit,
@ApiParam(value = "date ordering", allowableValues = "asc,desc") @QueryParam("order") @DefaultValue("desc") ReadingOrder order,
@ApiParam(
value = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
@ApiParam(value = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds) {
Response response = getFeedEntries(user, id, readType, newerThan, offset, limit, order, keywords, onlyIds);
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(config.getApplicationSettings().getPublicUrl());
feed.setEntries(entries.getEntries().stream().map(Entry::asRss).collect(Collectors.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();
}
private FeedInfo fetchFeedInternal(String url) {
FeedInfo info = null;
url = StringUtils.trimToEmpty(url);
url = prependHttp(url);
try {
FetchedFeed feed = feedFetcher.fetch(url, true, null, null, null, null);
info = new FeedInfo();
info.setUrl(feed.getUrlAfterRedirect());
info.setTitle(feed.getTitle());
} catch (Exception e) {
log.debug(e.getMessage(), e);
throw new WebApplicationException(e, Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build());
}
return info;
}
@POST
@Path("/fetch")
@UnitOfWork
@ApiOperation(value = "Fetch a feed", notes = "Fetch a feed by its url", response = FeedInfo.class)
@Timed
public Response fetchFeed(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(value = "feed url", required = true) FeedInfoRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getUrl());
FeedInfo info = null;
try {
info = fetchFeedInternal(req.getUrl());
} catch (Exception e) {
return Response.status(Status.INTERNAL_SERVER_ERROR)
.entity(Throwables.getStackTraceAsString(Throwables.getRootCause(e)))
.type(MediaType.TEXT_PLAIN)
.build();
}
return Response.ok(info).build();
}
@Path("/refreshAll")
@GET
@UnitOfWork
@ApiOperation(value = "Queue all feeds of the user for refresh", notes = "Manually add all feeds of the user to the refresh queue")
@Timed
public Response queueAllForRefresh(@ApiParam(hidden = true) @SecurityCheck User user) {
feedSubscriptionService.refreshAll(user);
return Response.ok().build();
}
@Path("/refresh")
@POST
@UnitOfWork
@ApiOperation(value = "Queue a feed for refresh", notes = "Manually add a feed to the refresh queue")
@Timed
public Response queueForRefresh(@ApiParam(hidden = true) @SecurityCheck User user,
@ApiParam(value = "Feed id", required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
FeedSubscription sub = feedSubscriptionDAO.findById(user, req.getId());
if (sub != null) {
Feed feed = sub.getFeed();
queues.add(feed, true);
return Response.ok().build();
}
return Response.ok(Status.NOT_FOUND).build();
}
@Path("/mark")
@POST
@UnitOfWork
@ApiOperation(value = "Mark feed entries", notes = "Mark feed entries as read (unread is not supported)")
@Timed
public Response markFeedEntries(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(value = "Mark request", required = true) MarkRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
Date olderThan = req.getOlderThan() == null ? null : new Date(req.getOlderThan());
String keywords = req.getKeywords();
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
FeedSubscription subscription = feedSubscriptionDAO.findById(user, Long.valueOf(req.getId()));
if (subscription != null) {
feedEntryService.markSubscriptionEntries(user, Arrays.asList(subscription), olderThan, entryKeywords);
}
return Response.ok().build();
}
@GET
@Path("/get/{id}")
@UnitOfWork
@ApiOperation(value = "get feed", response = Subscription.class)
@Timed
public Response getFeed(@ApiParam(hidden = true) @SecurityCheck User user,
@ApiParam(value = "user id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
FeedSubscription sub = feedSubscriptionDAO.findById(user, id);
if (sub == null) {
return Response.status(Status.NOT_FOUND).build();
}
UnreadCount unreadCount = feedSubscriptionService.getUnreadCount(user).get(id);
return Response.ok(Subscription.build(sub, config.getApplicationSettings().getPublicUrl(), unreadCount)).build();
}
@GET
@Path("/favicon/{id}")
@UnitOfWork
@ApiOperation(value = "Fetch a feed's icon", notes = "Fetch a feed's icon")
@Timed
public Response getFeedFavicon(@ApiParam(hidden = true) @SecurityCheck User user,
@ApiParam(value = "subscription id", required = true) @PathParam("id") Long id) {
Preconditions.checkNotNull(id);
FeedSubscription subscription = feedSubscriptionDAO.findById(user, id);
if (subscription == null) {
return Response.status(Status.NOT_FOUND).build();
}
Feed feed = subscription.getFeed();
Favicon icon = feedService.fetchFavicon(feed);
ResponseBuilder builder = Response.ok(icon.getIcon(), StringUtils.defaultIfBlank(icon.getMediaType(), "image/x-icon"));
CacheControl cacheControl = new CacheControl();
cacheControl.setMaxAge(2592000);
cacheControl.setPrivate(false);
builder.cacheControl(cacheControl);
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MONTH, 1);
builder.expires(calendar.getTime());
builder.lastModified(CommaFeedApplication.STARTUP_TIME);
return builder.build();
}
@POST
@Path("/subscribe")
@UnitOfWork
@ApiOperation(value = "Subscribe to a feed", notes = "Subscribe to a feed")
@Timed
public Response subscribe(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(value = "subscription request", required = true) SubscribeRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getTitle());
Preconditions.checkNotNull(req.getUrl());
String url = prependHttp(req.getUrl());
try {
url = fetchFeedInternal(url).getUrl();
FeedCategory category = null;
if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) {
category = feedCategoryDAO.findById(Long.valueOf(req.getCategoryId()));
}
FeedInfo info = fetchFeedInternal(url);
feedSubscriptionService.subscribe(user, info.getUrl(), req.getTitle(), category);
} catch (Exception e) {
log.error("Failed to subscribe to URL {}: {}", url, e.getMessage(), e);
return Response.status(Status.SERVICE_UNAVAILABLE).entity("Failed to subscribe to URL " + url + ": " + e.getMessage()).build();
}
return Response.ok().build();
}
@GET
@Path("/subscribe")
@UnitOfWork
@ApiOperation(value = "Subscribe to a feed", notes = "Subscribe to a feed")
@Timed
public Response subscribeFromUrl(@ApiParam(hidden = true) @SecurityCheck User user,
@ApiParam(value = "feed url", required = true) @QueryParam("url") String url) {
try {
Preconditions.checkNotNull(url);
url = prependHttp(url);
url = fetchFeedInternal(url).getUrl();
FeedInfo info = fetchFeedInternal(url);
feedSubscriptionService.subscribe(user, info.getUrl(), info.getTitle());
} catch (Exception e) {
log.info("Could not subscribe to url {} : {}", url, e.getMessage());
}
return Response.temporaryRedirect(URI.create(config.getApplicationSettings().getPublicUrl())).build();
}
private String prependHttp(String url) {
if (!url.startsWith("http")) {
url = "http://" + url;
}
return url;
}
@POST
@Path("/unsubscribe")
@UnitOfWork
@ApiOperation(value = "Unsubscribe from a feed", notes = "Unsubscribe from a feed")
@Timed
public Response unsubscribe(@ApiParam(hidden = true) @SecurityCheck User user, @ApiParam(required = true) IDRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
boolean deleted = feedSubscriptionService.unsubscribe(user, req.getId());
if (deleted) {
return Response.ok().build();
} else {
return Response.status(Status.NOT_FOUND).build();
}
}
@POST
@Path("/modify")
@UnitOfWork
@ApiOperation(value = "Modify a subscription", notes = "Modify a feed subscription")
@Timed
public Response modifyFeed(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(value = "subscription id", required = true) FeedModificationRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getId());
try {
feedEntryFilteringService.filterMatchesEntry(req.getFilter(), TEST_ENTRY);
} catch (FeedEntryFilterException e) {
return Response.status(Status.BAD_REQUEST).entity(e.getCause().getMessage()).type(MediaType.TEXT_PLAIN).build();
}
FeedSubscription subscription = feedSubscriptionDAO.findById(user, req.getId());
subscription.setFilter(StringUtils.lowerCase(req.getFilter()));
if (StringUtils.isNotBlank(req.getName())) {
subscription.setTitle(req.getName());
}
FeedCategory parent = null;
if (req.getCategoryId() != null && !CategoryREST.ALL.equals(req.getCategoryId())) {
parent = feedCategoryDAO.findById(user, Long.valueOf(req.getCategoryId()));
}
subscription.setCategory(parent);
if (req.getPosition() != null) {
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategory(user, parent);
Collections.sort(subs, new Comparator<FeedSubscription>() {
@Override
public int compare(FeedSubscription o1, FeedSubscription o2) {
return ObjectUtils.compare(o1.getPosition(), o2.getPosition());
}
});
int existingIndex = -1;
for (int i = 0; i < subs.size(); i++) {
if (Objects.equals(subs.get(i).getId(), subscription.getId())) {
existingIndex = i;
}
}
if (existingIndex != -1) {
subs.remove(existingIndex);
}
subs.add(Math.min(req.getPosition(), subs.size()), subscription);
for (int i = 0; i < subs.size(); i++) {
subs.get(i).setPosition(i);
}
feedSubscriptionDAO.saveOrUpdate(subs);
} else {
feedSubscriptionDAO.saveOrUpdate(subscription);
}
cache.invalidateUserRootCategory(user);
return Response.ok().build();
}
@POST
@Path("/import")
@UnitOfWork
@Consumes(MediaType.MULTIPART_FORM_DATA)
@ApiOperation(value = "OPML import", notes = "Import an OPML file, posted as a FORM with the 'file' name")
@Timed
public Response importOpml(@ApiParam(hidden = true) @SecurityCheck User user,
@ApiParam(value = "ompl file", required = true) @FormDataParam("file") InputStream input) {
String publicUrl = config.getApplicationSettings().getPublicUrl();
if (StringUtils.isBlank(publicUrl)) {
throw new WebApplicationException(
Response.status(Status.INTERNAL_SERVER_ERROR).entity("Set the public URL in the admin section.").build());
}
if (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).entity("Import is disabled for the demo account").build();
}
try {
String opml = IOUtils.toString(input, "UTF-8");
opmlImporter.importOpml(user, opml);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new WebApplicationException(Response.status(Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build());
}
return Response.seeOther(URI.create(config.getApplicationSettings().getPublicUrl())).build();
}
@GET
@Path("/export")
@UnitOfWork
@Produces(MediaType.APPLICATION_XML)
@ApiOperation(value = "OPML export", notes = "Export an OPML file of the user's subscriptions")
@Timed
public Response exportOpml(@ApiParam(hidden = true) @SecurityCheck User user) {
Opml opml = opmlExporter.export(user);
WireFeedOutput output = new WireFeedOutput();
String opmlString = null;
try {
opmlString = output.outputString(opml);
} catch (Exception e) {
return Response.status(Status.INTERNAL_SERVER_ERROR).entity(e).build();
}
return Response.ok(opmlString).build();
}
}

View File

@@ -0,0 +1,126 @@
package com.commafeed.frontend.resource;
import java.util.Date;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.FeedDAO;
import com.commafeed.backend.feed.FeedParser;
import com.commafeed.backend.feed.FeedQueues;
import com.commafeed.backend.feed.FetchedFeed;
import com.commafeed.backend.model.Feed;
import com.google.common.base.Preconditions;
import io.dropwizard.hibernate.UnitOfWork;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/push")
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class PubSubHubbubCallbackREST {
@Context
private HttpServletRequest request;
private final FeedDAO feedDAO;
private final FeedParser parser;
private final FeedQueues queues;
private final CommaFeedConfiguration config;
private final MetricRegistry metricRegistry;
@Path("/callback")
@GET
@UnitOfWork
@Produces(MediaType.TEXT_PLAIN)
@Timed
public Response verify(@QueryParam("hub.mode") String mode, @QueryParam("hub.topic") String topic,
@QueryParam("hub.challenge") String challenge, @QueryParam("hub.lease_seconds") String leaseSeconds,
@QueryParam("hub.verify_token") String verifyToken) {
if (!config.getApplicationSettings().getPubsubhubbub()) {
return Response.status(Status.FORBIDDEN).entity("pubsubhubbub is disabled").build();
}
Preconditions.checkArgument(StringUtils.isNotEmpty(topic));
Preconditions.checkArgument("subscribe".equals(mode));
log.debug("confirmation callback received for {}", topic);
List<Feed> feeds = feedDAO.findByTopic(topic);
if (!feeds.isEmpty()) {
for (Feed feed : feeds) {
log.debug("activated push notifications for {}", feed.getPushTopic());
feed.setPushLastPing(new Date());
}
feedDAO.saveOrUpdate(feeds);
return Response.ok(challenge).build();
} else {
log.debug("rejecting callback: no push info for {}", topic);
return Response.status(Status.NOT_FOUND).build();
}
}
@Path("/callback")
@POST
@UnitOfWork
@Consumes({ MediaType.APPLICATION_ATOM_XML, "application/rss+xml" })
@Timed
public Response callback() {
if (!config.getApplicationSettings().getPubsubhubbub()) {
return Response.status(Status.FORBIDDEN).entity("pubsubhubbub is disabled").build();
}
try {
byte[] bytes = IOUtils.toByteArray(request.getInputStream());
if (ArrayUtils.isEmpty(bytes)) {
return Response.status(Status.BAD_REQUEST).entity("empty body received").build();
}
FetchedFeed fetchedFeed = parser.parse(null, bytes);
String topic = fetchedFeed.getFeed().getPushTopic();
if (StringUtils.isBlank(topic)) {
return Response.status(Status.BAD_REQUEST).entity("empty topic received").build();
}
log.debug("content callback received for {}", topic);
List<Feed> feeds = feedDAO.findByTopic(topic);
if (feeds.isEmpty()) {
return Response.status(Status.BAD_REQUEST).entity("no feeds found for that topic").build();
}
for (Feed feed : feeds) {
log.debug("pushing content to queue for {}", feed.getUrl());
queues.add(feed, false);
}
metricRegistry.meter(MetricRegistry.name(getClass(), "pushReceived")).mark();
} catch (Exception e) {
log.error("Could not parse pubsub callback: " + e.getMessage());
}
return Response.ok().build();
}
}

View File

@@ -0,0 +1,78 @@
package com.commafeed.frontend.resource;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.apache.commons.lang3.StringUtils;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.HttpGetter;
import com.commafeed.backend.HttpGetter.HttpResult;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.User;
import com.commafeed.frontend.auth.SecurityCheck;
import com.commafeed.frontend.model.ServerInfo;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
@Path("/server")
@Api(value = "/server")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class ServerREST {
private final HttpGetter httpGetter;
private final CommaFeedConfiguration config;
@Path("/get")
@GET
@UnitOfWork
@ApiOperation(value = "Get server infos", notes = "Get server infos", response = ServerInfo.class)
@Timed
public Response getServerInfos() {
ServerInfo infos = new ServerInfo();
infos.setAnnouncement(config.getApplicationSettings().getAnnouncement());
infos.setVersion(config.getVersion());
infos.setGitCommit(config.getGitCommit());
infos.setAllowRegistrations(config.getApplicationSettings().getAllowRegistrations());
infos.setGoogleAnalyticsCode(config.getApplicationSettings().getGoogleAnalyticsTrackingCode());
infos.setSmtpEnabled(StringUtils.isNotBlank(config.getApplicationSettings().getSmtpHost()));
return Response.ok(infos).build();
}
@Path("/proxy")
@GET
@UnitOfWork
@ApiOperation(value = "proxy image")
@Produces("image/png")
@Timed
public Response getProxiedImage(@ApiParam(hidden = true) @SecurityCheck User user,
@ApiParam(value = "image url", required = true) @QueryParam("u") String url) {
if (!config.getApplicationSettings().getImageProxyEnabled()) {
return Response.status(Status.FORBIDDEN).build();
}
url = FeedUtils.imageProxyDecoder(url);
try {
HttpResult result = httpGetter.getBinary(url, 20000);
return Response.ok(result.getContent()).build();
} catch (Exception e) {
return Response.status(Status.SERVICE_UNAVAILABLE).entity(e.getMessage()).build();
}
}
}

View File

@@ -0,0 +1,348 @@
package com.commafeed.frontend.resource;
import java.util.Arrays;
import java.util.Date;
import java.util.Optional;
import java.util.UUID;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.validation.Valid;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateUtils;
import org.apache.http.client.utils.URIBuilder;
import com.codahale.metrics.annotation.Timed;
import com.commafeed.CommaFeedApplication;
import com.commafeed.CommaFeedConfiguration;
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.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.model.UserSettings.ViewMode;
import com.commafeed.backend.service.MailService;
import com.commafeed.backend.service.PasswordEncryptionService;
import com.commafeed.backend.service.UserService;
import com.commafeed.frontend.auth.SecurityCheck;
import com.commafeed.frontend.model.Settings;
import com.commafeed.frontend.model.UserModel;
import com.commafeed.frontend.model.request.LoginRequest;
import com.commafeed.frontend.model.request.PasswordResetRequest;
import com.commafeed.frontend.model.request.ProfileModificationRequest;
import com.commafeed.frontend.model.request.RegistrationRequest;
import com.commafeed.frontend.session.SessionHelper;
import com.google.common.base.Preconditions;
import io.dropwizard.hibernate.UnitOfWork;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Path("/user")
@Api(value = "/user")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Slf4j
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class UserREST {
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;
@Path("/settings")
@GET
@UnitOfWork
@ApiOperation(value = "Retrieve user settings", notes = "Retrieve user settings", response = Settings.class)
@Timed
public Response getUserSettings(@ApiParam(hidden = true) @SecurityCheck User user) {
Settings s = new Settings();
UserSettings settings = userSettingsDAO.findByUser(user);
if (settings != null) {
s.setReadingMode(settings.getReadingMode().name());
s.setReadingOrder(settings.getReadingOrder().name());
s.setViewMode(settings.getViewMode().name());
s.setShowRead(settings.isShowRead());
s.setEmail(settings.isEmail());
s.setGmail(settings.isGmail());
s.setFacebook(settings.isFacebook());
s.setTwitter(settings.isTwitter());
s.setTumblr(settings.isTumblr());
s.setPocket(settings.isPocket());
s.setInstapaper(settings.isInstapaper());
s.setBuffer(settings.isBuffer());
s.setScrollMarks(settings.isScrollMarks());
s.setTheme(settings.getTheme());
s.setCustomCss(settings.getCustomCss());
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
} else {
s.setReadingMode(ReadingMode.unread.name());
s.setReadingOrder(ReadingOrder.desc.name());
s.setViewMode(ViewMode.title.name());
s.setShowRead(true);
s.setTheme("default");
s.setEmail(true);
s.setGmail(true);
s.setFacebook(true);
s.setTwitter(true);
s.setTumblr(true);
s.setPocket(true);
s.setInstapaper(true);
s.setBuffer(true);
s.setScrollMarks(true);
s.setLanguage("en");
s.setScrollSpeed(400);
}
return Response.ok(s).build();
}
@Path("/settings")
@POST
@UnitOfWork
@ApiOperation(value = "Save user settings", notes = "Save user settings")
@Timed
public Response saveUserSettings(@ApiParam(hidden = true) @SecurityCheck User user, @ApiParam(required = true) Settings settings) {
Preconditions.checkNotNull(settings);
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.setViewMode(ViewMode.valueOf(settings.getViewMode()));
s.setScrollMarks(settings.isScrollMarks());
s.setTheme(settings.getTheme());
s.setCustomCss(settings.getCustomCss());
s.setLanguage(settings.getLanguage());
s.setScrollSpeed(settings.getScrollSpeed());
s.setEmail(settings.isEmail());
s.setGmail(settings.isGmail());
s.setFacebook(settings.isFacebook());
s.setTwitter(settings.isTwitter());
s.setTumblr(settings.isTumblr());
s.setPocket(settings.isPocket());
s.setInstapaper(settings.isInstapaper());
s.setBuffer(settings.isBuffer());
userSettingsDAO.saveOrUpdate(s);
return Response.ok().build();
}
@Path("/profile")
@GET
@UnitOfWork
@ApiOperation(value = "Retrieve user's profile", response = UserModel.class)
@Timed
public Response getUserProfile(@ApiParam(hidden = true) @SecurityCheck User user) {
UserModel userModel = new UserModel();
userModel.setId(user.getId());
userModel.setName(user.getName());
userModel.setEmail(user.getEmail());
userModel.setEnabled(!user.isDisabled());
userModel.setApiKey(user.getApiKey());
for (UserRole role : userRoleDAO.findAll(user)) {
if (role.getRole() == Role.ADMIN) {
userModel.setAdmin(true);
}
}
return Response.ok(userModel).build();
}
@Path("/profile")
@POST
@UnitOfWork
@ApiOperation(value = "Save user's profile")
@Timed
public Response saveUserProfile(@ApiParam(hidden = true) @SecurityCheck User user,
@Valid @ApiParam(required = true) ProfileModificationRequest request) {
if (CommaFeedApplication.USERNAME_DEMO.equals(user.getName())) {
return Response.status(Status.FORBIDDEN).build();
}
Optional<User> login = userService.login(user.getEmail(), request.getCurrentPassword());
if (!login.isPresent()) {
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.update(user);
return Response.ok().build();
}
@Path("/register")
@POST
@UnitOfWork
@ApiOperation(value = "Register a new account")
@Timed
public Response registerUser(@Valid @ApiParam(required = true) RegistrationRequest req,
@Context @ApiParam(hidden = true) SessionHelper sessionHelper) {
try {
User registeredUser = userService.register(req.getName(), req.getPassword(), req.getEmail(), Arrays.asList(Role.USER));
userService.login(req.getName(), req.getPassword());
sessionHelper.setLoggedInUser(registeredUser);
return Response.ok().build();
} catch (final IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}
@Path("/login")
@POST
@UnitOfWork
@ApiOperation(value = "Login and create a session")
@Timed
public Response login(@Valid @ApiParam(required = true) LoginRequest req,
@ApiParam(hidden = true) @Context SessionHelper sessionHelper) {
Optional<User> user = userService.login(req.getName(), req.getPassword());
if (user.isPresent()) {
sessionHelper.setLoggedInUser(user.get());
return Response.ok().build();
} else {
return Response.status(Response.Status.UNAUTHORIZED).entity("wrong username or password").type(MediaType.TEXT_PLAIN).build();
}
}
@Path("/passwordReset")
@POST
@UnitOfWork
@ApiOperation(value = "send a password reset email")
@Timed
public Response sendPasswordReset(@Valid @ApiParam(required = true) PasswordResetRequest req) {
User user = userDAO.findByEmail(req.getEmail());
if (user == null) {
return Response.ok().build();
}
try {
user.setRecoverPasswordToken(DigestUtils.sha1Hex(UUID.randomUUID().toString()));
user.setRecoverPasswordTokenDate(new Date());
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(config.getApplicationSettings().getPublicUrl());
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")
@GET
@UnitOfWork
@Produces(MediaType.TEXT_HTML)
@Timed
public Response passwordRecoveryCallback(@ApiParam(required = true) @QueryParam("email") String email,
@ApiParam(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 (user.getRecoverPasswordTokenDate().before(DateUtils.addDays(new Date(), -2))) {
return Response.status(Status.UNAUTHORIZED).entity("token expired.").build();
}
String passwd = RandomStringUtils.randomAlphanumeric(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>", config.getApplicationSettings().getPublicUrl());
return Response.ok(message).build();
}
@Path("/profile/deleteAccount")
@POST
@UnitOfWork
@ApiOperation(value = "Delete the user account")
@Timed
public Response deleteUser(@ApiParam(hidden = true) @SecurityCheck User user) {
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

@@ -0,0 +1,52 @@
package com.commafeed.frontend.servlet;
import java.io.IOException;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import com.commafeed.CommaFeedConfiguration;
@SuppressWarnings("serial")
@Singleton
public class AnalyticsServlet extends HttpServlet {
private final CommaFeedConfiguration config;
private final String script;
@Inject
public AnalyticsServlet(CommaFeedConfiguration config) {
this.config = config;
// @formatter:off
this.script = "(function(i, s, o, g, r, a, m) {" +
"i['GoogleAnalyticsObject'] = r;" +
"i[r] = i[r] || function() {" +
"(i[r].q = i[r].q || []).push(arguments)" +
"}, i[r].l = 1 * new Date();" +
"a = s.createElement(o), m = s.getElementsByTagName(o)[0];" +
"a.async = 1;" +
"a.src = g;" +
"m.parentNode.insertBefore(a, m)" +
"})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');" +
"ga('create', '" + config.getApplicationSettings().getGoogleAnalyticsTrackingCode() + "');" +
"ga('send', 'pageview');";
// @formatter:on
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/javascript");
if (StringUtils.isNotBlank(config.getApplicationSettings().getGoogleAnalyticsTrackingCode())) {
resp.getWriter().write(script);
}
}
}

View File

@@ -0,0 +1,47 @@
package com.commafeed.frontend.servlet;
import java.io.IOException;
import java.util.Optional;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.hibernate.SessionFactory;
import com.commafeed.backend.dao.UnitOfWork;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.frontend.session.SessionHelper;
import lombok.RequiredArgsConstructor;
@SuppressWarnings("serial")
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class CustomCssServlet extends HttpServlet {
private final SessionFactory sessionFactory;
private final UserSettingsDAO userSettingsDAO;
@Override
protected void doGet(final HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/css");
final Optional<User> user = new SessionHelper(req).getLoggedInUser();
if (!user.isPresent()) {
return;
}
UserSettings settings = UnitOfWork.call(sessionFactory, () -> userSettingsDAO.findByUser(user.get()));
if (settings == null || settings.getCustomCss() == null) {
return;
}
resp.getWriter().write(settings.getCustomCss());
}
}

View File

@@ -0,0 +1,28 @@
package com.commafeed.frontend.servlet;
import java.io.IOException;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.commafeed.CommaFeedConfiguration;
import lombok.RequiredArgsConstructor;
@SuppressWarnings("serial")
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class LogoutServlet extends HttpServlet {
private final CommaFeedConfiguration config;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.getSession().invalidate();
resp.sendRedirect(resp.encodeRedirectURL(config.getApplicationSettings().getPublicUrl()));
}
}

View File

@@ -0,0 +1,97 @@
package com.commafeed.frontend.servlet;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.SessionFactory;
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.dao.UnitOfWork;
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.UserService;
import com.commafeed.frontend.resource.CategoryREST;
import com.commafeed.frontend.session.SessionHelper;
import com.google.common.collect.Iterables;
import lombok.RequiredArgsConstructor;
@SuppressWarnings("serial")
@RequiredArgsConstructor(onConstructor = @__({ @Inject }))
@Singleton
public class NextUnreadServlet extends HttpServlet {
private static final String PARAM_CATEGORYID = "category";
private static final String PARAM_READINGORDER = "order";
private final SessionFactory sessionFactory;
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryStatusDAO feedEntryStatusDAO;
private final FeedCategoryDAO feedCategoryDAO;
private final UserService userService;
private final CommaFeedConfiguration config;
@Override
protected void doGet(final HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
final String categoryId = req.getParameter(PARAM_CATEGORYID);
String orderParam = req.getParameter(PARAM_READINGORDER);
SessionHelper sessionHelper = new SessionHelper(req);
Optional<User> user = sessionHelper.getLoggedInUser();
if (user.isPresent()) {
UnitOfWork.run(sessionFactory, () -> userService.performPostLoginActivities(user.get()));
}
if (!user.isPresent()) {
resp.sendRedirect(resp.encodeRedirectURL(config.getApplicationSettings().getPublicUrl()));
return;
}
final ReadingOrder order = StringUtils.equals(orderParam, "asc") ? ReadingOrder.asc : ReadingOrder.desc;
FeedEntryStatus status = UnitOfWork.call(sessionFactory, () -> {
FeedEntryStatus s = null;
if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user.get());
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subs, true, null, null, 0, 1, order,
true, false, null);
s = Iterables.getFirst(statuses, null);
} else {
FeedCategory category = feedCategoryDAO.findById(user.get(), Long.valueOf(categoryId));
if (category != null) {
List<FeedCategory> children = feedCategoryDAO.findAllChildrenCategories(user.get(), category);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user.get(), children);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user.get(), subscriptions, true, null, null, 0,
1, order, true, false, null);
s = Iterables.getFirst(statuses, null);
}
}
if (s != null) {
s.setRead(true);
feedEntryStatusDAO.saveOrUpdate(s);
}
return s;
});
if (status == null) {
resp.sendRedirect(resp.encodeRedirectURL(config.getApplicationSettings().getPublicUrl()));
} else {
String url = status.getEntry().getUrl();
resp.sendRedirect(resp.encodeRedirectURL(url));
}
}
}

View File

@@ -0,0 +1,48 @@
package com.commafeed.frontend.session;
import java.io.File;
import javax.servlet.SessionTrackingMode;
import org.eclipse.jetty.server.session.DefaultSessionCache;
import org.eclipse.jetty.server.session.FileSessionDataStore;
import org.eclipse.jetty.server.session.SessionCache;
import org.eclipse.jetty.server.session.SessionHandler;
import com.google.common.collect.ImmutableSet;
import io.dropwizard.util.Duration;
public class SessionHandlerFactory {
private String path = "sessions";
private Duration cookieMaxAge = Duration.days(30);
private Duration cookieRefreshAge = Duration.days(1);
private Duration maxInactiveInterval = Duration.days(30);
private Duration savePeriod = Duration.minutes(5);
public SessionHandler build() {
SessionHandler sessionHandler = new SessionHandler() {
{
// no setter available for maxCookieAge
_maxCookieAge = (int) cookieMaxAge.toSeconds();
}
};
SessionCache sessionCache = new DefaultSessionCache(sessionHandler);
sessionHandler.setSessionCache(sessionCache);
FileSessionDataStore dataStore = new FileSessionDataStore();
sessionCache.setSessionDataStore(dataStore);
sessionHandler.setHttpOnly(true);
sessionHandler.setSessionTrackingModes(ImmutableSet.of(SessionTrackingMode.COOKIE));
sessionHandler.setMaxInactiveInterval((int) maxInactiveInterval.toSeconds());
sessionHandler.setRefreshCookieAge((int) cookieRefreshAge.toSeconds());
dataStore.setDeleteUnrestorableFiles(true);
dataStore.setStoreDir(new File(path));
dataStore.setSavePeriodSec((int) savePeriod.toSeconds());
return sessionHandler;
}
}

View File

@@ -0,0 +1,40 @@
package com.commafeed.frontend.session;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import com.commafeed.backend.model.User;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor()
public class SessionHelper {
private static final String SESSION_KEY_USER = "user";
private final HttpServletRequest request;
public Optional<User> getLoggedInUser() {
Optional<HttpSession> session = getSession(false);
if (session.isPresent()) {
User user = (User) session.get().getAttribute(SESSION_KEY_USER);
return Optional.ofNullable(user);
}
return Optional.empty();
}
public void setLoggedInUser(User user) {
Optional<HttpSession> session = getSession(true);
session.get().setAttribute(SESSION_KEY_USER, user);
}
private Optional<HttpSession> getSession(boolean force) {
HttpSession session = request.getSession(force);
return Optional.ofNullable(session);
}
}

View File

@@ -0,0 +1,51 @@
package com.commafeed.frontend.session;
import java.util.function.Function;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Context;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
import org.glassfish.jersey.server.ContainerRequest;
import org.glassfish.jersey.server.internal.inject.AbstractValueParamProvider;
import org.glassfish.jersey.server.internal.inject.MultivaluedParameterExtractorProvider;
import org.glassfish.jersey.server.model.Parameter;
import org.glassfish.jersey.server.spi.internal.ValueParamProvider;
@Singleton
public class SessionHelperFactoryProvider extends AbstractValueParamProvider {
private HttpServletRequest request;
@Inject
public SessionHelperFactoryProvider(final MultivaluedParameterExtractorProvider extractorProvider, HttpServletRequest request) {
super(() -> extractorProvider, Parameter.Source.CONTEXT);
this.request = request;
}
@Override
protected Function<ContainerRequest, ?> createValueProvider(Parameter parameter) {
final Class<?> classType = parameter.getRawType();
Context context = parameter.getAnnotation(Context.class);
if (context == null) {
return null;
}
if (!classType.isAssignableFrom(SessionHelper.class)) {
return null;
}
return r -> new SessionHelper(request);
}
public static class Binder extends AbstractBinder {
@Override
protected void configure() {
bind(SessionHelperFactoryProvider.class).to(ValueParamProvider.class).in(Singleton.class);
}
}
}