diff options
author | Orangerot <purple@orangerot.dev> | 2024-06-19 00:14:49 +0200 |
---|---|---|
committer | Orangerot <purple@orangerot.dev> | 2024-06-27 12:11:14 +0200 |
commit | 5b8851b6c268d0e93c158908fbfae9f8473db5ff (patch) | |
tree | 7010eb85d86fa2da06ea4ffbcdb01a685d502ae8 /pse-server/src/main/java/org |
Diffstat (limited to 'pse-server/src/main/java/org')
55 files changed, 4579 insertions, 0 deletions
diff --git a/pse-server/src/main/java/org/psesquared/server/ServerApplication.java b/pse-server/src/main/java/org/psesquared/server/ServerApplication.java new file mode 100644 index 0000000..a71f451 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/ServerApplication.java @@ -0,0 +1,27 @@ +package org.psesquared.server; + +import org.psesquared.server.config.EmailConfigProperties; +import org.psesquared.server.config.SecurityConfigProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; + +/** + * The main class responsible for starting the application. + */ +@SpringBootApplication +@EnableConfigurationProperties({SecurityConfigProperties.class, + EmailConfigProperties.class}) +public class ServerApplication { + + /** + * The main function starting the spring application. + * + * @param args Arguments may be given + */ + public static void main(final String[] args) { + SpringApplication.run(ServerApplication.class, args); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java new file mode 100644 index 0000000..f580969 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java @@ -0,0 +1,251 @@ +package org.psesquared.server.authentication.api.controller; + +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.service.AuthenticationService; +import org.psesquared.server.config.EmailConfigProperties; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * This is a controller class for the Authentication API that handles the + * requests from the client concerning login/logout and user account management. + */ +@RequestMapping("/api/2") +@RestController +@RequiredArgsConstructor +public class AuthenticationController { + + /** + * The name of the HTTP location header. + */ + private static final String LOCATION_HEADER = "Location"; + + /** + * The service class that this controller calls to further process requests. + */ + private final AuthenticationService authenticationService; + + /** + * The properties class that is used to return some externally stored URLs. + */ + private final EmailConfigProperties emailConfigProperties; + + /** + * The API-endpoint for registering a new + * {@link org.psesquared.server.model.User} with a username, email address and + * password. In order for the account to be used, the registration process + * must be concluded with the verification of the email address. For this an + * email with a link for verification is sent to {@code userInfo.email()}. + * + * @param userInfo The request-wrapper containing username, email and + * password. + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} for invalid user information + * @see AuthenticationService#registerUser(UserInfoRequest) + */ + @PostMapping("/auth/register.json") + public ResponseEntity<String> registerUser( + @RequestBody final UserInfoRequest userInfo) { + return new ResponseEntity<>(authenticationService.registerUser(userInfo)); + } + + /** + * The API-endpoint for verifying a newly created + * {@link org.psesquared.server.model.User}. This method is invoked via the + * link in the verification email that is sent in + * {@link #registerUser(UserInfoRequest)}. + * On success, it transfers the user to the dashboard and on failure it sets + * the following status codes: + * {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} user exists and is already verified, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * + * @param username The username of the user that needs to be verified + * @param token The JWT that indicates the authority of the request + * @param response The {@link HttpServletResponse} for setting up a + * redirection to the frontend + * @see AuthenticationService#verifyRegistration(String, String) + */ + @GetMapping("/auth/{username}/verify.json") + public void verifyRegistration( + @PathVariable final String username, + @RequestParam("token") final String token, + @NonNull final HttpServletResponse response) { + HttpStatus status + = authenticationService.verifyRegistration(username, token); + if (status.equals(HttpStatus.OK)) { + response.setHeader(LOCATION_HEADER, + emailConfigProperties.dashboardBaseUrl()); + response.setStatus(HttpStatus.FOUND.value()); + } else { + response.setStatus(status.value()); + } + } + + /** + * The API-endpoint for setting a JWT access token with a lifespan of one hour + * as the "sessionid" cookie for authorization with further requests. <br> + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to log in + * @param response The {@link HttpServletResponse} for setting the "sessionid" + * cookie + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#login(String, HttpServletResponse) + */ + @PostMapping("/auth/{username}/login.json") + public ResponseEntity<String> login( + @PathVariable final String username, + @NonNull final HttpServletResponse response) { + return new ResponseEntity<>( + authenticationService.login(username, response)); + } + + /** + * The API-endpoint for invalidating the "sessionid" cookie containing a JWT + * access token. Following authorized requests require HTTP basic + * authentication or a new login. <br> + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to log out + * @param response The {@link HttpServletResponse} for invalidating the + * "sessionid" cookie + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#logout(String, HttpServletResponse) + */ + @PostMapping("/auth/{username}/logout.json") + public ResponseEntity<String> logout( + @PathVariable final String username, + @NonNull final HttpServletResponse response) { + return new ResponseEntity<>( + authenticationService.logout(username, response)); + } + + /** + * The API-endpoint for sending an email to the given address + * ({@link ForgotPasswordRequest#email()} with an url to reset the password of + * the user with that email address. + * + * @param email The email address of the user who wants to reset their + * password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#forgotPassword(String) + */ + @PostMapping("/auth/{email}/forgot.json") + public ResponseEntity<String> forgotPassword( + @PathVariable final String email) { + return new ResponseEntity<>(authenticationService.forgotPassword(email)); + } + + /** + * The API-endpoint for resetting the password of a + * {@link org.psesquared.server.model.User}. This method is invoked via the + * link in the verification email that is sent in + * {@link #forgotPassword(String)}. + * + * @param username The username of the user who wants to reset their + * password + * @param token The JWT that indicates the authority of the request + * @param requestBody The request-wrapper containing the new password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} password doesn't meet requirements, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#resetPassword(String, String, PasswordRequest) + */ + @PutMapping("/auth/{username}/resetpassword.json") + public ResponseEntity<String> resetPassword( + @PathVariable final String username, + @RequestParam("token") final String token, + @RequestBody final PasswordRequest requestBody) { + return new ResponseEntity<>( + authenticationService.resetPassword(username, token, requestBody)); + } + + /** + * The API-endpoint for changing the password of a + * {@link org.psesquared.server.model.User}, who is logged-in in the + * dashboard. + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to change their + * password + * @param requestBody The request-wrapper containing old and new password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} old password is wrong, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#changePassword(String, ChangePasswordRequest) + */ + @PutMapping("/auth/{username}/changepassword.json") + public ResponseEntity<String> changePassword( + @PathVariable final String username, + @RequestBody final ChangePasswordRequest requestBody) { + return new ResponseEntity<>( + authenticationService.changePassword(username, requestBody)); + } + + /** + * The API-endpoint for deleting a {@link org.psesquared.server.model.User}. + * This action is performed by a logged-in user from the dashboard. + * The user must enter their password ({@link PasswordRequest#password()}) + * and if correct, the user along with all associated data is deleted. + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to delete their + * account + * @param requestBody The request-wrapper containing the user's password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} wrong password, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#deleteUser(String, PasswordRequest) + */ + @DeleteMapping("/auth/{username}/delete.json") + public ResponseEntity<String> deleteUser( + @PathVariable final String username, + @RequestBody final PasswordRequest requestBody) { + return new ResponseEntity<>( + authenticationService.deleteUser(username, requestBody)); + } + + /** + * This API-endpoint exists for compatibility with podcatchers, especially + * AntennaPod and Kasts, which initially call this endpoint instead of + * {@link #login(String, HttpServletResponse)}. + * Accordingly, a call to this endpoint is internally treated as a login. + * In particular, devices remain unsupported. + * + * @param username The username of the user to be synchronized + * @param response The {@link HttpServletResponse} for setting the "sessionid" + * cookie + * @return A dummy response with a single dummy device for the given user + * @see AuthenticationService#login(String, HttpServletResponse) + */ + @GetMapping("/devices/{username}.json") + public ResponseEntity<List<DeviceWrapper>> getDeviceList( + @PathVariable final String username, + @NonNull final HttpServletResponse response) { + DeviceWrapper dummyDevice = new DeviceWrapper(); + return new ResponseEntity<>( + List.of(dummyDevice), + authenticationService.login(username, response)); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java new file mode 100644 index 0000000..d8b2357 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java @@ -0,0 +1,15 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request for changing the password containing the old, i.e. current, and new + * password. + * + * @param oldPassword The user's current password + * @param newPassword The new password + */ +public record ChangePasswordRequest( + @JsonProperty(value = "password", required = true) String oldPassword, + @JsonProperty(value = "new_password", required = true) String newPassword) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java new file mode 100644 index 0000000..35dae3d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java @@ -0,0 +1,46 @@ +package org.psesquared.server.authentication.api.controller; + +/** + * This record wraps a dummy device that is required to be returned by <br> + * {@link AuthenticationController#getDeviceList(String, + * jakarta.servlet.http.HttpServletResponse)}. + * + * @param id The device id + * @param caption The caption, i.e. name, of the device + * @param type The device type + * @param subscriptions The number of subscriptions of the device + */ +public record DeviceWrapper( + String id, + String caption, + String type, + int subscriptions) { + + /** + * The id of the dummy device. + */ + private static final String DUMMY_ID = "dummy"; + + /** + * The name of the dummy device. + */ + private static final String DUMMY_DEVICE = "device"; + + /** + * The type of the dummy device. + */ + private static final String DUMMY_TYPE = "other"; + + /** + * The number of subscriptions of the dummy device. + */ + private static final int DUMMY_SUBSCRIPTIONS = 0; + + /** + * The no-args-constructor for a device-wrapper containing a dummy device. + */ + public DeviceWrapper() { + this(DUMMY_ID, DUMMY_DEVICE, DUMMY_TYPE, DUMMY_SUBSCRIPTIONS); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java new file mode 100644 index 0000000..700fb08 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java @@ -0,0 +1,13 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request for sending an email with a link for resetting a user's password to + * the user's {@link #email} address. + * + * @param email The email address + */ +public record ForgotPasswordRequest( + @JsonProperty(required = true) String email) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java new file mode 100644 index 0000000..f772c7b --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java @@ -0,0 +1,13 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request that contains a {@link #password}, which is either set as a new + * password or used for confirming the deletion of an account. + * + * @param password The password + */ +public record PasswordRequest( + @JsonProperty(required = true) String password) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java new file mode 100644 index 0000000..9c112b1 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java @@ -0,0 +1,16 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request that contains the {@link #username}, {@link #email} address and + * {@link #password} for registering a new user. + * + * @param username The username + * @param email The email + * @param password The password + */ +public record UserInfoRequest(@JsonProperty(required = true) String username, + @JsonProperty(required = true) String email, + @JsonProperty(required = true) String password) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java new file mode 100644 index 0000000..79ae33d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the highest logical layer of the authentication API + * ({@link org.psesquared.server.authentication.api}) - the controller layer. + * <br> + * It contains the + * {@link + * org.psesquared.server.authentication.api.controller.AuthenticationController} + * along with a series of wrapper classes for JSON request and response bodies. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.authentication.api.controller; diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java new file mode 100644 index 0000000..2073633 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java @@ -0,0 +1,62 @@ +package org.psesquared.server.authentication.api.data.access; + +import java.util.Optional; +import org.psesquared.server.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * This JPA repository manages all database transactions by automatically + * implementing the logic behind custom queries via method naming convention. + */ +@Repository +public interface AuthenticationDao extends JpaRepository<User, Long> { + + /** + * Checks if a user exists via their username. + * + * @param username The username + * @return {@code true} if the user with the given username exists, <br> + * {@code false} otherwise + */ + boolean existsByUsername(String username); + + /** + * Finds the {@link User} with the given username if present. + * + * @param username The username of the user that is being searched for + * @return An {@link Optional} containing the user with the given + * username if present + */ + Optional<User> findByUsername(String username); + + /** + * Finds the {@link User} with the given email address if present. + * + * @param email The email address of the user that is being searched for + * @return An {@link Optional} containing the user with the given email + * address if present + */ + Optional<User> findByEmail(String email); + + /** + * Finds a {@link User} with the given username if present or with the + * given email address otherwise. + * + * @param username The username of the user that is being searched for + * @param email The email address of the user that is being searched for + * @return An {@link Optional} containing the user with the given username + * or email address if present + */ + Optional<User> findByUsernameOrEmail(String username, String email); + + /** + * Deletes all users that haven't been verified yet and have registered + * before the time specified by the given timestamp. + * + * @param timestamp The timestamp representing the number of seconds from + * the epoch of 1970-01-01T00:00:00Z. + */ + void deleteAllByEnabledFalseAndCreatedAtLessThan(long timestamp); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java new file mode 100644 index 0000000..1b20cab --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java @@ -0,0 +1,11 @@ +/** + * This package represents the lowest logical layer of the authentication API + * ({@link org.psesquared.server.authentication.api}) - the data-access layer. + * <br> + * It features the interface {@link + * org.psesquared.server.authentication.api.data.access.AuthenticationDao}. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.authentication.api.data.access; diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java new file mode 100644 index 0000000..e21c3fc --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java @@ -0,0 +1,389 @@ +package org.psesquared.server.authentication.api.service; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.controller.ChangePasswordRequest; +import org.psesquared.server.authentication.api.controller.PasswordRequest; +import org.psesquared.server.authentication.api.controller.UserInfoRequest; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.psesquared.server.config.JwtService; +import org.psesquared.server.model.Role; +import org.psesquared.server.model.User; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * This service class manages all business logic associated with the + * authentication API. + * <br> + * It is called from the + * {@link + * org.psesquared.server.authentication.api.controller.AuthenticationController} + * and passes on requests concerning data access to the + * {@link AuthenticationDao}. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class AuthenticationService { + + /** + * A {@link User} is not enabled until verification. + */ + private static final boolean ENABLED_DEFAULT = false; + + /** + * A {@link User} becomes enabled after verification. + */ + private static final boolean VERIFIED = true; + + /** + * The age of an expired cookie. + */ + private static final int EXPIRED_AGE = 0; + + /** + * The name of the cookie used by podcatchers for authentication. + * If a {@link User} has logged in, this cookie holds the JWT. + */ + private static final String COOKIE_NAME = "sessionid"; + + /** + * Specifies that the cookie should be sent to all URLs. + */ + private static final String COOKIE_PATH_GLOBAL = "/"; + + /** + * The default role of a {@link User} is {@link Role#USER}. + */ + private static final Role DEFAULT_USER = Role.USER; + + /** + * The JPA repository that handles all user related database requests. + */ + private final AuthenticationDao authenticationDao; + + /** + * The class used for the encryption of passwords. + */ + private final PasswordEncoder passwordEncoder; + + /** + * The service class used for managing JWTs. + */ + private final JwtService jwtService; + + /** + * The service class used for sending emails. + */ + private final EmailServiceImpl emailService; + + /** + * The service class used for encrypting email addresses. + */ + private final EncryptionService encryptionService; + + /** + * The service class used for checking if the given user information meets + * the specified requirements. + */ + private final InputCheckService inputCheckService; + + /** + * This method is invoked by the register method of the authentication + * controller. + * <br> + * 1. Checks if the given user information meets the requirements. + * <br> + * 2. Checks if no user with the given username already exists (if so, + * and email as well as password match, the verification email is sent again). + * <br> + * 3. Creates a user with the given information and sends verification email. + * + * @param userInfo The wrapper object containing username, email and password + * @return @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} for invalid user information + */ + public HttpStatus registerUser(final UserInfoRequest userInfo) { + if (inputCheckService.checkUsernameInvalid(userInfo.username()) + || inputCheckService.checkEmailInvalid(userInfo.email()) + || inputCheckService.checkPasswordInvalid(userInfo.password())) { + return HttpStatus.BAD_REQUEST; + } + + final String encryptedEmailFromRequest + = encryptionService.saltAndHashEmail(userInfo.email()); + User user; + + try { + user = authenticationDao + .findByUsernameOrEmail(userInfo.username(), encryptedEmailFromRequest) + .orElseThrow(); + + if (user.isEnabled() + || !user.getEmail().equals(encryptedEmailFromRequest) + || !user.getUsername().equals(userInfo.username()) + || !passwordEncoder + .matches(userInfo.password(), user.getPassword())) { + return HttpStatus.BAD_REQUEST; + } + + user.setCreatedAt(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)); + + } catch (NoSuchElementException e) { + user = User.builder() + .username(userInfo.username()) + .email(encryptionService.saltAndHashEmail(userInfo.email())) + .password(passwordEncoder.encode(userInfo.password())) + .enabled(ENABLED_DEFAULT) + .createdAt(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .role(DEFAULT_USER) + .build(); + authenticationDao.save(user); + } + emailService.sendVerification(userInfo.email(), user); + return HttpStatus.OK; + } + + /** + * This method is invoked by the verifyRegistration method of the + * authentication controller. + * <br> + * If a not yet verified {@link User} with the given username exists, + * this user is verified via {@link User#setEnabled(boolean)}. + * + * @param username The username of the to be verified user + * @param token The JWT for authentication + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} user exists and is already verified, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus verifyRegistration(final String username, + final String token) { + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + + if (user.isEnabled()) { + return HttpStatus.BAD_REQUEST; + } + + if (!jwtService.isUrlTokenValid(token, user)) { + return HttpStatus.UNAUTHORIZED; + } + + user.setEnabled(VERIFIED); + return HttpStatus.OK; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the login method of the authentication + * controller. + * <br> + * Sets the "sessionid" cookie with a valid JWT for further authentication. + * + * @param username The username of the user who wants to log in + * @param response The {@link HttpServletResponse} for setting the "sessionid" + * cookie + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus login(final String username, + final HttpServletResponse response) { + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + var token = jwtService.generateAccessTokenString(user); + Cookie cookie = new Cookie(COOKIE_NAME, token); + cookie.setPath(COOKIE_PATH_GLOBAL); + response.addCookie(cookie); + return HttpStatus.OK; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the logout method of the authentication + * controller. + * <br> + * Invalidates the "sessionid" cookie. + * Thus, for further authentication until the next login only HTTP basic + * authentication (and no JWT authentication) is possible. + * + * @param username The username of the user who wants to log out + * @param response The {@link HttpServletResponse} for invalidating the + * "sessionid" cookie + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus logout(final String username, + final HttpServletResponse response) { + if (authenticationDao.existsByUsername(username)) { + Cookie cookie = new Cookie(COOKIE_NAME, null); + cookie.setMaxAge(EXPIRED_AGE); + cookie.setPath(COOKIE_PATH_GLOBAL); + response.addCookie(cookie); + return HttpStatus.OK; + } + return HttpStatus.NOT_FOUND; + } + + /** + * This method is invoked by the forgotPassword method of the authentication + * controller. + * <br> + * Sends an email with a link to reset the password to the given email + * address, if a user with this email address exists. + * + * @param email The email address of the user who wants to reset their + * password + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus forgotPassword(final String email) { + try { + var user = authenticationDao + .findByEmail(encryptionService.saltAndHashEmail(email)) + .orElseThrow(); + emailService.sendPasswordReset(email, user); + return HttpStatus.OK; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the resetPassword method of the authentication + * controller. + * <br> + * Sets a new password for the given user who has forgotten their + * password if the JWT is valid. + * + * @param username The username of the user who wants to reset their + * password + * @param token The JWT for authentication + * @param requestBody The request-wrapper containing the new password + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} password doesn't meet requirements, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus resetPassword(final String username, + final String token, + final PasswordRequest requestBody) { + if (inputCheckService.checkPasswordInvalid(requestBody.password())) { + return HttpStatus.BAD_REQUEST; + } + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + if (jwtService.isUrlTokenValid(token, user)) { + user.setPassword(passwordEncoder.encode(requestBody.password())); + return HttpStatus.OK; + } + return HttpStatus.UNAUTHORIZED; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the changePassword method of the authentication + * controller. + * <br> + * Changes the password of a logged-in user. + * + * @param username The username of the user who wants to change their + * password + * @param requestBody The request-wrapper containing old and new password + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} old password is wrong, or new + * password doesn't meet requirements, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus changePassword(final String username, + final ChangePasswordRequest requestBody) { + if (inputCheckService.checkPasswordInvalid(requestBody.newPassword())) { + return HttpStatus.BAD_REQUEST; + } + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + if (passwordEncoder + .matches(requestBody.oldPassword(), user.getPassword())) { + user.setPassword(passwordEncoder.encode(requestBody.newPassword())); + return HttpStatus.OK; + } + return HttpStatus.BAD_REQUEST; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the deleteUser method of the authentication + * controller. + * <br> + * Deletes the user with the given username if existent and if the given + * password for confirmation is correct. + * + * @param username The username of the user who wants to delete their account + * @param requestBody The request-wrapper containing the password for + * confirmation + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} wrong password, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus deleteUser(final String username, + final PasswordRequest requestBody) { + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + if (passwordEncoder.matches(requestBody.password(), user.getPassword())) { + authenticationDao.delete(user); + return HttpStatus.OK; + } + return HttpStatus.BAD_REQUEST; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by {@link org.psesquared.server.util.Scheduler} + * for cleaning the database from expired {@link User}s. + * + * @param timestamp The timestamp representing the number of seconds from + * the epoch of 1970-01-01T00:00:00Z. + * @see AuthenticationDao#deleteAllByEnabledFalseAndCreatedAtLessThan(long) + */ + public void deleteInvalidUsersOlderThan(final long timestamp) { + authenticationDao.deleteAllByEnabledFalseAndCreatedAtLessThan(timestamp); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java new file mode 100644 index 0000000..c752286 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java @@ -0,0 +1,222 @@ +package org.psesquared.server.authentication.api.service; + +import lombok.RequiredArgsConstructor; +import org.psesquared.server.config.EmailConfigProperties; +import org.psesquared.server.config.JwtService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +/** + * This service class is responsible for sending emails to + * {@link org.springframework.security.core.userdetails.User}s. + * + * @see JavaMailSender + */ +@Service +@RequiredArgsConstructor +public class EmailServiceImpl { + + /** + * The {@link JavaMailSender} used for the sending of emails. + */ + private final JavaMailSender emailSender; + + /** + * The properties class that is used to return some externally stored URLs. + */ + private final EmailConfigProperties emailConfigProperties; + + /** + * The service class for managing the JWTs that are sent via email. + */ + private final JwtService jwtService; + + /** + * The email address from which the emails are sent. + */ + @Value("${spring.mail.username}") + private String sender; + + /** + * The subject of the email that is sent for account verification. + */ + private static final String VERIFICATION_MAIL_SUBJECT + = "Bestätige deine E-Mail-Adresse für unseren" + + " Podcast-Synchronisations-Server | Validate your Mail"; + + /** + * The subject of the email that is sent for resetting the password of a user. + */ + private static final String PASSWORD_RESET_MAIL_SUBJECT + = "Setze dein Passwort für unseren Podcast-Synchronisation-Server" + + " zurück! | Reset Password"; + + /** + * The placeholder for the username. + */ + private static final String USERNAME_MAIL_PLACEHOLDER = "username"; + + /** + * The placeholder for the verification URL. + */ + private static final String VERIFICATION_MAIL_PLACEHOLDER + = "verificationURL"; + + /** + * The placeholder for the URL for resetting the password of a user. + */ + private static final String PASSWORD_RESET_MAIL_PLACEHOLDER + = "passwordResetURL"; + + /** + * The question mark symbol announcing a URL query parameter. + */ + private static final String URL_QUERY_PARAM = "?"; + + /** + * The format of the username URL query parameter. + */ + private static final String USERNAME_PARAM = "username="; + + /** + * The separator for URL query parameters. + */ + private static final String PARAM_SEPARATOR = "&"; + + /** + * The format of the token URL query parameter. + */ + private static final String TOKEN_PARAM = "token="; + + /** + * The contents of the verification URL with placeholders read from an + * external file. + */ + @Value("#{T(org.psesquared.server.authentication.api.service" + + ".ResourceReader).readFileToString('VerificationMail.txt')}") + private String verificationMailText; + + /** + * The contents of the URL for resetting the password of a user with + * placeholders read from an external file. + */ + @Value("#{T(org.psesquared.server.authentication.api.service" + + ".ResourceReader).readFileToString('PasswordResetMail.txt')}") + private String passwordResetMailText; + + /** + * Sends a generic email to a user enabling him/her to perform a certain + * action when clicking on the contained url. + * This method uses a template which lies at resources and contains a + * "verificationURL"-placeholder, which is replaced by the url. + * + * @param to Recipients email address + * @param mailSubject Subject of the email + * @param body Body of the email + */ + private void sendMail(final String to, + final String mailSubject, + final String body) { + // send simple mail message with credential from application.properties + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(sender); + message.setTo(to); + message.setSubject(mailSubject); + message.setText(body); + emailSender.send(message); + } + + /** + * Substitutes username and URL placeholders in email template. + * + * @param template The email template with placeholders + * @param user The name of the user + * @param url The URL with the JWT for request authentication + * @return The email text with the actual username and URL + */ + private String substitutePlaceholders(final String template, + final UserDetails user, + final String url) { + return template + .replace(USERNAME_MAIL_PLACEHOLDER, user.getUsername()) + .replace(VERIFICATION_MAIL_PLACEHOLDER, url) + .replace(PASSWORD_RESET_MAIL_PLACEHOLDER, url); + } + + /** + * Generates the URL for verifying the account of a + * {@link org.springframework.security.core.userdetails.User} containing + * a JWT for authentication. + * + * @param userDetails The user details of the user who wants to verify their + * account + * @return The URL for verifying the user's account + */ + private String generateVerificationUrlString(final UserDetails userDetails) { + String token = jwtService.generateUrlTokenString(userDetails); + String verificationUrl + = String.format(emailConfigProperties.verificationUrl(), + userDetails.getUsername()); + + return verificationUrl + URL_QUERY_PARAM + TOKEN_PARAM + token; + } + + /** + * Generates the URL for resetting the password of a + * {@link org.springframework.security.core.userdetails.User} containing + * a JWT for authentication. + * + * @param userDetails The user details of the user who wants to reset their + * password + * @return The URL for resetting the user's password + */ + private String generatePasswordResetUrlString(final UserDetails userDetails) { + final String token = jwtService.generateUrlTokenString(userDetails); + return emailConfigProperties.dashboardBaseUrl() + + emailConfigProperties.resetUrlPath() + + URL_QUERY_PARAM + + USERNAME_PARAM + + userDetails.getUsername() + + PARAM_SEPARATOR + + TOKEN_PARAM + + token; + } + + /** + * Sends a validation E-Mail to validate a user account by clicking on the + * given URL. + * It uses a template which lies at resources/ValidationMail.txt and contains + * a "validationURL"-placeholder. + * + * @param to The email address of the user who wants to verify their account + * @param userDetails The user details of that user + */ + public void sendVerification(final String to, final UserDetails userDetails) { + final String url = generateVerificationUrlString(userDetails); + String mailText + = substitutePlaceholders(verificationMailText, userDetails, url); + + sendMail(to, VERIFICATION_MAIL_SUBJECT, mailText); + } + + /** + * Sends a password-reset E-Mail to a user with a URL which lets the user + * change his/her password. + * It uses a template which lies at resources/PasswordResetMail.txt and + * contains a "passwordResetURL"-placeholder. + * + * @param to The email address of the user who wants to reset their password + * @param userDetails The user details of that user + */ + public void sendPasswordReset(final String to, + final UserDetails userDetails) { + final String url = generatePasswordResetUrlString(userDetails); + String mailText + = substitutePlaceholders(passwordResetMailText, userDetails, url); + + sendMail(to, PASSWORD_RESET_MAIL_SUBJECT, mailText); + } +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java new file mode 100644 index 0000000..f9cb68c --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java @@ -0,0 +1,85 @@ +package org.psesquared.server.authentication.api.service; + +import io.jsonwebtoken.io.Decoders; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.config.SecurityConfigProperties; +import org.springframework.stereotype.Service; + +/** + * The service class responsible for encrypting the email addresses of + * {@link org.psesquared.server.model.User}s. + */ +@Service +@RequiredArgsConstructor +public class EncryptionService { + + /** + * The mask for a byte. + */ + private static final int BYTE_MASK = 0xff; + + /** + * The value added to the byte before conversion to String. + */ + private static final int ADDITION = 0x100; + + /** + * The hexadecimal radix. + */ + private static final int RADIX = 16; + + /** + * The index specifying the starting index for the substring method. + */ + private static final int BEGIN_INDEX = 1; + + /** + * The name of the hashing algorithm. + */ + private static final String SHA_512_ALGORITHM_NAME = "SHA-512"; + + /** + * The properties class that is used to return externally stored secret salt. + */ + private final SecurityConfigProperties securityConfigProperties; + + /** + * Encrypts a given email address by salting it with a fixed salt and hashing + * it afterwards. + * + * @param email The email address that needs to be salted and hashed + * @return The salted and hashed email address + */ + public String saltAndHashEmail(final String email) { + String generatedEmail = null; + try { + MessageDigest md = MessageDigest.getInstance(SHA_512_ALGORITHM_NAME); + md.update(getSalt()); + byte[] bytes = md.digest(email.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(Integer + .toString((b & BYTE_MASK) + ADDITION, RADIX) + .substring(BEGIN_INDEX)); + } + generatedEmail = sb.toString(); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + return generatedEmail; + } + + /** + * Returns the salt for encrypting email addresses in the form of + * base64-decoded bytes of a locally stored secret signing key. + * + * @return {@code byte[]} containing the salt + */ + private byte[] getSalt() { + return Decoders.BASE64.decode(securityConfigProperties.emailSigningKey()); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java new file mode 100644 index 0000000..be74a88 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java @@ -0,0 +1,170 @@ +package org.psesquared.server.authentication.api.service; + +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.InternetAddress; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * The service class responsible for checking if user information (i.e. + * username, email address and password) meets the specified requirements. + */ +@Service +@RequiredArgsConstructor +public class InputCheckService { + + /** + * The strict boolean for + * {@link InternetAddress#InternetAddress(String, boolean)}. + */ + private static final boolean STRICT = true; + + /** + * The return value for valid user information. + */ + private static final boolean VALID = false; + + /** + * The return value for invalid user information. + */ + private static final boolean INVALID = true; + + /** + * Asserts position at start of a line. + */ + private static final String REGEX_START = "^"; + + /** + * Matches any word character (equivalent to [a-zA-Z0-9_]) character '-' + * between 1 and 255 times. + */ + private static final String USERNAME_REGEX_GROUP = "[\\w\\u002d]{1,255}"; + + /** + * Asserts that the password contains at least one digit. + */ + private static final String PW_REGEX_GROUP1 = "(?=.*\\d)"; + + /** + * Asserts that the password contains at least one lower case character. + */ + private static final String PW_REGEX_GROUP2 = "(?=.*[a-z])"; + + /** + * Asserts that the password contains at least one upper case character. + */ + private static final String PW_REGEX_GROUP3 = "(?=.*[A-Z])"; + + /** + * Asserts that the password contains at least one special character from + * the list [€°§´] or the Punct script extension. + */ + private static final String PW_REGEX_GROUP4 = "(?=.*[\\p{Punct}€°§´])"; + + /** + * Asserts that the password contains only word characters + * (equivalent to [a-zA-Z0-9_]) and the special characters specified in + * {@link InputCheckService#PW_REGEX_GROUP4}. + */ + private static final String PW_REGEX_GROUP5 = "[\\w\\p{Punct}€°§´]{8,255}"; + + /** + * Asserts position at the end of a line. + */ + private static final String REGEX_END = "$"; + + + /** + * The complete regex for a valid username consisting of the following regex + * groups: + * <br> + * {@link #REGEX_START}, {@link #USERNAME_REGEX_GROUP}, {@link #REGEX_END}. + */ + private static final String USERNAME_REGEX = REGEX_START + + USERNAME_REGEX_GROUP + + REGEX_END; + + /** + * The complete regex for a valid password consisting of the following regex + * groups: + * <br> + * {@link #REGEX_START}, {@link #PW_REGEX_GROUP1}, {@link #PW_REGEX_GROUP2}, + * {@link #PW_REGEX_GROUP3}, {@link #PW_REGEX_GROUP4}, + * {@link #PW_REGEX_GROUP5}, {@link #REGEX_END}. + */ + private static final String PW_REGEX = REGEX_START + + PW_REGEX_GROUP1 + + PW_REGEX_GROUP2 + + PW_REGEX_GROUP3 + + PW_REGEX_GROUP4 + + PW_REGEX_GROUP5 + + REGEX_END; + + /** + * Checks if the given {@code username} meets the following requirements: + * <br> + * - contains only word characters (equivalent to [a-zA-Z0-9_]) + * and the character '-'. + * <br> + * - is between 1 and 255 characters long. + * + * @param username The username that needs to be validated + * @return {@code false} if the username meets the requirements, + * <br> + * {@code true} otherwise + */ + public boolean checkUsernameInvalid(final String username) { + return !Pattern + .compile(USERNAME_REGEX) + .matcher(username) + .matches(); + } + + /** + * Checks if the given email address conforms to the RFC822 standard using + * {@link InternetAddress#validate()}. + * + * @param email The email address that needs to be validated + * @return {@code false} if the username meets the requirements, + * <br> + * {@code true} otherwise + */ + public boolean checkEmailInvalid(final String email) { + try { + InternetAddress internetAddress = new InternetAddress(email, STRICT); + internetAddress.validate(); + return VALID; + } catch (AddressException e) { + return INVALID; + } + } + + /** + * Checks if the given {@code password} meets the following requirements: + * <br> + * - contains at least one digit. + * <br> + * - contains at least one lower case character. + * <br> + * - contains at least one upper case character. + * <br> + * - contains at least one special character from the list [€°§´] or the + * Punct script extension. + * <br> + * - contains only word characters (equivalent to [a-zA-Z0-9_]) and special + * characters specified above. + * + * @param password The username that needs to be validated + * @return {@code false} if the username meets the requirements, + * <br> + * {@code true} otherwise + */ + public boolean checkPasswordInvalid(final String password) { + return !Pattern + .compile(PW_REGEX) + .matcher(password) + .matches(); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java new file mode 100644 index 0000000..3501f9e --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java @@ -0,0 +1,33 @@ +package org.psesquared.server.authentication.api.service; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import org.apache.commons.io.IOUtils; + +/** + * This class allows reading text from files. + */ +public final class ResourceReader { + + /** + * Private constructor - cannot be called. + */ + private ResourceReader() { } + + /** + * This method reads text from a file specified by the given path. + * + * @param path The path to the file + * @return The contents of the file + * @throws java.io.IOException If an I/O error occurs + */ + public static String readFileToString(final String path) + throws java.io.IOException { + return IOUtils.toString(Objects.requireNonNull( + ResourceReader.class.getClassLoader().getResourceAsStream(path)), + StandardCharsets.UTF_8); + + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java new file mode 100644 index 0000000..9f16a0d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the logical middle layer of the authentication API + * ({@link org.psesquared.server.authentication.api}) - the service layer. + * <br> + * All business logic is handled here with the + * {@link + * org.psesquared.server.authentication.api.service.AuthenticationService} + * class, which in turn relies on some other service classes. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.authentication.api.service; diff --git a/pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java b/pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java new file mode 100644 index 0000000..a67e53d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java @@ -0,0 +1,125 @@ +package org.psesquared.server.config; + +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * The application configuration class declaring several beans. + */ +@Configuration +@EnableScheduling +@EnableTransactionManagement +@EnableAsync +@RequiredArgsConstructor +public class ApplicationConfig implements WebMvcConfigurer { + + /** + * The message passed on to {@link UsernameNotFoundException}. + */ + private static final String USERNAME_NOT_FOUND + = "No user with the given username was found."; + + /** + * The JPA repository that handles user related database requests. + */ + private final AuthenticationDao authenticationDao; + + /** + * Returns a {@link UserDetailsService} bean for retrieving users via username + * from the database. + * + * @return {@link UserDetailsService} + */ + @Bean + public UserDetailsService userDetailsService() { + return username -> authenticationDao.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException(USERNAME_NOT_FOUND)); + } + + /** + * Returns an {@link AuthenticationProvider} bean for authenticating + * {@link org.springframework.security.core.userdetails.User}s with username + * and password using {@link #userDetailsService()} and + * {@link #passwordEncoder()}. + * + * @return {@link AuthenticationProvider} + */ + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService()); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + /** + * Returns a {@link BCryptPasswordEncoder} bean for password encryption. + * + * @return {@link PasswordEncoder} + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Returns an {@link AuthenticationManager} bean for processing authentication + * requests from the given {@link AuthenticationConfiguration}. + * + * @param config The application's authentication configuration + * @return {@link AuthenticationManager} + * @throws Exception When the authentication manager couldn't be retrieved + * from the given configuration + */ + @Bean + public AuthenticationManager authenticationManager( + final AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + /** + * Returns a {@link WebMvcConfigurer} bean with CORS enabled globally. + * + * @return {@link WebMvcConfigurer} + */ + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(@NonNull final CorsRegistry registry) { + registry + .addMapping("/**") + .allowedOrigins("*") + .allowedMethods("*"); + } + }; + } + + /** + * Registers an {@link AuthenticationValidatorInterceptor}. + * + * @param registry The {@link InterceptorRegistry} + */ + @Override + public void addInterceptors(final InterceptorRegistry registry) { + registry.addInterceptor(new AuthenticationValidatorInterceptor()); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java b/pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java new file mode 100644 index 0000000..3482164 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java @@ -0,0 +1,95 @@ +package org.psesquared.server.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; + +/** + * This interceptor class intercepts requests between the DispatcherServlet and + * the Controller (i.e. when already mapped to Controller method). + * It checks if the currently authenticated + * {@link org.psesquared.server.model.User} is the same user for whom the + * request is sent. + */ +public class AuthenticationValidatorInterceptor implements HandlerInterceptor { + + /** + * The return value for aborting the execution of the Controller method. + */ + private static final boolean ABORT_EXECUTION = false; + + /** + * The return value for resuming the execution of the Controller method. + */ + private static final boolean RESUME_EXECUTION = true; + + /** + * The name of the username URL path variable. + */ + private static final String PATH_VARIABLE_USERNAME = "username"; + + /** + * The default name associated with authentication. + */ + private static final String USERNAME_NO_AUTH = "anonymousUser"; + + /** + * Checks if the currently authenticated + * {@link org.psesquared.server.model.User} is the same user specified in the + * URL path variable of the request. + * + * @param request The {@link HttpServletRequest} + * @param response The {@link HttpServletResponse} + * @param handler The chosen handler + * @return {@code true} if the users match, + * <br> + * {@code false} otherwise + */ + @Override + public boolean preHandle(@NonNull final HttpServletRequest request, + @NonNull final HttpServletResponse response, + @NonNull final Object handler) { + + final String usernamePathVariable = extractUsernamePathVariable(request); + final AbstractAuthenticationToken auth + = (AbstractAuthenticationToken) SecurityContextHolder.getContext() + .getAuthentication(); + final String usernameAuthenticated; + + if (usernamePathVariable == null || auth == null) { + return RESUME_EXECUTION; + } + + usernameAuthenticated = auth.getName(); + if (usernameAuthenticated == null + || usernameAuthenticated.equals(USERNAME_NO_AUTH) + || usernameAuthenticated.equals(usernamePathVariable)) { + return RESUME_EXECUTION; + } + + return ABORT_EXECUTION; + } + + /** + * Extracts the username path variable from the {@link HttpServletRequest}. + * + * @param request The {@link HttpServletRequest} + * @return The value of the username path variable + */ + private String extractUsernamePathVariable(final HttpServletRequest request) { + // returns HttpServletRequest attribute that contains the URI templates map, + // mapping variable names to values + final Map<String, String> pathVariables = (Map<String, String>) request + .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + // this attribute is of type Map<String, String> per definition, + // so no type checks are needed + return (pathVariables != null) + ? pathVariables.get(PATH_VARIABLE_USERNAME) : null; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java b/pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java new file mode 100644 index 0000000..4b2c79e --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java @@ -0,0 +1,16 @@ +package org.psesquared.server.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The properties class that is used to return some externally stored URLs. + * + * @param dashboardBaseUrl The base URL of the PSE-Dashboard + * @param verificationUrl The URL for account verification + * @param resetUrlPath The URL for resetting the password of a user + */ +@ConfigurationProperties("email") +public record EmailConfigProperties(String dashboardBaseUrl, + String verificationUrl, + String resetUrlPath) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java b/pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..bf00ecb --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java @@ -0,0 +1,143 @@ +package org.psesquared.server.config; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.WebUtils; + +/** + * This filter class handles authentication via JWT. + * <br> + * Its method + * {@link + * #doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain)} + * is invoked before the mapping of the request to the Controller happens. + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + /** + * The URL for the unsecured register-API-endpoint. + */ + private static final String REGISTER_URL + = "/api/2/auth/register.json"; + + /** + * The URL for the unsecured forgotPassword-API-endpoint. + */ + private static final String FORGOT_URL + = "/api/2/auth/{email}/forgot.json"; + + /** + * The URL for the unsecured verify-API-endpoint. + */ + private static final String VERIFY_URL + = "/api/2/auth/{username}/verify.json"; + + /** + * The URL for the unsecured resetPassword-API-endpoint. + */ + private static final String RESET_PASSWORD_URL + = "/api/2/auth/{username}/resetpassword.json"; + + /** + * The name of the cookie used for JWT authentication. + */ + private static final String COOKIE_NAME = "sessionid"; + + /** + * The service class used for managing JWTs. + */ + private final JwtService jwtService; + + /** + * The service class used for retrieving users from the database. + */ + private final UserDetailsService userDetailsService; + + /** + * The filter method does nothing for the specified unsecured URLs + * and otherwise calls + * {@link #authenticateIfValid(Cookie, HttpServletRequest)}. + * + * @param request The {@link HttpServletRequest} + * @param response The {@link HttpServletResponse} + * @param filterChain The {@link FilterChain} + * @throws ServletException If error occurs when processing request + * @throws IOException If I/O error occurs + */ + @Override + protected void doFilterInternal(@NonNull final HttpServletRequest request, + @NonNull final HttpServletResponse response, + @NonNull final FilterChain filterChain) + throws ServletException, IOException { + + final Cookie cookie = WebUtils.getCookie(request, COOKIE_NAME); + final String url = request.getRequestURI(); + + if (url.equals(REGISTER_URL) || url.equals(FORGOT_URL) + || url.equals(VERIFY_URL) || url.equals(RESET_PASSWORD_URL) + || cookie == null) { + filterChain.doFilter(request, response); + return; + } + + authenticateIfValid(cookie, request); + filterChain.doFilter(request, response); + } + + /** + * Authenticates the {@link org.psesquared.server.model.User} associated with + * the JWT from the cookie if it is valid. + * + * @param cookie The cookie containing the JWT + * @param request The {@link HttpServletRequest} for creating a new + * authentication details instance + */ + private void authenticateIfValid(final Cookie cookie, + final HttpServletRequest request) { + final String jwt = cookie.getValue(); + final String usernameFromToken; + + try { + usernameFromToken = jwtService.extractAuthUsername(jwt); + } catch (ExpiredJwtException e) { + return; + } + + if (usernameFromToken != null + && SecurityContextHolder + .getContext() + .getAuthentication() == null) { + UserDetails userDetails + = userDetailsService.loadUserByUsername(usernameFromToken); + if (jwtService.isAuthTokenValid(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken + = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/JwtService.java b/pse-server/src/main/java/org/psesquared/server/config/JwtService.java new file mode 100644 index 0000000..157055a --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/JwtService.java @@ -0,0 +1,283 @@ +package org.psesquared.server.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +/** + * The service class responsible for creating, managing and validating JWTs. + */ +@Service +@RequiredArgsConstructor +public class JwtService { + + /** + * The boolean value for expressing that a JWT is not valid. + */ + private static final boolean INVALID = false; + + /** + * The 1h lifespan of an access token. + */ + private static final long ACCESS_TOKEN_LIFESPAN_MILLIS + = 1000 * 60 * (long) 60; + + /** + * The 24h lifespan of a URL token (verification/resetting password). + */ + private static final long URL_TOKEN_LIFESPAN_MILLIS + = ACCESS_TOKEN_LIFESPAN_MILLIS * 24; + + /** + * The properties class that is used to return externally stored signing key. + */ + private final SecurityConfigProperties securityConfigProperties; + + /** + * Extracts the username from a JWT for authentication. + * + * @param token The JWT + * @return The extracted username + */ + public String extractAuthUsername(final String token) { + return extractClaim(token, getAuthSigningKey(), Claims::getSubject); + } + + /** + * Extracts a generic claim from the JWT. + * + * @param <T> The type of the claim + * @param token The JWT + * @param signingKey The JWT signing key + * @param claimsResolver The function to resolve the claim + * @return The extracted generic claim + * @throws ExpiredJwtException If the JWT has expired + * @throws UnsupportedJwtException If the JWT is not supported + * @throws MalformedJwtException If the JWT is malformed + * @throws SignatureException If the signature doesn't match + * @throws IllegalArgumentException If the token has an inappropriate format + */ + public <T> T extractClaim(final String token, + final Key signingKey, + final Function<Claims, T> claimsResolver) + throws ExpiredJwtException, + UnsupportedJwtException, + MalformedJwtException, + SignatureException, + IllegalArgumentException { + + final Claims claims = extractAllClaims(token, signingKey); + return claimsResolver.apply(claims); + } + + /** + * Generates the JWT with additional claims and a lifespan for the + * {@link org.psesquared.server.model.User} with the given details. + * + * @param additionalClaims The {@link Map} with additional claims + * @param userDetails The user details + * @param tokenLifespan The lifespan of the token + * @param signingKey The JWT signing key + * @return The generated JWT + */ + public String generateTokenString(final Map<String, Object> additionalClaims, + final UserDetails userDetails, + final long tokenLifespan, + final Key signingKey) { + return Jwts.builder() + .setClaims(additionalClaims) + .setSubject(userDetails.getUsername()) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + tokenLifespan)) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * Generates a JWT access token for the + * {@link org.psesquared.server.model.User} with the given details. + * (no additional claims). + * + * @param userDetails The user details + * @return The generated JWT access token + */ + public String generateAccessTokenString(final UserDetails userDetails) { + //no additional claims supported but open for extension with roles e.g. + return generateTokenString(new HashMap<>(), + userDetails, + ACCESS_TOKEN_LIFESPAN_MILLIS, + getAuthSigningKey()); + } + + /** + * Generates a JWT URL token required for authentication/resetting password + * for the {@link org.psesquared.server.model.User} with the given details + * (no additional claims). + * + * @param userDetails The user details + * @return The generated JWT access token + */ + public String generateUrlTokenString(final UserDetails userDetails) { + return generateTokenString(new HashMap<>(), + userDetails, + URL_TOKEN_LIFESPAN_MILLIS, + getUrlSigningKey()); + } + + /** + * Validates the given JWT for authentication against the given + * {@link UserDetails} and checks if it has not expired. + * + * @param token The to be validated JWT + * @param userDetails The user details + * @return {@code true} if the JWT is valid, + * <br> + * {@code false} otherwise + */ + public boolean isAuthTokenValid(final String token, + final UserDetails userDetails) { + return isTokenValid(token, userDetails, getAuthSigningKey()); + } + + /** + * Validates the given JWT for URLs against the given {@link UserDetails} + * and checks if it has not expired. + * + * @param token The to be validated JWT + * @param userDetails The user details + * @return {@code true} if the JWT is valid, + * <br> + * {@code false} otherwise + */ + public boolean isUrlTokenValid(final String token, + final UserDetails userDetails) { + return isTokenValid(token, userDetails, getUrlSigningKey()); + } + + /** + * Validates the given JWT against the given {@link UserDetails} + * with the given signing key and checks if it has not expired. + * + * @param token The to be validated JWT + * @param userDetails The user details + * @param signingKey The JWT signing key + * @return {@code true} if the JWT is valid, + * <br> + * {@code false} otherwise + */ + private boolean isTokenValid(final String token, + final UserDetails userDetails, + final Key signingKey) { + try { + final String username = extractUsername(token, signingKey); + return username.equals(userDetails.getUsername()) + && !isTokenExpired(token, signingKey); + } catch (ExpiredJwtException + | UnsupportedJwtException + | MalformedJwtException + | SignatureException + | IllegalArgumentException e) { + return INVALID; + } + } + + /** + * Checks if the given JWT is expired. + * + * @param token The JWT + * @param signingKey The JWT signing key + * @return {@code true} if the JWT is expired, + * <br> + * {@code false} otherwise + */ + private boolean isTokenExpired(final String token, final Key signingKey) { + return extractExpiration(token, signingKey).before(new Date()); + } + + /** + * Extracts the username from a JWT. + * + * @param token The JWT + * @param signingKey The JWT signing key + * @return The extracted username + */ + private String extractUsername(final String token, final Key signingKey) { + return extractClaim(token, signingKey, Claims::getSubject); + } + + /** + * Extracts the expiration {@link Date} of the JWT. + * + * @param token The JWT + * @param signingKey The JWT signing key + * @return The expiration date + */ + private Date extractExpiration(final String token, final Key signingKey) { + return extractClaim(token, signingKey, Claims::getExpiration); + } + + /** + * Extracts all claims from the JWT in order for + * {@link #extractClaim(String, Key, Function)} to be able + * to filter out one claim. + * + * @param token The JWT + * @param signingKey The JWT signing key + * @return All claims of the JWT + * @throws ExpiredJwtException If the JWT has expired + * @throws UnsupportedJwtException If the JWT is not supported + * @throws MalformedJwtException If the JWT is malformed + * @throws SignatureException If the signature doesn't match + * @throws IllegalArgumentException If the token has an inappropriate format + */ + private Claims extractAllClaims(final String token, final Key signingKey) + throws ExpiredJwtException, + UnsupportedJwtException, + MalformedJwtException, + SignatureException, + IllegalArgumentException { + + return Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /** + * Returns the signing {@link Key} for signing the JWT. + * + * @return The signing key + */ + private Key getAuthSigningKey() { + byte[] keyBytes + = Decoders.BASE64.decode(securityConfigProperties.jwtAuthSigningKey()); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * Returns the signing {@link Key} for signing the JWT. + * + * @return The signing key + */ + private Key getUrlSigningKey() { + byte[] keyBytes + = Decoders.BASE64.decode(securityConfigProperties.jwtUrlSigningKey()); + return Keys.hmacShaKeyFor(keyBytes); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java new file mode 100644 index 0000000..8005160 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java @@ -0,0 +1,117 @@ +package org.psesquared.server.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +/** + * This class is responsible for configuring the {@link SecurityFilterChain} + * which determines the way authentication is handled with the server. + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + /** + * The URL of the unsecured register-API-endpoint. + */ + private static final String REGISTER_URL + = "/api/2/auth/register.json"; + + /** + * The URL of the unsecured forgotPassword-API-endpoint. + */ + private static final String FORGOT_URL + = "/api/2/auth/{email}/forgot.json"; + + /** + * The URL of the unsecured verify-API-endpoint. + */ + private static final String VERIFY_URL + = "/api/2/auth/{username}/verify.json"; + + /** + * The URL of the unsecured resetPassword-API-endpoint. + */ + private static final String RESET_PASSWORD_URL + = "/api/2/auth/{username}/resetpassword.json"; + + /** + * The authentication filter for JWT authentication. + */ + private final JwtAuthenticationFilter jwtAuthFilter; + + /** + * The authentication provider specified in {@link ApplicationConfig}. + */ + private final AuthenticationProvider authenticationProvider; + + /** + * Configures the {@link SecurityFilterChain} with {@link HttpSecurity} + * in the following way: + * <br> + * 1. JWT authentication ("sessionid" cookie) + * <br> + * 2. HTTP basic authentication ("Authorization" header) + * + * @param http The HTTP security class + * @return The security filter chain + * @throws Exception If an error occurs + */ + @Bean + public SecurityFilterChain securityFilterChain(final HttpSecurity http) + throws Exception { + http + .cors() + .and() + .csrf() + .disable() + .authorizeHttpRequests() + .requestMatchers( + REGISTER_URL, + FORGOT_URL, + VERIFY_URL, + RESET_PASSWORD_URL) + .permitAll() + .anyRequest() + .authenticated() + .and() + .authenticationProvider(authenticationProvider) + .addFilterBefore(jwtAuthFilter, + UsernamePasswordAuthenticationFilter.class) + .httpBasic() + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + return http.build(); + } + + /** + * Ensures CORS is processed before Spring Security. + * + * @return The specified CORS configuration source + */ + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowCredentials(true); + configuration.addAllowedOriginPattern("*"); + configuration.addAllowedHeader("*"); + configuration.addAllowedMethod("*"); + UrlBasedCorsConfigurationSource source + = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java new file mode 100644 index 0000000..74303fe --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java @@ -0,0 +1,17 @@ +package org.psesquared.server.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The properties class that is used to return externally stored signing key. + * + * @param jwtAuthSigningKey The base64-encoded JWT signing key for + * authentication + * @param jwtUrlSigningKey The base64-encoded JWT signing key for URLs + * @param emailSigningKey The base64-encoded salt for email encryption + */ +@ConfigurationProperties("security") +public record SecurityConfigProperties(String jwtAuthSigningKey, + String jwtUrlSigningKey, + String emailSigningKey) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/package-info.java b/pse-server/src/main/java/org/psesquared/server/config/package-info.java new file mode 100644 index 0000000..814be40 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/package-info.java @@ -0,0 +1,8 @@ +/** + * This package features all relevant classes for the application + * configuration and security. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.config; diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java new file mode 100644 index 0000000..9a0d898 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java @@ -0,0 +1,129 @@ +package org.psesquared.server.episode.actions.api.controller; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.episode.actions.api.service.EpisodeActionService; +import org.psesquared.server.util.UpdateUrlsWrapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * This is a controller class for the Episode Action API that + * handles the requests from the client concerning the synchronization + * of episodes between clients. + * In the end an appropriate response is sent back to the user. + */ +@RequestMapping("/api/2/episodes/{username}.json") +@RestController +@RequiredArgsConstructor +public class EpisodeActionController { + + /** + * The service class that this controller calls to further process requests. + */ + private final EpisodeActionService episodeActionService; + + /** + * Takes a list of EpisodeActionPosts of a user and adds them to the database. + * + * @param username The username of the user uploading the + * EpisodeActions + * @param episodeActionPosts The list of EpisodeActionPosts to be uploaded + * @return The exit status of the function + */ + @PostMapping + public ResponseEntity<UpdateUrlsWrapper> addEpisodeActions( + @PathVariable final String username, + @RequestBody final List<EpisodeActionPost> episodeActionPosts) { + episodeActionService.addEpisodeActions(username, episodeActionPosts); + return ResponseEntity.ok(new UpdateUrlsWrapper()); + } + + /** + * Returns a list of all EpisodeActions a user has uploaded so far in the form + * of an EpisodeActionGetResponse. + * + * @param username The username of the user whose EpisodeActions are requested + * @return The exit status with a response body containing all requested + * EpisodeActions + */ + @GetMapping + public ResponseEntity<EpisodeActionGetResponse> getEpisodeActions( + @PathVariable final String username) { + EpisodeActionGetResponse responseBody + = new EpisodeActionGetResponse(episodeActionService + .getEpisodeActions(username)); + return ResponseEntity.ok(responseBody); + } + + /** + * Returns a list of EpisodeActions of a user for a given podcast in the form + * of an EpisodeActionGetResponse. + * + * @param username The username of the user whose EpisodeActions are + * requested + * @param podcastUrl The RSS-Feed URL of the podcast in question + * @return The exit status with a response body containing all requested + * EpisodeActions + */ + @GetMapping(params = {"podcast"}) + public ResponseEntity<EpisodeActionGetResponse> getEpisodeActionsOfPodcast( + @PathVariable final String username, + @RequestParam("podcastUrl") final String podcastUrl) { + EpisodeActionGetResponse responseBody + = new EpisodeActionGetResponse(episodeActionService + .getEpisodeActionsOfPodcast(username, podcastUrl)); + return ResponseEntity.ok(responseBody); + } + + /** + * Returns a list of EpisodeActions of a user since a given timestamp in the + * form of an EpisodeActionGetResponse. + * + * @param username The username of the user whose EpisodeActions are requested + * @param since The timestamp signifying how old the EpisodeActions are + * allowed to be + * @return The exit status with a response body containing all requested + * EpisodeActions + */ + @GetMapping(params = {"since"}) + public ResponseEntity<EpisodeActionGetResponse> getEpisodeActionsSince( + @PathVariable final String username, + @RequestParam("since") final long since) { + EpisodeActionGetResponse responseBody + = new EpisodeActionGetResponse(episodeActionService + .getEpisodeActionsSince(username, since)); + return ResponseEntity.ok(responseBody); + } + + /** + * Returns a list of EpisodeActions of a user for a given podcast, since a + * given time in the form of an EpisodeActionGetResponse. + * + * @param username The username of the user whose EpisodeActions are + * requested + * @param podcastUrl The RSS-Feed URL of the podcast in question + * @param since The timestamp signifying how old the EpisodeActions are + * allowed to be + * @return The exit status with a response body containing all requested + * EpisodeActions + */ + @GetMapping(params = {"podcast", "since"}) + public ResponseEntity<EpisodeActionGetResponse> + getEpisodeActionsOfPodcastSince( + @PathVariable final String username, + @RequestParam("podcastUrl") final String podcastUrl, + @RequestParam("since") final long since) { + EpisodeActionGetResponse responseBody + = new EpisodeActionGetResponse(episodeActionService + .getEpisodeActionsOfPodcastSince(username, podcastUrl, since)); + return ResponseEntity.ok(responseBody); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java new file mode 100644 index 0000000..d1facac --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java @@ -0,0 +1,37 @@ +package org.psesquared.server.episode.actions.api.controller; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import lombok.Data; + +/** + * The Response Object for a GET-Request concerning an EpisodeAction. + * <br> + * May contain multiple EpisodeActions. + */ +@Data +public class EpisodeActionGetResponse { + + /** + * The list of EpisodeActionPosts. + */ + private final List<EpisodeActionPost> actions; + + /** + * The timestamp of the response. + */ + private final long timestamp; + + /** + * Instantiates a new EpisodeActionGetResponse with the current timestamp. + * + * @param episodeActionPosts A list of EpisodeActionPosts + */ + public EpisodeActionGetResponse( + final List<EpisodeActionPost> episodeActionPosts) { + this.actions = episodeActionPosts; + this.timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java new file mode 100644 index 0000000..28613f2 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java @@ -0,0 +1,61 @@ +package org.psesquared.server.episode.actions.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.psesquared.server.model.EpisodeAction; + +/** + * An Episode Action that is being sent to the server via a POST Request. + * <br> + * If the user listened to an episode or did another action, an + * EpisodeActionPOST is uploaded. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EpisodeActionPost { + + /** + * The URL of the podcast the posted episode action belongs to. + */ + @JsonProperty(value = "podcast", required = true) + @NotBlank + private String podcastUrl; + + /** + * The URL of the corresponding episode. + */ + @JsonProperty(value = "episode", required = true) + @NotBlank + private String episodeUrl; + + /** + * The title of the corresponding episode. + */ + private String title; + + /** + * The GUID of the corresponding episode. + */ + private String guid; + + /** + * The total length of the corresponding episode in milliseconds. + */ + private int total; + + /** + * The actual episode action whose attributes are presented unwrapped. + * + * @see JsonUnwrapped + */ + @JsonUnwrapped + private EpisodeAction episodeAction; + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java new file mode 100644 index 0000000..3115012 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the highest logical layer of the episode action API + * ({@link org.psesquared.server.episode.actions.api}) - the controller layer. + * <br> + * It contains the + * {@link + * org.psesquared.server.episode.actions.api.controller.EpisodeActionController} + * along with some wrapper classes for JSON request and response bodies. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.episode.actions.api.controller; diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java new file mode 100644 index 0000000..7f23312 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java @@ -0,0 +1,107 @@ +package org.psesquared.server.episode.actions.api.data.access; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.psesquared.server.model.Action; +import org.psesquared.server.model.EpisodeAction; +import org.psesquared.server.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * A DAO interface responsible for transactions involving EpisodeActions. + */ +@Repository +public interface EpisodeActionDao extends JpaRepository<EpisodeAction, Long> { + + /** + * Find all EpisodeActions a user has uploaded. + * + * @param username The username of the user who uploaded the EpisodeActions + * @return The list of EpisodeActions regarding the user + */ + List<EpisodeAction> findByUserUsername(String username); + + /** + * Find all EpisodeActions of a user that concern a certain podcast identified + * by its RSS-Feed URL. + * + * @param username The username of the user who uploaded the EpisodeActions + * @param url The RSS-Feed URL of the podcast in question + * @return The list of EpisodeActions regarding the user and the given podcast + */ + List<EpisodeAction> findByUserUsernameAndEpisodeSubscriptionUrl( + String username, + String url); + + /** + * Deletes all EpisodeActions of a podcast for a user. + * + * @param username The username of the user + * @param url The podcast URL + */ + void deleteByUserUsernameAndEpisodeSubscriptionUrl( + String username, + String url); + + /** + * Checks if an EpisodeAction of the specified action type for a given user + * and episode already exists. + * + * @param user The user + * @param url The episode URL + * @param action The type of action + * @return {@code true} if such an EpisodeAction exists, + * <br> + * {@code false} otherwise + */ + boolean existsByUserAndEpisodeUrlAndAction(User user, + String url, + Action action); + + /** + * Finds the EpisodeAction of the specified action type and of an Episode + * for a User. + * + * @param user The user + * @param url The episode URL + * @param action The type of action + * @return An {@link Optional} containing the EpisodeAction if present + */ + Optional<EpisodeAction> findByUserAndEpisodeUrlAndAction(User user, + String url, + Action action); + + /** + * Find all EpisodeActions of a user since a given timestamp. + * + * @param username The username of the user + * @param timestamp The timestamp signifying how old an EpisodeAction is + * allowed + * to be + * @return A list containing all EpisodeActions not older than the timestamp + */ + List<EpisodeAction> findByUserUsernameAndTimestampGreaterThanEqual( + String username, + LocalDateTime timestamp); + + /** + * Find all EpisodeActions of a user since a given timestamp of a given + * podcast. + * + * @param username The username of the user + * @param timestamp The timestamp signifying how old an EpisodeAction is + * allowed to be + * @param url The RSS-Feed URL of the podcast whose EpisodeActions are + * requested + * @return A list containing all EpisodeActions of the given podcast not older + * than the timestamp + */ + List<EpisodeAction> + findByUserUsernameAndTimestampGreaterThanEqualAndEpisodeSubscriptionUrl( + String username, + LocalDateTime timestamp, + String url); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java new file mode 100644 index 0000000..16c647f --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java @@ -0,0 +1,46 @@ +package org.psesquared.server.episode.actions.api.data.access; + +import java.util.Optional; +import org.psesquared.server.model.Episode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * A DAO interface responsible for transactions involving Episodes. + */ +@Repository +public interface EpisodeDao extends JpaRepository<Episode, Long> { + + /** + * Find an episode by its URL. + * + * @param url The URL of the episode + * @return The matching episode / NULL, if there was no match. + */ + Optional<Episode> findByUrl(String url); + + /** + * Returns true if there is an episode that matches a given URL. + * + * @param url The URL of the episode + * @return A boolean value signifying whether the episode exists + */ + boolean existsByUrl(String url); + + /** + * Returns true if there is an episode that matches a given GUID. + * + * @param guid The GUID of the episode + * @return A boolean value signifying whether the episode exists + */ + boolean existsByGuid(String guid); + + /** + * Find an episode by its GUID. + * + * @param guid The GUID of the episode + * @return The matching episode / NULL, if there was no match. + */ + Optional<Episode> findByGuid(String guid); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java new file mode 100644 index 0000000..b32f249 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the lowest logical layer of the episode action API + * ({@link org.psesquared.server.episode.actions.api}) - the data-access layer. + * <br> + * It features the interfaces {@link + * org.psesquared.server.episode.actions.api.data.access.EpisodeActionDao} + * and {@link + * org.psesquared.server.episode.actions.api.data.access.EpisodeDao}. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.episode.actions.api.data.access; diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java new file mode 100644 index 0000000..8c4f93a --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java @@ -0,0 +1,360 @@ +package org.psesquared.server.episode.actions.api.service; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost; +import org.psesquared.server.episode.actions.api.data.access.EpisodeActionDao; +import org.psesquared.server.episode.actions.api.data.access.EpisodeDao; +import org.psesquared.server.model.Action; +import org.psesquared.server.model.Episode; +import org.psesquared.server.model.EpisodeAction; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.model.User; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionActionDao; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionDao; +import org.psesquared.server.util.RssParser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * This service class manages all business logic associated with the + * episode action API. + * <br> + * It is called from the + * {@link + * org.psesquared.server.episode.actions.api.controller.EpisodeActionController} + * and passes on requests concerning data access mainly to the + * {@link EpisodeDao} and {@link EpisodeActionDao}. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class EpisodeActionService { + + /** + * The nano of second default value for + * {@link LocalDateTime#ofEpochSecond(long, int, ZoneOffset)}. + */ + private static final int NANO_OF_SECOND_DEFAULT = 0; + + /** + * The JPA repository that handles all episode action related database + * requests. + */ + private final EpisodeActionDao episodeActionDao; + + /** + * The JPA repository that handles all episode related database requests. + */ + private final EpisodeDao episodeDao; + + /** + * The JPA repository that handles all user related database requests. + */ + private final AuthenticationDao authenticationDao; + + /** + * The JPA repository that handles all subscription related database requests. + */ + private final SubscriptionDao subscriptionDao; + + /** + * The JPA repository that handles all subscription action related database + * requests. + */ + private final SubscriptionActionDao subscriptionActionDao; + + /** + * The class for asynchronously fetching data from RSS feeds. + */ + private final RssParser rssParser; + + /** + * A map of subscription that need to be fetched with the {@link RssParser}. + */ + private final Map<String, Subscription> subscriptionsToFetch + = new HashMap<>(); + + /** + * Takes a list of EpisodeActionPosts, converts them to EpisodeActions and + * saves them to the database. + * + * @param username The username of the user who uploads these + * EpisodeActions + * @param episodeActionPosts List of EpisodeActionPosts that were sent via the + * POST request + */ + public void addEpisodeActions( + final String username, + final List<EpisodeActionPost> episodeActionPosts) { + User user = authenticationDao.findByUsername(username).orElseThrow(); + List<EpisodeActionPost> filteredEpisodeActionPosts + = new ArrayList<>(filterNewestAction(episodeActionPosts)); + List<EpisodeAction> episodeActions + = episodeActionPostsToEpisodeActions(user, filteredEpisodeActionPosts); + addEpisodeActionsToDatabase(user, episodeActions); + validateDummyEpisodes(); + } + + private Collection<EpisodeActionPost> filterNewestAction( + final List<EpisodeActionPost> episodeActionPosts) { + Map<String, EpisodeActionPost> relevantEpisodeActionPosts = new HashMap<>(); + for (EpisodeActionPost episodeActionPost : episodeActionPosts) { + if (episodeActionPost.getEpisodeAction().getAction() != Action.PLAY) { + continue; + } + String url = episodeActionPost.getEpisodeUrl(); + if (relevantEpisodeActionPosts.containsKey(url)) { + EpisodeActionPost currentEpisodeActionPost + = relevantEpisodeActionPosts.get(url); + if (episodeActionPost.getEpisodeAction().getTimestamp() + .isAfter( + currentEpisodeActionPost.getEpisodeAction().getTimestamp())) { + relevantEpisodeActionPosts.put(url, episodeActionPost); + } + } else { + relevantEpisodeActionPosts.put(url, episodeActionPost); + } + } + return relevantEpisodeActionPosts.values(); + } + + private List<EpisodeAction> episodeActionPostsToEpisodeActions( + final User user, + final List<EpisodeActionPost> episodeActionPosts) { + List<EpisodeAction> episodeActions = new ArrayList<>(); + for (EpisodeActionPost episodeActionPost : episodeActionPosts) { + if (episodeActionPost.getEpisodeAction().getAction() == Action.PLAY) { + episodeActions.add(episodeActionPostToEpisodeAction( + user, + episodeActionPost)); + } + } + return episodeActions; + } + + private EpisodeAction episodeActionPostToEpisodeAction( + final User user, + final EpisodeActionPost episodeActionPost) { + EpisodeAction episodeAction = episodeActionPost.getEpisodeAction(); + episodeAction.setUser(user); + // If Subscription does not exist, create dummy Subscription + Subscription subscription = null; + if (!subscriptionDao.existsByUrl(episodeActionPost.getPodcastUrl())) { + subscription = new Subscription(); + subscription.setTimestamp( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)); + subscription.setUrl(episodeActionPost.getPodcastUrl()); + subscription = subscriptionDao.save(subscription); + // create Subscription Action + SubscriptionAction subscriptionAction = SubscriptionAction.builder() + .user(user) + .added(true) + .subscription( + subscriptionDao.findByUrl( + episodeActionPost.getPodcastUrl()).orElseThrow()) + .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .build(); + subscriptionActionDao.save(subscriptionAction); + } else { + subscription = subscriptionDao + .findByUrl(episodeActionPost.getPodcastUrl()).orElseThrow(); + } + Episode episode = getEpisodeFromDatabase(episodeActionPost); + episodeAction.setEpisode(episode); + subscription.addEpisode(episode); + return episodeAction; + } + + private Episode getEpisodeFromDatabase( + final EpisodeActionPost episodeActionPost) { + Episode episode; + String episodeUrl = episodeActionPost.getEpisodeUrl(); + String episodeGuid = episodeActionPost.getGuid(); + // If guid is passed and a matching episode exists get it + if (episodeGuid != null && episodeDao.existsByGuid(episodeGuid)) { + episode = episodeDao.findByGuid(episodeGuid).orElseThrow(); + } else if (episodeDao.existsByUrl(episodeUrl)) { + // No episode with matching guid found -> search by url + episode = episodeDao.findByUrl(episodeUrl).orElseThrow(); + // If guid was passed, pass it along to the database + if (episodeGuid != null) { + episode.setGuid(episodeGuid); + episodeDao.save(episode); + } + } else { + // Episode does not exist, so construct a new one + episode = createEpisode(episodeActionPost); + } + return episode; + } + + private Episode createEpisode(final EpisodeActionPost episodeActionPost) { + Episode episode = Episode.builder() + .title(episodeActionPost.getTitle()) + .url(episodeActionPost.getEpisodeUrl()) + .total(episodeActionPost.getTotal()) + .subscription(subscriptionDao + .findByUrl(episodeActionPost.getPodcastUrl()).orElseThrow()) + .build(); + if (episodeActionPost.getGuid() != null) { + episode.setGuid(episodeActionPost.getGuid()); + } + episodeDao.save(episode); + Subscription subscription = episode.getSubscription(); + subscriptionsToFetch.put(subscription.getUrl(), subscription); + return episode; + } + + private void addEpisodeActionsToDatabase( + final User user, + final List<EpisodeAction> episodeActions) { + for (EpisodeAction episodeAction : episodeActions) { + addEpisodeActionToDatabase(user, episodeAction); + } + } + + private void addEpisodeActionToDatabase( + final User user, + final EpisodeAction episodeAction) { + if (episodeActionDao.existsByUserAndEpisodeUrlAndAction( + user, + episodeAction.getEpisode().getUrl(), + episodeAction.getAction())) { + addNewestEpisodeActionToDatabase(user, episodeAction); + } else { + episodeActionDao.save(episodeAction); + } + } + + private void addNewestEpisodeActionToDatabase( + final User user, + final EpisodeAction episodeAction) { + EpisodeAction oldEpisodeAction + = episodeActionDao.findByUserAndEpisodeUrlAndAction( + user, + episodeAction.getEpisode().getUrl(), + episodeAction.getAction()).orElseThrow(); + if (episodeAction.getTimestamp().isAfter(oldEpisodeAction.getTimestamp())) { + episodeActionDao.delete(oldEpisodeAction); + episodeActionDao.save(episodeAction); + } + } + + private void validateDummyEpisodes() { + Collection<Subscription> subscriptions = subscriptionsToFetch.values(); + for (Subscription subscription : subscriptions) { + rssParser.validate(subscription); + } + subscriptionsToFetch.clear(); + } + + /** + * Gets all EpisodeActions of a user and converts them to EpisodeActionPosts + * before returning them. + * + * @param username The username of the user whose EpisodeActions are requested + * @return A list containing the requested EpisodeActions as + * EpisodeActionPosts + */ + public List<EpisodeActionPost> getEpisodeActions(final String username) { + List<EpisodeAction> episodeActions + = episodeActionDao.findByUserUsername(username); + return episodeActionsToEpisodeActionPosts(episodeActions); + } + + /** + * Gets all EpisodeActions of a user that correspond to a given podcast. + * Returns the EpisodeActions after converting them to EpisodeActionPosts. + * + * @param username The username of the user whose EpisodeActions are + * requested + * @param podcastUrl The RSS-Feed URL of the podcast + * @return A list containing the requested EpisodeActions as + * EpisodeActionPosts + */ + public List<EpisodeActionPost> getEpisodeActionsOfPodcast( + final String username, + final String podcastUrl) { + List<EpisodeAction> episodeActions + = episodeActionDao.findByUserUsernameAndEpisodeSubscriptionUrl( + username, + podcastUrl); + return episodeActionsToEpisodeActionPosts(episodeActions); + } + + /** + * Gets all EpisodeActions of a user since a given timestamp and converts them + * to EpisodeActionPosts before returning them. + * + * @param username The username of the user whose EpisodeActions are requested + * @param since the timestamp signifying how old the EpisodeActions are + * allowed to be + * @return A list containing the requested EpisodeActions as + * EpisodeActionPosts + */ + public List<EpisodeActionPost> getEpisodeActionsSince( + final String username, + final long since) { + LocalDateTime sinceTimestamp + = LocalDateTime.ofEpochSecond( + since, + NANO_OF_SECOND_DEFAULT, + ZoneOffset.UTC); + List<EpisodeAction> episodeActions + = episodeActionDao.findByUserUsernameAndTimestampGreaterThanEqual( + username, + sinceTimestamp); + return episodeActionsToEpisodeActionPosts(episodeActions); + } + + /** + * Gets all EpisodeActions of a user concerning a certain podcast since a + * given timestamp and converts them to EpisodeActionPosts before returning + * them. + * + * @param username The username of the user whose EpisodeActions are + * requested + * @param podcastUrl The RSS-Feed URL of the podcast + * @param since The timestamp signifying how old the EpisodeActions are + * allowed to be + * @return A list containing the requested EpisodeActions as + * EpisodeActionPosts + */ + public List<EpisodeActionPost> getEpisodeActionsOfPodcastSince( + final String username, + final String podcastUrl, + final long since) { + LocalDateTime sinceTimestamp + = LocalDateTime.ofEpochSecond( + since, + NANO_OF_SECOND_DEFAULT, + ZoneOffset.UTC); + List<EpisodeAction> episodeActions = episodeActionDao + .findByUserUsernameAndTimestampGreaterThanEqualAndEpisodeSubscriptionUrl( + username, + sinceTimestamp, + podcastUrl); + return episodeActionsToEpisodeActionPosts(episodeActions); + } + + private List<EpisodeActionPost> episodeActionsToEpisodeActionPosts( + final List<EpisodeAction> episodeActions) { + List<EpisodeActionPost> episodeActionPosts = new ArrayList<>(); + + for (EpisodeAction episodeAction : episodeActions) { + episodeActionPosts.add(episodeAction.toEpisodeActionPost()); + } + + return episodeActionPosts; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java new file mode 100644 index 0000000..c431539 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the logical middle layer of the episode action API + * ({@link org.psesquared.server.episode.actions.api}) - the service layer. + * <br> + * All business logic is handled here with the + * {@link + * org.psesquared.server.episode.actions.api.service.EpisodeActionService} + * class. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.episode.actions.api.service; diff --git a/pse-server/src/main/java/org/psesquared/server/model/Action.java b/pse-server/src/main/java/org/psesquared/server/model/Action.java new file mode 100644 index 0000000..19b1c51 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/Action.java @@ -0,0 +1,45 @@ +package org.psesquared.server.model; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * An enum with all different action types of an {@link EpisodeAction}. + */ +public enum Action { + + /** + * The download action type. + */ + DOWNLOAD, + + /** + * The play action type. + */ + PLAY, + + /** + * The delete action type. + */ + DELETE, + + /** + * The new action type. + */ + NEW, + + /** + * The flattr action type. + */ + FLATTR; + + /** + * Getter for the value of the "action" JSON property. + * + * @return The JSON value + */ + @JsonValue + public String getJsonProperty() { + return name().toLowerCase(); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/Episode.java b/pse-server/src/main/java/org/psesquared/server/model/Episode.java new file mode 100644 index 0000000..7a409fc --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/Episode.java @@ -0,0 +1,74 @@ +package org.psesquared.server.model; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * An episode of a podcast. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "episodes") +public class Episode implements Serializable { + + /** + * The primary key for the table. + */ + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", updatable = false) + private Long id; + + /** + * The GUID of an episode. + */ + @Column(name = "guid", unique = true) + private String guid; + + /** + * The URL where the episode is located at. + */ + @Column(name = "url", nullable = false) + private String url; + + /** + * The title of the episode. + */ + @Column(name = "title") + private String title; + + /** + * The total length of an episode. + */ + @Column(name = "total") + private int total; + + /** + * The podcast the episode is a part of. + */ + @ManyToOne(optional = false) + private Subscription subscription; + + /** + * The actions of an episode. + */ + @OneToMany(mappedBy = "episode", cascade = CascadeType.REMOVE) + private List<EpisodeAction> episodeActions; + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/EpisodeAction.java b/pse-server/src/main/java/org/psesquared/server/model/EpisodeAction.java new file mode 100644 index 0000000..a7e2375 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/EpisodeAction.java @@ -0,0 +1,101 @@ +package org.psesquared.server.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost; + +/** + * An action a user took regarding an episode of a podcast. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "episode_actions") +public class EpisodeAction implements Serializable { + + /** + * The primary key for the table. + */ + @JsonIgnore + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", updatable = false) + private Long id; + + /** + * The user who is responsible for the action. + */ + @JsonIgnore + @ManyToOne(optional = false) + private User user; + + /** + * The episode that is affected. + */ + @JsonIgnore + @ManyToOne(optional = false) + private Episode episode; + + /** + * The timestamp of when this action took place. + */ + @Column(name = "timestamp", + nullable = false) + private LocalDateTime timestamp; + + /** + * The type of action that happened. + */ + @JsonProperty(required = true) + @Column(name = "action", + nullable = false, + updatable = false) + private Action action; + + /** + * In case of play action: The starting time of the episode. + */ + @Column(name = "started", + updatable = false) + private int started; + + /** + * In case of play action: The time at which the episode was stopped. + */ + @Column(name = "position", + nullable = false, + updatable = false) + private int position; + + /** + * Generates a EpisodeActionPost from the given EpisodeAction for the + * EpisodeAction Controller. + * + * @return The generated EpisodeActionPost + */ + public EpisodeActionPost toEpisodeActionPost() { + String podcastUrl = this.getEpisode().getSubscription().getUrl(); + String episodeUrl = this.getEpisode().getUrl(); + String title = this.getEpisode().getTitle(); + String guid = this.getEpisode().getGuid(); + int total = this.getEpisode().getTotal(); + return + new EpisodeActionPost(podcastUrl, episodeUrl, title, guid, total, this); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/Role.java b/pse-server/src/main/java/org/psesquared/server/model/Role.java new file mode 100644 index 0000000..37a316f --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/Role.java @@ -0,0 +1,28 @@ +package org.psesquared.server.model; + +/** + * Available user roles. + */ +public enum Role { + + /** + * Standard role. + */ + USER, + + /** + * Privileged role. + */ + ADMIN; + + /** + * The starting index. + */ + private static final int FIRST_INDEX = 0; + + @Override + public String toString() { + return name().charAt(FIRST_INDEX) + name().substring(1).toLowerCase(); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/Subscription.java b/pse-server/src/main/java/org/psesquared/server/model/Subscription.java new file mode 100644 index 0000000..b130fca --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/Subscription.java @@ -0,0 +1,87 @@ +package org.psesquared.server.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.NamedAttributeNode; +import jakarta.persistence.NamedEntityGraph; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +/** + * A podcast that was subscribed. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "subscriptions") +@NamedEntityGraph(name = "graph.Subscription.episodes", + attributeNodes = @NamedAttributeNode("episodes")) +public class Subscription implements Serializable { + + /** + * A primary key for the table. + */ + @JsonIgnore + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", updatable = false) + private Long id; + + /** + * The URL for the RSS-Feed of the Podcast. + */ + @Column(name = "url", nullable = false) + private String url; + + /** + * The title of the Podcast. + */ + @Column(name = "title") + private String title; + + /** + * Timestamp of the last time the RSS-Feed was fetched. + */ + @Column(name = "timestamp") + private long timestamp; + + /** + * The list of SubscriptionActions of this podcast. + */ + @JsonIgnore + @OneToMany(mappedBy = "subscription", + cascade = CascadeType.REMOVE) + private List<SubscriptionAction> subscriptionActions; + + /** + * The episodes of a subscription. + */ + @JsonIgnore + @OneToMany(mappedBy = "subscription", cascade = CascadeType.REMOVE) + private final List<Episode> episodes = new ArrayList<>(); + + /** + * Adds an episode to the list of episodes. + * + * @param episode The to be added episode + */ + public void addEpisode(@NonNull final Episode episode) { + this.episodes.add(episode); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/SubscriptionAction.java b/pse-server/src/main/java/org/psesquared/server/model/SubscriptionAction.java new file mode 100644 index 0000000..3d850eb --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/SubscriptionAction.java @@ -0,0 +1,62 @@ +package org.psesquared.server.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * An action a user took regarding a podcast. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "subscription_actions") +public class SubscriptionAction implements Serializable { + + /** + * The primary key for the table. + */ + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", + updatable = false) + private int id; + + /** + * The user who took this action. + */ + @ManyToOne(optional = false) + private User user; + + /** + * The timestamp of when this action took place. + */ + @Column(name = "timestamp", + nullable = false) + private long timestamp; + + /** + * The podcast that was affected. + */ + @ManyToOne(optional = false) + private Subscription subscription; + + /** + * Whether the podcast was added or removed. + */ + @Column(name = "added", + nullable = false) + private boolean added; + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/User.java b/pse-server/src/main/java/org/psesquared/server/model/User.java new file mode 100644 index 0000000..7279aae --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/User.java @@ -0,0 +1,148 @@ +package org.psesquared.server.model; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.Collection; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * A user that synchronizes their podcasts via this server. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "users") +public class User implements UserDetails { + + /** + * The primary key for the table. + */ + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", + updatable = false) + private Long id; + + /** + * The username of the user. + */ + @Column(name = "username", + unique = true, + nullable = false, + updatable = false) + private String username; + + /** + * The email address of the user. + */ + @Column(name = "email", + unique = true, + nullable = false) + private String email; + + /** + * The password of the user. + */ + @Column(name = "password", + nullable = false) + private String password; + + /** + * The verification status of the user. + */ + @Column(name = "enabled", + nullable = false) + private boolean enabled; + + /** + * Timestamp of when this user account was created. + */ + @Column(name = "created_at", + nullable = false, + updatable = false) + private long createdAt; + + /** + * The role of the user. + */ + @Column(name = "role", + nullable = false) + private Role role; + + /** + * The subscription actions the user made. + */ + @OneToMany(mappedBy = "user", + cascade = CascadeType.REMOVE) + private List<SubscriptionAction> subscriptionActions; + + /** + * The episode actions the user made. + */ + @OneToMany(mappedBy = "user", + cascade = CascadeType.REMOVE) + private List<EpisodeAction> episodeActions; + + /** + * Returns a collection with one {@link SimpleGrantedAuthority} + * with {@link #role}. + * + * @return The collection of granted authorities + */ + @Override + public Collection<? extends GrantedAuthority> getAuthorities() { + return List.of(new SimpleGrantedAuthority(role.toString())); + } + + /** + * Checks if this user account has not expired. + * + * @return {@code true} if the user account has not expired, + * <br> + * {@code false} otherwise + */ + @Override + public boolean isAccountNonExpired() { + return enabled; + } + + /** + * Checks if this user account is not locked. + * + * @return {@code true} if the user account is not locked, + * <br> + * {@code false} otherwise + */ + @Override + public boolean isAccountNonLocked() { + return enabled; + } + + /** + * Checks if this user account's credentials have not expired. + * + * @return {@code true} if the credentials have not expired, + * <br> + * {@code false} otherwise + */ + @Override + public boolean isCredentialsNonExpired() { + return enabled; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/package-info.java b/pse-server/src/main/java/org/psesquared/server/model/package-info.java new file mode 100644 index 0000000..2bb9c13 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/package-info.java @@ -0,0 +1,8 @@ +/** + * This package features all classes that map to database entities via ORM + * as well as some classes that the former rely on. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.model; diff --git a/pse-server/src/main/java/org/psesquared/server/package-info.java b/pse-server/src/main/java/org/psesquared/server/package-info.java new file mode 100644 index 0000000..95cfd41 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/package-info.java @@ -0,0 +1,5 @@ +/** + * This package features the + * {@link org.psesquared.server.ServerApplication} class. + */ +package org.psesquared.server; diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionController.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionController.java new file mode 100644 index 0000000..1ffe137 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionController.java @@ -0,0 +1,155 @@ +package org.psesquared.server.subscriptions.api.controller; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.subscriptions.api.service.SubscriptionService; +import org.psesquared.server.util.UpdateUrlsWrapper; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * This is a controller class for the Subscription API that handles the requests + * from the client concerning adding subscriptions, removing subscriptions and + * getting all current subscriptions. + * In the end an appropriate response is sent back to the user. + */ +@RestController +@RequiredArgsConstructor +public class SubscriptionController { + + /** + * The response for uploading subscriptions successfully. + */ + private static final String UPLOAD_SUCCESS = ""; + + /** + * The service class that this controller calls to further process requests. + */ + private final SubscriptionService subscriptionService; + + /** + * It takes a list of strings containing the URLs of all subscribed podcasts, + * and saves them to the database. + * + * @param username The username of the user + * @param deviceId The device ID of the device that is uploading the + * subscriptions (will be ignored in this implementation) + * @param subscriptions A list of strings, each string is the URL to a podcast + * RSS-Feed that was subscribed + * @return The response containing an empty String with + * <br> + * {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + @PutMapping(path = "/subscriptions/{username}/{deviceId}.json") + public ResponseEntity<String> uploadSubscriptions( + @PathVariable final String username, + @PathVariable final String deviceId, + @RequestBody final List<String> subscriptions) { + HttpStatus status + = subscriptionService.uploadSubscriptions(username, subscriptions); + return new ResponseEntity<>(UPLOAD_SUCCESS, status); + } + + /** + * This function returns a list of subscriptions for a given user. + * + * @param username The username of the user whose subscriptions you want + * to retrieve + * @param deviceId This is the unique identifier for the device of the + * user whose subscriptions are asked for + * (will be ignored in this implementation) + * @param functionJsonp This parameter is not supported in this implementation + * and is thus ignored + * @return A list of strings containing the RSS-Feed URLs of all subscribed + * podcasts + */ + @GetMapping(path = {"/subscriptions/{username}.json", + "/subscriptions/{username}/{deviceId}.json"}) + public ResponseEntity<List<String>> getSubscriptions( + @PathVariable final String username, + @PathVariable(required = false) final String deviceId, + @RequestParam(value = "jsonp", + required = false) final String functionJsonp) { + List<String> subscriptions = subscriptionService.getSubscriptions(username); + return ResponseEntity.ok(subscriptions); + } + + /** + * This function takes the information of added and removed podcasts in the + * form of a SubcriptionDelta as a JSON object. + * <br> + * After that, it applies the changes to the given user in the database. + * + * @param username The username of the user who is making changes to their + * subscriptions + * @param deviceId The device ID of the device that is requesting the update + * (will be ignored in this implementation) + * @param delta Contains all the changes that were made to the + * subscriptions of the user + * @return The response containing a placeholder for not + * supported function with + * <br> + * {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user or subscription not found + */ + @PostMapping(path = "/api/2/subscriptions/{username}/{deviceId}.json") + public ResponseEntity<UpdateUrlsWrapper> applySubscriptionDelta( + @PathVariable final String username, + @PathVariable final String deviceId, + @RequestBody final SubscriptionDelta delta) { + subscriptionService.applySubscriptionDelta(username, delta); + return ResponseEntity.ok(new UpdateUrlsWrapper()); + } + + /** + * It returns a list of all the changes to the subscriptions of a user since a + * given time. + * + * @param username The username of the user whose SubscriptionDeltas are being + * requested + * @param deviceId The device ID of the device that is requesting the delta + * (will be ignored in this implementation) + * @param since The timestamp of the last time the client checked for + * updates + * @return A response containing the SubscriptionDelta of all changes that + * were made in a JSON format + */ + @GetMapping(path = "/api/2/subscriptions/{username}/{deviceId}.json") + public ResponseEntity<SubscriptionDelta> getSubscriptionDelta( + @PathVariable final String username, + @PathVariable final String deviceId, + @RequestParam("since") final long since) { + SubscriptionDelta delta + = subscriptionService.getSubscriptionDelta(username, since); + return ResponseEntity.ok(delta); + } + + /** + * This function returns a list of podcasts a user is subscribed to. + * <br> + * This includes not only the podcast itself, but also the latest 20 Episodes + * of the podcast. + * + * @param username The username of the user whose podcasts are being requested + * @return A response containing a List of podcasts and their episodes the + * user is subscribed to + */ + @GetMapping(path = "/subscriptions/titles/{username}.json") + public ResponseEntity<List<SubscriptionTitles>> getTitles( + @PathVariable final String username) { + List<SubscriptionTitles> responseBody + = subscriptionService.getTitles(username); + return ResponseEntity.ok(responseBody); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionDelta.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionDelta.java new file mode 100644 index 0000000..7611b05 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionDelta.java @@ -0,0 +1,80 @@ +package org.psesquared.server.subscriptions.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import lombok.NonNull; + +/** + * SubscriptionDeltas contain all changes that were made to the subscriptions + * of a user (added / removed podcasts) at a certain time. + */ +public class SubscriptionDelta { + + /** + * The list of recently subscribed podcasts. + */ + @JsonProperty(required = true) + @NonNull + private final List<String> add; + + /** + * The list of recently unsubscribed podcasts. + */ + @JsonProperty(required = true) + @NonNull + private final List<String> remove; + + /** + * The timestamp of the delta. + */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private final long timestamp; + + /** + * Instantiates a new SubscriptionDelta with a current timestamp. + * + * @param addedPodcastUrls List of Strings containing the RSS-Feed URLs of + * all added podcasts + * @param removedPodcastUrls List of Strings containing the RSS-Feed URLs of + * all removed podcasts + */ + public SubscriptionDelta( + @org.springframework.lang.NonNull final List<String> addedPodcastUrls, + @org.springframework.lang.NonNull final List<String> removedPodcastUrls) { + this.add = addedPodcastUrls; + this.remove = removedPodcastUrls; + this.timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); + } + + /** + * Returns the list of RSS-Feed URLs of all added podcasts. + * + * @return RSS-Feed URLs of all added podcasts + */ + @org.springframework.lang.NonNull + public List<String> getAdd() { + return add; + } + + /** + * Returns the list of RSS-Feed URLs of all removed podcasts. + * + * @return RSS-Feed URLs of all removed podcasts + */ + @org.springframework.lang.NonNull + public List<String> getRemove() { + return remove; + } + + /** + * Returns the timestamp of when this Subscription Delta was uploaded. + * + * @return The timestamp of when this Subscription Delta was uploaded + */ + public long getTimestamp() { + return timestamp; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionTitles.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionTitles.java new file mode 100644 index 0000000..682497b --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionTitles.java @@ -0,0 +1,17 @@ +package org.psesquared.server.subscriptions.api.controller; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import java.util.List; +import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost; +import org.psesquared.server.model.Subscription; + +/** + * Contains a podcast and its latest 20 Episodes. + * + * @param subscription The podcast + * @param episodes The episodes of the podcast + */ +public record SubscriptionTitles( + @JsonUnwrapped Subscription subscription, + List<EpisodeActionPost> episodes) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/package-info.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/package-info.java new file mode 100644 index 0000000..0238039 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the highest logical layer of the subscription API + * ({@link org.psesquared.server.subscriptions.api}) - the controller layer. + * <br> + * It contains the + * {@link + * org.psesquared.server.subscriptions.api.controller.SubscriptionController} + * along with some wrapper classes for JSON request and response bodies. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.subscriptions.api.controller; diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDao.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDao.java new file mode 100644 index 0000000..5d91453 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDao.java @@ -0,0 +1,91 @@ +package org.psesquared.server.subscriptions.api.data.access; + +import java.util.List; +import java.util.Optional; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * A DAO interface responsible for transactions involving SubscriptionActions. + */ +@Repository +public interface SubscriptionActionDao + extends JpaRepository<SubscriptionAction, Long> { + + /** + * True, if the given user is already subscribed to the given Subscription. + * + * @param user The user that could be subscribed + * @param subscription The subscription the user could be subscribed to + * @return A boolean value signifying whether the user is subscribed to the + * given subscription + */ + boolean existsByUserAndSubscription(User user, Subscription subscription); + + /** + * Find the SubscriptionAction signifying that the user is subscribed to the + * given Subscription. + * + * @param user The user who is subscribed to the subscription + * @param subscription The subscription that the user is subscribed to + * @return Contains the relevant SubscriptionAction. Could also be NULL if + * none was found. + */ + Optional<SubscriptionAction> findByUserAndSubscription( + User user, + Subscription subscription); + + /** + * Find the SubscriptionAction for a {@link User} with the given username + * and for a {@link Subscription} with the given URL. + * + * @param username The username of the user who is subscribed to + * the subscription + * @param subscriptionUrl The URL of the subscription that the user is + * subscribed to + * @return Contains the relevant SubscriptionAction. Could also be NULL if + * none was found. + */ + Optional<SubscriptionAction> findByUserUsernameAndSubscriptionUrl( + String username, String subscriptionUrl); + + /** + * All SubscriptionActions of a given user that were applied since a given + * timestamp are searched for and returned. + * + * @param username The username of the user whose SubscriptionActions are + * requested + * @param timestamp The timestamp signifying how old the SubscriptionActions + * are allowed to be + * @return A list of SubscriptionActions that have since been applied + */ + List<SubscriptionAction> findByUserUsernameAndTimestampGreaterThanEqual( + String username, + long timestamp); + + /** + * Returns a List of all Subscriptions the user is subscribed to. + * + * @param username The username of the user whose subscriptions are requested + * @return A list of subscriptions the user is subscribed to + */ + List<SubscriptionAction> findByUserUsernameAndAddedTrue(String username); + + /** + * Returns a List of RSS-Feed URLs of all podcasts the given user is + * subscribed to since a given timestamp. + * + * @param username The username of the user whose subscriptions are requested + * @param timestamp The timestamp signifying the time since when the user must + * have been subscribed + * @return List of RSS-Feed URLs + */ + List<SubscriptionAction> + findByUserUsernameAndAddedTrueAndTimestampGreaterThanEqual( + String username, + long timestamp); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDao.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDao.java new file mode 100644 index 0000000..d3d1fbf --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDao.java @@ -0,0 +1,34 @@ +package org.psesquared.server.subscriptions.api.data.access; + +import java.util.Optional; +import org.psesquared.server.model.Subscription; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * A DAO interface responsible for transactions involving Subscriptions. + */ +@Repository +public interface SubscriptionDao extends JpaRepository<Subscription, Long> { + + /** + * Find a subscription by its URL. + * + * @param url The URL of the subscription + * @return The found subscription (could be NULL, if there was no match) + */ + @EntityGraph(value = "graph.Subscription.episodes") + Optional<Subscription> findByUrl(String url); + + /** + * Returns true if the database already has a Subscription that has the given + * URL. + * + * @param url The URL of the Subscription that could already exist in the + * database + * @return A boolean value signifying the existence of the Subscription + */ + boolean existsByUrl(String url); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/package-info.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/package-info.java new file mode 100644 index 0000000..db23d5f --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the lowest logical layer of the subscription API + * ({@link org.psesquared.server.subscriptions.api}) - the data-access layer. + * <br> + * It features the interfaces {@link + * org.psesquared.server.subscriptions.api.data.access.SubscriptionActionDao} + * and {@link + * org.psesquared.server.subscriptions.api.data.access.SubscriptionDao}. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.subscriptions.api.data.access; diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/SubscriptionService.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/SubscriptionService.java new file mode 100644 index 0000000..08dc1f9 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/SubscriptionService.java @@ -0,0 +1,299 @@ +package org.psesquared.server.subscriptions.api.service; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Map; +import java.util.HashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost; +import org.psesquared.server.episode.actions.api.data.access.EpisodeActionDao; +import org.psesquared.server.episode.actions.api.service.EpisodeActionService; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.model.User; +import org.psesquared.server.subscriptions.api.controller.SubscriptionDelta; +import org.psesquared.server.subscriptions.api.controller.SubscriptionTitles; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionActionDao; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionDao; +import org.psesquared.server.util.RssParser; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * This service class manages all business logic associated with the + * episode action API. + * <br> + * It is called from the + * {@link + * org.psesquared.server.subscriptions.api.controller.SubscriptionController} + * and passes on requests concerning data access mainly to the + * {@link SubscriptionDao} and {@link SubscriptionActionDao}. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class SubscriptionService { + + /** + * The error message that is logged if no subscription exists + * for a remove action. + */ + private static final String NO_SUB_WARNING + = "Subscription for remove action does not exist!"; + + /** + * The logger for logging some warnings. + */ + private static final Logger LOGGER + = Logger.getLogger(SubscriptionService.class.getName()); + + /** + * The class for fetching data from RSS feeds. + */ + private final RssParser rssParser; + + /** + * The JPA repository that handles all user related database requests. + */ + private final AuthenticationDao authenticationDao; + + /** + * The JPA repository that handles all subscription related database requests. + */ + private final SubscriptionDao subscriptionDao; + + /** + * The JPA repository that handles all subscription action related database + * requests. + */ + private final SubscriptionActionDao subscriptionActionDao; + + /** + * The JPA repository that handles all episode action related database + * requests. + */ + private final EpisodeActionDao episodeActionDao; + + /** + * The service class of the episode action API. + */ + private final EpisodeActionService episodeActionService; + + /** + * It takes a list of podcast URLs in the form of strings and checks if they + * exist in the database. + * If they do not exist yet, it creates them. + * <br> + * Then it checks, if the user already has a subscription action for each + * subscription, separately. + * If not, it creates one. + * If yes, it updates the action. + * + * @param username The username of the user + * @param subscriptionStrings List of Strings, each String is a URL of a + * podcast + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus uploadSubscriptions( + final String username, + final List<String> subscriptionStrings) { + User user; + try { + user = authenticationDao.findByUsername(username) + .orElseThrow(); + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + + Subscription subscription; + for (String subscriptionString : subscriptionStrings) { + + try { + subscription + = subscriptionDao.findByUrl(subscriptionString).orElseThrow(); + } catch (NoSuchElementException e) { + subscription = Subscription.builder() + .url(subscriptionString) + .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .build(); + subscriptionDao.save(subscription); + rssParser.validate(subscription); + } + + try { + SubscriptionAction subscriptionAction + = subscriptionActionDao + .findByUserAndSubscription(user, subscription) + .orElseThrow(); + subscriptionAction.setAdded(true); + subscriptionAction.setTimestamp( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)); + subscriptionActionDao.save(subscriptionAction); + } catch (NoSuchElementException e) { + SubscriptionAction subscriptionAction = SubscriptionAction.builder() + .user(user) + .added(true) + .subscription(subscription) + .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .build(); + subscriptionActionDao.save(subscriptionAction); + } + } + + return HttpStatus.OK; + } + + /** + * It returns a URL List of all podcasts the user is subscribed to in the form + * of a String list. + * + * @param username The username of the user whose subscriptions are being + * requested + * @return A list of RSS-Feed URLs of all subscribed podcasts + */ + public List<String> getSubscriptions(final String username) { + List<SubscriptionAction> subscriptionActions + = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + List<String> subscriptionUrls = new ArrayList<>(); + for (SubscriptionAction subscriptionAction : subscriptionActions) { + subscriptionUrls.add(subscriptionAction.getSubscription().getUrl()); + } + return subscriptionUrls; + } + + /** + * All subscription changes of the user are uploaded to the database. + * + * @param username The username of the user uploading their subscription + * changes + * @param delta The subscription changes in the form of a SubscriptionDelta + * containing the added / removed subscriptions + */ + public void applySubscriptionDelta( + final String username, + final SubscriptionDelta delta) { + + SubscriptionDelta minimizedDelta = minimizeDelta(delta); + + uploadSubscriptions(username, minimizedDelta.getAdd()); + for (String removeSub : minimizedDelta.getRemove()) { + try { + SubscriptionAction subscriptionAction + = subscriptionActionDao + .findByUserUsernameAndSubscriptionUrl(username, removeSub) + .orElseThrow(); + subscriptionAction.setAdded(false); + subscriptionAction.setTimestamp( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)); + subscriptionActionDao.save(subscriptionAction); + episodeActionDao + .deleteByUserUsernameAndEpisodeSubscriptionUrl(username, removeSub); + } catch (NoSuchElementException e) { + LOGGER.log(Level.WARNING, NO_SUB_WARNING); + } + } + } + + private SubscriptionDelta minimizeDelta(SubscriptionDelta oldDelta){ + SubscriptionDelta minimizedDelta = new SubscriptionDelta(new ArrayList<>(), new ArrayList<>()); + + Map<String, Integer> deltaMap = new HashMap<>(); + for (String addString : oldDelta.getAdd()) { + if(deltaMap.containsKey(addString)) { + deltaMap.put(addString, deltaMap.get(addString) + 1); + } + else{ + deltaMap.put(addString, 1); + } + } + + for (String removeString : oldDelta.getRemove()) { + if(deltaMap.containsKey(removeString)) { + deltaMap.put(removeString, deltaMap.get(removeString) - 1); + } + else{ + deltaMap.put(removeString, -1); + } + } + + for(Map.Entry<String, Integer> deltaEntry : deltaMap.entrySet()) { + if(deltaEntry.getValue() > 0) { + minimizedDelta.getAdd().add(deltaEntry.getKey()); + } else if(deltaEntry.getValue() < 0) { + minimizedDelta.getRemove().add(deltaEntry.getKey()); + } + } + + return minimizedDelta; + } + + /** + * Returns a SubscriptionDelta of all changes made to a users subscriptions + * since a given point in time. + * + * @param username The username of the user whose subscription changes are + * being requested + * @param since The timestamp signifying how old the changes are allowed to + * be + * @return The SubscriptionDelta of all changes made since the given timestamp + */ + public SubscriptionDelta getSubscriptionDelta( + final String username, + final long since) { + List<String> added = new ArrayList<>(); + List<String> removed = new ArrayList<>(); + + List<SubscriptionAction> subscriptionActions = subscriptionActionDao + .findByUserUsernameAndTimestampGreaterThanEqual(username, since); + for (SubscriptionAction subscriptionAction : subscriptionActions) { + if (subscriptionAction.isAdded()) { + added.add(subscriptionAction.getSubscription().getUrl()); + } else { + removed.add(subscriptionAction.getSubscription().getUrl()); + } + } + + return new SubscriptionDelta(added, removed); + } + + /** + * Returns all Subscriptions and their 20 latest episodes of a given user as a + * List of SubscriptionTitles. + * + * @param username The username of the user whose subscriptions are being + * requested + * @return A list of SubscriptionTitles containing each Subscription and their + * 20 latest Episodes + */ + public List<SubscriptionTitles> getTitles(final String username) { + List<SubscriptionAction> subscriptionActions + = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + List<Subscription> subscriptions = new ArrayList<>(); + List<SubscriptionTitles> subscriptionTitlesList = new ArrayList<>(); + + for (SubscriptionAction subscriptionAction : subscriptionActions) { + subscriptions.add(subscriptionAction.getSubscription()); + } + + for (Subscription subscription : subscriptions) { + List<EpisodeActionPost> episodeActionPosts + = episodeActionService + .getEpisodeActionsOfPodcast(username, subscription.getUrl()); + SubscriptionTitles subscriptionTitles + = new SubscriptionTitles(subscription, episodeActionPosts); + subscriptionTitlesList.add(subscriptionTitles); + } + + return subscriptionTitlesList; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/package-info.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/package-info.java new file mode 100644 index 0000000..9e2a598 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the logical middle layer of the subscription API + * ({@link org.psesquared.server.subscriptions.api}) - the service layer. + * <br> + * All business logic is handled here with the + * {@link + * org.psesquared.server.subscriptions.api.service.SubscriptionService} + * class. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.subscriptions.api.service; diff --git a/pse-server/src/main/java/org/psesquared/server/util/RssParser.java b/pse-server/src/main/java/org/psesquared/server/util/RssParser.java new file mode 100644 index 0000000..7647fee --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/util/RssParser.java @@ -0,0 +1,257 @@ +package org.psesquared.server.util; + +import com.rometools.rome.feed.synd.SyndEnclosure; +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.FeedException; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.DateTimeException; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.jdom2.Content; +import org.jdom2.Element; +import org.psesquared.server.episode.actions.api.data.access.EpisodeDao; +import org.psesquared.server.model.Episode; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionDao; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * The class responsible for fetching data from RSS feeds. + */ +@Component +@RequiredArgsConstructor +public class RssParser { + + /** + * The Index of the Map in the List of Maps that uses GUIDs as keys. + */ + private static final int GUID_KEY_MAP_INDEX = 0; + + /** + * The Index of the Map in the List of Maps that uses URLs as keys. + */ + private static final int URL_KEY_MAP_INDEX = 1; + + /** + * The JPA repository that handles all episode related database requests. + */ + private final EpisodeDao episodeDao; + + /** + * The JPA repository that handles all subscription related database requests. + */ + private final SubscriptionDao subscriptionDao; + + /** + * Validates that the RSS-Feed associated with the Subscription is of the + * expected Format and that the Episodes of the Subscription are part of the + * feed. + * If the Feed is invalid the Subscription is deleted using the + * SubscriptionDao. + * Otherwise, the Episodes that are Part of the Feed are saved using the + * EpisodeDao, those that are not are deleted using the EpisodeDao. + * + * @param subscription The Subscription to validate + */ + @Async + @Transactional + public void validate(final Subscription subscription) { + if (subscription == null) { + return; + } + + List<Map<String, Episode>> fetchedData + = fetchSubscriptionFeed(subscription); + if (fetchedData.get(URL_KEY_MAP_INDEX).isEmpty()) { + subscriptionDao.deleteById(subscription.getId()); + return; + } + + Subscription retrievedSubscription = subscriptionDao.save(subscription); + List<Episode> subscriptionEpisodes = retrievedSubscription.getEpisodes(); + if (subscriptionEpisodes == null + || subscriptionEpisodes.isEmpty()) { + return; + } + + List<Episode> invalidEpisodes = new ArrayList<>(); + List<Episode> validEpisodes = new ArrayList<>(); + for (Episode episode : subscriptionEpisodes) { + Episode fetchedEpisode + = getFetchedEpisode(episode, fetchedData); + if (fetchedEpisode == null) { + invalidEpisodes.add(episode); + } else { + fetchedEpisode.setId(episode.getId()); + validEpisodes.add(fetchedEpisode); + } + } + if (!invalidEpisodes.isEmpty()) { + episodeDao.deleteAll(invalidEpisodes); + } + if (!validEpisodes.isEmpty()) { + episodeDao.saveAll(validEpisodes); + } + } + + private List<Map<String, Episode>> fetchSubscriptionFeed( + final Subscription subscription) { + final List<Map<String, Episode>> empty + = List.of(new HashMap<>(), new HashMap<>()); + + if (subscription.getUrl() == null) { + return empty; + } + // fetch feed + URL feedUrl; + try { + feedUrl = new URL(subscription.getUrl()); + } catch (MalformedURLException e) { + return empty; + } + + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed; + try { + feed = input.build(new XmlReader(feedUrl)); + } catch (FeedException | IOException | IllegalArgumentException e) { + return empty; + } + + String subscriptionTitle = feed.getTitle(); + if (subscriptionTitle == null) { + return empty; + } + subscription.setTitle(subscriptionTitle); + + Map<String, Episode> episodesByGuid = new HashMap<>(); + Map<String, Episode> episodesByUrl = new HashMap<>(); + List<SyndEntry> entries = feed.getEntries(); + for (SyndEntry syndEntry : entries) { + // parse syndEntry to Episode + Episode episode = parseEpisode(syndEntry, subscription); + if (episode == null) { + return empty; + } + if (episode.getGuid() != null) { + episodesByGuid.put(episode.getGuid(), episode); + } + episodesByUrl.put(episode.getUrl(), episode); + } + return List.of(episodesByGuid, episodesByUrl); + } + + private Episode parseEpisode(final SyndEntry syndEntry, + final Subscription subscription) { + if (syndEntry == null) { + return null; + } + final String title = syndEntry.getTitle(); + final String guid = syndEntry.getUri(); + + List<SyndEnclosure> enclosureList = syndEntry.getEnclosures(); + if (enclosureList.size() != 1) { + return null; + } + SyndEnclosure enclosure = enclosureList.get(0); + String url = enclosure.getUrl(); + if (title == null || url == null) { + return null; + } + + int total = 0; + List<Element> itunesTags = syndEntry.getForeignMarkup(); + for (Element element : itunesTags) { + if (!element.getName().equals("duration")) { + continue; + } + + List<Content> content = element.getContent(); + if (content.size() != 1) { + return null; + } + String timeString = content.get(0).getValue(); + total = parseTimeToSeconds(timeString); + } + + return Episode.builder() + .guid(guid) + .url(url) + .title(title) + .total(total) + .subscription(subscription) + .build(); + } + + private Episode getFetchedEpisode( + final Episode episode, + final List<Map<String, Episode>> fetchedData) { + final String episodeUrl = episode.getUrl(); + if (episodeUrl == null) { + return null; + } + final String episodeGuid = episode.getGuid(); + if (fetchedData.get(GUID_KEY_MAP_INDEX).containsKey(episodeGuid)) { + return fetchedData.get(GUID_KEY_MAP_INDEX).get(episodeGuid); + } + return fetchedData.get(URL_KEY_MAP_INDEX).get(episodeUrl); + } + + private static int parseTimeToSeconds(final String time) { + final String delim = ":"; + + if (time == null) { + // Returning default value + return 0; + } + + if (!time.contains(delim)) { + try { + return Integer.parseInt(time); + } catch (NumberFormatException e) { + return 0; + } + } + + StringBuilder formattedTime = new StringBuilder(); + + String[] datetimeStrings = time.split(delim); + + if (datetimeStrings.length == 2) { + formattedTime.append("00" + delim); + } + + for (int i = 0; i < datetimeStrings.length; i++) { + String part = datetimeStrings[i]; + if (part.length() < 2) { + String toAdd = "0"; + part = toAdd.repeat(2 - part.length()) + part; + } + formattedTime.append(part); + + if (i + 1 < datetimeStrings.length) { + formattedTime.append(delim); + } + } + + int toReturn = 0; + + try { + toReturn = LocalTime.parse(formattedTime.toString()).toSecondOfDay(); + } catch (DateTimeException e) { + // Do nothing, default value has already been set + } + return toReturn; + } +} diff --git a/pse-server/src/main/java/org/psesquared/server/util/Scheduler.java b/pse-server/src/main/java/org/psesquared/server/util/Scheduler.java new file mode 100644 index 0000000..fbe8194 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/util/Scheduler.java @@ -0,0 +1,41 @@ +package org.psesquared.server.util; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.service.AuthenticationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * A scheduler responsible for running scheduled actions. + */ +@Component +@RequiredArgsConstructor +public class Scheduler { + + /** + * The seconds of one day. + */ + private static final long ONE_DAY = 24 * 60 * (long) 60; + + /** + * The service class of the authentication API. + */ + @Autowired + private final AuthenticationService authenticationService; + + /** + * A scheduled operation that removes all non-verified users from the server, + * that haven't been verified since at least 24 hours. + * <br> + * Standard: Runs every day at 3 AM. + */ + @Scheduled(cron = "0 0 3 * * *") + public void clean() { + authenticationService.deleteInvalidUsersOlderThan( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) - ONE_DAY); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/util/UpdateUrlsWrapper.java b/pse-server/src/main/java/org/psesquared/server/util/UpdateUrlsWrapper.java new file mode 100644 index 0000000..004df12 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/util/UpdateUrlsWrapper.java @@ -0,0 +1,35 @@ +package org.psesquared.server.util; + +import ch.qos.logback.core.joran.sanity.Pair; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * Placeholder for a function this implementation does not support. + */ +@Data +public class UpdateUrlsWrapper { + + /** + * The timestamp of this response-wrapper. + */ + private final long timestamp; + + /** + * An empty list of URL pairs. + */ + @JsonProperty(value = "update_urls") + private final List<Pair<String, String>> updateUrls = new ArrayList<>(); + + /** + * Creates a placeholder for a function this implementation does not support. + */ + public UpdateUrlsWrapper() { + this.timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/util/package-info.java b/pse-server/src/main/java/org/psesquared/server/util/package-info.java new file mode 100644 index 0000000..169098c --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/util/package-info.java @@ -0,0 +1,11 @@ +/** + * This package features the following utility classes: + * <br> + * {@link org.psesquared.server.util.RssParser}, + * {@link org.psesquared.server.util.Scheduler}, + * {@link org.psesquared.server.util.UpdateUrlsWrapper}. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.util; |