summaryrefslogtreecommitdiff
path: root/pse-server
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
Initial commitHEADmain
Diffstat (limited to 'pse-server')
-rw-r--r--pse-server/.dockerignore1
-rw-r--r--pse-server/.env10
-rw-r--r--pse-server/.gitignore39
-rw-r--r--pse-server/.gitlab-ci.yml56
-rw-r--r--pse-server/.mvn/wrapper/maven-wrapper.properties2
-rw-r--r--pse-server/Dockerfile27
-rw-r--r--pse-server/LICENSE661
-rw-r--r--pse-server/README.md150
-rw-r--r--pse-server/docker-compose.yml50
-rwxr-xr-xpse-server/mvnw316
-rw-r--r--pse-server/mvnw.cmd188
-rw-r--r--pse-server/pom.xml150
-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
-rw-r--r--pse-server/src/main/resources/PasswordResetMail.txt34
-rw-r--r--pse-server/src/main/resources/VerificationMail.txt34
-rw-r--r--pse-server/src/main/resources/application.properties31
-rw-r--r--pse-server/src/main/resources/security.properties4
-rw-r--r--pse-server/src/test/java/org/psesquared/server/BaseTest.java163
-rw-r--r--pse-server/src/test/java/org/psesquared/server/ServerApplicationTests.java11
-rw-r--r--pse-server/src/test/java/org/psesquared/server/TestAsyncConfig.java16
-rw-r--r--pse-server/src/test/java/org/psesquared/server/authentication/api/data/access/AuthenticationDaoTest.java51
-rw-r--r--pse-server/src/test/java/org/psesquared/server/authentication/api/service/AuthenticationServiceTest.java222
-rw-r--r--pse-server/src/test/java/org/psesquared/server/authentication/api/service/EmailServiceTests.java36
-rw-r--r--pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDaoTests.java20
-rw-r--r--pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDaoTests.java29
-rw-r--r--pse-server/src/test/java/org/psesquared/server/episode/actions/api/service/EpisodeActionServiceTests.java368
-rw-r--r--pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDaoTests.java89
-rw-r--r--pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDaoTests.java36
-rw-r--r--pse-server/src/test/java/org/psesquared/server/subscriptions/api/service/SubscriptionServiceTests.java128
-rw-r--r--pse-server/src/test/java/org/psesquared/server/util/RssParserTests.java135
-rw-r--r--pse-server/testfeeds/dirtest.txt1
-rw-r--r--pse-server/testfeeds/multipleEnclosuresFeed.xml17
-rw-r--r--pse-server/testfeeds/newTestSubscription1.xml28
-rw-r--r--pse-server/testfeeds/newTestSubscription2.xml28
-rw-r--r--pse-server/testfeeds/template.txt33
-rw-r--r--pse-server/testfeeds/testPodcast0.xml35
-rw-r--r--pse-server/testfeeds/testPodcast1.xml28
-rw-r--r--pse-server/testfeeds/timeTestPodcast.xml42
92 files changed, 7848 insertions, 0 deletions
diff --git a/pse-server/.dockerignore b/pse-server/.dockerignore
new file mode 100644
index 0000000..1de5659
--- /dev/null
+++ b/pse-server/.dockerignore
@@ -0,0 +1 @@
+target \ No newline at end of file
diff --git a/pse-server/.env b/pse-server/.env
new file mode 100644
index 0000000..3ec0643
--- /dev/null
+++ b/pse-server/.env
@@ -0,0 +1,10 @@
+FRONTEND_DOMAIN=<YOUR FRONTEND DOMAIN>
+BACKEND_DOMAIN=<YOUR BACKEND DOMAIN>
+
+SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP>
+SPRING_MAIL_PORT=587
+SPRING_MAIL_USERNAME=<YOUR MAIL ADDRESS>
+SPRING_MAIL_PASSWORD=<YOUR MAIL PASSWORD>
+
+# Ensure the container uses the timezone of the host
+SERVER_TIMEZONE=Europe/Berlin
diff --git a/pse-server/.gitignore b/pse-server/.gitignore
new file mode 100644
index 0000000..8b99cd7
--- /dev/null
+++ b/pse-server/.gitignore
@@ -0,0 +1,39 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+.env
+src/main/resources/application.properties
+src/main/resources/security.properties
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Security Properties ###
+#security.properties
diff --git a/pse-server/.gitlab-ci.yml b/pse-server/.gitlab-ci.yml
new file mode 100644
index 0000000..9404844
--- /dev/null
+++ b/pse-server/.gitlab-ci.yml
@@ -0,0 +1,56 @@
+# https://about.gitlab.com/blog/2016/12/14/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/
+
+services:
+ - mariadb:latest
+
+variables:
+ MYSQL_DATABASE: "demo"
+ MYSQL_ROOT_PASSWORD: "dbpass"
+ MYSQL_USER: "pse"
+ MYSQL_PASSWORD: "PSEsq1702!mdb"
+ SPRING_DATASOURCE_URL: "jdbc:mariadb://mariadb:3306/demo"
+
+stages:
+ - deploy
+ - build
+ - test
+ # - package
+
+checkstyle:
+ image: maven:3.8-eclipse-temurin-17-alpine
+ stage: test
+ script:
+ - "mvn checkstyle:check"
+ allow_failure: true
+
+spring-test:
+ image: maven:3.8-eclipse-temurin-17-alpine
+ stage: test
+ script:
+ - "apk update && apk add mysql-client"
+ - "mvn test"
+
+pages:
+ image: maven:3.8-eclipse-temurin-17-alpine
+ script:
+ - "apk update && apk add mysql-client"
+ - "mkdir -p public/{checkstyle,surefire}"
+ - "mvn checkstyle:checkstyle"
+ - "mv target/site/* public/checkstyle"
+ - "mvn surefire-report:report"
+ - "mv target/site/* public/surefire"
+ artifacts:
+ paths:
+ - public
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+
+# https://stackoverflow.com/questions/37403120/gitlab-ci-docker-executor-how-to-setup-mysql-service
+maven-build:
+ image: maven:3.8-eclipse-temurin-17-alpine
+ stage: build
+ script:
+ - "mvn package -B -DskipTests"
+ artifacts:
+ paths:
+ - target/*.jar
diff --git a/pse-server/.mvn/wrapper/maven-wrapper.properties b/pse-server/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..b74bf7f
--- /dev/null
+++ b/pse-server/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,2 @@
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip
+wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar
diff --git a/pse-server/Dockerfile b/pse-server/Dockerfile
new file mode 100644
index 0000000..2f74e27
--- /dev/null
+++ b/pse-server/Dockerfile
@@ -0,0 +1,27 @@
+# syntax=docker/dockerfile:1
+
+FROM maven:3.8-eclipse-temurin-17-alpine AS builder
+
+COPY . ./app
+WORKDIR /app
+RUN mvn package -B -DskipTests -DfinalName=server.jar
+
+FROM eclipse-temurin:17-jdk-jammy
+COPY --from=builder /app/target/server.jar .
+
+# Ensure the docker container uses the same timezone as the server
+ARG SERVER_TIMEZONE=Europe/Berlin
+ENV TZ=$SERVER_TIMEZONE
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update && apt-get install -y tzdata
+
+ENV SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP>
+ENV SPRING_MAIL_PORT=587
+ENV SPRING_MAIL_USERNAME=<YOUR MAIL ADDRESS>
+ENV SPRING_MAIL_PASSWORD=<YOUR MAIL PASSWORD>
+ENV EMAIL_DASHBOARD_BASE_URL=http://<YOUR FRONTEND DOMAIN>
+ENV EMAIL_VERIFICATION_URL=http://<YOUR BACKEND DOMAIN>/api/2/auth/%s/verify.json
+ENV EMAIL_RESET_URL_PATH=/resetPassword
+
+CMD ["java", "-server", "-Xms1G", "-Xmx1G", "-XX:+UseZGC", "-jar", "server.jar"]
+
diff --git a/pse-server/LICENSE b/pse-server/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/pse-server/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
diff --git a/pse-server/README.md b/pse-server/README.md
new file mode 100644
index 0000000..0c7b04b
--- /dev/null
+++ b/pse-server/README.md
@@ -0,0 +1,150 @@
+# Podcast Synchronisation made Efficient Server
+> Gpodder Server written in Java Spring Boot
+
+## About
+
+The aim of this software project is to provide a synchronization server for
+podcasts that is to be used by so-called podcatchers and that is as lean and
+efficient as possible compared to other synchronizations Server.
+
+## Getting Started
+
+## Pre requirements
+
+- MariaDB-Server 10.6
+- Java JDK 17
+
+### MariaDB
+
+Start the MariaDB Server and login as root to manage the databases.
+```sh
+$ sudo systemctl start mariadb # start mariadb
+$ sudo mariadb --password # log in as root
+```
+
+Create Database and User.
+```sql
+create database demo; -- Creates the new database
+create user 'pse'@'localhost' identified by 'PSEsq1702!mdb'; -- Creates the user
+grant all on demo.* to 'pse'@'localhost'; -- Gives all privileges to the new user on the newly created database
+```
+
+### Spring Boot
+
+Compile and run the Spring Boot Project
+```sh
+$ mvn spring-boot:run
+```
+
+Compile to JAR for production
+```sh
+$ mvn package -B -DskipTests
+```
+
+Execute jar
+```
+$ java -jar target/server.jar
+```
+
+### Configuration
+
+You can configuration by editing the files under `src/main/resources/`.
+
+```
+src/main/resources
+├── application.properties # main config file
+├── PasswordResetMail.txt # Mail text for reseting password
+├── security.properties # keys for signing cookies/links
+└── VerificationMail.txt # Mail text for verifying account
+```
+
+All configurations in the application.properties can also be set using
+environment variables by putting all letters of the setting to uppercase and
+replacing any non-word character to an underscore.
+
+```
+application.properties
+----------------------
+spring.mail.host=<YOUR MAIL HOST SMTP>
+spring.mail.port=587
+spring.mail.username=<YOUR MAIL ADDRESS>
+spring.mail.password=<YOUR MAIL PASSWORD>
+
+enivorment variables
+--------------------
+export SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP>
+export SPRING_MAIL_PORT=587
+export SPRING_MAIL_USERNAME=<YOUR MAIL ADDRESS>
+export SPRING_MAIL_PASSWORD=<YOUR MAIL PASSWORD>
+```
+
+## Docker-Compose (Java Spring + Database)
+
+> Note that you are running this backend standalone!
+> Checkout `pse-docker` to run both front- and backend.
+
+Build the composition
+```sh
+$ docker compose build
+```
+
+Run the composition
+```sh
+$ docker compose run
+```
+
+The port is automatically exposed to port 80.
+You can configure the composition by editing the `.env` and `docker-compose.yml`
+files.
+
+## Docker (just Java Spring)
+
+> Note that you are running this server standalone!
+> Checkout `pse-docker` to run both front- and backend.
+> You need to connect to a Database to the image as well.
+
+The docker image can be build using
+```sh
+$ docker build -t pse-server .
+```
+
+Then the image can be run using
+```sh
+$ docker run -p 80:8080 -it pse-server
+```
+
+Here you can change the configuration by editing the `Dockerfile` by setting
+ENV VARS:
+```
+ENV SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP>
+ENV SPRING_MAIL_PORT=587
+ENV SPRING_MAIL_USERNAME=<YOUR MAIL ADDRESS>
+ENV SPRING_MAIL_PASSWORD=<YOUR MAIL PASSWORD>
+ENV EMAIL_DASHBOARD_BASE_URL=http://<YOUR FRONTEND DOMAIN>
+ENV EMAIL_VERIFICATION_URL=http://<YOUR BACKEND DOMAIN>/api/2/auth/%s/verify.json
+ENV EMAIL_RESET_URL_PATH=/resetPassword
+```
+
+These can also be set by supplying them when starting the image
+```sh
+$ docker run -p 80:8080 -it pse-server -e "SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP>"
+```
+
+Or you can use the env vars from the host by only writing the name of the var
+```sh
+$ docker run -p 80:8080 -it pse-server -e SPRING_MAIL_HOST
+```
+
+## Used Dependencies
+
+- Spring Web
+- Spring Security
+- Spring Mail Sender
+- Spring Data JPA
+- Lombok
+- Rome (RSS parsing/fetching)
+
+## License
+
+This project is licensed under the AGPL-3 License - see the `LICENSE` file for details
+
diff --git a/pse-server/docker-compose.yml b/pse-server/docker-compose.yml
new file mode 100644
index 0000000..35ad877
--- /dev/null
+++ b/pse-server/docker-compose.yml
@@ -0,0 +1,50 @@
+version: '3.9'
+
+services:
+
+ maria_db:
+ image: "mariadb:10.4.28"
+ restart: always
+ networks:
+ - backend
+ environment:
+ MARIADB_DATABASE: demo
+ MARIADB_USER: pse
+ MARIADB_PASSWORD: PSEsq1702!mdb
+ MARIADB_RANDOM_ROOT_PASSWORD: yes
+ volumes:
+ - database:/var/lib/mysql
+
+ pse-backend:
+ restart: always
+ hostname: pse-backend
+ network_mode: "bridge"
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ SERVER_TIMEZONE: ${SERVER_TIMEZONE}
+ networks:
+ - backend
+ environment:
+ EMAIL_DASHBOARD_BASE_URL: http://${FRONTEND_DOMAIN}
+ EMAIL_VERIFICATION_URL: http://${BACKEND_DOMAIN}/api/2/auth/%s/verify.json
+ EMAIL_RESET_URL_PATH: /resetPassword
+ SPRING_MAIL_HOST: ${SPRING_MAIL_HOST}
+ SPRING_MAIL_PORT: ${SPRING_MAIL_PORT}
+ SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME}
+ SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD}
+ depends_on:
+ - maria_db
+ links:
+ - maria_db:maria_db
+ ports:
+ - 80:8080
+
+networks:
+ frontend:
+ backend:
+
+volumes:
+ database:
+
diff --git a/pse-server/mvnw b/pse-server/mvnw
new file mode 100755
index 0000000..8a8fb22
--- /dev/null
+++ b/pse-server/mvnw
@@ -0,0 +1,316 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Maven Start Up Batch script
+#
+# Required ENV vars:
+# ------------------
+# JAVA_HOME - location of a JDK home dir
+#
+# Optional ENV vars
+# -----------------
+# M2_HOME - location of maven2's installed home dir
+# MAVEN_OPTS - parameters passed to the Java VM when running Maven
+# e.g. to debug Maven itself, use
+# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+# ----------------------------------------------------------------------------
+
+if [ -z "$MAVEN_SKIP_RC" ] ; then
+
+ if [ -f /usr/local/etc/mavenrc ] ; then
+ . /usr/local/etc/mavenrc
+ fi
+
+ if [ -f /etc/mavenrc ] ; then
+ . /etc/mavenrc
+ fi
+
+ if [ -f "$HOME/.mavenrc" ] ; then
+ . "$HOME/.mavenrc"
+ fi
+
+fi
+
+# OS specific support. $var _must_ be set to either true or false.
+cygwin=false;
+darwin=false;
+mingw=false
+case "`uname`" in
+ CYGWIN*) cygwin=true ;;
+ MINGW*) mingw=true;;
+ Darwin*) darwin=true
+ # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
+ # See https://developer.apple.com/library/mac/qa/qa1170/_index.html
+ if [ -z "$JAVA_HOME" ]; then
+ if [ -x "/usr/libexec/java_home" ]; then
+ export JAVA_HOME="`/usr/libexec/java_home`"
+ else
+ export JAVA_HOME="/Library/Java/Home"
+ fi
+ fi
+ ;;
+esac
+
+if [ -z "$JAVA_HOME" ] ; then
+ if [ -r /etc/gentoo-release ] ; then
+ JAVA_HOME=`java-config --jre-home`
+ fi
+fi
+
+if [ -z "$M2_HOME" ] ; then
+ ## resolve links - $0 may be a link to maven's home
+ PRG="$0"
+
+ # need this for relative symlinks
+ while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG="`dirname "$PRG"`/$link"
+ fi
+ done
+
+ saveddir=`pwd`
+
+ M2_HOME=`dirname "$PRG"`/..
+
+ # make it fully qualified
+ M2_HOME=`cd "$M2_HOME" && pwd`
+
+ cd "$saveddir"
+ # echo Using m2 at $M2_HOME
+fi
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched
+if $cygwin ; then
+ [ -n "$M2_HOME" ] &&
+ M2_HOME=`cygpath --unix "$M2_HOME"`
+ [ -n "$JAVA_HOME" ] &&
+ JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
+ [ -n "$CLASSPATH" ] &&
+ CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
+fi
+
+# For Mingw, ensure paths are in UNIX format before anything is touched
+if $mingw ; then
+ [ -n "$M2_HOME" ] &&
+ M2_HOME="`(cd "$M2_HOME"; pwd)`"
+ [ -n "$JAVA_HOME" ] &&
+ JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+ javaExecutable="`which javac`"
+ if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
+ # readlink(1) is not available as standard on Solaris 10.
+ readLink=`which readlink`
+ if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
+ if $darwin ; then
+ javaHome="`dirname \"$javaExecutable\"`"
+ javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
+ else
+ javaExecutable="`readlink -f \"$javaExecutable\"`"
+ fi
+ javaHome="`dirname \"$javaExecutable\"`"
+ javaHome=`expr "$javaHome" : '\(.*\)/bin'`
+ JAVA_HOME="$javaHome"
+ export JAVA_HOME
+ fi
+ fi
+fi
+
+if [ -z "$JAVACMD" ] ; then
+ if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ else
+ JAVACMD="`\\unset -f command; \\command -v java`"
+ fi
+fi
+
+if [ ! -x "$JAVACMD" ] ; then
+ echo "Error: JAVA_HOME is not defined correctly." >&2
+ echo " We cannot execute $JAVACMD" >&2
+ exit 1
+fi
+
+if [ -z "$JAVA_HOME" ] ; then
+ echo "Warning: JAVA_HOME environment variable is not set."
+fi
+
+CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
+
+# traverses directory structure from process work directory to filesystem root
+# first directory with .mvn subdirectory is considered project base directory
+find_maven_basedir() {
+
+ if [ -z "$1" ]
+ then
+ echo "Path not specified to find_maven_basedir"
+ return 1
+ fi
+
+ basedir="$1"
+ wdir="$1"
+ while [ "$wdir" != '/' ] ; do
+ if [ -d "$wdir"/.mvn ] ; then
+ basedir=$wdir
+ break
+ fi
+ # workaround for JBEAP-8937 (on Solaris 10/Sparc)
+ if [ -d "${wdir}" ]; then
+ wdir=`cd "$wdir/.."; pwd`
+ fi
+ # end of workaround
+ done
+ echo "${basedir}"
+}
+
+# concatenates all lines of a file
+concat_lines() {
+ if [ -f "$1" ]; then
+ echo "$(tr -s '\n' ' ' < "$1")"
+ fi
+}
+
+BASE_DIR=`find_maven_basedir "$(pwd)"`
+if [ -z "$BASE_DIR" ]; then
+ exit 1;
+fi
+
+##########################################################################################
+# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+# This allows using the maven wrapper in projects that prohibit checking in binary data.
+##########################################################################################
+if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Found .mvn/wrapper/maven-wrapper.jar"
+ fi
+else
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
+ fi
+ if [ -n "$MVNW_REPOURL" ]; then
+ jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
+ else
+ jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
+ fi
+ while IFS="=" read key value; do
+ case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
+ esac
+ done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Downloading from: $jarUrl"
+ fi
+ wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
+ if $cygwin; then
+ wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"`
+ fi
+
+ if command -v wget > /dev/null; then
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Found wget ... using wget"
+ fi
+ if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+ wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
+ else
+ wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
+ fi
+ elif command -v curl > /dev/null; then
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Found curl ... using curl"
+ fi
+ if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+ curl -o "$wrapperJarPath" "$jarUrl" -f
+ else
+ curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f
+ fi
+
+ else
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo "Falling back to using Java to download"
+ fi
+ javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
+ # For Cygwin, switch paths to Windows format before running javac
+ if $cygwin; then
+ javaClass=`cygpath --path --windows "$javaClass"`
+ fi
+ if [ -e "$javaClass" ]; then
+ if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo " - Compiling MavenWrapperDownloader.java ..."
+ fi
+ # Compiling the Java class
+ ("$JAVA_HOME/bin/javac" "$javaClass")
+ fi
+ if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
+ # Running the downloader
+ if [ "$MVNW_VERBOSE" = true ]; then
+ echo " - Running MavenWrapperDownloader.java ..."
+ fi
+ ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
+ fi
+ fi
+ fi
+fi
+##########################################################################################
+# End of extension
+##########################################################################################
+
+export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
+if [ "$MVNW_VERBOSE" = true ]; then
+ echo $MAVEN_PROJECTBASEDIR
+fi
+MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin; then
+ [ -n "$M2_HOME" ] &&
+ M2_HOME=`cygpath --path --windows "$M2_HOME"`
+ [ -n "$JAVA_HOME" ] &&
+ JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
+ [ -n "$CLASSPATH" ] &&
+ CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
+ [ -n "$MAVEN_PROJECTBASEDIR" ] &&
+ MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
+fi
+
+# Provide a "standardized" way to retrieve the CLI args that will
+# work with both Windows and non-Windows executions.
+MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@"
+export MAVEN_CMD_LINE_ARGS
+
+WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+exec "$JAVACMD" \
+ $MAVEN_OPTS \
+ $MAVEN_DEBUG_OPTS \
+ -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
+ "-Dmaven.home=${M2_HOME}" \
+ "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
+ ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
diff --git a/pse-server/mvnw.cmd b/pse-server/mvnw.cmd
new file mode 100644
index 0000000..1d8ab01
--- /dev/null
+++ b/pse-server/mvnw.cmd
@@ -0,0 +1,188 @@
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM https://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Maven Start Up Batch script
+@REM
+@REM Required ENV vars:
+@REM JAVA_HOME - location of a JDK home dir
+@REM
+@REM Optional ENV vars
+@REM M2_HOME - location of maven2's installed home dir
+@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
+@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
+@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
+@REM e.g. to debug Maven itself, use
+@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+@REM ----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
+
+@REM set %HOME% to equivalent of $HOME
+if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
+
+@REM Execute a user defined script before this one
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
+@REM check for pre script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
+if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
+:skipRcPre
+
+@setlocal
+
+set ERROR_CODE=0
+
+@REM To isolate internal variables from possible post scripts, we use another setlocal
+@setlocal
+
+@REM ==== START VALIDATION ====
+if not "%JAVA_HOME%" == "" goto OkJHome
+
+echo.
+echo Error: JAVA_HOME not found in your environment. >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+:OkJHome
+if exist "%JAVA_HOME%\bin\java.exe" goto init
+
+echo.
+echo Error: JAVA_HOME is set to an invalid directory. >&2
+echo JAVA_HOME = "%JAVA_HOME%" >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo.
+goto error
+
+@REM ==== END VALIDATION ====
+
+:init
+
+@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
+@REM Fallback to current working directory if not found.
+
+set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
+IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
+
+set EXEC_DIR=%CD%
+set WDIR=%EXEC_DIR%
+:findBaseDir
+IF EXIST "%WDIR%"\.mvn goto baseDirFound
+cd ..
+IF "%WDIR%"=="%CD%" goto baseDirNotFound
+set WDIR=%CD%
+goto findBaseDir
+
+:baseDirFound
+set MAVEN_PROJECTBASEDIR=%WDIR%
+cd "%EXEC_DIR%"
+goto endDetectBaseDir
+
+:baseDirNotFound
+set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
+cd "%EXEC_DIR%"
+
+:endDetectBaseDir
+
+IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
+
+@setlocal EnableExtensions EnableDelayedExpansion
+for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
+@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
+
+:endReadAdditionalConfig
+
+SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
+set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
+set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
+
+FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
+ IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
+)
+
+@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
+if exist %WRAPPER_JAR% (
+ if "%MVNW_VERBOSE%" == "true" (
+ echo Found %WRAPPER_JAR%
+ )
+) else (
+ if not "%MVNW_REPOURL%" == "" (
+ SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar"
+ )
+ if "%MVNW_VERBOSE%" == "true" (
+ echo Couldn't find %WRAPPER_JAR%, downloading it ...
+ echo Downloading from: %DOWNLOAD_URL%
+ )
+
+ powershell -Command "&{"^
+ "$webclient = new-object System.Net.WebClient;"^
+ "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
+ "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
+ "}"^
+ "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^
+ "}"
+ if "%MVNW_VERBOSE%" == "true" (
+ echo Finished downloading %WRAPPER_JAR%
+ )
+)
+@REM End of extension
+
+@REM Provide a "standardized" way to retrieve the CLI args that will
+@REM work with both Windows and non-Windows executions.
+set MAVEN_CMD_LINE_ARGS=%*
+
+%MAVEN_JAVA_EXE% ^
+ %JVM_CONFIG_MAVEN_PROPS% ^
+ %MAVEN_OPTS% ^
+ %MAVEN_DEBUG_OPTS% ^
+ -classpath %WRAPPER_JAR% ^
+ "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
+ %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
+if ERRORLEVEL 1 goto error
+goto end
+
+:error
+set ERROR_CODE=1
+
+:end
+@endlocal & set ERROR_CODE=%ERROR_CODE%
+
+if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
+@REM check for post script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
+if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
+:skipRcPost
+
+@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
+if "%MAVEN_BATCH_PAUSE%"=="on" pause
+
+if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
+
+cmd /C exit /B %ERROR_CODE%
diff --git a/pse-server/pom.xml b/pse-server/pom.xml
new file mode 100644
index 0000000..adfbf74
--- /dev/null
+++ b/pse-server/pom.xml
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-parent</artifactId>
+ <version>3.0.2</version>
+ <relativePath/> <!-- lookup parent from repository -->
+ </parent>
+ <groupId>org.pse-squared</groupId>
+ <artifactId>server</artifactId>
+ <version>0.0.1-SNAPSHOT</version>
+ <name>server</name>
+ <description>Backend for Podcast Synchronisation made Efficient</description>
+ <properties>
+ <java.version>17</java.version>
+ </properties>
+ <dependencies>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-data-jpa</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.mariadb.jdbc</groupId>
+ <artifactId>mariadb-java-client</artifactId>
+ <version>3.1.2</version>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-mail</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-security</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-web</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.projectlombok</groupId>
+ <artifactId>lombok</artifactId>
+ <optional>true</optional>
+ </dependency>
+ <dependency>
+ <groupId>io.jsonwebtoken</groupId>
+ <artifactId>jjwt-api</artifactId>
+ <version>0.11.5</version>
+ </dependency>
+ <dependency>
+ <groupId>io.jsonwebtoken</groupId>
+ <artifactId>jjwt-impl</artifactId>
+ <version>0.11.5</version>
+ </dependency>
+ <dependency>
+ <groupId>io.jsonwebtoken</groupId>
+ <artifactId>jjwt-jackson</artifactId>
+ <version>0.11.5</version>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-test</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.security</groupId>
+ <artifactId>spring-security-test</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-engine</artifactId>
+ <version>5.9.2</version>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-validation</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-configuration-processor</artifactId>
+ <optional>true</optional>
+ </dependency>
+ <dependency>
+ <groupId>com.rometools</groupId>
+ <artifactId>rome</artifactId>
+ <version>1.18.0</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-checkstyle-plugin</artifactId>
+ <version>3.2.1</version>
+ <type>maven-plugin</type>
+ </dependency>
+ </dependencies>
+ <reporting>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-checkstyle-plugin</artifactId>
+ <version>3.2.1</version>
+ <reportSets>
+ <reportSet>
+ <reports>
+ <report>checkstyle</report>
+ </reports>
+ </reportSet>
+ </reportSets>
+ </plugin>
+ </plugins>
+ </reporting>
+ <build>
+ <finalName>server</finalName>
+ <plugins>
+ <plugin>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-maven-plugin</artifactId>
+ <version>${project.parent.version}</version>
+ <configuration>
+ <excludes>
+ <exclude>
+ <groupId>org.projectlombok</groupId>
+ <artifactId>lombok</artifactId>
+ </exclude>
+ </excludes>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.jacoco</groupId>
+ <artifactId>jacoco-maven-plugin</artifactId>
+ <version>0.8.8</version>
+ <executions>
+ <execution>
+ <goals>
+ <goal>prepare-agent</goal>
+ </goals>
+ </execution>
+ <execution>
+ <id>report</id>
+ <phase>test</phase>
+ <goals>
+ <goal>report</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
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;
diff --git a/pse-server/src/main/resources/PasswordResetMail.txt b/pse-server/src/main/resources/PasswordResetMail.txt
new file mode 100644
index 0000000..bf5e9fc
--- /dev/null
+++ b/pse-server/src/main/resources/PasswordResetMail.txt
@@ -0,0 +1,34 @@
+Hallo username,
+
+wir haben festgestellt, dass du dein Passwort für unseren Podcast-Synchronisations-Server PSE-SQUARED vergessen hast.
+Kein Problem, das kann jedem einmal passieren!
+
+Wenn du dein Passwort zurücksetzen möchtest, klicke einfach auf den folgenden Link und folge den Anweisungen:
+
+passwordResetURL
+
+Falls du dich nicht für unser Projekt angemeldet hast oder diese E-Mail irrtümlich erhalten hast, kannst du sie einfach ignorieren.
+
+Falls du Fragen oder Probleme hast, zögere nicht, uns zu kontaktieren. Wir helfen dir gerne weiter!
+
+Viele Grüße,
+das PSE-SQUARED-Team
+
+-----
+
+Hello username,
+
+we have noticed that you have forgotten your password for our podcast synchronization server PSE-SQUARED.
+No problem, this can happen to anyone!
+
+If you want to reset your password, just click on the following link and follow the instructions:
+
+passwordResetURL
+
+If you did not sign up for our project or received this email by mistake, you can simply ignore it.
+
+If you have any questions or problems, don't hesitate to contact us. We will be happy to help you!
+
+Best regards,
+the PSE-SQUARED team
+
diff --git a/pse-server/src/main/resources/VerificationMail.txt b/pse-server/src/main/resources/VerificationMail.txt
new file mode 100644
index 0000000..722b88b
--- /dev/null
+++ b/pse-server/src/main/resources/VerificationMail.txt
@@ -0,0 +1,34 @@
+Hallo username,
+
+wir möchten sicherstellen, dass du vollständig für unseren Podcast-Synchonisations-Server PSE-Squared angemeldet bist
+und Zugang zu allen Funktionen hast. Dazu ist es notwendig, dass du deine E-Mail-Adresse bestätigst.
+
+Klicke einfach auf den folgenden Link, um deine E-Mail-Adresse zu bestätigen:
+
+verificationURL
+
+Falls du dich nicht für unser Projekt angemeldet hast oder diese E-Mail irrtümlich erhalten hast, kannst du sie einfach ignorieren.
+
+Falls du Fragen oder Probleme hast, zögere nicht, uns zu kontaktieren. Wir helfen dir gerne weiter!
+
+Viele Grüße,
+das PSE-SQUARED-Team
+
+-----
+
+Hello username,
+
+we want to make sure that you are fully signed up for our podcast synchronization server PSE-Squared
+and have access to all features. For this purpose it is necessary that you confirm your email address.
+
+Just click on the following link to confirm your email address:
+
+verificationURL
+
+If you did not sign up for our project or received this email by mistake, you can simply ignore it.
+
+If you have any questions or problems, don't hesitate to contact us. We will be happy to help you!
+
+Best regards,
+the PSE-SQUARED team
+
diff --git a/pse-server/src/main/resources/application.properties b/pse-server/src/main/resources/application.properties
new file mode 100644
index 0000000..0ddba59
--- /dev/null
+++ b/pse-server/src/main/resources/application.properties
@@ -0,0 +1,31 @@
+# spring.datasource.url=jdbc:mariadb://maria_db:3306/demo?autoReconnect=true&maxReconnects=10
+spring.datasource.url=jdbc:mariadb://maria_db:3306/demo?autoReconnect=true&maxReconnects=10
+spring.datasource.username=pse
+spring.datasource.password=PSEsq1702!mdb
+spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
+spring.datasource.testWhileIdle=true
+spring.datasource.validationQuery=SELECT 1
+spring.datasource.hikari.maxLifetime = 590000
+spring.jpa.show-sql=true
+
+# change to validate for production
+spring.jpa.hibernate.ddl-auto=update
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect
+spring.jpa.properties.hibernate.format_sql=true
+spring.jpa.properties.jakarta.persistence.sharedCache.mode=UNSPECIFIED
+
+# email credentials
+spring.mail.host=<YOUR MAIL HOST SMTP>
+spring.mail.port=587
+spring.mail.username=<YOUR MAIL ADDRESS>
+spring.mail.password=<YOUR MAIL PASSWORD>
+spring.mail.properties.mail.smtp.auth=true
+spring.mail.properties.mail.smtp.starttls.enable=true
+
+# urls for mails
+email.dashboard-base-url=http://<YOUR FRONTEND DOMAIN>
+email.verification-url=http://<YOUR BACKEND DOMAIN>/api/2/auth/%s/verify.json
+email.reset-url-path=/resetPassword
+
+# secret signing keys
+spring.config.import=security.properties
diff --git a/pse-server/src/main/resources/security.properties b/pse-server/src/main/resources/security.properties
new file mode 100644
index 0000000..f02370b
--- /dev/null
+++ b/pse-server/src/main/resources/security.properties
@@ -0,0 +1,4 @@
+security.jwt-auth-signing-key=<YOUR JWT AUTH KEY>
+security.jwt-url-signing-key=<YOUR JWT URL KEY>
+security.email-signing-key=<YOUR EMAIL KEY>
+
diff --git a/pse-server/src/test/java/org/psesquared/server/BaseTest.java b/pse-server/src/test/java/org/psesquared/server/BaseTest.java
new file mode 100644
index 0000000..0d200e6
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/BaseTest.java
@@ -0,0 +1,163 @@
+package org.psesquared.server;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.File;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.Optional;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.psesquared.server.authentication.api.data.access.AuthenticationDao;
+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.Role;
+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.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+public abstract class BaseTest {
+
+ @Autowired
+ public AuthenticationDao authenticationDao;
+
+ @Autowired
+ public SubscriptionDao subscriptionDao;
+
+ @Autowired
+ public EpisodeDao episodeDao;
+
+ @Autowired
+ public SubscriptionActionDao subscriptionActionDao;
+
+ @Autowired
+ public EpisodeActionDao episodeActionDao;
+
+ @Autowired
+ public RssParser rssParser;
+
+ public static int numberOfUsers = 1;
+ public static int numberOfSubscriptionsPerUser = 2;
+ public static int numberOfEpisodesPerSubscription = 3;
+
+ @BeforeEach
+ @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+ public void setUp() {
+ setUpUsers(numberOfUsers, numberOfSubscriptionsPerUser, numberOfEpisodesPerSubscription);
+ }
+
+ // URL Scheme:
+ // Subscriptions: file:/"your project path"/testfeeds/testPodcast0.xml
+ // Episodes: /testfeeds/testPodcast0/episode0
+ private void setUpUsers(int userCount, int subCount, int epCount) {
+ for (int i = 0; i < userCount; i++) {
+ User user = new User();
+ user.setUsername("testUser" + i);
+ user.setPassword("testPassword123!" + i);
+ user.setEmail(user.getUsername() + "@mail.de");
+ user.setRole(Role.USER);
+
+ // Store user in database
+ authenticationDao.save(user);
+
+ // Check if the user exists in the database
+ Optional<User> savedUser = authenticationDao.findByUsername("testuser" + i);
+ assertNotNull(savedUser);
+
+ setUpSubscriptionsAndEpisodes(subCount, epCount, user);
+ }
+ }
+
+ private void setUpSubscriptionsAndEpisodes(int subCount, int epCount, User user) {
+ for (int i = 0; i < subCount; i++) {
+ Subscription subscription = new Subscription();
+ subscription.setTitle("testPodcast" + i);
+ String url = new File(String.format("testfeeds/testPodcast%d.xml", i)).toURI().toString();
+ subscription.setUrl(url);
+ subscription.setTimestamp(i * 1000000);
+
+ // Save the Subscription in the database
+ subscriptionDao.save(subscription);
+
+ // Check if the Subscription exists in the database
+ Optional<Subscription> savedSubscription = subscriptionDao
+ .findByUrl(url);
+ assertNotNull(savedSubscription);
+
+ // create SubscriptionAction for User and Subscription
+ SubscriptionAction subscriptionAction = new SubscriptionAction();
+ subscriptionAction.setAdded(true);
+ // subscriptionAction.setAdded(i % 2 == 0); (every other Action is marked
+ // as inactive/removed)
+ subscriptionAction.setUser(user);
+ subscriptionAction.setTimestamp(i * 1000000);
+ subscriptionAction.setSubscription(subscription);
+
+ // save SubscriptionAction to the database
+ subscriptionActionDao.save(subscriptionAction);
+
+ // Check if the SubscriptionAction exists in the database
+ Optional<SubscriptionAction> savedSubscriptionAction = subscriptionActionDao.findByUserAndSubscription(user,
+ subscription);
+ assertNotNull(savedSubscriptionAction);
+
+ for (int j = 0; j < epCount; j++) {
+ Episode episode = new Episode();
+ episode.setSubscription(subscription);
+
+ String episodeUrl = String.format("/testfeeds/testPodcast%d/episode%d.mp3", i, j);
+ episode.setUrl(episodeUrl);
+ episode.setTotal((j + 1) * 100);
+ episode.setTitle("testEpisode" + j);
+
+ // save the Episdoe in the database
+ episodeDao.save(episode);
+
+ // Check if the Episode exists in the database
+ Optional<Episode> savedEpisode = episodeDao.findByUrl(episodeUrl);
+ assertNotNull(savedEpisode);
+
+ // create EpisodeAction for User and Episode
+ EpisodeAction episodeAction = new EpisodeAction();
+ episodeAction.setEpisode(episode);
+ episodeAction.setAction(Action.PLAY);
+ episodeAction.setUser(user);
+ episodeAction.setTimestamp(LocalDateTime.ofEpochSecond(j * 1000000,
+ 0,
+ ZoneOffset.UTC));
+ episodeAction.setStarted((j + 1));
+ episodeAction.setPosition((j + 1) * 10);
+
+ // save EpisodeAction in the Database
+ episodeActionDao.save(episodeAction);
+
+ // Check if the EpisodeAction exists in the database
+ Optional<EpisodeAction> savedEpisodeAction = episodeActionDao.findById(episodeAction.getId());
+ assertNotNull(savedEpisodeAction);
+ }
+
+ }
+ }
+
+ @AfterEach
+ public void cleanUp() {
+ authenticationDao.deleteAll();
+ episodeDao.deleteAll();
+ episodeActionDao.deleteAll();
+ subscriptionDao.deleteAll();
+ subscriptionActionDao.deleteAll();
+ }
+
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/ServerApplicationTests.java b/pse-server/src/test/java/org/psesquared/server/ServerApplicationTests.java
new file mode 100644
index 0000000..68a6993
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/ServerApplicationTests.java
@@ -0,0 +1,11 @@
+package org.psesquared.server;
+
+import org.junit.jupiter.api.Test;
+
+class ServerApplicationTests extends BaseTest {
+
+ @Test
+ void contextLoads() {
+ }
+
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/TestAsyncConfig.java b/pse-server/src/test/java/org/psesquared/server/TestAsyncConfig.java
new file mode 100644
index 0000000..6fe9803
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/TestAsyncConfig.java
@@ -0,0 +1,16 @@
+package org.psesquared.server;
+
+import java.util.concurrent.Executor;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.core.task.SyncTaskExecutor;
+
+@TestConfiguration
+public class TestAsyncConfig {
+
+ @Bean
+ public Executor taskExecutor() {
+ return new SyncTaskExecutor();
+ }
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/authentication/api/data/access/AuthenticationDaoTest.java b/pse-server/src/test/java/org/psesquared/server/authentication/api/data/access/AuthenticationDaoTest.java
new file mode 100644
index 0000000..c6325ad
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/authentication/api/data/access/AuthenticationDaoTest.java
@@ -0,0 +1,51 @@
+package org.psesquared.server.authentication.api.data.access;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.model.Role;
+import org.psesquared.server.model.User;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@DataJpaTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+public class AuthenticationDaoTest {
+
+ @Autowired
+ private AuthenticationDao authenticationDao;
+
+ @BeforeEach
+ public void init() {
+ var user = User.builder()
+ .username("username")
+ .email("email")
+ .password("password")
+ .enabled(false)
+ .createdAt(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC))
+ .role(Role.USER)
+ .build();
+ authenticationDao.save(user);
+ }
+
+ @Test
+ public void updateUser() {
+ var user = authenticationDao.findByUsername("username")
+ .orElseThrow();
+ user.setEnabled(true);
+ }
+
+ @AfterEach
+ public void assertUpdated() {
+ var foundUser = authenticationDao.findByUsername("username")
+ .orElseThrow();
+ assertTrue(foundUser.isEnabled());
+ }
+
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/authentication/api/service/AuthenticationServiceTest.java b/pse-server/src/test/java/org/psesquared/server/authentication/api/service/AuthenticationServiceTest.java
new file mode 100644
index 0000000..c8f10b6
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/authentication/api/service/AuthenticationServiceTest.java
@@ -0,0 +1,222 @@
+package org.psesquared.server.authentication.api.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.BaseTest;
+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.config.JwtService;
+import org.psesquared.server.model.Subscription;
+import org.psesquared.server.model.SubscriptionAction;
+import org.psesquared.server.model.User;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+public class AuthenticationServiceTest extends BaseTest {
+
+ private static final String recipient = "pse-squared@outlook.com";
+
+ @Autowired
+ public AuthenticationService authenticationService;
+
+ @Autowired
+ public JwtService jwtService;
+
+ @Autowired
+ public EncryptionService encryptionService;
+
+ @Autowired
+ PasswordEncoder passwordEncoder;
+
+ @Test
+ public void testRegisterUser() {
+ UserInfoRequest newUserInfo = new UserInfoRequest("newUsername", "newUserMail@test.com", "123abcABC!");
+ HttpStatus registrationStatus = authenticationService.registerUser(newUserInfo);
+ assertEquals(HttpStatus.OK, registrationStatus);
+
+ UserInfoRequest wrongEmail = new UserInfoRequest("newUsername", "wrongNewUserMail@test.com", "123abcABC!");
+ HttpStatus wrongEmailStatus = authenticationService.registerUser(wrongEmail);
+ assertEquals(HttpStatus.BAD_REQUEST, wrongEmailStatus);
+
+ UserInfoRequest wrongPassword = new UserInfoRequest("newUsername", "newUserMail@test.com", "wrong123abcABC!");
+ HttpStatus wrongPasswordStatus = authenticationService.registerUser(wrongPassword);
+ assertEquals(HttpStatus.BAD_REQUEST, wrongPasswordStatus);
+
+ UserInfoRequest userInfo = new UserInfoRequest("testUser0", "testUser0@mail.de", "testPassword123!0");
+ HttpStatus status = authenticationService.registerUser(userInfo);
+ assertEquals(HttpStatus.BAD_REQUEST, status);
+ }
+
+ @Test
+ public void testInvalidVerifyRegistration() {
+ HttpStatus status = authenticationService.verifyRegistration("notARegisteredUser", "notAValidToken");
+ assertEquals(HttpStatus.NOT_FOUND, status);
+ status = authenticationService.verifyRegistration("testUser0", "stillNotAValidToken");
+ assertEquals(HttpStatus.UNAUTHORIZED, status);
+ User user = authenticationDao.findByUsername("testUser0").orElseThrow();
+ user.setEnabled(true);
+ authenticationDao.save(user);
+ status = authenticationService.verifyRegistration("testUser0", "stillNotAValidToken");
+ assertEquals(HttpStatus.BAD_REQUEST, status);
+ }
+
+ @Test
+ public void testVerifyRegistration() {
+ User user = authenticationDao.findByUsername("testUser0").orElseThrow();
+ String token = jwtService.generateUrlTokenString(user);
+ HttpStatus status = authenticationService.verifyRegistration("testUser0", token);
+ assertEquals(HttpStatus.OK, status);
+ Assertions.assertTrue(authenticationDao.findByUsername("testUser0").orElseThrow().isEnabled());
+ }
+
+ @Test
+ public void testLogin() {
+ HttpServletResponse response = new MockHttpServletResponse();
+ HttpStatus status = authenticationService.login("notARegisteredUser", response);
+ assertEquals(HttpStatus.NOT_FOUND, status);
+ status = authenticationService.login("testUser0", response);
+ assertEquals(HttpStatus.OK, status);
+ }
+
+ @Test
+ public void testLogout() {
+ HttpServletResponse response = new MockHttpServletResponse();
+ HttpStatus status = authenticationService.logout("notARegisteredUser", response);
+ assertEquals(HttpStatus.NOT_FOUND, status);
+ status = authenticationService.logout("testUser0", response);
+ assertEquals(HttpStatus.OK, status);
+ }
+
+ @Test
+ public void testForgotPassword() {
+ final String email = "testUser0@mail.de";
+ HttpStatus status = authenticationService.forgotPassword(email);
+ assertEquals(HttpStatus.NOT_FOUND, status);
+
+ User user = authenticationDao.findByUsername("testUser0").orElseThrow();
+ user.setEmail(encryptionService.saltAndHashEmail(user.getEmail()));
+ authenticationDao.save(user);
+
+ final String saltedAndHashedEmail = user.getEmail();
+
+ status = authenticationService.forgotPassword(saltedAndHashedEmail);
+ assertEquals(HttpStatus.NOT_FOUND, status);
+ status = authenticationService.forgotPassword(email);
+ assertEquals(HttpStatus.OK, status);
+ }
+
+ @Test
+ public void testResetPassword() {
+ User user = authenticationDao.findByUsername("testUser0").orElseThrow();
+ String token = "";
+ PasswordRequest passwordRequest = new PasswordRequest("");
+ HttpStatus status = authenticationService.resetPassword("notAValidUser", token, passwordRequest);
+ assertEquals(HttpStatus.BAD_REQUEST, status);
+
+ final String password = "abcAbc123!";
+ passwordRequest = new PasswordRequest(password);
+ status = authenticationService.resetPassword("notAValidUser", token, passwordRequest);
+ assertEquals(HttpStatus.NOT_FOUND, status);
+
+ status = authenticationService.resetPassword(user.getUsername(), token, passwordRequest);
+ assertEquals(HttpStatus.UNAUTHORIZED, status);
+
+ token = jwtService.generateUrlTokenString(user);
+ status = authenticationService.resetPassword(user.getUsername(), token, passwordRequest);
+ assertEquals(HttpStatus.OK, status);
+
+ user = authenticationDao.findByUsername("testUser0").orElseThrow();
+ assertTrue(passwordEncoder.matches(password, user.getPassword()));
+ }
+
+ @Test
+ public void testChangePassword() {
+ User user = authenticationDao.findByUsername("testUser0").orElseThrow();
+ ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest("", "");
+ HttpStatus status = authenticationService.changePassword("notAValidUser", changePasswordRequest);
+ assertEquals(HttpStatus.BAD_REQUEST, status);
+
+ final String newPassword = "abcAbc123!";
+ changePasswordRequest = new ChangePasswordRequest("", newPassword);
+ status = authenticationService.changePassword("notAValidUser", changePasswordRequest);
+ assertEquals(HttpStatus.NOT_FOUND, status);
+
+ changePasswordRequest = new ChangePasswordRequest("notTheRightPassword", newPassword);
+ status = authenticationService.changePassword(user.getUsername(), changePasswordRequest);
+ assertEquals(HttpStatus.BAD_REQUEST, status);
+
+ changePasswordRequest = new ChangePasswordRequest(user.getPassword(), newPassword);
+ user.setPassword(passwordEncoder.encode(user.getPassword()));
+ authenticationDao.save(user);
+ status = authenticationService.changePassword(user.getUsername(), changePasswordRequest);
+ assertEquals(HttpStatus.OK, status);
+ }
+
+ @Test
+ public void testDeleteUser() {
+ PasswordRequest passwordRequest = new PasswordRequest("");
+ HttpStatus status = authenticationService.deleteUser("notAValidUser", passwordRequest);
+ assertEquals(HttpStatus.NOT_FOUND, status);
+
+ User user = authenticationDao.findByUsername("testUser0").orElseThrow();
+
+ passwordRequest = new PasswordRequest("notTheRightPassword");
+ status = authenticationService.deleteUser(user.getUsername(), passwordRequest);
+ assertEquals(HttpStatus.BAD_REQUEST, status);
+
+ passwordRequest = new PasswordRequest(user.getPassword());
+ user.setPassword(passwordEncoder.encode(user.getPassword()));
+ authenticationDao.save(user);
+
+ status = authenticationService.deleteUser(user.getUsername(), passwordRequest);
+ assertEquals(HttpStatus.OK, status);
+ }
+
+ @Test
+ public void testCascadeDelete() {
+ subscriptionActionDao.deleteAll();
+ UserInfoRequest userInfo = new UserInfoRequest("username", recipient, "123abcABC!");
+ authenticationService.registerUser(userInfo);
+
+ var user = authenticationDao.findByUsername(userInfo.username())
+ .orElseThrow();
+
+ var sub = Subscription.builder()
+ .url("url")
+ .title("title")
+ .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC))
+ .build();
+ subscriptionDao.save(sub);
+
+ var subAction1 = SubscriptionAction.builder()
+ .user(user)
+ .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC))
+ .subscription(sub)
+ .added(true)
+ .build();
+ subscriptionActionDao.save(subAction1);
+ var subAction2 = SubscriptionAction.builder()
+ .user(user)
+ .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC))
+ .subscription(sub)
+ .added(false)
+ .build();
+ subscriptionActionDao.save(subAction2);
+
+ authenticationService.deleteUser(userInfo.username(), new PasswordRequest(userInfo.password()));
+
+ assertEquals(0L, subscriptionActionDao.count());
+ }
+
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/authentication/api/service/EmailServiceTests.java b/pse-server/src/test/java/org/psesquared/server/authentication/api/service/EmailServiceTests.java
new file mode 100644
index 0000000..ed1061e
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/authentication/api/service/EmailServiceTests.java
@@ -0,0 +1,36 @@
+package org.psesquared.server.authentication.api.service;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.model.User;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+public class EmailServiceTests {
+
+ @Autowired
+ private EmailServiceImpl emailService;
+
+ private static final String recipient = "pse-squared@outlook.com";
+
+ private User user;
+
+ @BeforeEach
+ void beforeEach() {
+ user = User.builder()
+ .username("Jeff")
+ .email(recipient)
+ .build();
+ }
+
+ @Test
+ void sendValidationMail() {
+ emailService.sendVerification(user.getEmail(), user);
+ }
+
+ @Test
+ void sendPasswordResetMail() {
+ emailService.sendPasswordReset(user.getEmail(), user);
+ }
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDaoTests.java b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDaoTests.java
new file mode 100644
index 0000000..3448330
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDaoTests.java
@@ -0,0 +1,20 @@
+package org.psesquared.server.episode.actions.api.data.access;
+
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.BaseTest;
+import org.psesquared.server.model.User;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class EpisodeActionDaoTests extends BaseTest {
+
+ @Test
+ public void deleteUserTest() {
+ String username = "testUser0";
+ User user = authenticationDao.findByUsername(username).orElseThrow();
+ assertEquals(numberOfEpisodesPerSubscription * numberOfSubscriptionsPerUser, episodeActionDao.findByUserUsername(username).size());
+ authenticationDao.delete(user);
+ assertEquals(0, episodeActionDao.findByUserUsername(username).size());
+ }
+
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDaoTests.java b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDaoTests.java
new file mode 100644
index 0000000..6c1d70f
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDaoTests.java
@@ -0,0 +1,29 @@
+package org.psesquared.server.episode.actions.api.data.access;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.File;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.BaseTest;
+import org.psesquared.server.model.Subscription;
+
+public class EpisodeDaoTests extends BaseTest {
+
+ @Test
+ public void deleteCascadeTest() {
+ String username = "testUser0";
+ assertDoesNotThrow(() -> authenticationDao.findByUsername(username).orElseThrow());
+ String subscriptionsUrl = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ Subscription subscription = subscriptionDao.findByUrl(subscriptionsUrl).orElseThrow();
+ assertDoesNotThrow(() -> subscription.getEpisodes());
+ Assertions.assertEquals(numberOfEpisodesPerSubscription,
+ episodeActionDao.findByUserUsernameAndEpisodeSubscriptionUrl(username, subscriptionsUrl).size());
+ episodeDao.deleteAll(subscription.getEpisodes());
+ Assertions.assertEquals(0,
+ episodeActionDao.findByUserUsernameAndEpisodeSubscriptionUrl(username, subscriptionsUrl).size());
+ }
+
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/episode/actions/api/service/EpisodeActionServiceTests.java b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/service/EpisodeActionServiceTests.java
new file mode 100644
index 0000000..75dd110
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/service/EpisodeActionServiceTests.java
@@ -0,0 +1,368 @@
+package org.psesquared.server.episode.actions.api.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.BaseTest;
+import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost;
+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.User;
+import org.springframework.beans.factory.annotation.Autowired;
+
+public class EpisodeActionServiceTests extends BaseTest {
+
+ @Autowired
+ private EpisodeActionService episodeActionService;
+
+ @Test
+ public void addEpisodeActionsTest1() {
+ // episodeActionPosts zu Subscriptions und Episoden hinzufügen, die noch nicht
+ // existieren
+ List<EpisodeActionPost> episodeActionPosts = new ArrayList<>();
+ String username = "testUser0";
+ Assertions.assertEquals(numberOfEpisodesPerSubscription * numberOfSubscriptionsPerUser,
+ episodeActionDao.findByUserUsername(username).size());
+ int numberOfNewEpisodes = 2;
+ for (int i = 0; i < numberOfNewEpisodes; i++) {
+ episodeActionPosts.add(EpisodeActionPost.builder()
+ .podcastUrl(new File("testfeeds/newTestSubscription1.xml").toURI().toString())
+ .episodeUrl(String.format("/testfeeds/newTestSubscription1/episode%d.mp3", i))
+ .title("testEpisode" + i)
+ .guid(UUID.randomUUID().toString())
+ .total(10 * i)
+ .episodeAction(EpisodeAction.builder()
+ .timestamp(LocalDateTime.now())
+ .action(Action.PLAY)
+ .started(i)
+ .position(i + 3)
+ .build())
+ .build());
+ }
+ episodeActionService.addEpisodeActions(username, episodeActionPosts);
+
+ Assertions.assertEquals(numberOfSubscriptionsPerUser * numberOfEpisodesPerSubscription + numberOfNewEpisodes,
+ episodeActionDao.findByUserUsername(username).size());
+ }
+
+ @Test
+ public void addEpisodeActionsTest2() {
+ // Alte EpisodeAction durch neuere Überschreiben
+
+ final String username = "testUser0";
+ final String episodeUrl = "/testfeeds/testPodcast0/episode0.mp3";
+ final User user = authenticationDao.findByUsername(username).orElseThrow();
+
+ EpisodeAction oldEpisodeActionTest = episodeActionDao
+ .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY).orElseThrow();
+ List<EpisodeActionPost> episodeActionPosts = new ArrayList<>();
+ episodeActionPosts.add(EpisodeActionPost.builder()
+ .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl())
+ .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl())
+ .title(oldEpisodeActionTest.getEpisode().getTitle())
+ .guid(oldEpisodeActionTest.getEpisode().getGuid())
+ .total(oldEpisodeActionTest.getEpisode().getTotal())
+ .episodeAction(EpisodeAction.builder()
+ .timestamp(LocalDateTime.ofEpochSecond(
+ oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 1,
+ 0,
+ ZoneOffset.UTC))
+ .action(Action.PLAY)
+ .started(oldEpisodeActionTest.getStarted())
+ .position(oldEpisodeActionTest.getPosition())
+ .build())
+ .build());
+
+ LocalDateTime oldTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY)
+ .orElseThrow().getTimestamp();
+ episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts);
+ LocalDateTime newTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY)
+ .orElseThrow().getTimestamp();
+ assertNotEquals(oldTimestamp, newTimestamp);
+ }
+
+ @Test
+ public void addEpisodeActionTest3() {
+ // EpisodeActions nicht nach Timestamp sortiert
+ // Überschreibe EpisodeAction durch neueste Action
+
+ final String username = "testUser0";
+ final String episodeUrl = "/testfeeds/testPodcast0/episode0.mp3";
+ final User user = authenticationDao.findByUsername(username).orElseThrow();
+
+ EpisodeAction oldEpisodeActionTest = episodeActionDao
+ .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY).orElseThrow();
+ List<EpisodeActionPost> episodeActionPosts = new ArrayList<>();
+ episodeActionPosts.add(EpisodeActionPost.builder()
+ .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl())
+ .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl())
+ .title(oldEpisodeActionTest.getEpisode().getTitle())
+ .guid(oldEpisodeActionTest.getEpisode().getGuid())
+ .total(oldEpisodeActionTest.getEpisode().getTotal())
+ .episodeAction(EpisodeAction.builder()
+ .timestamp(LocalDateTime.ofEpochSecond(
+ oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 2,
+ 0,
+ ZoneOffset.UTC))
+ .action(Action.PLAY)
+ .started(oldEpisodeActionTest.getStarted())
+ .position(oldEpisodeActionTest.getPosition())
+ .build())
+ .build());
+
+ episodeActionPosts.add(EpisodeActionPost.builder()
+ .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl())
+ .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl())
+ .title(oldEpisodeActionTest.getEpisode().getTitle())
+ .guid(oldEpisodeActionTest.getEpisode().getGuid())
+ .total(oldEpisodeActionTest.getEpisode().getTotal())
+ .episodeAction(EpisodeAction.builder()
+ .timestamp(LocalDateTime.ofEpochSecond(
+ oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 1,
+ 0,
+ ZoneOffset.UTC))
+ .action(Action.PLAY)
+ .started(oldEpisodeActionTest.getStarted())
+ .position(oldEpisodeActionTest.getPosition())
+ .build())
+ .build());
+
+ episodeActionPosts.add(EpisodeActionPost.builder()
+ .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl())
+ .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl())
+ .title(oldEpisodeActionTest.getEpisode().getTitle())
+ .guid(oldEpisodeActionTest.getEpisode().getGuid())
+ .total(oldEpisodeActionTest.getEpisode().getTotal())
+ .episodeAction(EpisodeAction.builder()
+ .timestamp(LocalDateTime.ofEpochSecond(
+ oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 3,
+ 0,
+ ZoneOffset.UTC))
+ .action(Action.PLAY)
+ .started(oldEpisodeActionTest.getStarted())
+ .position(oldEpisodeActionTest.getPosition())
+ .build())
+ .build());
+
+ LocalDateTime oldTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY)
+ .orElseThrow().getTimestamp();
+ episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts);
+ LocalDateTime newTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY)
+ .orElseThrow().getTimestamp();
+ assertTrue(newTimestamp.isAfter(oldTimestamp));
+ assertEquals(3, (int) ChronoUnit.SECONDS.between(oldTimestamp, newTimestamp));
+ }
+
+ @Test
+ public void addEpisodeActionsTest4() {
+ // Ignoriere Episode Action, die nicht PLAY als Action hat
+
+ final String username = "testUser0";
+ final String episodeUrl = "/testfeeds/testPodcast0/episode0.mp3";
+ final User user = authenticationDao.findByUsername(username).orElseThrow();
+
+ EpisodeAction oldEpisodeActionTest = episodeActionDao
+ .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY).orElseThrow();
+ List<EpisodeActionPost> episodeActionPosts = new ArrayList<>();
+ episodeActionPosts.add(EpisodeActionPost.builder()
+ .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl())
+ .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl())
+ .title(oldEpisodeActionTest.getEpisode().getTitle())
+ .guid(oldEpisodeActionTest.getEpisode().getGuid())
+ .total(oldEpisodeActionTest.getEpisode().getTotal())
+ .episodeAction(EpisodeAction.builder()
+ .timestamp(LocalDateTime.ofEpochSecond(
+ oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 1,
+ 0,
+ ZoneOffset.UTC))
+ .action(Action.DOWNLOAD)
+ .started(oldEpisodeActionTest.getStarted())
+ .position(oldEpisodeActionTest.getPosition())
+ .build())
+ .build());
+
+ LocalDateTime oldTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY)
+ .orElseThrow().getTimestamp();
+ episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts);
+ LocalDateTime newTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY)
+ .orElseThrow().getTimestamp();
+ assertEquals(oldTimestamp, newTimestamp);
+ }
+
+ @Test
+ public void addEpisodeActionsTest5() {
+ // Episode in Datenbank ohne GUID, EpisodeAction mit selbem Link mit GUID
+
+ final String username = "testUser0";
+ final String podcastName = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ final String episodeBaseUrl = "/testfeeds/testPodcast0/episode";
+ final User user = authenticationDao.findByUsername(username).orElseThrow();
+
+ final int episodeIndex = numberOfEpisodesPerSubscription;
+
+ // Setup - Episode ohne GUID einfügen
+ Optional<Subscription> savedSubscription = subscriptionDao.findByUrl(podcastName);
+ assertTrue(savedSubscription.isPresent());
+
+ final String episodeUrl = episodeBaseUrl + episodeIndex + ".mp3";
+
+ Episode newEpisode = new Episode();
+ newEpisode.setSubscription(savedSubscription.get());
+ newEpisode.setUrl(episodeUrl);
+ newEpisode.setTotal((episodeIndex + 1) * 100);
+ newEpisode.setTitle("testEpisode" + episodeIndex);
+
+ episodeDao.save(newEpisode);
+ Optional<Episode> savedEpisode = episodeDao.findByUrl(episodeUrl);
+ assertTrue(savedEpisode.isPresent());
+ assertNull(savedEpisode.get().getGuid());
+
+ EpisodeAction episodeAction = new EpisodeAction();
+ episodeAction.setEpisode(newEpisode);
+ episodeAction.setAction(Action.PLAY);
+ episodeAction.setUser(user);
+ episodeAction.setTimestamp(LocalDateTime.ofEpochSecond(
+ LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + (long) episodeIndex * 1000000,
+ 0,
+ ZoneOffset.UTC));
+ episodeAction.setStarted((episodeIndex + 1));
+ episodeAction.setPosition((episodeIndex + 1) * 10);
+
+ episodeActionDao.save(episodeAction);
+ Optional<EpisodeAction> savedEpisodeAction = episodeActionDao.findById(episodeAction.getId());
+ assertTrue(savedEpisodeAction.isPresent());
+
+ EpisodeAction oldEpisodeActionTest = episodeActionDao
+ .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY).orElseThrow();
+ List<EpisodeActionPost> episodeActionPosts = new ArrayList<>();
+ episodeActionPosts.add(EpisodeActionPost.builder()
+ .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl())
+ .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl())
+ .title(oldEpisodeActionTest.getEpisode().getTitle())
+ .guid("Alphabet")
+ .total(oldEpisodeActionTest.getEpisode().getTotal())
+ .episodeAction(EpisodeAction.builder()
+ .timestamp(LocalDateTime.ofEpochSecond(
+ oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 1,
+ 0,
+ ZoneOffset.UTC))
+ .action(Action.PLAY)
+ .started(oldEpisodeActionTest.getStarted())
+ .position(oldEpisodeActionTest.getPosition())
+ .build())
+ .build());
+
+ LocalDateTime oldTimestamp = episodeActionDao
+ .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY)
+ .orElseThrow().getTimestamp();
+ episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts);
+ LocalDateTime newTimestamp = episodeActionDao
+ .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY)
+ .orElseThrow().getTimestamp();
+ assertNotEquals(oldTimestamp, newTimestamp);
+ savedEpisode = episodeDao.findByUrl(episodeUrl);
+ assertTrue(savedEpisode.isPresent());
+ assertEquals("Alphabet", savedEpisode.get().getGuid());
+ }
+
+ @Test
+ public void addEpisodeActionsTest6() {
+ // Episode nicht in Datenbank, EpisodeAction mit neuer Episode
+
+ final String username = "testUser0";
+ final String podcastName = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ final String episodeBaseUrl = "/testfeeds/testPodcast0/episode";
+ final User user = authenticationDao.findByUsername(username).orElseThrow();
+
+ final int episodeIndex = numberOfEpisodesPerSubscription;
+
+ // Setup - Episode ohne GUID einfügen
+ Optional<Subscription> savedSubscription = subscriptionDao.findByUrl(podcastName);
+ assertTrue(savedSubscription.isPresent());
+
+ final String episodeUrl = episodeBaseUrl + episodeIndex + ".mp3";
+
+ Optional<Episode> noEpisode = episodeDao.findByUrl(episodeUrl);
+ assertFalse(noEpisode.isPresent());
+
+ List<EpisodeActionPost> episodeActionPosts = new ArrayList<>();
+ episodeActionPosts.add(EpisodeActionPost.builder()
+ .podcastUrl(podcastName)
+ .episodeUrl(episodeUrl)
+ .title("testEpisode" + episodeIndex)
+ .guid("Alphabet")
+ .total((episodeIndex + 1) * 100)
+ .episodeAction(EpisodeAction.builder()
+ .timestamp(LocalDateTime.ofEpochSecond(
+ LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + (long) episodeIndex * 1000000,
+ 0,
+ ZoneOffset.UTC))
+ .action(Action.PLAY)
+ .started((episodeIndex + 1))
+ .position((episodeIndex + 1) * 10)
+ .build())
+ .build());
+ episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts);
+ noEpisode = episodeDao.findByUrl(episodeUrl);
+ assertTrue(noEpisode.isPresent());
+ }
+
+ @Test
+ public void getEpisodeActionsTest() {
+ String username = "testUser0";
+ List<EpisodeActionPost> episodeActionPosts = episodeActionService.getEpisodeActions(username);
+ assertEquals(numberOfSubscriptionsPerUser * numberOfEpisodesPerSubscription, episodeActionPosts.size());
+ }
+
+ @Test
+ public void getEpisodeActionsOfPodcastTest() {
+ String username = "testUser0";
+ String subscriptionUrl = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ List<EpisodeActionPost> episodeActionPosts = episodeActionService.getEpisodeActionsOfPodcast(username,
+ subscriptionUrl);
+ assertEquals(numberOfEpisodesPerSubscription, episodeActionPosts.size());
+ }
+
+ @Test
+ public void getEpisodeActionsSinceTest() {
+ String username = "testUser0";
+ // Jede Episode in einer Subscription hat einen Timestamp von zusätzlichen
+ // 1000000 zur Zeit 0
+ int factor = 2;
+ int timeDifference = 1000000;
+ int numberOfEpisodes = numberOfSubscriptionsPerUser * numberOfEpisodesPerSubscription;
+ long since = factor * timeDifference;
+ List<EpisodeActionPost> episodeActionPosts = episodeActionService.getEpisodeActionsSince(username, since);
+ assertEquals(numberOfEpisodes - factor * numberOfSubscriptionsPerUser, episodeActionPosts.size());
+ }
+
+ @Test
+ public void getEpisodeActionsOfPodcastSinceTest() {
+ String username = "testUser0";
+ String subscriptionUrl = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ // Jede EpisodeAction in einer Subscription hat einen Timestamp von zusätzlichen
+ // 1000000 zur Zeit 0
+ int factor = 1;
+ int timeDifference = 1000000;
+ long since = factor * timeDifference;
+ List<EpisodeActionPost> episodeActionPosts = episodeActionService.getEpisodeActionsOfPodcastSince(username,
+ subscriptionUrl, since);
+ assertEquals(numberOfEpisodesPerSubscription - factor, episodeActionPosts.size());
+ }
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDaoTests.java b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDaoTests.java
new file mode 100644
index 0000000..f296a72
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDaoTests.java
@@ -0,0 +1,89 @@
+package org.psesquared.server.subscriptions.api.data.access;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.List;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.BaseTest;
+import org.psesquared.server.model.Subscription;
+import org.psesquared.server.model.SubscriptionAction;
+import org.psesquared.server.model.User;
+
+public class SubscriptionActionDaoTests extends BaseTest {
+
+ @Test
+ public void existsByUserAndSubscriptionTest() {
+ String username = "testUser0";
+ String subscriptionUrl = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ User user = authenticationDao.findByUsername(username).orElseThrow();
+ Subscription subscription = subscriptionDao.findByUrl(subscriptionUrl).orElseThrow();
+ boolean exists = subscriptionActionDao.existsByUserAndSubscription(user, subscription);
+ assertTrue(exists);
+ }
+
+ @Test
+ public void findByUserAndSubscriptionTest() {
+ String username = "testUser0";
+ String subscriptionUrl = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ User user = authenticationDao.findByUsername(username).orElseThrow();
+ Subscription subscription = subscriptionDao.findByUrl(subscriptionUrl).orElseThrow();
+ SubscriptionAction subscriptionAction = subscriptionActionDao.findByUserAndSubscription(user, subscription)
+ .orElseThrow();
+ assertEquals(subscriptionAction.getSubscription().getUrl(), subscriptionUrl);
+ }
+
+ @Test
+ public void findByUserUsernameAndTimestampGreaterThanEqualTest() {
+ String username = "testUser0";
+ // Jede SubscriptionAction eines Nutzers hat einen Timestamp von zusätzlichen
+ // 1000000 zur Zeit 0
+ int factor = 1;
+ int timeDifference = 1000000;
+ long since = factor * timeDifference;
+ List<SubscriptionAction> subscriptionActions = subscriptionActionDao
+ .findByUserUsernameAndTimestampGreaterThanEqual(username, since);
+ assertEquals(numberOfSubscriptionsPerUser - factor, subscriptionActions.size());
+ }
+
+ @Test
+ public void findByUserUsernameAndAddedTrueTest() {
+ String username = "testUser0";
+ List<SubscriptionAction> subscriptionActions = subscriptionActionDao.findByUserUsernameAndAddedTrue(username);
+ assertEquals(subscriptionActions.size(), numberOfSubscriptionsPerUser);
+ }
+
+ @Test
+ public void findByUserUsernameAndAddedTrueAndTimestampGreaterThanEqualTest() {
+ String username = "testUser0";
+ // Jede SubscriptionAction eines Nutzers hat einen Timestamp von zusätzlichen
+ // 1000000 zur Zeit 0
+ // int puffer = 1000;
+ int factor = 1;
+ int timeDifference = 1000000;
+ long since = factor * timeDifference;
+ List<SubscriptionAction> subscriptionActions = subscriptionActionDao
+ .findByUserUsernameAndTimestampGreaterThanEqual(username, since);
+ assertEquals(numberOfSubscriptionsPerUser - factor, subscriptionActions.size());
+ // Eine EpisodeAction der gefundenen EpisodeActions auf added=false setzen
+ subscriptionActions.get(0).setAdded(false);
+ subscriptionActionDao.save(subscriptionActions.get(0));
+ List<SubscriptionAction> subscriptionActions2 = subscriptionActionDao
+ .findByUserUsernameAndAddedTrueAndTimestampGreaterThanEqual(username, since);
+ assertEquals(numberOfSubscriptionsPerUser - factor - 1, subscriptionActions2.size());
+ }
+
+ @Test
+ public void deleteUserTest() {
+ String username = "testUser0";
+ User user = authenticationDao.findByUsername(username).orElseThrow();
+ Assertions.assertEquals(numberOfSubscriptionsPerUser,
+ subscriptionActionDao.findByUserUsernameAndAddedTrue(username).size());
+ authenticationDao.delete(user);
+ Assertions.assertEquals(0, subscriptionActionDao.findByUserUsernameAndAddedTrue(username).size());
+ }
+
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDaoTests.java b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDaoTests.java
new file mode 100644
index 0000000..3ae0089
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDaoTests.java
@@ -0,0 +1,36 @@
+package org.psesquared.server.subscriptions.api.data.access;
+
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.BaseTest;
+import org.psesquared.server.model.Subscription;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.File;
+
+public class SubscriptionDaoTests extends BaseTest {
+
+ @Test
+ public void findByUrlTest() {
+ String url = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ Subscription subscription = subscriptionDao.findByUrl(url).orElseThrow();
+ assertEquals(subscription.getUrl(), url);
+ }
+
+ @Test
+ public void existsByUrlTest() {
+ String url = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ boolean exists = subscriptionDao.existsByUrl(url);
+ assertTrue(exists);
+ boolean notExists = subscriptionDao.existsByUrl("blablabla");
+ assertFalse(notExists);
+ }
+
+ @Test
+ public void testCascadeDelete() {
+ String url = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ Subscription subscription = subscriptionDao.findByUrl(url).orElseThrow();
+ assertDoesNotThrow(() -> subscriptionDao.delete(subscription));
+ }
+
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/subscriptions/api/service/SubscriptionServiceTests.java b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/service/SubscriptionServiceTests.java
new file mode 100644
index 0000000..3345185
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/service/SubscriptionServiceTests.java
@@ -0,0 +1,128 @@
+package org.psesquared.server.subscriptions.api.service;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.BaseTest;
+import org.psesquared.server.model.Subscription;
+import org.psesquared.server.model.SubscriptionAction;
+import org.psesquared.server.subscriptions.api.controller.SubscriptionDelta;
+import org.psesquared.server.subscriptions.api.controller.SubscriptionTitles;
+import org.springframework.beans.factory.annotation.Autowired;
+
+public class SubscriptionServiceTests extends BaseTest {
+
+ @Autowired
+ SubscriptionService subscriptionService;
+
+ @Test
+ public void uploadSubscriptionsTest() {
+ String username = "testUser0";
+ assertDoesNotThrow(() -> authenticationDao.findByUsername(username).orElseThrow());
+ String newSubscriptionUrl1 = new File("testfeeds/newTestSubscription1.xml").toURI().toString();
+ String newSubscriptionUrl2 = new File("testfeeds/newTestSubscription2.xml").toURI().toString();
+ List<String> subscriptionStrings = List.of(newSubscriptionUrl1, newSubscriptionUrl2);
+
+ subscriptionService.uploadSubscriptions(username, subscriptionStrings);
+ List<SubscriptionAction> subscriptionActions = subscriptionActionDao.findByUserUsernameAndAddedTrue(username);
+ int size = subscriptionActions.size();
+ assertEquals(numberOfSubscriptionsPerUser + 2, size);
+
+ subscriptionActions.get(0).setAdded(false);
+ subscriptionActionDao.save(subscriptionActions.get(0));
+ subscriptionActions = subscriptionActionDao.findByUserUsernameAndAddedTrue(username);
+ size = subscriptionActions.size();
+ assertEquals(numberOfSubscriptionsPerUser + 1, size);
+
+ String testPodcast0Url = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ List<String> subscriptionString = List.of(testPodcast0Url);
+ subscriptionService.uploadSubscriptions(username, subscriptionString);
+ subscriptionActions = subscriptionActionDao.findByUserUsernameAndAddedTrue(username);
+ size = subscriptionActions.size();
+ assertEquals(numberOfSubscriptionsPerUser + 2, size);
+ }
+
+ @Test
+ public void getSubscriptionsTest() {
+ String username = "testUser0";
+ List<String> subscriptionStrings = subscriptionService.getSubscriptions(username);
+ assertEquals(subscriptionStrings.size(), numberOfSubscriptionsPerUser);
+ }
+
+ @Test
+ public void applySubscriptionDeltaTest() {
+ String username = "testUser0";
+ String subscriptionString = new File("testfeeds/testPodcast0.xml").toURI().toString();;
+ // Überprüfen, ob Anzahl der Subscriptions des Users mit der definierten Anzahl
+ // übereinstimmt
+ assertEquals(subscriptionService.getSubscriptions(username).size(), numberOfSubscriptionsPerUser);
+ // Subscription mithilfe des Subscription-Deltas entfernen
+ subscriptionService.applySubscriptionDelta(username,
+ new SubscriptionDelta(List.of(), List.of(subscriptionString)));
+ assertEquals(subscriptionService.getSubscriptions(username).size(), numberOfSubscriptionsPerUser - 1);
+ // Subscription mithilfe des Subscription-Deltas wieder hinzufügen
+ subscriptionService.applySubscriptionDelta(username,
+ new SubscriptionDelta(List.of(subscriptionString), List.of()));
+ assertEquals(subscriptionService.getSubscriptions(username).size(), numberOfSubscriptionsPerUser);
+ }
+
+ @Test
+ public void getSubscriptionDeltaTest() {
+ String username = "testUser0";
+ String subscriptionString = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ long since = 0;
+ // Überprüfen, ob Anzahl der Subscriptions des Users mit der definierten Anzahl
+ // übereinstimmt
+ SubscriptionDelta subscriptionDelta = subscriptionService.getSubscriptionDelta(username, since);
+ assertEquals(subscriptionDelta.getAdd().size(), numberOfSubscriptionsPerUser);
+ assertEquals(0, subscriptionDelta.getRemove().size());
+ // Subscription mithilfe des Subscription-Deltas entfernen
+ subscriptionService.applySubscriptionDelta(username,
+ new SubscriptionDelta(List.of(), List.of(subscriptionString)));
+ subscriptionDelta = subscriptionService.getSubscriptionDelta(username, since);
+ assertEquals(subscriptionDelta.getAdd().size(), numberOfSubscriptionsPerUser - 1);
+ assertEquals(1, subscriptionDelta.getRemove().size());
+ // Subscription mithilfe des Subscription-Deltas wieder hinzufügen
+ subscriptionService.applySubscriptionDelta(username,
+ new SubscriptionDelta(List.of(subscriptionString), List.of()));
+
+ subscriptionDelta = subscriptionService.getSubscriptionDelta(username, since);
+ List<SubscriptionAction> subscriptionActions = subscriptionActionDao
+ .findByUserUsernameAndTimestampGreaterThanEqual(username, since);
+ LocalDateTime deltaTime = LocalDateTime
+ .ofEpochSecond(subscriptionDelta.getTimestamp(), 0, ZoneOffset.UTC);
+ LocalDateTime maxTime = deltaTime;
+ for (SubscriptionAction subscriptionAction : subscriptionActions) {
+
+ LocalDateTime subTime = LocalDateTime
+ .ofEpochSecond(subscriptionAction.getTimestamp(), 0, ZoneOffset.UTC);
+ if (maxTime.isBefore(subTime)) {
+ maxTime = subTime;
+ }
+ }
+ assertEquals(deltaTime, maxTime);
+ assertEquals(subscriptionDelta.getAdd().size(), numberOfSubscriptionsPerUser);
+ assertEquals(0, subscriptionDelta.getRemove().size());
+ }
+
+ @Test
+ public void getTitlesTest() {
+ String username = "testUser0";
+ List<SubscriptionTitles> subscriptionTitlesList = subscriptionService.getTitles(username);
+ assertEquals(numberOfSubscriptionsPerUser, subscriptionTitlesList.size());
+ for (SubscriptionTitles subscriptionTitles : subscriptionTitlesList) {
+ assertEquals(numberOfEpisodesPerSubscription, subscriptionTitles.episodes().size());
+ Optional<Subscription> savedSubscription =
+ subscriptionDao.findByUrl(subscriptionTitles.subscription().getUrl());
+ assertTrue(savedSubscription.isPresent());
+ }
+ }
+}
diff --git a/pse-server/src/test/java/org/psesquared/server/util/RssParserTests.java b/pse-server/src/test/java/org/psesquared/server/util/RssParserTests.java
new file mode 100644
index 0000000..73c5497
--- /dev/null
+++ b/pse-server/src/test/java/org/psesquared/server/util/RssParserTests.java
@@ -0,0 +1,135 @@
+package org.psesquared.server.util;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+
+import org.junit.jupiter.api.Test;
+import org.psesquared.server.BaseTest;
+import org.psesquared.server.TestAsyncConfig;
+import org.psesquared.server.model.Episode;
+import org.psesquared.server.model.Subscription;
+import org.springframework.test.context.ContextConfiguration;
+
+// Disable async behavior for this Test Class
+@ContextConfiguration(classes = TestAsyncConfig.class)
+public class RssParserTests extends BaseTest {
+
+ private static final String TAGESSCHAU_URL = "https://www.tagesschau.de/multimedia/podcasts/mal-angenommen-feed-101.xml";
+ private final Subscription tagesschauPodcast = Subscription.builder()
+ .url(TAGESSCHAU_URL)
+ .build();
+ private final String relativePathToTestFeeds = "./testfeeds/";
+
+ @Test
+ public void testSubscriptionInvalid() {
+ Subscription nullSubscription = null;
+ Subscription nullUrlSubscription = Subscription.builder().build();
+ Subscription invalidUrlSubscription = Subscription.builder()
+ .url("Not a url")
+ .title("Some inventive Title")
+ .build();
+
+ assertDoesNotThrow(() -> rssParser.validate(nullSubscription));
+ assertDoesNotThrow(() -> rssParser.validate(nullUrlSubscription));
+ assertDoesNotThrow(() -> rssParser.validate(invalidUrlSubscription));
+ assertFalse(subscriptionDao.findByUrl(invalidUrlSubscription.getUrl()).isPresent());
+ }
+
+ // Does currently only work in debugger by making sure the Parser has time to
+ // validate, before the Test evaluates the Assertions.
+ @Test
+ public void testValidSubscription() {
+ final String expectedURL = "https://media.tagesschau.de/audio/2023/0125/AU-20230125-1854-5200.hi.mp3";
+ final String expectedGuid = "tagesschau-podcast-mal-angenommen-tierrechte-101";
+ final String expectedTitle = "Gleiche Rechte für Tiere? Was dann?";
+ final int expectedTotal = 1442;
+ Episode expectedEpisode = Episode.builder().url(expectedURL).guid(expectedGuid).title(expectedTitle)
+ .total(expectedTotal).subscription(tagesschauPodcast).build();
+ Episode testEpisode = Episode.builder().url(expectedURL).id(expectedEpisode.getId()).build();
+ tagesschauPodcast.addEpisode(testEpisode);
+
+ assertDoesNotThrow(() -> rssParser.validate(tagesschauPodcast));
+ assertTrue(subscriptionDao.findByUrl(TAGESSCHAU_URL).isPresent());
+ }
+
+ @Test
+ public void currentDirTest() {
+ //System.out.println(System.getProperty("user.dir"));
+ final String pathToTestFile = relativePathToTestFeeds + "dirtest.txt";
+ File testFile = new File(pathToTestFile);
+ assertTrue(testFile.exists() && !testFile.isDirectory());
+ }
+
+ public void testByteHamsterEdgeCasePodcast() {
+ final String subscriptionURL = "https://tools.bytehamster.com/podcast/rss.xml";
+ final String firstEpisodeURL = "http://tools.bytehamster.com/podcast/piano.mp3?1.mp3";
+ final String lastEpisodeURL = "http://tools.bytehamster.com/podcast/piano.mp3?13.mp3";
+
+ Subscription subscription = Subscription.builder().url(subscriptionURL).build();
+ Episode firstEpisode = Episode.builder().url(firstEpisodeURL)
+ .subscription(subscription).build();
+ subscription.addEpisode(firstEpisode);
+
+ Episode lastEpisode = Episode.builder().url(lastEpisodeURL)
+ .subscription(subscription).build();
+ subscription.addEpisode(lastEpisode);
+ subscriptionDao.save(subscription);
+ episodeDao.save(firstEpisode);
+ episodeDao.save(lastEpisode);
+
+ // Feed contains an Episode that does not meet minimum requirements, so
+ // Subscription and its Episodes should be deleted
+ assertDoesNotThrow(() -> rssParser.validate(subscription));
+ assertFalse(subscriptionDao.findByUrl(subscriptionURL).isPresent());
+ assertFalse(episodeDao.findByUrl(firstEpisodeURL).isPresent());
+ assertFalse(episodeDao.findByUrl(lastEpisodeURL).isPresent());
+ }
+
+ @Test
+ public void testDeletionOfEpisodeNotInFeed() {
+ final String subscriptionURL = "https://tools.bytehamster.com/podcast/alwaysNew.php";
+ final String notIncludedURL = "http://tools.bytehamster.com/podcast/piano.mp3?2023-03-15-21:53:21.mp3";
+
+ Subscription subscription = Subscription.builder().url(subscriptionURL).build();
+ Episode episodeToDelete = Episode.builder().url(notIncludedURL)
+ .subscription(subscription).build();
+ subscription.addEpisode(episodeToDelete);
+ subscriptionDao.save(subscription);
+ episodeDao.save(episodeToDelete);
+
+ // Feed does not contain the Episode, so the Episode should be deleted, but the
+ // Subscription remains
+ assertDoesNotThrow(() -> rssParser.validate(subscription));
+ assertTrue(subscriptionDao.findByUrl(subscriptionURL).isPresent());
+ assertFalse(episodeDao.findByUrl(notIncludedURL).isPresent());
+ }
+
+ @Test
+ public void testValidateBaseTestFeed() {
+ final String testPodcastUrl = new File("testfeeds/testPodcast0.xml").toURI().toString();
+ Subscription subscription = subscriptionDao.findByUrl(testPodcastUrl).orElseThrow();
+ assertDoesNotThrow(() -> rssParser.validate(subscription));
+ assertNotNull(subscriptionDao.findByUrl(testPodcastUrl));
+ }
+
+ @Test
+ public void testTimeParsing() {
+ final String subscriptionUrl = new File("testfeeds/timeTestPodcast.xml").toURI().toString();
+ Subscription subscription = subscriptionDao.save(Subscription.builder().url(subscriptionUrl).build());
+ assertDoesNotThrow(() -> rssParser.validate(subscription));
+ assertFalse(subscriptionDao.findByUrl(subscriptionUrl).isPresent());
+ }
+
+ @Test
+ public void doubleEnclosureTest() {
+ final String subscriptionUrl = new File("testfeeds/multipleEnclosuresFeed.xml").toURI().toString();
+ Subscription subscription = subscriptionDao.save(Subscription.builder().url(subscriptionUrl).build());
+ assertDoesNotThrow(() -> rssParser.validate(subscription));
+ assertFalse(subscriptionDao.findByUrl(subscriptionUrl).isPresent());
+ }
+
+}
diff --git a/pse-server/testfeeds/dirtest.txt b/pse-server/testfeeds/dirtest.txt
new file mode 100644
index 0000000..28c9395
--- /dev/null
+++ b/pse-server/testfeeds/dirtest.txt
@@ -0,0 +1 @@
+Lol \ No newline at end of file
diff --git a/pse-server/testfeeds/multipleEnclosuresFeed.xml b/pse-server/testfeeds/multipleEnclosuresFeed.xml
new file mode 100644
index 0000000..52a6630
--- /dev/null
+++ b/pse-server/testfeeds/multipleEnclosuresFeed.xml
@@ -0,0 +1,17 @@
+<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
+ xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
+ <channel>
+ <title>doubleEnclosurePodcast</title>
+ <item>
+ <title>doubleEnclosureEpisode</title>
+ <itunes:duration>00:03:20</itunes:duration>
+ <enclosure
+ url="/testfeeds/doubleEnclosurePodcast/enclosure1.mp3"
+ type="audio/mp3" />
+ <enclosure
+ url="/testfeeds/doubleEnclosurePodcast/enclosure2.mp3"
+ type="audio/mp3" />
+ </item>
+ </channel>
+</rss> \ No newline at end of file
diff --git a/pse-server/testfeeds/newTestSubscription1.xml b/pse-server/testfeeds/newTestSubscription1.xml
new file mode 100644
index 0000000..a6ab832
--- /dev/null
+++ b/pse-server/testfeeds/newTestSubscription1.xml
@@ -0,0 +1,28 @@
+<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
+ xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
+ <channel>
+ <title>newTestSubscription1</title>
+ <item>
+ <title>newTestEpisode0</title>
+ <itunes:duration>00:01:40</itunes:duration>
+ <enclosure
+ url="/testfeeds/newTestSubscription1/episode0.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>newTestEpisode1</title>
+ <itunes:duration>00:03:20</itunes:duration>
+ <enclosure
+ url="/testfeeds/newTestSubscription1/episode1.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>newTestEpisode2</title>
+ <itunes:duration>00:05:00</itunes:duration>
+ <enclosure
+ url="/testfeeds/newTestSubscription1/episode2.mp3"
+ type="audio/mp3" />
+ </item>
+ </channel>
+</rss> \ No newline at end of file
diff --git a/pse-server/testfeeds/newTestSubscription2.xml b/pse-server/testfeeds/newTestSubscription2.xml
new file mode 100644
index 0000000..a014e5e
--- /dev/null
+++ b/pse-server/testfeeds/newTestSubscription2.xml
@@ -0,0 +1,28 @@
+<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
+ xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
+ <channel>
+ <title>newTestSubscription2</title>
+ <item>
+ <title>newTestEpisode0</title>
+ <itunes:duration>00:01:40</itunes:duration>
+ <enclosure
+ url="/testfeeds/newTestSubscription2/episode0.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>newTestEpisode1</title>
+ <itunes:duration>00:03:20</itunes:duration>
+ <enclosure
+ url="/testfeeds/newTestSubscription2/episode1.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>newTestEpisode2</title>
+ <itunes:duration>00:05:00</itunes:duration>
+ <enclosure
+ url="/testfeeds/newTestSubscription2/episode2.mp3"
+ type="audio/mp3" />
+ </item>
+ </channel>
+</rss> \ No newline at end of file
diff --git a/pse-server/testfeeds/template.txt b/pse-server/testfeeds/template.txt
new file mode 100644
index 0000000..1b4a8eb
--- /dev/null
+++ b/pse-server/testfeeds/template.txt
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0"
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
+ <channel>
+ <title>Dafna's Zebra Podcast</title>
+ <itunes:owner>
+ <itunes:email>dafna@example.com</itunes:email>
+ </itunes:owner>
+ <itunes:author>Dafna</itunes:author>
+ <description>A pet-owner's guide to the popular striped equine.</description>
+ <itunes:image href="https://www.example.com/podcasts/dafnas-zebras/img/dafna-zebra-pod-logo.jpg"/>
+ <language>en-us</language>
+ <link>https://www.example.com/podcasts/dafnas-zebras/</link>
+ <item>
+ <title>Top 10 myths about caring for a zebra</title>
+ <description>Here are the top 10 misunderstandings about the care, feeding, and breeding of these lovable striped animals.</description>
+ <pubDate>Tue, 14 Mar 2017 12:00:00 GMT</pubDate>
+ <enclosure url="https://www.example.com/podcasts/dafnas-zebras/audio/toptenmyths.mp3"
+ type="audio/mpeg" length="34216300"/>
+ <itunes:duration>30:00</itunes:duration>
+ <guid isPermaLink="false">dzpodtop10</guid>
+ </item>
+ <item>
+ <title>Keeping those stripes neat and clean</title>
+ <description>Keeping your zebra clean is time consuming, but worth the effort.</description>
+ <pubDate>Fri, 24 Feb 2017 12:00:00 GMT</pubDate>
+ <enclosure url="https://www.example.com/podcasts/dafnas-zebras/audio/cleanstripes.mp3"
+ type="audio/mpeg" length="26004388"/>
+ <itunes:duration>22:48</itunes:duration>
+ <guid>dzpodclean</guid>
+ </item>
+ </channel>
+</rss> \ No newline at end of file
diff --git a/pse-server/testfeeds/testPodcast0.xml b/pse-server/testfeeds/testPodcast0.xml
new file mode 100644
index 0000000..c7656eb
--- /dev/null
+++ b/pse-server/testfeeds/testPodcast0.xml
@@ -0,0 +1,35 @@
+<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
+ xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
+ <channel>
+ <title>testPodcast0</title>
+ <item>
+ <title>testEpisode0</title>
+ <itunes:duration>00:01:40</itunes:duration>
+ <enclosure
+ url="/testfeeds/testPodcast0/episode0.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>testEpisode1</title>
+ <itunes:duration>00:03:20</itunes:duration>
+ <enclosure
+ url="/testfeeds/testPodcast0/episode1.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>testEpisode2</title>
+ <itunes:duration>00:05:00</itunes:duration>
+ <enclosure
+ url="/testfeeds/testPodcast0/episode2.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>testEpisode3</title>
+ <itunes:duration>01:00:00</itunes:duration>
+ <enclosure
+ url="/testfeeds/testPodcast0/episode3.mp3"
+ type="audio/mp3" />
+ </item>
+ </channel>
+</rss> \ No newline at end of file
diff --git a/pse-server/testfeeds/testPodcast1.xml b/pse-server/testfeeds/testPodcast1.xml
new file mode 100644
index 0000000..a7eeb63
--- /dev/null
+++ b/pse-server/testfeeds/testPodcast1.xml
@@ -0,0 +1,28 @@
+<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
+ xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
+ <channel>
+ <title>testPodcast1</title>
+ <item>
+ <title>testEpisode0</title>
+ <itunes:duration>00:01:40</itunes:duration>
+ <enclosure
+ url="/testfeeds/testPodcast1/episode0.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>testEpisode1</title>
+ <itunes:duration>00:03:20</itunes:duration>
+ <enclosure
+ url="/testfeeds/testPodcast1/episode1.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>testEpisode2</title>
+ <itunes:duration>00:05:00</itunes:duration>
+ <enclosure
+ url="/testfeeds/testPodcast1/episode2.mp3"
+ type="audio/mp3" />
+ </item>
+ </channel>
+</rss> \ No newline at end of file
diff --git a/pse-server/testfeeds/timeTestPodcast.xml b/pse-server/testfeeds/timeTestPodcast.xml
new file mode 100644
index 0000000..0f24193
--- /dev/null
+++ b/pse-server/testfeeds/timeTestPodcast.xml
@@ -0,0 +1,42 @@
+<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
+ xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
+ <channel>
+ <title>timeTestPodcast</title>
+ <item>
+ <title>anInteger</title>
+ <itunes:duration>123</itunes:duration>
+ <enclosure
+ url="/testfeeds/timeTestPodcast/anInteger.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>notAnInteger</title>
+ <itunes:duration>00-2*</itunes:duration>
+ <enclosure
+ url="/testfeeds/timeTestPodcast/notAnInteger.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>miutesAndSeconds</title>
+ <itunes:duration>10:50</itunes:duration>
+ <enclosure
+ url="/testfeeds/timeTestPodcast/minutesAndSeconds.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>noMinutes</title>
+ <itunes:duration>01::01</itunes:duration>
+ <enclosure
+ url="/testfeeds/timeTestPodcast/noMinutes.mp3"
+ type="audio/mp3" />
+ </item>
+ <item>
+ <title>emptyDurationTag</title>
+ <itunes:duration></itunes:duration>
+ <enclosure
+ url="/testfeeds/timeTestPodcast/emptyDurationTag.mp3"
+ type="audio/mp3" />
+ </item>
+ </channel>
+</rss> \ No newline at end of file