summaryrefslogtreecommitdiff
path: root/pse-server/src/main/java
diff options
context:
space:
mode:
authorOrangerot <purple@orangerot.dev>2024-06-19 00:14:49 +0200
committerOrangerot <purple@orangerot.dev>2024-06-27 12:11:14 +0200
commit5b8851b6c268d0e93c158908fbfae9f8473db5ff (patch)
tree7010eb85d86fa2da06ea4ffbcdb01a685d502ae8 /pse-server/src/main/java
Initial commitHEADmain
Diffstat (limited to 'pse-server/src/main/java')
-rw-r--r--pse-server/src/main/java/org/psesquared/server/ServerApplication.java27
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java251
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java15
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java46
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java16
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java62
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java11
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java389
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java222
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java85
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java170
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java33
-rw-r--r--pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java125
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java95
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java16
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java143
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/JwtService.java283
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java117
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java17
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/package-info.java8
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java129
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java37
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java61
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java107
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java46
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java360
-rw-r--r--pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/model/Action.java45
-rw-r--r--pse-server/src/main/java/org/psesquared/server/model/Episode.java74
-rw-r--r--pse-server/src/main/java/org/psesquared/server/model/EpisodeAction.java101
-rw-r--r--pse-server/src/main/java/org/psesquared/server/model/Role.java28
-rw-r--r--pse-server/src/main/java/org/psesquared/server/model/Subscription.java87
-rw-r--r--pse-server/src/main/java/org/psesquared/server/model/SubscriptionAction.java62
-rw-r--r--pse-server/src/main/java/org/psesquared/server/model/User.java148
-rw-r--r--pse-server/src/main/java/org/psesquared/server/model/package-info.java8
-rw-r--r--pse-server/src/main/java/org/psesquared/server/package-info.java5
-rw-r--r--pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionController.java155
-rw-r--r--pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionDelta.java80
-rw-r--r--pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionTitles.java17
-rw-r--r--pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDao.java91
-rw-r--r--pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDao.java34
-rw-r--r--pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/SubscriptionService.java299
-rw-r--r--pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/package-info.java13
-rw-r--r--pse-server/src/main/java/org/psesquared/server/util/RssParser.java257
-rw-r--r--pse-server/src/main/java/org/psesquared/server/util/Scheduler.java41
-rw-r--r--pse-server/src/main/java/org/psesquared/server/util/UpdateUrlsWrapper.java35
-rw-r--r--pse-server/src/main/java/org/psesquared/server/util/package-info.java11
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;