summaryrefslogtreecommitdiff
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
Initial commitHEADmain
-rw-r--r--.env10
-rw-r--r--.gitignore1
-rw-r--r--LICENSE661
-rw-r--r--README.md82
-rw-r--r--docker-compose.yml104
-rw-r--r--pse-dashboard/.env.production2
-rw-r--r--pse-dashboard/.eslintrc.js15
-rw-r--r--pse-dashboard/.gitignore26
-rw-r--r--pse-dashboard/.gitlab-ci.yml31
-rw-r--r--pse-dashboard/.vscode/extensions.json3
-rw-r--r--pse-dashboard/Dockerfile24
-rw-r--r--pse-dashboard/LICENSE661
-rw-r--r--pse-dashboard/README.md83
-rw-r--r--pse-dashboard/conf.d/nginx.conf11
-rw-r--r--pse-dashboard/index.html13
-rw-r--r--pse-dashboard/package-lock.json3393
-rw-r--r--pse-dashboard/package.json29
-rw-r--r--pse-dashboard/public/logo.svg211
-rw-r--r--pse-dashboard/src/App.vue13
-rw-r--r--pse-dashboard/src/api/gpodder.js97
-rw-r--r--pse-dashboard/src/api/gpodder.test.js29
-rw-r--r--pse-dashboard/src/api/pse-squared.js61
-rw-r--r--pse-dashboard/src/assets/logo.svg211
-rw-r--r--pse-dashboard/src/components/DashboardLayout.vue10
-rw-r--r--pse-dashboard/src/components/EpisodeEntry.vue53
-rw-r--r--pse-dashboard/src/components/ErrorLog.vue36
-rw-r--r--pse-dashboard/src/components/FloatingLabelInput.vue35
-rw-r--r--pse-dashboard/src/components/FormLayout.vue24
-rw-r--r--pse-dashboard/src/components/HelpModal.vue44
-rw-r--r--pse-dashboard/src/components/LastUpdate.vue46
-rw-r--r--pse-dashboard/src/components/LoadingConditional.vue18
-rw-r--r--pse-dashboard/src/components/NavBar.vue134
-rw-r--r--pse-dashboard/src/components/PasswordInput.vue45
-rw-r--r--pse-dashboard/src/components/PasswordValidator.vue112
-rw-r--r--pse-dashboard/src/components/ProgressTime.vue23
-rw-r--r--pse-dashboard/src/components/SubscriptionEntry.vue118
-rw-r--r--pse-dashboard/src/components/index.js30
-rw-r--r--pse-dashboard/src/i18n.js11
-rw-r--r--pse-dashboard/src/locales/de.help.html10
-rw-r--r--pse-dashboard/src/locales/de.json77
-rw-r--r--pse-dashboard/src/locales/en.help.html10
-rw-r--r--pse-dashboard/src/locales/en.json77
-rw-r--r--pse-dashboard/src/locales/index.js13
-rw-r--r--pse-dashboard/src/logger.js84
-rw-r--r--pse-dashboard/src/main.js20
-rw-r--r--pse-dashboard/src/router.js116
-rw-r--r--pse-dashboard/src/store.js54
-rw-r--r--pse-dashboard/src/style.css33
-rw-r--r--pse-dashboard/src/views/EpisodesView.vue42
-rw-r--r--pse-dashboard/src/views/ForgotPasswordView.vue52
-rw-r--r--pse-dashboard/src/views/LoginView.vue91
-rw-r--r--pse-dashboard/src/views/RegistrationView.vue78
-rw-r--r--pse-dashboard/src/views/ResetPasswordView.vue72
-rw-r--r--pse-dashboard/src/views/SettingsView.vue347
-rw-r--r--pse-dashboard/src/views/SubscriptionsView.vue270
-rw-r--r--pse-dashboard/src/views/index.js18
-rw-r--r--pse-dashboard/vite.config.js14
-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
-rw-r--r--reverse-proxy/Dockerfile10
-rw-r--r--reverse-proxy/conf.d/nginx.conf77
151 files changed, 15923 insertions, 0 deletions
diff --git a/.env b/.env
new file mode 100644
index 0000000..3ec0643
--- /dev/null
+++ b/.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/.gitignore b/.gitignore
new file mode 100644
index 0000000..4c49bd7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.env
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/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/README.md b/README.md
new file mode 100644
index 0000000..fd69968
--- /dev/null
+++ b/README.md
@@ -0,0 +1,82 @@
+# Podcast Synchronisation made Efficient - Docker Compose
+
+> Docker Compose for PSE Frontend, Backend and Database
+
+## Configuration
+
+All configuration can be done in the `.env` file.
+```
+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>
+```
+
+Domains can be tested locally by editing `/etc/hosts` or
+`C:\Windows\System32\drivers\etc\hosts` on the host.
+```
+# Static table lookup for hostnames.
+# See hosts(5) for details.
+127.0.0.1 pse-squared.de
+127.0.0.1 api.pse-squared.de
+```
+
+## Build the Image
+
+To build the composition run
+```sh
+$ docker compose build
+```
+
+You might want to build without cached results to be absolutely sure
+```sh
+$ docker-compose build --no-cache
+```
+
+
+## To run the server
+
+You need docker compose to run the server. In order to launch it, you go to the
+repo folder `pse-docker` where the `docker-compose.yml` is located and run
+```sh
+$ docker compose up
+```
+
+The server is now running. In order to shut it down, run
+```sh
+$ docker compose down
+```
+
+Have fun.
+
+## To use SSL
+
+You need to shut down the server if it is running and remove the current `reverse-proxy` image.
+
+First you need to uncomment the `certbot` service in `docker-compose.yml`, as well as
+```
+location /.well-known/acme-challenge {
+ root /letsencrypt/;
+}
+```
+in `nginx.conf` located at `reverse-proxy/conf.d/`.
+
+Next you need to restart the server for the first time.
+In the console it should tell you that the certificates were created, if everything went correctly.
+
+Shut the server down and once more remove the current `reverse-proxy` image.
+In the earlier used `nginx.conf` you need to comment / uncomment the rest of the file designated by the corresponding comments.
+Also enable port 443 and the commented volumes in the `docker-compose.yml` for the `reverse-proxy` service.
+
+Run the server again.
+If everything went well, the server should now use HTTPS.
+
+The certificates should be located in `reverse-proxy/letsencrypt/`.
+
+## License
+
+This project is licensed under the AGPL-3 License - see the `LICENSE` file for details
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e0d57c8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,104 @@
+##################################################################################################################
+# Please put any variable changes for the environment or build arguments into the .env file of the source folder #
+##################################################################################################################
+
+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
+ healthcheck:
+ test: "/usr/bin/mysql --user=$${MARIADB_USER} --password=$${MARIADB_PASSWORD} --execute \"SHOW DATABASES;\""
+ interval: 5s
+
+ pse-backend:
+ restart: always
+ hostname: pse-backend
+ network_mode: "bridge"
+ build:
+ context: ./pse-server
+ 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:
+ condition: service_healthy
+ links:
+ - maria_db:maria_db
+
+ pse-frontend:
+ restart: always
+ build:
+ context: ./pse-dashboard
+ dockerfile: Dockerfile
+ args:
+ VITE_BACKEND_URL: //${BACKEND_DOMAIN}
+ networks:
+ - frontend
+
+ reverse-proxy:
+ restart: always
+ environment:
+ NGINX_ENVSUBST_TEMPLATE_SUFFIX: ".tmpl"
+ FRONTEND_DOMAIN: ${FRONTEND_DOMAIN}
+ BACKEND_DOMAIN: ${BACKEND_DOMAIN}
+ build:
+ context: ./reverse-proxy
+ dockerfile: Dockerfile
+ networks:
+ - frontend
+ - backend
+ depends_on:
+ - pse-backend
+ - pse-frontend
+ # Uncomment volumes when using SSL (location of certificates)
+ volumes:
+ - ./reverse-proxy/letsencrypt:/letsencrypt
+ - ./reverse-proxy/letsencrypt/certs:/etc/letsencrypt
+ ports:
+ - 80:80
+ # Uncomment port when using SSL
+ - 443:443
+
+
+ ######################################################################################
+ # Uncomment following text to create / renew SSL certificates for Front- and Backend #
+ ######################################################################################
+ #
+ # certbot:
+ # image: certbot/certbot
+ # container_name: certbot
+ # volumes:
+ # - ./reverse-proxy/letsencrypt:/letsencrypt
+ # - ./reverse-proxy/letsencrypt/certs:/etc/letsencrypt
+ # command: certonly --webroot -w /letsencrypt -d ${FRONTEND_DOMAIN} -d ${BACKEND_DOMAIN} --email ${SPRING_MAIL_USERNAME} --agree-tos
+ # depends_on:
+ # - reverse-proxy
+
+networks:
+ frontend:
+ backend:
+
+volumes:
+ database:
diff --git a/pse-dashboard/.env.production b/pse-dashboard/.env.production
new file mode 100644
index 0000000..4de60bf
--- /dev/null
+++ b/pse-dashboard/.env.production
@@ -0,0 +1,2 @@
+VITE_BACKEND_URL=http://<YOUR BACKEND DOMAIN>
+
diff --git a/pse-dashboard/.eslintrc.js b/pse-dashboard/.eslintrc.js
new file mode 100644
index 0000000..6c74704
--- /dev/null
+++ b/pse-dashboard/.eslintrc.js
@@ -0,0 +1,15 @@
+module.exports = {
+ extends: [
+ // add more generic rulesets here, such as:
+ // 'eslint:recommended',
+ 'plugin:vue/vue3-recommended',
+ // 'plugin:vue/recommended' // Use this if you are using Vue.js 2.x.
+ ],
+ rules: {
+ // override/add rules settings here, such as:
+ // 'vue/no-unused-vars': 'error'
+ "vue/script-indent": ["error", 4],
+ "vue/html-indent": ["error", 4]
+ }
+}
+
diff --git a/pse-dashboard/.gitignore b/pse-dashboard/.gitignore
new file mode 100644
index 0000000..24073c9
--- /dev/null
+++ b/pse-dashboard/.gitignore
@@ -0,0 +1,26 @@
+.env.production
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/pse-dashboard/.gitlab-ci.yml b/pse-dashboard/.gitlab-ci.yml
new file mode 100644
index 0000000..5d70b0c
--- /dev/null
+++ b/pse-dashboard/.gitlab-ci.yml
@@ -0,0 +1,31 @@
+variables:
+ #BASE_DIR: $CI_PAGES_URL
+ BASE_DIR: /pse-dashboard/
+
+image: node
+
+stages:
+ - test
+ - deploy
+
+lint:
+ stage: test
+ script:
+ - npm install
+ - npm run lint
+ allow_failure: true
+
+
+pages:
+ stage: deploy
+ script:
+ - npm install
+ - npm run build
+ - rm -rf public
+ - mv dist public
+ artifacts:
+ paths:
+ - public
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+
diff --git a/pse-dashboard/.vscode/extensions.json b/pse-dashboard/.vscode/extensions.json
new file mode 100644
index 0000000..c0a6e5a
--- /dev/null
+++ b/pse-dashboard/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
+}
diff --git a/pse-dashboard/Dockerfile b/pse-dashboard/Dockerfile
new file mode 100644
index 0000000..c2da218
--- /dev/null
+++ b/pse-dashboard/Dockerfile
@@ -0,0 +1,24 @@
+# syntax=docker/dockerfile:1
+
+#
+# Building phase
+#
+FROM node:19-bullseye AS builder
+
+ARG VITE_BACKEND_URL=http://localhost:8080
+
+ENV VITE_BACKEND_URL=$VITE_BACKEND_URL
+
+WORKDIR /app
+COPY . .
+RUN npm install
+RUN npm run build
+
+#
+# NGINX phase
+#
+FROM nginx:alpine
+
+COPY --from=builder /app/dist/ /usr/share/nginx/html/
+COPY ./conf.d/nginx.conf /etc/nginx/conf.d/default.conf
+
diff --git a/pse-dashboard/LICENSE b/pse-dashboard/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/pse-dashboard/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-dashboard/README.md b/pse-dashboard/README.md
new file mode 100644
index 0000000..16ee1eb
--- /dev/null
+++ b/pse-dashboard/README.md
@@ -0,0 +1,83 @@
+# Podcast Synchronisation made Efficient Dashboard
+> Dashboard for PSE-Server
+
+## About
+
+The synchronization of the podcasts is to be managed via a web interface. For
+this purpose a single-page application will be created. This can be displayed in
+a user-friendly way for desktop and mobile devices.
+
+The web interface contains the subscribed podcasts and listened episodes
+including metadata from the backend.
+
+## Getting Started
+
+### Pre requirements
+
+- Node.js 19
+- npm
+
+### Install dependencies
+
+```sh
+$ npm install
+```
+
+### Run development server
+
+Runs dev server with live-preview
+
+```sh
+$ npm run dev
+```
+
+### Build to static files
+
+```sh
+$ npm run build
+```
+
+You can define that backend domain by editing the `.env.production` file or
+setting the environment variable.
+```sh
+$ VITE_BACKEND_URL=http://<YOUR BACKEND DOMAIN> npm run build
+```
+
+### Docker
+
+> Note that you are running the frontend standalone!
+> Checkout `pse-docker` to run both front- and backend.
+
+The docker image can be build using
+```sh
+$ docker build -t pse-frontend .
+```
+
+Here you can change the backend domain by editing the `Dockerfile` or by
+supplying it when building.
+```sh
+$ docker build --build-arg VITE_BACKEND_URL=http://<YOUR BACKEND DOMAIN> -t pse-frontend .
+```
+
+Then the image can be run using
+```sh
+$ docker run -p 80:80 -it pse-frontend
+```
+
+## Recommended IDE Setup
+
+- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
+
+## Used Dependencies
+
+- vite
+- vue
+- vue-router
+- bootstrap
+- fontawesome
+- vue-i18n (Support für mehrere Sprachen)
+
+## License
+
+This project is licensed under the AGPL-3 License - see the `LICENSE` file for details.
+
diff --git a/pse-dashboard/conf.d/nginx.conf b/pse-dashboard/conf.d/nginx.conf
new file mode 100644
index 0000000..063656a
--- /dev/null
+++ b/pse-dashboard/conf.d/nginx.conf
@@ -0,0 +1,11 @@
+server {
+
+ listen 80;
+ server_name pse-frontend;
+
+ location / {
+ root /usr/share/nginx/html/;
+ try_files $uri $uri/ /index.html =404;
+ }
+}
+
diff --git a/pse-dashboard/index.html b/pse-dashboard/index.html
new file mode 100644
index 0000000..1fd5972
--- /dev/null
+++ b/pse-dashboard/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <link rel="icon" type="image/svg+xml" href="/logo.svg" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>PSE-Dashboard</title>
+ </head>
+ <body>
+ <div id="app"></div>
+ <script type="module" src="/src/main.js"></script>
+ </body>
+</html>
diff --git a/pse-dashboard/package-lock.json b/pse-dashboard/package-lock.json
new file mode 100644
index 0000000..6ea67eb
--- /dev/null
+++ b/pse-dashboard/package-lock.json
@@ -0,0 +1,3393 @@
+{
+ "name": "pse-dashboard",
+ "version": "0.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "pse-dashboard",
+ "version": "0.0.0",
+ "dependencies": {
+ "@fortawesome/fontawesome-free": "^6.2.1",
+ "axios": "^1.3.4",
+ "bootstrap": "^5.2.3",
+ "dayjs": "^1.11.7",
+ "file-saver": "^2.0.5",
+ "jszip": "^3.10.1",
+ "vue": "^3.2.45",
+ "vue-i18n": "^9.2.2",
+ "vue-router": "^4.1.6"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^4.0.0",
+ "eslint": "^8.34.0",
+ "eslint-plugin-vue": "^9.9.0",
+ "vite": "^4.0.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.20.13",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.13.tgz",
+ "integrity": "sha512-gFDLKMfpiXCsjt4za2JA9oTMn70CeseCehb11kRZgvd7+F67Hih3OHOK24cRrWECJ/ljfPGac6ygXAs/C8kIvw==",
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.16.17",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz",
+ "integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz",
+ "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.4.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@fortawesome/fontawesome-free": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.2.1.tgz",
+ "integrity": "sha512-viouXhegu/TjkvYQoiRZK3aax69dGXxgEjpvZW81wIJdxm5Fnvp3VVIP4VHKqX4SvFw6qpmkILkD4RJWAdrt7A==",
+ "hasInstallScript": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.11.8",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
+ "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+ "dev": true,
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^1.2.1",
+ "debug": "^4.1.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+ "dev": true
+ },
+ "node_modules/@intlify/core-base": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.2.2.tgz",
+ "integrity": "sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==",
+ "dependencies": {
+ "@intlify/devtools-if": "9.2.2",
+ "@intlify/message-compiler": "9.2.2",
+ "@intlify/shared": "9.2.2",
+ "@intlify/vue-devtools": "9.2.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/@intlify/devtools-if": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/devtools-if/-/devtools-if-9.2.2.tgz",
+ "integrity": "sha512-4ttr/FNO29w+kBbU7HZ/U0Lzuh2cRDhP8UlWOtV9ERcjHzuyXVZmjyleESK6eVP60tGC9QtQW9yZE+JeRhDHkg==",
+ "dependencies": {
+ "@intlify/shared": "9.2.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/@intlify/message-compiler": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.2.2.tgz",
+ "integrity": "sha512-IUrQW7byAKN2fMBe8z6sK6riG1pue95e5jfokn8hA5Q3Bqy4MBJ5lJAofUsawQJYHeoPJ7svMDyBaVJ4d0GTtA==",
+ "dependencies": {
+ "@intlify/shared": "9.2.2",
+ "source-map": "0.6.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/@intlify/shared": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz",
+ "integrity": "sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q==",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/@intlify/vue-devtools": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/vue-devtools/-/vue-devtools-9.2.2.tgz",
+ "integrity": "sha512-+dUyqyCHWHb/UcvY1MlIpO87munedm3Gn6E9WWYdWrMuYLcoIoOEVDWSS8xSwtlPU+kA+MEQTP6Q1iI/ocusJg==",
+ "dependencies": {
+ "@intlify/core-base": "9.2.2",
+ "@intlify/shared": "9.2.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@popperjs/core": {
+ "version": "2.11.6",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
+ "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
+ "integrity": "sha512-e0X4jErIxAB5oLtDqbHvHpJe/uWNkdpYV83AOG2xo2tEVSzCzewgJMtREZM30wXnM5ls90hxiOtAuVU6H5JgbA==",
+ "dev": true,
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.45.tgz",
+ "integrity": "sha512-rcMj7H+PYe5wBV3iYeUgbCglC+pbpN8hBLTJvRiK2eKQiWqu+fG9F+8sW99JdL4LQi7Re178UOxn09puSXvn4A==",
+ "dependencies": {
+ "@babel/parser": "^7.16.4",
+ "@vue/shared": "3.2.45",
+ "estree-walker": "^2.0.2",
+ "source-map": "^0.6.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.45.tgz",
+ "integrity": "sha512-tyYeUEuKqqZO137WrZkpwfPCdiiIeXYCcJ8L4gWz9vqaxzIQRccTSwSWZ/Axx5YR2z+LvpUbmPNXxuBU45lyRw==",
+ "dependencies": {
+ "@vue/compiler-core": "3.2.45",
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.45.tgz",
+ "integrity": "sha512-1jXDuWah1ggsnSAOGsec8cFjT/K6TMZ0sPL3o3d84Ft2AYZi2jWJgRMjw4iaK0rBfA89L5gw427H4n1RZQBu6Q==",
+ "dependencies": {
+ "@babel/parser": "^7.16.4",
+ "@vue/compiler-core": "3.2.45",
+ "@vue/compiler-dom": "3.2.45",
+ "@vue/compiler-ssr": "3.2.45",
+ "@vue/reactivity-transform": "3.2.45",
+ "@vue/shared": "3.2.45",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.25.7",
+ "postcss": "^8.1.10",
+ "source-map": "^0.6.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.45.tgz",
+ "integrity": "sha512-6BRaggEGqhWht3lt24CrIbQSRD5O07MTmd+LjAn5fJj568+R9eUD2F7wMQJjX859seSlrYog7sUtrZSd7feqrQ==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.2.45",
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz",
+ "integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q=="
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.45.tgz",
+ "integrity": "sha512-PRvhCcQcyEVohW0P8iQ7HDcIOXRjZfAsOds3N99X/Dzewy8TVhTCT4uXpAHfoKjVTJRA0O0K+6QNkDIZAxNi3A==",
+ "dependencies": {
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "node_modules/@vue/reactivity-transform": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.45.tgz",
+ "integrity": "sha512-BHVmzYAvM7vcU5WmuYqXpwaBHjsS8T63jlKGWVtHxAHIoMIlmaMyurUSEs1Zcg46M4AYT5MtB1U274/2aNzjJQ==",
+ "dependencies": {
+ "@babel/parser": "^7.16.4",
+ "@vue/compiler-core": "3.2.45",
+ "@vue/shared": "3.2.45",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.25.7"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.45.tgz",
+ "integrity": "sha512-gzJiTA3f74cgARptqzYswmoQx0fIA+gGYBfokYVhF8YSXjWTUA2SngRzZRku2HbGbjzB6LBYSbKGIaK8IW+s0A==",
+ "dependencies": {
+ "@vue/reactivity": "3.2.45",
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.45.tgz",
+ "integrity": "sha512-cy88YpfP5Ue2bDBbj75Cb4bIEZUMM/mAkDMfqDTpUYVgTf/kuQ2VQ8LebuZ8k6EudgH8pYhsGWHlY0lcxlvTwA==",
+ "dependencies": {
+ "@vue/runtime-core": "3.2.45",
+ "@vue/shared": "3.2.45",
+ "csstype": "^2.6.8"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.45.tgz",
+ "integrity": "sha512-ebiMq7q24WBU1D6uhPK//2OTR1iRIyxjF5iVq/1a5I1SDMDyDu4Ts6fJaMnjrvD3MqnaiFkKQj+LKAgz5WIK3g==",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.2.45",
+ "@vue/shared": "3.2.45"
+ },
+ "peerDependencies": {
+ "vue": "3.2.45"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.45.tgz",
+ "integrity": "sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg=="
+ },
+ "node_modules/acorn": {
+ "version": "8.8.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
+ "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+ "dev": true,
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ },
+ "node_modules/axios": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz",
+ "integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==",
+ "dependencies": {
+ "follow-redirects": "^1.15.0",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true
+ },
+ "node_modules/bootstrap": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz",
+ "integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/twbs"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/bootstrap"
+ }
+ ],
+ "peerDependencies": {
+ "@popperjs/core": "^2.11.6"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "2.6.21",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz",
+ "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w=="
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.7",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
+ "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
+ },
+ "node_modules/debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.16.17",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.17.tgz",
+ "integrity": "sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/android-arm": "0.16.17",
+ "@esbuild/android-arm64": "0.16.17",
+ "@esbuild/android-x64": "0.16.17",
+ "@esbuild/darwin-arm64": "0.16.17",
+ "@esbuild/darwin-x64": "0.16.17",
+ "@esbuild/freebsd-arm64": "0.16.17",
+ "@esbuild/freebsd-x64": "0.16.17",
+ "@esbuild/linux-arm": "0.16.17",
+ "@esbuild/linux-arm64": "0.16.17",
+ "@esbuild/linux-ia32": "0.16.17",
+ "@esbuild/linux-loong64": "0.16.17",
+ "@esbuild/linux-mips64el": "0.16.17",
+ "@esbuild/linux-ppc64": "0.16.17",
+ "@esbuild/linux-riscv64": "0.16.17",
+ "@esbuild/linux-s390x": "0.16.17",
+ "@esbuild/linux-x64": "0.16.17",
+ "@esbuild/netbsd-x64": "0.16.17",
+ "@esbuild/openbsd-x64": "0.16.17",
+ "@esbuild/sunos-x64": "0.16.17",
+ "@esbuild/win32-arm64": "0.16.17",
+ "@esbuild/win32-ia32": "0.16.17",
+ "@esbuild/win32-x64": "0.16.17"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.34.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.34.0.tgz",
+ "integrity": "sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg==",
+ "dev": true,
+ "dependencies": {
+ "@eslint/eslintrc": "^1.4.1",
+ "@humanwhocodes/config-array": "^0.11.8",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "ajv": "^6.10.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.1.1",
+ "eslint-utils": "^3.0.0",
+ "eslint-visitor-keys": "^3.3.0",
+ "espree": "^9.4.0",
+ "esquery": "^1.4.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "grapheme-splitter": "^1.0.4",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.0.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-sdsl": "^4.1.4",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.1",
+ "regexpp": "^3.2.0",
+ "strip-ansi": "^6.0.1",
+ "strip-json-comments": "^3.1.0",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-plugin-vue": {
+ "version": "9.9.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.9.0.tgz",
+ "integrity": "sha512-YbubS7eK0J7DCf0U2LxvVP7LMfs6rC6UltihIgval3azO3gyDwEGVgsCMe1TmDiEkl6GdMKfRpaME6QxIYtzDQ==",
+ "dev": true,
+ "dependencies": {
+ "eslint-utils": "^3.0.0",
+ "natural-compare": "^1.4.0",
+ "nth-check": "^2.0.1",
+ "postcss-selector-parser": "^6.0.9",
+ "semver": "^7.3.5",
+ "vue-eslint-parser": "^9.0.1",
+ "xml-name-validator": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+ "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/eslint-utils": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
+ "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
+ "dev": true,
+ "dependencies": {
+ "eslint-visitor-keys": "^2.0.0"
+ },
+ "engines": {
+ "node": "^10.0.0 || ^12.0.0 || >= 14.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mysticatea"
+ },
+ "peerDependencies": {
+ "eslint": ">=5"
+ }
+ },
+ "node_modules/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+ "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
+ "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/espree": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz",
+ "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.8.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.2.tgz",
+ "integrity": "sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true
+ },
+ "node_modules/fastq": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
+ "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+ "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+ "dev": true,
+ "dependencies": {
+ "flatted": "^3.1.0",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
+ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
+ "dev": true
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.2",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
+ "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+ "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+ "dev": true
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "13.20.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
+ "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
+ "dev": true,
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/grapheme-splitter": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
+ "dev": true
+ },
+ "node_modules/has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+ "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/is-core-module": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
+ "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
+ "dev": true,
+ "dependencies": {
+ "has": "^1.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "node_modules/js-sdsl": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
+ "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/js-sdsl"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true
+ },
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true
+ },
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+ "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
+ "dependencies": {
+ "sourcemap-codec": "^1.4.8"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
+ "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+ "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+ "dev": true,
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.3"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "node_modules/picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
+ },
+ "node_modules/postcss": {
+ "version": "8.4.21",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
+ "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.4",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.0.11",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz",
+ "integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==",
+ "dev": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+ },
+ "node_modules/punycode": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
+ "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/regexpp": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+ "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mysticatea"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.1",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
+ "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
+ "dev": true,
+ "dependencies": {
+ "is-core-module": "^2.9.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.10.1.tgz",
+ "integrity": "sha512-3Er+yel3bZbZX1g2kjVM+FW+RUWDxbG87fcqFM5/9HbPCTpbVp6JOLn7jlxnNlbu7s/N/uDA4EV/91E2gWnxzw==",
+ "dev": true,
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=14.18.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "node_modules/semver": {
+ "version": "7.3.8",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+ "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sourcemap-codec": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
+ "deprecated": "Please use @jridgewell/sourcemap-codec instead"
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "dev": true
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+ },
+ "node_modules/vite": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz",
+ "integrity": "sha512-xevPU7M8FU0i/80DMR+YhgrzR5KS2ORy1B4xcX/cXLsvnUWvfHuqMmVU6N0YiJ4JWGRJJsLCgjEzKjG9/GKoSw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.16.3",
+ "postcss": "^8.4.20",
+ "resolve": "^1.22.1",
+ "rollup": "^3.7.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ },
+ "peerDependencies": {
+ "@types/node": ">= 14",
+ "less": "*",
+ "sass": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.45.tgz",
+ "integrity": "sha512-9Nx/Mg2b2xWlXykmCwiTUCWHbWIj53bnkizBxKai1g61f2Xit700A1ljowpTIM11e3uipOeiPcSqnmBg6gyiaA==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.2.45",
+ "@vue/compiler-sfc": "3.2.45",
+ "@vue/runtime-dom": "3.2.45",
+ "@vue/server-renderer": "3.2.45",
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "node_modules/vue-eslint-parser": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.1.0.tgz",
+ "integrity": "sha512-NGn/iQy8/Wb7RrRa4aRkokyCZfOUWk19OP5HP6JEozQFX5AoS/t+Z0ZN7FY4LlmWc4FNI922V7cvX28zctN8dQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.3.4",
+ "eslint-scope": "^7.1.1",
+ "eslint-visitor-keys": "^3.3.0",
+ "espree": "^9.3.1",
+ "esquery": "^1.4.0",
+ "lodash": "^4.17.21",
+ "semver": "^7.3.6"
+ },
+ "engines": {
+ "node": "^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mysticatea"
+ },
+ "peerDependencies": {
+ "eslint": ">=6.0.0"
+ }
+ },
+ "node_modules/vue-i18n": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.2.2.tgz",
+ "integrity": "sha512-yswpwtj89rTBhegUAv9Mu37LNznyu3NpyLQmozF3i1hYOhwpG8RjcjIFIIfnu+2MDZJGSZPXaKWvnQA71Yv9TQ==",
+ "dependencies": {
+ "@intlify/core-base": "9.2.2",
+ "@intlify/shared": "9.2.2",
+ "@intlify/vue-devtools": "9.2.2",
+ "@vue/devtools-api": "^6.2.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz",
+ "integrity": "sha512-DYWYwsG6xNPmLq/FmZn8Ip+qrhFEzA14EI12MsMgVxvHFDYvlr4NXpVF5hrRH1wVcDP8fGi5F4rxuJSl8/r+EQ==",
+ "dependencies": {
+ "@vue/devtools-api": "^6.4.5"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.2.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "node_modules/xml-name-validator": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+ "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ },
+ "dependencies": {
+ "@babel/parser": {
+ "version": "7.20.13",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.13.tgz",
+ "integrity": "sha512-gFDLKMfpiXCsjt4za2JA9oTMn70CeseCehb11kRZgvd7+F67Hih3OHOK24cRrWECJ/ljfPGac6ygXAs/C8kIvw=="
+ },
+ "@esbuild/linux-x64": {
+ "version": "0.16.17",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz",
+ "integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==",
+ "dev": true,
+ "optional": true
+ },
+ "@eslint/eslintrc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz",
+ "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.4.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ }
+ },
+ "@fortawesome/fontawesome-free": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.2.1.tgz",
+ "integrity": "sha512-viouXhegu/TjkvYQoiRZK3aax69dGXxgEjpvZW81wIJdxm5Fnvp3VVIP4VHKqX4SvFw6qpmkILkD4RJWAdrt7A=="
+ },
+ "@humanwhocodes/config-array": {
+ "version": "0.11.8",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
+ "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+ "dev": true,
+ "requires": {
+ "@humanwhocodes/object-schema": "^1.2.1",
+ "debug": "^4.1.1",
+ "minimatch": "^3.0.5"
+ }
+ },
+ "@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true
+ },
+ "@humanwhocodes/object-schema": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+ "dev": true
+ },
+ "@intlify/core-base": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.2.2.tgz",
+ "integrity": "sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==",
+ "requires": {
+ "@intlify/devtools-if": "9.2.2",
+ "@intlify/message-compiler": "9.2.2",
+ "@intlify/shared": "9.2.2",
+ "@intlify/vue-devtools": "9.2.2"
+ }
+ },
+ "@intlify/devtools-if": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/devtools-if/-/devtools-if-9.2.2.tgz",
+ "integrity": "sha512-4ttr/FNO29w+kBbU7HZ/U0Lzuh2cRDhP8UlWOtV9ERcjHzuyXVZmjyleESK6eVP60tGC9QtQW9yZE+JeRhDHkg==",
+ "requires": {
+ "@intlify/shared": "9.2.2"
+ }
+ },
+ "@intlify/message-compiler": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.2.2.tgz",
+ "integrity": "sha512-IUrQW7byAKN2fMBe8z6sK6riG1pue95e5jfokn8hA5Q3Bqy4MBJ5lJAofUsawQJYHeoPJ7svMDyBaVJ4d0GTtA==",
+ "requires": {
+ "@intlify/shared": "9.2.2",
+ "source-map": "0.6.1"
+ }
+ },
+ "@intlify/shared": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz",
+ "integrity": "sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q=="
+ },
+ "@intlify/vue-devtools": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@intlify/vue-devtools/-/vue-devtools-9.2.2.tgz",
+ "integrity": "sha512-+dUyqyCHWHb/UcvY1MlIpO87munedm3Gn6E9WWYdWrMuYLcoIoOEVDWSS8xSwtlPU+kA+MEQTP6Q1iI/ocusJg==",
+ "requires": {
+ "@intlify/core-base": "9.2.2",
+ "@intlify/shared": "9.2.2"
+ }
+ },
+ "@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "requires": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ }
+ },
+ "@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true
+ },
+ "@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "requires": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ }
+ },
+ "@popperjs/core": {
+ "version": "2.11.6",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
+ "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
+ "peer": true
+ },
+ "@vitejs/plugin-vue": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
+ "integrity": "sha512-e0X4jErIxAB5oLtDqbHvHpJe/uWNkdpYV83AOG2xo2tEVSzCzewgJMtREZM30wXnM5ls90hxiOtAuVU6H5JgbA==",
+ "dev": true,
+ "requires": {}
+ },
+ "@vue/compiler-core": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.45.tgz",
+ "integrity": "sha512-rcMj7H+PYe5wBV3iYeUgbCglC+pbpN8hBLTJvRiK2eKQiWqu+fG9F+8sW99JdL4LQi7Re178UOxn09puSXvn4A==",
+ "requires": {
+ "@babel/parser": "^7.16.4",
+ "@vue/shared": "3.2.45",
+ "estree-walker": "^2.0.2",
+ "source-map": "^0.6.1"
+ }
+ },
+ "@vue/compiler-dom": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.45.tgz",
+ "integrity": "sha512-tyYeUEuKqqZO137WrZkpwfPCdiiIeXYCcJ8L4gWz9vqaxzIQRccTSwSWZ/Axx5YR2z+LvpUbmPNXxuBU45lyRw==",
+ "requires": {
+ "@vue/compiler-core": "3.2.45",
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "@vue/compiler-sfc": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.45.tgz",
+ "integrity": "sha512-1jXDuWah1ggsnSAOGsec8cFjT/K6TMZ0sPL3o3d84Ft2AYZi2jWJgRMjw4iaK0rBfA89L5gw427H4n1RZQBu6Q==",
+ "requires": {
+ "@babel/parser": "^7.16.4",
+ "@vue/compiler-core": "3.2.45",
+ "@vue/compiler-dom": "3.2.45",
+ "@vue/compiler-ssr": "3.2.45",
+ "@vue/reactivity-transform": "3.2.45",
+ "@vue/shared": "3.2.45",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.25.7",
+ "postcss": "^8.1.10",
+ "source-map": "^0.6.1"
+ }
+ },
+ "@vue/compiler-ssr": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.45.tgz",
+ "integrity": "sha512-6BRaggEGqhWht3lt24CrIbQSRD5O07MTmd+LjAn5fJj568+R9eUD2F7wMQJjX859seSlrYog7sUtrZSd7feqrQ==",
+ "requires": {
+ "@vue/compiler-dom": "3.2.45",
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "@vue/devtools-api": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz",
+ "integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q=="
+ },
+ "@vue/reactivity": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.45.tgz",
+ "integrity": "sha512-PRvhCcQcyEVohW0P8iQ7HDcIOXRjZfAsOds3N99X/Dzewy8TVhTCT4uXpAHfoKjVTJRA0O0K+6QNkDIZAxNi3A==",
+ "requires": {
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "@vue/reactivity-transform": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.45.tgz",
+ "integrity": "sha512-BHVmzYAvM7vcU5WmuYqXpwaBHjsS8T63jlKGWVtHxAHIoMIlmaMyurUSEs1Zcg46M4AYT5MtB1U274/2aNzjJQ==",
+ "requires": {
+ "@babel/parser": "^7.16.4",
+ "@vue/compiler-core": "3.2.45",
+ "@vue/shared": "3.2.45",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.25.7"
+ }
+ },
+ "@vue/runtime-core": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.45.tgz",
+ "integrity": "sha512-gzJiTA3f74cgARptqzYswmoQx0fIA+gGYBfokYVhF8YSXjWTUA2SngRzZRku2HbGbjzB6LBYSbKGIaK8IW+s0A==",
+ "requires": {
+ "@vue/reactivity": "3.2.45",
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "@vue/runtime-dom": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.45.tgz",
+ "integrity": "sha512-cy88YpfP5Ue2bDBbj75Cb4bIEZUMM/mAkDMfqDTpUYVgTf/kuQ2VQ8LebuZ8k6EudgH8pYhsGWHlY0lcxlvTwA==",
+ "requires": {
+ "@vue/runtime-core": "3.2.45",
+ "@vue/shared": "3.2.45",
+ "csstype": "^2.6.8"
+ }
+ },
+ "@vue/server-renderer": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.45.tgz",
+ "integrity": "sha512-ebiMq7q24WBU1D6uhPK//2OTR1iRIyxjF5iVq/1a5I1SDMDyDu4Ts6fJaMnjrvD3MqnaiFkKQj+LKAgz5WIK3g==",
+ "requires": {
+ "@vue/compiler-ssr": "3.2.45",
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "@vue/shared": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.45.tgz",
+ "integrity": "sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg=="
+ },
+ "acorn": {
+ "version": "8.8.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
+ "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+ "dev": true
+ },
+ "acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "requires": {}
+ },
+ "ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
+ "asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ },
+ "axios": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz",
+ "integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==",
+ "requires": {
+ "follow-redirects": "^1.15.0",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true
+ },
+ "bootstrap": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz",
+ "integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
+ "requires": {}
+ },
+ "brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "requires": {
+ "delayed-stream": "~1.0.0"
+ }
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+ },
+ "cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "requires": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ }
+ },
+ "cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true
+ },
+ "csstype": {
+ "version": "2.6.21",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz",
+ "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w=="
+ },
+ "dayjs": {
+ "version": "1.11.7",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
+ "integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
+ },
+ "debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true
+ },
+ "delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+ },
+ "doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "requires": {
+ "esutils": "^2.0.2"
+ }
+ },
+ "esbuild": {
+ "version": "0.16.17",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.17.tgz",
+ "integrity": "sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==",
+ "dev": true,
+ "requires": {
+ "@esbuild/android-arm": "0.16.17",
+ "@esbuild/android-arm64": "0.16.17",
+ "@esbuild/android-x64": "0.16.17",
+ "@esbuild/darwin-arm64": "0.16.17",
+ "@esbuild/darwin-x64": "0.16.17",
+ "@esbuild/freebsd-arm64": "0.16.17",
+ "@esbuild/freebsd-x64": "0.16.17",
+ "@esbuild/linux-arm": "0.16.17",
+ "@esbuild/linux-arm64": "0.16.17",
+ "@esbuild/linux-ia32": "0.16.17",
+ "@esbuild/linux-loong64": "0.16.17",
+ "@esbuild/linux-mips64el": "0.16.17",
+ "@esbuild/linux-ppc64": "0.16.17",
+ "@esbuild/linux-riscv64": "0.16.17",
+ "@esbuild/linux-s390x": "0.16.17",
+ "@esbuild/linux-x64": "0.16.17",
+ "@esbuild/netbsd-x64": "0.16.17",
+ "@esbuild/openbsd-x64": "0.16.17",
+ "@esbuild/sunos-x64": "0.16.17",
+ "@esbuild/win32-arm64": "0.16.17",
+ "@esbuild/win32-ia32": "0.16.17",
+ "@esbuild/win32-x64": "0.16.17"
+ }
+ },
+ "escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true
+ },
+ "eslint": {
+ "version": "8.34.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.34.0.tgz",
+ "integrity": "sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg==",
+ "dev": true,
+ "requires": {
+ "@eslint/eslintrc": "^1.4.1",
+ "@humanwhocodes/config-array": "^0.11.8",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "ajv": "^6.10.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.1.1",
+ "eslint-utils": "^3.0.0",
+ "eslint-visitor-keys": "^3.3.0",
+ "espree": "^9.4.0",
+ "esquery": "^1.4.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "grapheme-splitter": "^1.0.4",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.0.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-sdsl": "^4.1.4",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.1",
+ "regexpp": "^3.2.0",
+ "strip-ansi": "^6.0.1",
+ "strip-json-comments": "^3.1.0",
+ "text-table": "^0.2.0"
+ }
+ },
+ "eslint-plugin-vue": {
+ "version": "9.9.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.9.0.tgz",
+ "integrity": "sha512-YbubS7eK0J7DCf0U2LxvVP7LMfs6rC6UltihIgval3azO3gyDwEGVgsCMe1TmDiEkl6GdMKfRpaME6QxIYtzDQ==",
+ "dev": true,
+ "requires": {
+ "eslint-utils": "^3.0.0",
+ "natural-compare": "^1.4.0",
+ "nth-check": "^2.0.1",
+ "postcss-selector-parser": "^6.0.9",
+ "semver": "^7.3.5",
+ "vue-eslint-parser": "^9.0.1",
+ "xml-name-validator": "^4.0.0"
+ }
+ },
+ "eslint-scope": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+ "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ }
+ },
+ "eslint-utils": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
+ "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
+ "dev": true,
+ "requires": {
+ "eslint-visitor-keys": "^2.0.0"
+ },
+ "dependencies": {
+ "eslint-visitor-keys": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+ "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+ "dev": true
+ }
+ }
+ },
+ "eslint-visitor-keys": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
+ "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
+ "dev": true
+ },
+ "espree": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz",
+ "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==",
+ "dev": true,
+ "requires": {
+ "acorn": "^8.8.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.3.0"
+ }
+ },
+ "esquery": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.2.tgz",
+ "integrity": "sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.1.0"
+ }
+ },
+ "esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.2.0"
+ }
+ },
+ "estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true
+ },
+ "estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true
+ },
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true
+ },
+ "fastq": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
+ "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+ "dev": true,
+ "requires": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "requires": {
+ "flat-cache": "^3.0.4"
+ }
+ },
+ "file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+ },
+ "find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ }
+ },
+ "flat-cache": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+ "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+ "dev": true,
+ "requires": {
+ "flatted": "^3.1.0",
+ "rimraf": "^3.0.2"
+ }
+ },
+ "flatted": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
+ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
+ "dev": true
+ },
+ "follow-redirects": {
+ "version": "1.15.2",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
+ "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
+ },
+ "form-data": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+ "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+ "requires": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ }
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true
+ },
+ "function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+ "dev": true
+ },
+ "glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "dev": true,
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.3"
+ }
+ },
+ "globals": {
+ "version": "13.20.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
+ "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
+ "dev": true,
+ "requires": {
+ "type-fest": "^0.20.2"
+ }
+ },
+ "grapheme-splitter": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
+ "dev": true
+ },
+ "has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1"
+ }
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "ignore": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+ "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+ "dev": true
+ },
+ "immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
+ },
+ "import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "requires": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ }
+ },
+ "imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "dev": true,
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "is-core-module": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
+ "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
+ "dev": true,
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true
+ },
+ "is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "js-sdsl": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
+ "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
+ "dev": true
+ },
+ "js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "requires": {
+ "argparse": "^2.0.1"
+ }
+ },
+ "json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true
+ },
+ "jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "requires": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ }
+ },
+ "lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "requires": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^5.0.0"
+ }
+ },
+ "lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true
+ },
+ "lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true
+ },
+ "lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ },
+ "magic-string": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+ "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
+ "requires": {
+ "sourcemap-codec": "^1.4.8"
+ }
+ },
+ "mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
+ },
+ "mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "requires": {
+ "mime-db": "1.52.0"
+ }
+ },
+ "minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "nanoid": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
+ "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw=="
+ },
+ "natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true
+ },
+ "nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "requires": {
+ "boolbase": "^1.0.0"
+ }
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "optionator": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+ "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+ "dev": true,
+ "requires": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.3"
+ }
+ },
+ "p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "requires": {
+ "yocto-queue": "^0.1.0"
+ }
+ },
+ "p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^3.0.2"
+ }
+ },
+ "pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+ },
+ "parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "requires": {
+ "callsites": "^3.0.0"
+ }
+ },
+ "path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true
+ },
+ "path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true
+ },
+ "path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
+ },
+ "postcss": {
+ "version": "8.4.21",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz",
+ "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==",
+ "requires": {
+ "nanoid": "^3.3.4",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ }
+ },
+ "postcss-selector-parser": {
+ "version": "6.0.11",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz",
+ "integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==",
+ "dev": true,
+ "requires": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true
+ },
+ "process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+ },
+ "proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+ },
+ "punycode": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
+ "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
+ "dev": true
+ },
+ "queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "regexpp": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+ "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+ "dev": true
+ },
+ "resolve": {
+ "version": "1.22.1",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
+ "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
+ "dev": true,
+ "requires": {
+ "is-core-module": "^2.9.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ }
+ },
+ "resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true
+ },
+ "reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true
+ },
+ "rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "rollup": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.10.1.tgz",
+ "integrity": "sha512-3Er+yel3bZbZX1g2kjVM+FW+RUWDxbG87fcqFM5/9HbPCTpbVp6JOLn7jlxnNlbu7s/N/uDA4EV/91E2gWnxzw==",
+ "dev": true,
+ "requires": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "requires": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "semver": {
+ "version": "7.3.8",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+ "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ }
+ },
+ "setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
+ },
+ "shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^3.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+ },
+ "source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
+ },
+ "sourcemap-codec": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ },
+ "strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
+ "supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true
+ },
+ "text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "dev": true
+ },
+ "type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "^1.2.1"
+ }
+ },
+ "type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true
+ },
+ "uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "requires": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+ },
+ "vite": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz",
+ "integrity": "sha512-xevPU7M8FU0i/80DMR+YhgrzR5KS2ORy1B4xcX/cXLsvnUWvfHuqMmVU6N0YiJ4JWGRJJsLCgjEzKjG9/GKoSw==",
+ "dev": true,
+ "requires": {
+ "esbuild": "^0.16.3",
+ "fsevents": "~2.3.2",
+ "postcss": "^8.4.20",
+ "resolve": "^1.22.1",
+ "rollup": "^3.7.0"
+ }
+ },
+ "vue": {
+ "version": "3.2.45",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.45.tgz",
+ "integrity": "sha512-9Nx/Mg2b2xWlXykmCwiTUCWHbWIj53bnkizBxKai1g61f2Xit700A1ljowpTIM11e3uipOeiPcSqnmBg6gyiaA==",
+ "requires": {
+ "@vue/compiler-dom": "3.2.45",
+ "@vue/compiler-sfc": "3.2.45",
+ "@vue/runtime-dom": "3.2.45",
+ "@vue/server-renderer": "3.2.45",
+ "@vue/shared": "3.2.45"
+ }
+ },
+ "vue-eslint-parser": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.1.0.tgz",
+ "integrity": "sha512-NGn/iQy8/Wb7RrRa4aRkokyCZfOUWk19OP5HP6JEozQFX5AoS/t+Z0ZN7FY4LlmWc4FNI922V7cvX28zctN8dQ==",
+ "dev": true,
+ "requires": {
+ "debug": "^4.3.4",
+ "eslint-scope": "^7.1.1",
+ "eslint-visitor-keys": "^3.3.0",
+ "espree": "^9.3.1",
+ "esquery": "^1.4.0",
+ "lodash": "^4.17.21",
+ "semver": "^7.3.6"
+ }
+ },
+ "vue-i18n": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.2.2.tgz",
+ "integrity": "sha512-yswpwtj89rTBhegUAv9Mu37LNznyu3NpyLQmozF3i1hYOhwpG8RjcjIFIIfnu+2MDZJGSZPXaKWvnQA71Yv9TQ==",
+ "requires": {
+ "@intlify/core-base": "9.2.2",
+ "@intlify/shared": "9.2.2",
+ "@intlify/vue-devtools": "9.2.2",
+ "@vue/devtools-api": "^6.2.1"
+ }
+ },
+ "vue-router": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz",
+ "integrity": "sha512-DYWYwsG6xNPmLq/FmZn8Ip+qrhFEzA14EI12MsMgVxvHFDYvlr4NXpVF5hrRH1wVcDP8fGi5F4rxuJSl8/r+EQ==",
+ "requires": {
+ "@vue/devtools-api": "^6.4.5"
+ }
+ },
+ "which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ },
+ "word-wrap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+ "dev": true
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "xml-name-validator": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+ "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+ "dev": true
+ },
+ "yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true
+ }
+ }
+}
diff --git a/pse-dashboard/package.json b/pse-dashboard/package.json
new file mode 100644
index 0000000..62af252
--- /dev/null
+++ b/pse-dashboard/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "pse-dashboard",
+ "private": true,
+ "version": "0.0.0",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "lint": "eslint 'src/**/*.{js,vue}'",
+ "fix": "eslint --fix 'src/**/*.{js,vue}'"
+ },
+ "dependencies": {
+ "@fortawesome/fontawesome-free": "^6.2.1",
+ "axios": "^1.3.4",
+ "bootstrap": "^5.2.3",
+ "dayjs": "^1.11.7",
+ "file-saver": "^2.0.5",
+ "jszip": "^3.10.1",
+ "vue": "^3.2.45",
+ "vue-i18n": "^9.2.2",
+ "vue-router": "^4.1.6"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^4.0.0",
+ "eslint": "^8.34.0",
+ "eslint-plugin-vue": "^9.9.0",
+ "vite": "^4.0.0"
+ }
+}
diff --git a/pse-dashboard/public/logo.svg b/pse-dashboard/public/logo.svg
new file mode 100644
index 0000000..1609066
--- /dev/null
+++ b/pse-dashboard/public/logo.svg
@@ -0,0 +1,211 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ version="1.2"
+ width="87.589989mm"
+ height="52.16547mm"
+ viewBox="0 0 8758.9989 5216.547"
+ preserveAspectRatio="xMidYMid"
+ fill-rule="evenodd"
+ stroke-width="28.222"
+ stroke-linejoin="round"
+ xml:space="preserve"
+ id="svg206"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <defs
+ class="ClipPathGroup"
+ id="defs8" />
+ <defs
+ id="defs51"><font
+ id="EmbeddedFont_1"
+ horiz-adv-x="2048"
+ horiz-origin-x="0"
+ horiz-origin-y="0"
+ vert-origin-x="512"
+ vert-origin-y="768"
+ vert-adv-y="1024">
+ <font-face
+ font-family="Noto Sans Display Light embedded"
+ units-per-em="2048"
+ font-weight="normal"
+ font-style="normal"
+ ascent="2170"
+ descent="609"
+ id="font-face10" />
+ <missing-glyph
+ horiz-adv-x="2048"
+ d="M 0,0 L 2047,0 2047,2047 0,2047 0,0 Z"
+ id="missing-glyph12" />
+ <glyph
+ unicode="y"
+ horiz-adv-x="927"
+ d="M 2,1089 L 127,1089 367,413 C 388,352 407,298 422,250 437,203 449,161 457,124 L 463,124 C 471,156 483,196 498,246 513,296 530,350 551,409 L 786,1089 913,1089 453,-193 C 418,-290 377,-364 328,-416 279,-468 213,-494 131,-494 107,-494 84,-493 63,-490 43,-487 25,-482 8,-476 L 8,-377 C 23,-383 40,-387 57,-391 75,-394 95,-396 117,-396 170,-396 214,-378 249,-342 284,-307 314,-252 340,-179 L 403,4 2,1089 Z"
+ id="glyph14" />
+ <glyph
+ unicode="t"
+ horiz-adv-x="610"
+ d="M 465,80 C 494,80 521,82 547,87 573,91 595,97 614,105 L 614,11 C 594,3 569,-5 541,-11 512,-17 481,-20 449,-20 363,-20 296,5 249,56 202,106 178,189 178,304 L 178,996 27,996 27,1061 178,1100 219,1350 295,1350 295,1090 608,1090 608,996 295,996 295,310 C 295,157 352,80 465,80 Z"
+ id="glyph16" />
+ <glyph
+ unicode="s"
+ horiz-adv-x="742"
+ d="M 817,289 C 817,191 782,115 713,61 643,7 545,-20 418,-20 347,-20 284,-14 228,-1 173,12 126,29 88,50 L 88,162 C 134,138 186,118 244,102 301,86 360,78 420,78 520,78 592,96 636,133 680,169 702,218 702,281 702,341 679,387 632,419 585,451 515,484 422,519 359,542 303,565 255,589 207,613 169,643 142,680 116,717 102,768 102,832 102,919 136,987 204,1037 271,1086 361,1110 473,1110 535,1110 592,1104 646,1092 700,1080 750,1063 795,1043 L 754,946 C 713,964 667,980 617,993 568,1006 518,1012 469,1012 388,1012 326,997 283,967 239,937 217,893 217,836 217,792 228,758 249,733 270,707 301,686 342,668 383,650 433,630 492,609 553,585 608,562 657,537 707,512 745,481 774,443 803,405 817,353 817,289 Z"
+ id="glyph18" />
+ <glyph
+ unicode="r"
+ horiz-adv-x="583"
+ d="M 596,1108 C 646,1108 692,1102 733,1091 L 717,983 C 674,995 632,1001 590,1001 497,1001 423,964 368,890 312,817 285,719 285,598 L 285,-1 168,-1 168,1089 266,1089 279,886 285,886 C 311,948 350,1000 402,1043 455,1086 520,1108 596,1108 Z"
+ id="glyph20" />
+ <glyph
+ unicode="o"
+ horiz-adv-x="927"
+ d="M 1030,547 C 1030,433 1012,333 976,248 940,164 887,98 818,51 749,4 665,-20 565,-20 471,-20 390,3 322,50 253,96 201,162 164,247 127,333 109,433 109,547 109,723 150,861 232,961 315,1061 429,1110 575,1110 672,1110 755,1087 822,1040 890,993 941,927 977,842 1012,757 1030,659 1030,547 Z M 229,547 C 229,407 257,294 312,208 368,123 453,80 567,80 685,80 771,123 826,209 882,295 909,408 909,547 909,637 898,717 875,787 851,856 815,911 766,951 717,990 653,1010 573,1010 459,1010 373,969 315,887 258,805 229,692 229,547 Z"
+ id="glyph22" />
+ <glyph
+ unicode="n"
+ horiz-adv-x="847"
+ d="M 633,1110 C 749,1110 838,1078 900,1014 962,950 993,850 993,713 L 993,1 877,1 877,705 C 877,809 854,885 810,935 766,985 701,1010 616,1010 395,1010 285,871 285,594 L 285,1 168,1 168,1090 262,1090 279,901 287,901 C 314,962 357,1011 416,1051 474,1091 547,1110 633,1110 Z"
+ id="glyph24" />
+ <glyph
+ unicode="m"
+ horiz-adv-x="1430"
+ d="M 1245,1110 C 1348,1110 1427,1080 1485,1018 1542,957 1571,860 1571,727 L 1571,1 1454,1 1454,723 C 1454,820 1434,892 1393,939 1352,986 1296,1010 1227,1010 1130,1010 1056,980 1005,919 953,858 928,764 928,637 L 928,1 811,1 811,723 C 811,820 791,892 750,939 709,986 653,1010 584,1010 487,1010 413,978 361,914 310,850 285,751 285,619 L 285,1 168,1 168,1090 262,1090 279,918 287,918 C 313,971 352,1016 403,1054 455,1092 521,1110 600,1110 675,1110 739,1093 791,1059 842,1025 879,975 899,908 L 907,908 C 936,972 980,1022 1038,1057 1097,1093 1166,1110 1245,1110 Z"
+ id="glyph26" />
+ <glyph
+ unicode="i"
+ horiz-adv-x="187"
+ d="M 227,1493 C 279,1493 305,1464 305,1405 305,1345 279,1315 227,1315 175,1315 150,1345 150,1405 150,1464 175,1493 227,1493 Z M 285,1090 L 285,0 168,0 168,1090 285,1090 Z"
+ id="glyph28" />
+ <glyph
+ unicode="h"
+ horiz-adv-x="847"
+ d="M 285,1059 C 285,1031 284,1003 283,977 281,951 279,926 276,901 L 285,901 C 312,962 355,1011 413,1051 471,1091 543,1110 629,1110 746,1110 836,1078 899,1014 962,950 993,850 993,713 L 993,1 877,1 877,705 C 877,809 854,885 810,935 766,985 701,1010 616,1010 395,1010 285,871 285,594 L 285,1 168,1 168,1557 285,1557 285,1059 Z"
+ id="glyph30" />
+ <glyph
+ unicode="f"
+ horiz-adv-x="689"
+ d="M 575,995 L 332,995 332,-1 213,-1 213,995 27,995 27,1058 213,1093 213,1202 C 213,1445 316,1566 522,1566 559,1566 593,1563 623,1557 653,1551 680,1544 705,1536 L 678,1439 C 655,1448 630,1454 603,1460 577,1465 550,1468 524,1468 456,1468 407,1447 377,1405 347,1362 332,1295 332,1202 L 332,1089 575,1089 575,995 Z"
+ id="glyph32" />
+ <glyph
+ unicode="e"
+ horiz-adv-x="874"
+ d="M 559,1110 C 646,1110 720,1089 779,1046 839,1003 883,944 913,869 943,794 958,708 958,611 L 958,531 229,531 C 231,386 262,275 325,198 387,121 476,82 592,82 656,82 712,88 759,100 806,111 858,130 915,156 L 915,50 C 865,25 814,7 764,-4 713,-15 655,-20 588,-20 434,-20 315,30 232,129 150,229 109,365 109,537 109,648 126,746 162,832 197,918 249,986 315,1036 382,1085 464,1110 559,1110 Z M 559,1012 C 465,1012 389,979 333,912 276,845 243,750 233,627 L 838,627 C 838,742 815,835 769,906 723,977 653,1012 559,1012 Z"
+ id="glyph34" />
+ <glyph
+ unicode="d"
+ horiz-adv-x="900"
+ d="M 535,-20 C 398,-20 293,27 219,120 145,214 109,352 109,535 109,722 147,865 224,963 301,1061 408,1110 545,1110 629,1110 698,1090 752,1050 805,1010 845,961 872,904 L 881,904 C 879,935 878,970 876,1009 873,1048 872,1084 872,1117 L 872,1557 989,1557 989,0 895,0 879,191 872,191 C 845,132 805,82 751,41 697,0 625,-20 535,-20 Z M 553,80 C 669,80 752,119 801,195 850,271 875,382 875,527 L 875,545 C 875,695 850,810 801,890 752,970 671,1010 559,1010 451,1010 369,969 313,886 257,804 229,686 229,533 229,385 256,273 309,196 363,119 444,80 553,80 Z"
+ id="glyph36" />
+ <glyph
+ unicode="c"
+ horiz-adv-x="768"
+ d="M 580,-20 C 429,-20 313,29 231,127 150,226 109,363 109,539 109,662 129,766 170,850 211,935 269,1000 343,1044 417,1088 504,1110 602,1110 651,1110 698,1106 742,1096 787,1087 825,1074 858,1057 L 825,957 C 791,972 754,984 714,993 673,1002 636,1006 600,1006 481,1006 390,964 326,881 261,798 229,685 229,541 229,405 258,294 314,210 371,126 459,84 580,84 630,84 678,90 723,101 768,112 809,125 846,142 L 846,37 C 812,20 773,6 729,-5 685,-15 636,-20 580,-20 Z"
+ id="glyph38" />
+ <glyph
+ unicode="a"
+ horiz-adv-x="822"
+ d="M 535,1108 C 651,1108 737,1078 795,1018 852,958 881,863 881,734 L 881,0 793,0 772,185 766,185 C 729,123 684,74 631,36 578,-1 503,-20 408,-20 311,-20 233,6 176,59 119,111 90,187 90,285 90,394 132,477 215,533 298,589 420,621 580,629 L 764,639 764,715 C 764,822 744,897 705,942 665,987 606,1010 528,1010 477,1010 426,1002 378,987 329,972 281,953 231,928 L 195,1022 C 242,1047 295,1067 352,1084 410,1100 470,1108 535,1108 Z M 594,543 C 466,536 370,512 307,470 244,429 213,367 213,285 213,217 232,165 271,131 310,96 363,78 430,78 535,78 617,111 676,176 735,240 764,330 764,445 L 764,551 594,543 Z"
+ id="glyph40" />
+ <glyph
+ unicode="S"
+ horiz-adv-x="875"
+ d="M 956,381 C 956,294 936,220 894,160 852,100 796,55 724,25 652,-5 571,-20 479,-20 396,-20 324,-14 262,-2 201,11 147,26 102,46 L 102,162 C 152,142 209,124 273,109 338,94 409,87 485,87 589,87 673,110 738,158 803,206 836,278 836,373 836,431 823,478 798,515 772,553 734,586 682,614 630,642 565,670 485,699 410,726 345,757 291,791 236,825 194,868 164,919 134,970 119,1035 119,1112 119,1192 138,1259 176,1314 214,1369 267,1411 333,1440 399,1469 474,1483 559,1483 626,1483 689,1476 750,1463 810,1449 868,1430 924,1405 L 883,1303 C 772,1352 663,1377 555,1377 462,1377 386,1354 328,1310 269,1266 240,1200 240,1114 240,1052 252,1001 278,964 303,926 340,895 389,869 438,843 498,817 567,791 648,762 717,731 775,698 833,664 878,623 909,573 941,523 956,459 956,381 Z"
+ id="glyph42" />
+ <glyph
+ unicode="P"
+ horiz-adv-x="848"
+ d="M 539,1462 C 869,1462 1034,1325 1034,1049 1034,908 992,798 907,718 823,638 690,598 510,598 L 311,598 311,0 193,0 193,1462 539,1462 Z M 528,1358 L 311,1358 311,702 498,702 C 629,702 730,727 803,776 875,825 911,914 911,1043 911,1152 880,1232 818,1282 756,1333 659,1358 528,1358 Z"
+ id="glyph44" />
+ <glyph
+ unicode="E"
+ horiz-adv-x="769"
+ d="M 950,0 L 193,0 193,1462 950,1462 950,1356 311,1356 311,821 913,821 913,715 311,715 311,107 950,107 950,0 Z"
+ id="glyph46" />
+ <glyph
+ unicode=" "
+ horiz-adv-x="503"
+ id="glyph48" />
+ </font></defs>
+ <defs
+ class="TextShapeIndex"
+ id="defs55" />
+ <defs
+ class="EmbeddedBulletChars"
+ id="defs87" />
+
+ <g
+ id="id10"
+ clip-path="none"
+ transform="translate(-700.00001,-2550)">
+
+ <text
+ class="SVGTextShape"
+ id="text151"
+ x="217.60002"
+ y="-56.506969"
+ style="letter-spacing:4.7625px;word-spacing:104.775px"><tspan
+ class="TextParagraph"
+ font-family="'Noto Sans Display Light', sans-serif"
+ font-size="494px"
+ font-weight="400"
+ id="tspan149"><tspan
+ class="TextPosition"
+ x="650.59998"
+ y="7647.4932"
+ id="tspan147"><tspan
+ fill="#808080"
+ stroke="none"
+ style="white-space:pre"
+ id="tspan145"
+ dx="2.1199999">Podcast Synchronisation made Efficient</tspan></tspan></tspan></text>
+ </g><g
+ id="g1277"
+ transform="translate(-700.00001,-2550)"><path
+ id="path131-3"
+ d="m 6694.0006,4763 c -559.5515,4.407 -980.3924,428.0893 -986.038,985.9863 18.2894,552.8957 454.3127,974.1166 989.0352,986.0379 v -235.0244 c -406.8751,-27.3888 -715.1078,-362.1649 -719.0259,-748.0163 12.7874,-421.0793 236.9242,-746.3999 750.0318,-749.98 895.0688,1.4728 1915.5158,0 2730.9957,0 V 4763 Z"
+ style="fill:#0084d1;fill-opacity:1"
+ clip-path="none" /><path
+ id="path131"
+ d="m 6685.0183,2562.996 c -559.5515,4.407 -980.3924,428.0893 -986.038,985.9863 18.2894,552.8957 454.3127,974.1167 989.0352,986.038 v -235.0244 c -406.8751,-27.3888 -715.1078,-362.165 -719.0259,-748.0164 12.7874,-421.0792 236.9242,-746.3998 750.0318,-749.98 895.0688,1.4728 1915.5158,0 2730.996,0 V 2562.996 Z"
+ style="fill:#0084d1;fill-opacity:1"
+ clip-path="none" /></g><g
+ id="g1263"
+ transform="translate(-700.00001,-2550)"><path
+ fill="none"
+ stroke="#069a2e"
+ stroke-width="265"
+ stroke-linejoin="round"
+ d="m 2793,5962 c 1283,0 429,-2762 1712,-2762"
+ id="path124" /><path
+ id="path110-6"
+ d="M 3198.0212,6550 V 6300.0411 H 2448.0411 V 6050.0305 5550.0094 H 2198.0305 V 6300.0411 6550 H 2448.0411 2698 Z"
+ style="fill:#069a2e;fill-opacity:1" /><path
+ id="path110"
+ d="m 4111.997,2550.0252 v 249.9589 h 749.9801 v 250.0106 500.0211 h 250.0106 v -750.0317 -249.9589 h -250.0106 -249.9589 z"
+ style="fill:#069a2e;fill-opacity:1" /></g>
+ <g
+ id="g1249"
+ transform="translate(-700.00001,-2550)"><path
+ fill="#ff8000"
+ stroke="none"
+ d="M 2215,4164 C 2412.6918,3832.6035 2379.3124,3383.7591 2135.4189,3084.8193 1956.8564,2857.4026 1671.3097,2718.8851 1382,2721 c 0,-52.6667 0,-105.3333 0,-158 -60.1303,-1.0792 190.6585,-1.2724 121.9814,2.7933 434.6311,30.3756 832.6257,336.7309 974.9655,748.2365 148.7336,402.6249 41.0263,883.7477 -266.0719,1183.8452 C 1996.6852,4716.1853 1689.0048,4838.6187 1382,4830 c 0,-61.6667 0,-123.3333 0,-185 338.199,2.9253 666.226,-186.816 833,-481 z"
+ id="path193" /><path
+ fill="#ff8000"
+ stroke="none"
+ d="m 1936,3979 c 175.8129,-283.3241 59.7943,-700.6319 -239.8255,-849.407 -94.2713,-50.9942 -201.887,-77.9344 -309.1745,-74.593 0,-57 0,-114 0,-171 396.3865,-24.0968 777.5367,297.6517 818.5878,693.1841 44.0348,337.5467 -149.4277,694.9971 -466.4953,826.9493 -110.1027,49.0687 -231.5272,73.543 -352.0925,67.8666 0,-61.6667 0,-123.3333 0,-185 220.6353,7.5804 440.8554,-115.3286 549,-308 z"
+ id="path186" /><path
+ fill="#ff8000"
+ stroke="none"
+ d="m 1659,3822 c 86.3933,-138.0398 18.2474,-344.669 -134.6962,-402.3205 C 1483.44,3402.2177 1438.3524,3394.2432 1394,3398 c 0,-56.6667 0,-113.3333 0,-170 223.1964,-19.6143 444.7886,153.3254 478.4886,375.1817 32.646,181.6951 -54.6854,381.4331 -217.3127,472.2205 -78.2316,46.131 -170.3991,69.3119 -261.1759,62.5978 0,-58.3333 0,-116.6667 0,-175 105.6736,9.5509 212.8962,-49.2218 265,-141 z"
+ id="path179" /><rect
+ class="BoundingBox"
+ stroke="none"
+ fill="none"
+ x="700"
+ y="2550"
+ width="501"
+ height="4001"
+ id="rect136"
+ style="fill:#ff8000;fill-opacity:1" /></g>
+
+</svg>
diff --git a/pse-dashboard/src/App.vue b/pse-dashboard/src/App.vue
new file mode 100644
index 0000000..bd387ff
--- /dev/null
+++ b/pse-dashboard/src/App.vue
@@ -0,0 +1,13 @@
+<script setup>
+import { ErrorLog, NavBar, HelpModal } from '@/components';
+</script>
+
+<template>
+ <NavBar />
+ <router-view />
+ <HelpModal />
+ <ErrorLog />
+</template>
+
+<style scoped>
+</style>
diff --git a/pse-dashboard/src/api/gpodder.js b/pse-dashboard/src/api/gpodder.js
new file mode 100644
index 0000000..057e5a7
--- /dev/null
+++ b/pse-dashboard/src/api/gpodder.js
@@ -0,0 +1,97 @@
+import axios from 'axios';
+
+// export default function useGpodder({
+export default function useGPodder({
+ baseURL,
+ throwHandler = (err) => err,
+ gPodderUser,
+ useCredentials
+ }) {
+
+ const gpodder = axios.create({
+ baseURL,
+ credentials: useCredentials ? "include" : "omit",
+ headers: {
+ 'Content-Type': 'application/json',
+ }
+ });
+
+ let auth = {
+ username: gPodderUser?.username || "",
+ password: gPodderUser?.password || ""
+ };
+
+ gpodder.interceptors.response.use((response) => response, (error) => {
+ // whatever you want to do with the error
+ throwHandler(error);
+ throw error;
+ });
+
+ return {
+
+ /******************************************************************************/
+ /* Authentication API */
+ /******************************************************************************/
+
+ async register(gPodderUser) {
+ return gpodder.post(`/api/2/auth/register.json`, gPodderUser);
+ },
+
+ async login(gPodderUserData) {
+ auth = gPodderUserData;
+ gPodderUser = gPodderUserData
+
+ return gpodder.post(`/api/2/auth/${gPodderUser.username}/login.json`, {}, {auth});
+ },
+
+ async logout() {
+ return gpodder.post(`/api/2/auth/${gPodderUser.username}/logout.json`, {}, {auth});
+ },
+
+ async changePassword(passwordChange) {
+ return gpodder.put(`/api/2/auth/${gPodderUser.username}/changepassword.json`, passwordChange, {auth});
+ },
+
+ async forgotPassword({email}) {
+ return gpodder.post(`/api/2/auth/${email}/forgot.json`, {});
+ },
+
+ // no auth!
+ async resetPassword({username, password, token}) {
+ return gpodder.put(`/api/2/auth/${username}/resetpassword.json?token=${token}`, {password});
+ },
+
+ async deleteAccount(passwordData) {
+ return gpodder.delete(`/api/2/auth/${gPodderUser.username}/delete.json`, {auth, data: passwordData});
+ },
+
+ /******************************************************************************/
+ /* Subscription API */
+ /******************************************************************************/
+
+ async getTitles() {
+ return gpodder.get(`/subscriptions/titles/${gPodderUser.username}.json`, {auth});
+ },
+
+ async putSubscriptions(subscriptions) {
+ return gpodder.put(`/subscriptions/${gPodderUser.username}/device.json`, subscriptions, {auth});
+ },
+
+ async postSubscriptions(subscriptions) {
+ return gpodder.post(`/api/2/subscriptions/${gPodderUser.username}/device.json`, subscriptions, {auth});
+ },
+
+ /******************************************************************************/
+ /* EpisodeActions API */
+ /******************************************************************************/
+
+ async getEpisodeActions() {
+ return gpodder.get(`/api/2/episodes/${gPodderUser.username}.json`, {auth});
+ },
+
+ async postEpisodeActions(episodeActions) {
+ return gpodder.post(`/api/2/episodes/${gPodderUser.username}.json`, episodeActions, {auth});
+ },
+ }
+}
+
diff --git a/pse-dashboard/src/api/gpodder.test.js b/pse-dashboard/src/api/gpodder.test.js
new file mode 100644
index 0000000..bf88ddd
--- /dev/null
+++ b/pse-dashboard/src/api/gpodder.test.js
@@ -0,0 +1,29 @@
+import * as GPodder from './gpodder.js'
+
+const user = new GPodder.GPodderUser({
+ username: "iam@not.real",
+ password: "12345678aB@"
+});
+
+console.log(user)
+console.log(user.username, user.password);
+
+GPodder.init({
+ baseURL: "http://localhost:8080",
+ throwHandler: err => err
+})
+
+async function testGPodder() {
+ const register = await GPodder.register(user);
+ console.log(register.status);
+
+ const login = await GPodder.login(user)
+ console.log(login.status, login.headers);
+
+ const response = await GPodder.getTitles();
+ const json = await response.text();
+ console.log(`${response.status} "${json}"`);
+
+}
+testGPodder();
+
diff --git a/pse-dashboard/src/api/pse-squared.js b/pse-dashboard/src/api/pse-squared.js
new file mode 100644
index 0000000..641542b
--- /dev/null
+++ b/pse-dashboard/src/api/pse-squared.js
@@ -0,0 +1,61 @@
+import useGPodder from '@/api/gpodder.js'
+import { useLogger } from '@/logger.js'
+
+const logger = useLogger();
+
+function errorHandler(error) {
+ if (error.response) {
+ // The request was made and the server responded with a status code
+ // that falls out of the range of 2xx
+ console.log(error.response.data);
+ console.log(error.response.status);
+ console.log(error.response.headers);
+
+ switch (error.response.status) {
+ case 400: logger.badRequestError(); break;
+ case 401: logger.unauthorizedError(); break;
+ case 404: logger.notFoundError(); break;
+ }
+
+ } else if (error.request) {
+ // The request was made but no response was received
+ // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
+ // http.ClientRequest in node.js
+ logger.connectionLostError()
+
+ console.log(error.request);
+ } else {
+ // Something happened in setting up the request that triggered an Error
+ console.log('Error', error.message);
+ logger.append({
+ type: "danger",
+ message: err.message
+ });
+ }
+}
+
+const backendURL = import.meta.env.VITE_BACKEND_URL || "http://localhost:8080";
+console.log("Backend-URL", backendURL);
+
+const pseSquared = useGPodder({
+ // baseURL: process.env.VUE_APP_BASE_URL || "http://localhost:8080",
+ // baseURL: "http://api.pse-squared.de",
+ baseURL: backendURL,
+ throwHandler: error => errorHandler(error)
+});
+
+export const {
+ changePassword,
+ deleteAccount,
+ forgotPassword,
+ getEpisodeActions,
+ getTitles,
+ login,
+ logout,
+ postEpisodeActions,
+ postSubscriptions,
+ putSubscriptions,
+ register,
+ resetPassword,
+} = pseSquared;
+
diff --git a/pse-dashboard/src/assets/logo.svg b/pse-dashboard/src/assets/logo.svg
new file mode 100644
index 0000000..1609066
--- /dev/null
+++ b/pse-dashboard/src/assets/logo.svg
@@ -0,0 +1,211 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ version="1.2"
+ width="87.589989mm"
+ height="52.16547mm"
+ viewBox="0 0 8758.9989 5216.547"
+ preserveAspectRatio="xMidYMid"
+ fill-rule="evenodd"
+ stroke-width="28.222"
+ stroke-linejoin="round"
+ xml:space="preserve"
+ id="svg206"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <defs
+ class="ClipPathGroup"
+ id="defs8" />
+ <defs
+ id="defs51"><font
+ id="EmbeddedFont_1"
+ horiz-adv-x="2048"
+ horiz-origin-x="0"
+ horiz-origin-y="0"
+ vert-origin-x="512"
+ vert-origin-y="768"
+ vert-adv-y="1024">
+ <font-face
+ font-family="Noto Sans Display Light embedded"
+ units-per-em="2048"
+ font-weight="normal"
+ font-style="normal"
+ ascent="2170"
+ descent="609"
+ id="font-face10" />
+ <missing-glyph
+ horiz-adv-x="2048"
+ d="M 0,0 L 2047,0 2047,2047 0,2047 0,0 Z"
+ id="missing-glyph12" />
+ <glyph
+ unicode="y"
+ horiz-adv-x="927"
+ d="M 2,1089 L 127,1089 367,413 C 388,352 407,298 422,250 437,203 449,161 457,124 L 463,124 C 471,156 483,196 498,246 513,296 530,350 551,409 L 786,1089 913,1089 453,-193 C 418,-290 377,-364 328,-416 279,-468 213,-494 131,-494 107,-494 84,-493 63,-490 43,-487 25,-482 8,-476 L 8,-377 C 23,-383 40,-387 57,-391 75,-394 95,-396 117,-396 170,-396 214,-378 249,-342 284,-307 314,-252 340,-179 L 403,4 2,1089 Z"
+ id="glyph14" />
+ <glyph
+ unicode="t"
+ horiz-adv-x="610"
+ d="M 465,80 C 494,80 521,82 547,87 573,91 595,97 614,105 L 614,11 C 594,3 569,-5 541,-11 512,-17 481,-20 449,-20 363,-20 296,5 249,56 202,106 178,189 178,304 L 178,996 27,996 27,1061 178,1100 219,1350 295,1350 295,1090 608,1090 608,996 295,996 295,310 C 295,157 352,80 465,80 Z"
+ id="glyph16" />
+ <glyph
+ unicode="s"
+ horiz-adv-x="742"
+ d="M 817,289 C 817,191 782,115 713,61 643,7 545,-20 418,-20 347,-20 284,-14 228,-1 173,12 126,29 88,50 L 88,162 C 134,138 186,118 244,102 301,86 360,78 420,78 520,78 592,96 636,133 680,169 702,218 702,281 702,341 679,387 632,419 585,451 515,484 422,519 359,542 303,565 255,589 207,613 169,643 142,680 116,717 102,768 102,832 102,919 136,987 204,1037 271,1086 361,1110 473,1110 535,1110 592,1104 646,1092 700,1080 750,1063 795,1043 L 754,946 C 713,964 667,980 617,993 568,1006 518,1012 469,1012 388,1012 326,997 283,967 239,937 217,893 217,836 217,792 228,758 249,733 270,707 301,686 342,668 383,650 433,630 492,609 553,585 608,562 657,537 707,512 745,481 774,443 803,405 817,353 817,289 Z"
+ id="glyph18" />
+ <glyph
+ unicode="r"
+ horiz-adv-x="583"
+ d="M 596,1108 C 646,1108 692,1102 733,1091 L 717,983 C 674,995 632,1001 590,1001 497,1001 423,964 368,890 312,817 285,719 285,598 L 285,-1 168,-1 168,1089 266,1089 279,886 285,886 C 311,948 350,1000 402,1043 455,1086 520,1108 596,1108 Z"
+ id="glyph20" />
+ <glyph
+ unicode="o"
+ horiz-adv-x="927"
+ d="M 1030,547 C 1030,433 1012,333 976,248 940,164 887,98 818,51 749,4 665,-20 565,-20 471,-20 390,3 322,50 253,96 201,162 164,247 127,333 109,433 109,547 109,723 150,861 232,961 315,1061 429,1110 575,1110 672,1110 755,1087 822,1040 890,993 941,927 977,842 1012,757 1030,659 1030,547 Z M 229,547 C 229,407 257,294 312,208 368,123 453,80 567,80 685,80 771,123 826,209 882,295 909,408 909,547 909,637 898,717 875,787 851,856 815,911 766,951 717,990 653,1010 573,1010 459,1010 373,969 315,887 258,805 229,692 229,547 Z"
+ id="glyph22" />
+ <glyph
+ unicode="n"
+ horiz-adv-x="847"
+ d="M 633,1110 C 749,1110 838,1078 900,1014 962,950 993,850 993,713 L 993,1 877,1 877,705 C 877,809 854,885 810,935 766,985 701,1010 616,1010 395,1010 285,871 285,594 L 285,1 168,1 168,1090 262,1090 279,901 287,901 C 314,962 357,1011 416,1051 474,1091 547,1110 633,1110 Z"
+ id="glyph24" />
+ <glyph
+ unicode="m"
+ horiz-adv-x="1430"
+ d="M 1245,1110 C 1348,1110 1427,1080 1485,1018 1542,957 1571,860 1571,727 L 1571,1 1454,1 1454,723 C 1454,820 1434,892 1393,939 1352,986 1296,1010 1227,1010 1130,1010 1056,980 1005,919 953,858 928,764 928,637 L 928,1 811,1 811,723 C 811,820 791,892 750,939 709,986 653,1010 584,1010 487,1010 413,978 361,914 310,850 285,751 285,619 L 285,1 168,1 168,1090 262,1090 279,918 287,918 C 313,971 352,1016 403,1054 455,1092 521,1110 600,1110 675,1110 739,1093 791,1059 842,1025 879,975 899,908 L 907,908 C 936,972 980,1022 1038,1057 1097,1093 1166,1110 1245,1110 Z"
+ id="glyph26" />
+ <glyph
+ unicode="i"
+ horiz-adv-x="187"
+ d="M 227,1493 C 279,1493 305,1464 305,1405 305,1345 279,1315 227,1315 175,1315 150,1345 150,1405 150,1464 175,1493 227,1493 Z M 285,1090 L 285,0 168,0 168,1090 285,1090 Z"
+ id="glyph28" />
+ <glyph
+ unicode="h"
+ horiz-adv-x="847"
+ d="M 285,1059 C 285,1031 284,1003 283,977 281,951 279,926 276,901 L 285,901 C 312,962 355,1011 413,1051 471,1091 543,1110 629,1110 746,1110 836,1078 899,1014 962,950 993,850 993,713 L 993,1 877,1 877,705 C 877,809 854,885 810,935 766,985 701,1010 616,1010 395,1010 285,871 285,594 L 285,1 168,1 168,1557 285,1557 285,1059 Z"
+ id="glyph30" />
+ <glyph
+ unicode="f"
+ horiz-adv-x="689"
+ d="M 575,995 L 332,995 332,-1 213,-1 213,995 27,995 27,1058 213,1093 213,1202 C 213,1445 316,1566 522,1566 559,1566 593,1563 623,1557 653,1551 680,1544 705,1536 L 678,1439 C 655,1448 630,1454 603,1460 577,1465 550,1468 524,1468 456,1468 407,1447 377,1405 347,1362 332,1295 332,1202 L 332,1089 575,1089 575,995 Z"
+ id="glyph32" />
+ <glyph
+ unicode="e"
+ horiz-adv-x="874"
+ d="M 559,1110 C 646,1110 720,1089 779,1046 839,1003 883,944 913,869 943,794 958,708 958,611 L 958,531 229,531 C 231,386 262,275 325,198 387,121 476,82 592,82 656,82 712,88 759,100 806,111 858,130 915,156 L 915,50 C 865,25 814,7 764,-4 713,-15 655,-20 588,-20 434,-20 315,30 232,129 150,229 109,365 109,537 109,648 126,746 162,832 197,918 249,986 315,1036 382,1085 464,1110 559,1110 Z M 559,1012 C 465,1012 389,979 333,912 276,845 243,750 233,627 L 838,627 C 838,742 815,835 769,906 723,977 653,1012 559,1012 Z"
+ id="glyph34" />
+ <glyph
+ unicode="d"
+ horiz-adv-x="900"
+ d="M 535,-20 C 398,-20 293,27 219,120 145,214 109,352 109,535 109,722 147,865 224,963 301,1061 408,1110 545,1110 629,1110 698,1090 752,1050 805,1010 845,961 872,904 L 881,904 C 879,935 878,970 876,1009 873,1048 872,1084 872,1117 L 872,1557 989,1557 989,0 895,0 879,191 872,191 C 845,132 805,82 751,41 697,0 625,-20 535,-20 Z M 553,80 C 669,80 752,119 801,195 850,271 875,382 875,527 L 875,545 C 875,695 850,810 801,890 752,970 671,1010 559,1010 451,1010 369,969 313,886 257,804 229,686 229,533 229,385 256,273 309,196 363,119 444,80 553,80 Z"
+ id="glyph36" />
+ <glyph
+ unicode="c"
+ horiz-adv-x="768"
+ d="M 580,-20 C 429,-20 313,29 231,127 150,226 109,363 109,539 109,662 129,766 170,850 211,935 269,1000 343,1044 417,1088 504,1110 602,1110 651,1110 698,1106 742,1096 787,1087 825,1074 858,1057 L 825,957 C 791,972 754,984 714,993 673,1002 636,1006 600,1006 481,1006 390,964 326,881 261,798 229,685 229,541 229,405 258,294 314,210 371,126 459,84 580,84 630,84 678,90 723,101 768,112 809,125 846,142 L 846,37 C 812,20 773,6 729,-5 685,-15 636,-20 580,-20 Z"
+ id="glyph38" />
+ <glyph
+ unicode="a"
+ horiz-adv-x="822"
+ d="M 535,1108 C 651,1108 737,1078 795,1018 852,958 881,863 881,734 L 881,0 793,0 772,185 766,185 C 729,123 684,74 631,36 578,-1 503,-20 408,-20 311,-20 233,6 176,59 119,111 90,187 90,285 90,394 132,477 215,533 298,589 420,621 580,629 L 764,639 764,715 C 764,822 744,897 705,942 665,987 606,1010 528,1010 477,1010 426,1002 378,987 329,972 281,953 231,928 L 195,1022 C 242,1047 295,1067 352,1084 410,1100 470,1108 535,1108 Z M 594,543 C 466,536 370,512 307,470 244,429 213,367 213,285 213,217 232,165 271,131 310,96 363,78 430,78 535,78 617,111 676,176 735,240 764,330 764,445 L 764,551 594,543 Z"
+ id="glyph40" />
+ <glyph
+ unicode="S"
+ horiz-adv-x="875"
+ d="M 956,381 C 956,294 936,220 894,160 852,100 796,55 724,25 652,-5 571,-20 479,-20 396,-20 324,-14 262,-2 201,11 147,26 102,46 L 102,162 C 152,142 209,124 273,109 338,94 409,87 485,87 589,87 673,110 738,158 803,206 836,278 836,373 836,431 823,478 798,515 772,553 734,586 682,614 630,642 565,670 485,699 410,726 345,757 291,791 236,825 194,868 164,919 134,970 119,1035 119,1112 119,1192 138,1259 176,1314 214,1369 267,1411 333,1440 399,1469 474,1483 559,1483 626,1483 689,1476 750,1463 810,1449 868,1430 924,1405 L 883,1303 C 772,1352 663,1377 555,1377 462,1377 386,1354 328,1310 269,1266 240,1200 240,1114 240,1052 252,1001 278,964 303,926 340,895 389,869 438,843 498,817 567,791 648,762 717,731 775,698 833,664 878,623 909,573 941,523 956,459 956,381 Z"
+ id="glyph42" />
+ <glyph
+ unicode="P"
+ horiz-adv-x="848"
+ d="M 539,1462 C 869,1462 1034,1325 1034,1049 1034,908 992,798 907,718 823,638 690,598 510,598 L 311,598 311,0 193,0 193,1462 539,1462 Z M 528,1358 L 311,1358 311,702 498,702 C 629,702 730,727 803,776 875,825 911,914 911,1043 911,1152 880,1232 818,1282 756,1333 659,1358 528,1358 Z"
+ id="glyph44" />
+ <glyph
+ unicode="E"
+ horiz-adv-x="769"
+ d="M 950,0 L 193,0 193,1462 950,1462 950,1356 311,1356 311,821 913,821 913,715 311,715 311,107 950,107 950,0 Z"
+ id="glyph46" />
+ <glyph
+ unicode=" "
+ horiz-adv-x="503"
+ id="glyph48" />
+ </font></defs>
+ <defs
+ class="TextShapeIndex"
+ id="defs55" />
+ <defs
+ class="EmbeddedBulletChars"
+ id="defs87" />
+
+ <g
+ id="id10"
+ clip-path="none"
+ transform="translate(-700.00001,-2550)">
+
+ <text
+ class="SVGTextShape"
+ id="text151"
+ x="217.60002"
+ y="-56.506969"
+ style="letter-spacing:4.7625px;word-spacing:104.775px"><tspan
+ class="TextParagraph"
+ font-family="'Noto Sans Display Light', sans-serif"
+ font-size="494px"
+ font-weight="400"
+ id="tspan149"><tspan
+ class="TextPosition"
+ x="650.59998"
+ y="7647.4932"
+ id="tspan147"><tspan
+ fill="#808080"
+ stroke="none"
+ style="white-space:pre"
+ id="tspan145"
+ dx="2.1199999">Podcast Synchronisation made Efficient</tspan></tspan></tspan></text>
+ </g><g
+ id="g1277"
+ transform="translate(-700.00001,-2550)"><path
+ id="path131-3"
+ d="m 6694.0006,4763 c -559.5515,4.407 -980.3924,428.0893 -986.038,985.9863 18.2894,552.8957 454.3127,974.1166 989.0352,986.0379 v -235.0244 c -406.8751,-27.3888 -715.1078,-362.1649 -719.0259,-748.0163 12.7874,-421.0793 236.9242,-746.3999 750.0318,-749.98 895.0688,1.4728 1915.5158,0 2730.9957,0 V 4763 Z"
+ style="fill:#0084d1;fill-opacity:1"
+ clip-path="none" /><path
+ id="path131"
+ d="m 6685.0183,2562.996 c -559.5515,4.407 -980.3924,428.0893 -986.038,985.9863 18.2894,552.8957 454.3127,974.1167 989.0352,986.038 v -235.0244 c -406.8751,-27.3888 -715.1078,-362.165 -719.0259,-748.0164 12.7874,-421.0792 236.9242,-746.3998 750.0318,-749.98 895.0688,1.4728 1915.5158,0 2730.996,0 V 2562.996 Z"
+ style="fill:#0084d1;fill-opacity:1"
+ clip-path="none" /></g><g
+ id="g1263"
+ transform="translate(-700.00001,-2550)"><path
+ fill="none"
+ stroke="#069a2e"
+ stroke-width="265"
+ stroke-linejoin="round"
+ d="m 2793,5962 c 1283,0 429,-2762 1712,-2762"
+ id="path124" /><path
+ id="path110-6"
+ d="M 3198.0212,6550 V 6300.0411 H 2448.0411 V 6050.0305 5550.0094 H 2198.0305 V 6300.0411 6550 H 2448.0411 2698 Z"
+ style="fill:#069a2e;fill-opacity:1" /><path
+ id="path110"
+ d="m 4111.997,2550.0252 v 249.9589 h 749.9801 v 250.0106 500.0211 h 250.0106 v -750.0317 -249.9589 h -250.0106 -249.9589 z"
+ style="fill:#069a2e;fill-opacity:1" /></g>
+ <g
+ id="g1249"
+ transform="translate(-700.00001,-2550)"><path
+ fill="#ff8000"
+ stroke="none"
+ d="M 2215,4164 C 2412.6918,3832.6035 2379.3124,3383.7591 2135.4189,3084.8193 1956.8564,2857.4026 1671.3097,2718.8851 1382,2721 c 0,-52.6667 0,-105.3333 0,-158 -60.1303,-1.0792 190.6585,-1.2724 121.9814,2.7933 434.6311,30.3756 832.6257,336.7309 974.9655,748.2365 148.7336,402.6249 41.0263,883.7477 -266.0719,1183.8452 C 1996.6852,4716.1853 1689.0048,4838.6187 1382,4830 c 0,-61.6667 0,-123.3333 0,-185 338.199,2.9253 666.226,-186.816 833,-481 z"
+ id="path193" /><path
+ fill="#ff8000"
+ stroke="none"
+ d="m 1936,3979 c 175.8129,-283.3241 59.7943,-700.6319 -239.8255,-849.407 -94.2713,-50.9942 -201.887,-77.9344 -309.1745,-74.593 0,-57 0,-114 0,-171 396.3865,-24.0968 777.5367,297.6517 818.5878,693.1841 44.0348,337.5467 -149.4277,694.9971 -466.4953,826.9493 -110.1027,49.0687 -231.5272,73.543 -352.0925,67.8666 0,-61.6667 0,-123.3333 0,-185 220.6353,7.5804 440.8554,-115.3286 549,-308 z"
+ id="path186" /><path
+ fill="#ff8000"
+ stroke="none"
+ d="m 1659,3822 c 86.3933,-138.0398 18.2474,-344.669 -134.6962,-402.3205 C 1483.44,3402.2177 1438.3524,3394.2432 1394,3398 c 0,-56.6667 0,-113.3333 0,-170 223.1964,-19.6143 444.7886,153.3254 478.4886,375.1817 32.646,181.6951 -54.6854,381.4331 -217.3127,472.2205 -78.2316,46.131 -170.3991,69.3119 -261.1759,62.5978 0,-58.3333 0,-116.6667 0,-175 105.6736,9.5509 212.8962,-49.2218 265,-141 z"
+ id="path179" /><rect
+ class="BoundingBox"
+ stroke="none"
+ fill="none"
+ x="700"
+ y="2550"
+ width="501"
+ height="4001"
+ id="rect136"
+ style="fill:#ff8000;fill-opacity:1" /></g>
+
+</svg>
diff --git a/pse-dashboard/src/components/DashboardLayout.vue b/pse-dashboard/src/components/DashboardLayout.vue
new file mode 100644
index 0000000..55695ae
--- /dev/null
+++ b/pse-dashboard/src/components/DashboardLayout.vue
@@ -0,0 +1,10 @@
+<script setup>
+</script>
+<template>
+ <div class="container p-5 my-5 mt-2">
+ <slot />
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/EpisodeEntry.vue b/pse-dashboard/src/components/EpisodeEntry.vue
new file mode 100644
index 0000000..c651484
--- /dev/null
+++ b/pse-dashboard/src/components/EpisodeEntry.vue
@@ -0,0 +1,53 @@
+<script setup>
+import { LastUpdate, ProgressTime } from '@/components'
+
+const props = defineProps({
+ action: {
+ type: Object,
+ default: undefined
+ }
+});
+
+</script>
+<template>
+ <a
+ href="#"
+ class="list-group-item list-group-item-action py-3"
+ aria-current="true"
+ >
+ <!-- title with timestamp -->
+ <div class="d-flex gap-3">
+ <i
+ class="fa fa-podcast rounded-circle flex-shring-0"
+ style="font-size: 32px"
+ />
+ <div class="d-flex gap-2 w-100 justify-content-between">
+ <div>
+ <h6 class="mb-0">{{ action.title }}</h6>
+ <p class="mb-0 opacity-75">{{ action.description }}</p>
+ </div>
+ <small class="opacity-50 text-nowrap">
+ <LastUpdate :iso="action.timestamp" />
+ </small>
+ </div>
+ </div>
+
+ <!-- Progress bar with Progress time -->
+ <div class="d-flex">
+ <ProgressTime :unix="action.position" />
+ <div
+ class="progress flex-grow-1 m-2"
+ style="height:10px; "
+ >
+ <div
+ class="progress-bar"
+ :style="{width: 100*action.position/action.total + '%'}"
+ />
+ </div>
+ <ProgressTime :unix="action.total" />
+ </div>
+ </a>
+</template>
+<style>
+</style>
+
diff --git a/pse-dashboard/src/components/ErrorLog.vue b/pse-dashboard/src/components/ErrorLog.vue
new file mode 100644
index 0000000..3c36359
--- /dev/null
+++ b/pse-dashboard/src/components/ErrorLog.vue
@@ -0,0 +1,36 @@
+<script setup>
+import { Logger } from '@/logger.js'
+
+const icon = {
+ success: "fa-trophy",
+ info: "fa-circle-exclamation",
+ warning: "fa-triangle-exclamation",
+ danger: "fa-fire",
+};
+
+</script>
+<template>
+ <div class="row">
+ <div class="col-sm-12 col-md-6 col-lg-5 position-fixed bottom-0 end-0 my-1">
+ <div
+ v-for="(item, index) in Logger.items"
+ :key="index"
+ class="alert alert-dismissible d-flex align-items-center"
+ :class="'alert-' + item.type"
+ >
+ <button
+ type="button"
+ class="btn-close"
+ @click="Logger.delete(item)"
+ />
+ <i
+ class="fs-3 me-3 fa"
+ :class="icon[item.type]"
+ /> {{ item.message }}
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/FloatingLabelInput.vue b/pse-dashboard/src/components/FloatingLabelInput.vue
new file mode 100644
index 0000000..6f718fd
--- /dev/null
+++ b/pse-dashboard/src/components/FloatingLabelInput.vue
@@ -0,0 +1,35 @@
+<script setup>
+defineProps({
+ type: {
+ type: String,
+ default: "text"
+ },
+ label: {
+ type: String,
+ default: "Text"
+ },
+ modelValue: {
+ type: String,
+ default: ""
+ }
+});
+defineEmits(['update:modelValue']);
+
+</script>
+<template>
+ <div class="form-floating form-input">
+ <input
+ :id="$.uid"
+ :type="type"
+ class="form-control"
+ :value="modelValue"
+ :placeholder="label"
+ required
+ @input="$emit('update:modelValue', $event.target.value)"
+ >
+ <label :for="$.uid">{{ label }}</label>
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/FormLayout.vue b/pse-dashboard/src/components/FormLayout.vue
new file mode 100644
index 0000000..549339f
--- /dev/null
+++ b/pse-dashboard/src/components/FormLayout.vue
@@ -0,0 +1,24 @@
+<script setup>
+</script>
+<template>
+ <!-- centers the form -->
+ <div class="container my-5 text-center">
+ <div class="row">
+ <div class="col-sm-9 col-md-7 col-lg-5 mx-auto">
+ <!-- Logo -->
+ <img
+ class="mb-4"
+ src="../assets/logo.svg"
+ alt=""
+ width="250"
+ height="120"
+ >
+
+ <slot />
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/HelpModal.vue b/pse-dashboard/src/components/HelpModal.vue
new file mode 100644
index 0000000..b4f45ef
--- /dev/null
+++ b/pse-dashboard/src/components/HelpModal.vue
@@ -0,0 +1,44 @@
+<script setup>
+</script>
+<template>
+ <div
+ id="help"
+ class="modal fade"
+ >
+ <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
+ <div class="modal-content">
+ <!-- Modal Header -->
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t("message.help") }}
+ </h5>
+ <button
+ type="button"
+ class="btn-close"
+ data-bs-dismiss="modal"
+ />
+ </div>
+
+ <!-- Modal Body -->
+ <div
+ class="modal-body"
+ v-html="$t('message.helpModal')"
+ />
+
+ <!-- Modal Footer -->
+ <div class="modal-footer">
+ <button
+ type="button"
+ class="btn btn-secondary"
+ data-bs-dismiss="modal"
+ >
+ {{ $t("message.close") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/LastUpdate.vue b/pse-dashboard/src/components/LastUpdate.vue
new file mode 100644
index 0000000..b30b254
--- /dev/null
+++ b/pse-dashboard/src/components/LastUpdate.vue
@@ -0,0 +1,46 @@
+<script setup>
+import { computed } from 'vue'
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs/esm'
+import relativeTime from 'dayjs/esm/plugin/relativeTime'
+import utc from 'dayjs/esm/plugin/utc'
+import 'dayjs/esm/locale/de.js'
+dayjs.extend(relativeTime)
+dayjs.extend(utc)
+
+const props = defineProps({
+ iso: {
+ type: String,
+ default: ""
+ },
+ unix: {
+ type: Number,
+ default: 0
+ }
+});
+
+const { locale } = useI18n();
+
+const tzOffset = (new Date()).getTimezoneOffset();
+
+// compute difference from given iso string and now whenever data changes
+const lastUpdate = computed(() => {
+ let lastUpdateTime;
+
+ if (props.iso) {
+ lastUpdateTime = dayjs(props.iso).utc(true);
+ } else {
+ // lastUpdateTime = dayjs(props.unix * 1000);
+ lastUpdateTime = dayjs(props.unix * 1000).utcOffset(tzOffset).utc(true);
+ }
+
+ return lastUpdateTime.locale(locale.value).fromNow();
+});
+
+</script>
+<template>
+ {{ lastUpdate }}
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/LoadingConditional.vue b/pse-dashboard/src/components/LoadingConditional.vue
new file mode 100644
index 0000000..2bf233c
--- /dev/null
+++ b/pse-dashboard/src/components/LoadingConditional.vue
@@ -0,0 +1,18 @@
+<script setup>
+defineProps({
+ waitingFor: Boolean
+});
+
+</script>
+<template>
+ <slot v-if="waitingFor" />
+ <div
+ v-else
+ class="text-center"
+ >
+ <div class="spinner-border text-center" />
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/NavBar.vue b/pse-dashboard/src/components/NavBar.vue
new file mode 100644
index 0000000..2cc36e5
--- /dev/null
+++ b/pse-dashboard/src/components/NavBar.vue
@@ -0,0 +1,134 @@
+<script setup>
+import { store } from '@/store.js';
+</script>
+<template>
+ <nav class="navbar navbar-expand-sm bg-light">
+ <div class="container">
+ <!-- brand -->
+ <router-link
+ class="navbar-brand"
+ to="/subscriptions"
+ >
+ <img
+ src="@/assets/logo.svg"
+ alt=""
+ width="200"
+ height="45"
+ >
+ </router-link>
+
+ <!-- mobile view -->
+ <button
+ class="navbar-toggler"
+ type="button"
+ data-bs-toggle="collapse"
+ data-bs-target="#mynavbar"
+ >
+ <span class="navbar-toggler-icon" />
+ </button>
+ <div
+ id="mynavbar"
+ class="collapse navbar-collapse"
+ >
+ <!-- routes -->
+ <div class="me-auto">
+ <ul
+ v-if="store.isLoggedIn"
+ class="navbar-nav"
+ >
+ <li class="nav-item">
+ <router-link
+ to="/subscriptions"
+ class="nav-link"
+ >
+ {{ $t("message.podcast", {n:2}) }}
+ </router-link>
+ </li>
+ <li class="nav-item">
+ <router-link
+ to="/episodes"
+ class="nav-link"
+ >
+ {{ $t("message.mostRecentlyHeared") }}
+ </router-link>
+ </li>
+ </ul>
+ </div>
+
+ <!-- right side of navbar -->
+ <div class="d-flex navbar-nav">
+ <!-- change language -->
+ <!-- https://vue-i18n.intlify.dev/guide/essentials/scope.html -->
+ <div class="nav-item dropdown">
+ <a
+ class="nav-link dropdown-toggle"
+ href="#"
+ role="button"
+ data-bs-toggle="dropdown"
+ >
+ <i class="fa fa-language" /> {{ $i18n.locale }}
+ </a>
+ <ul class="dropdown-menu">
+ <li
+ v-for="locale in $i18n.availableLocales"
+ :key="locale"
+ >
+ <a
+ class="dropdown-item"
+ :class="{active: $i18n.locale == locale}"
+ @click="$i18n.locale = locale"
+ >{{ locale }}</a>
+ </li>
+ </ul>
+ </div>
+
+ <!-- Help Modal -->
+ <a
+ href="#"
+ class="nav-link"
+ data-bs-toggle="modal"
+ data-bs-target="#help"
+ >{{ $t("message.help") }}</a>
+
+ <!-- User Account -->
+ <div
+ v-if="store.isLoggedIn"
+ class="nav-item dropdown"
+ >
+ <a
+ href="#"
+ class="nav-link dropdown-toggle"
+ role="button"
+ data-bs-toggle="dropdown"
+ >
+ {{ store.username || "John Doe" }} <i class="m-1 fa fa-user" />
+ </a>
+ <ul class="dropdown-menu">
+ <li>
+ <router-link
+ to="/settings"
+ class="dropdown-item"
+ >
+ {{ $t("message.settings") }}
+ </router-link>
+ </li>
+ <li><hr class="dropdown-divider"></li>
+ <li>
+ <router-link
+ to="/login"
+ class="dropdown-item"
+ @click="store.logout()"
+ >
+ {{ $t("message.logout") }}
+ </router-link>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ </nav>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/PasswordInput.vue b/pse-dashboard/src/components/PasswordInput.vue
new file mode 100644
index 0000000..599f438
--- /dev/null
+++ b/pse-dashboard/src/components/PasswordInput.vue
@@ -0,0 +1,45 @@
+<script setup>
+import { ref } from 'vue';
+import { FloatingLabelInput } from '@/components';
+
+defineProps({
+ modelValue: {
+ type: String,
+ default: ""
+ },
+ label: {
+ type: String,
+ default: "Password"
+ }
+});
+
+defineEmits(['update:modelValue'])
+const isPasswordVisible = ref(false);
+</script>
+<template>
+ <div class="input-group form-input">
+ <FloatingLabelInput
+ :type="isPasswordVisible ? 'text' : 'password'"
+ :label="label"
+ :model-value="modelValue"
+ @update:model-value="newValue =>
+ $emit('update:modelValue', newValue)"
+ />
+
+ <label class="btn btn-outline-secondary d-flex align-items-center password-visible">
+ <input
+ v-model="isPasswordVisible"
+ type="checkbox"
+ class="btn-check"
+ autocomplete="off"
+ >
+ <i
+ class="fa"
+ :class="isPasswordVisible ? 'fa-eye-slash' : 'fa-eye'"
+ />
+ </label>
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/PasswordValidator.vue b/pse-dashboard/src/components/PasswordValidator.vue
new file mode 100644
index 0000000..a269426
--- /dev/null
+++ b/pse-dashboard/src/components/PasswordValidator.vue
@@ -0,0 +1,112 @@
+<script setup>
+import { PasswordInput } from '@/components';
+import { computed, watch, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+ modelValue: {
+ type: Object,
+ default: undefined
+ }
+});
+
+const emit = defineEmits(['update:modelValue']);
+
+const { t } = useI18n();
+
+const password1 = ref("");
+const password2 = ref("");
+
+// Überprüft, ob Passwort und Wiederholungspasswort identisch sind
+const passwordMatch = computed(() => {
+ return password1.value === password2.value;
+});
+
+watch(() => props.modelValue, (newVal) => {
+ if ( newVal.password == "" ) {
+ password1.value = "";
+ password2.value = "";
+ }
+});
+
+// Überprüft, ob Passwort lang genug ist
+const passwordLength = computed(() => {
+ return password1.value.length >= 8;
+});
+
+// Überprüft, ob das Passwort ein Sonderzeichen enthält
+const passwordSpecialChar = computed(() => {
+ const specialCharRegex = /[!@#$%^&*()_+\-=\[\]{});':"`\\|,.<>\/?$§€°~`´]/;
+ return specialCharRegex.test(password1.value);
+});
+
+// Überprüft, ob das Passwort Zahlen enthält
+const passwordNumbers = computed(() => {
+ const numbersRegex = /[0-9]/;
+ return numbersRegex.test(password1.value);
+});
+
+// Überprüft ob das Passwort Groß- und Kleinschreibung enthält
+const passwordUpperLower = computed(() => {
+ const upperLowerRegex = /^(?=.*[A-Z])(?=.*[a-z]).+$/;
+ return upperLowerRegex.test(password1.value);
+});
+
+// Wahr, falls das Passwort alle Eigenschaften für ein sicheres Passwort erfüllt
+const isPasswordValidArray = computed(() => [
+ { rule: passwordLength, text: t("passwordRequirements.passwordLength", 8) },
+ { rule: passwordSpecialChar, text: t("passwordRequirements.passwordSpecialChar") },
+ { rule: passwordNumbers, text: t("passwordRequirements.passwordNumbers") },
+ { rule: passwordUpperLower, text: t("passwordRequirements.passwordUpperLower") },
+ { rule: passwordMatch, text: t("passwordRequirements.passwordMatch") },
+]);
+
+watch(password1, emitPassword);
+watch(password2, emitPassword);
+
+// emit password object with password and valid whenever passwort1 or 2 changes
+function emitPassword() {
+ emit('update:modelValue', {
+ password: password1.value,
+ valid: isPasswordValidArray.value.every(({rule}) => rule.value)
+ });
+};
+
+// Nimmt eine Funktion entgegen,
+// falls diese wahr auswertet, wird ein Häckchen Emoji zurückgegeben,
+// falls diese falsch auswertet, wird ein Kreuz Emoji zurückgegeben
+function returnEmoji(fn) {
+ return fn ? '✅' : '❌';
+};
+
+</script>
+<template>
+ <!-- Eingabefeld für Passwort -->
+ <PasswordInput
+ v-model="password1"
+ :label="$t('form.password')"
+ />
+
+ <!-- Eingabefeld für Passwortwiederholung -->
+ <PasswordInput
+ v-model="password2"
+ :label="$t('message.repeatPassword')"
+ />
+
+ <!-- Liste mit allen Anforderungen für ein sicheres Passwort -->
+ <!-- Falls Anforderung erfüllt, wird ein Häckchen Emoji vor der Anforderung angezeigt, -->
+ <!-- falls nicht, ein Kreuz -->
+ <ul>
+ <li
+ v-for="(rule, index) in isPasswordValidArray"
+ :key="index"
+ class="d-flex"
+ >
+ {{ returnEmoji(rule.rule.value) }} {{ rule.text }}
+ </li>
+ </ul>
+</template>
+<style>
+
+</style>
+
diff --git a/pse-dashboard/src/components/ProgressTime.vue b/pse-dashboard/src/components/ProgressTime.vue
new file mode 100644
index 0000000..61bd421
--- /dev/null
+++ b/pse-dashboard/src/components/ProgressTime.vue
@@ -0,0 +1,23 @@
+<script setup>
+import { computed } from 'vue'
+import dayjs from 'dayjs/esm'
+import utc from 'dayjs/esm/plugin/utc'
+dayjs.extend(utc)
+
+const props = defineProps({
+ unix: {
+ type: Number,
+ default: 0
+ }
+});
+
+// compute hours, minutes and seconds from unix-seconds whenever data changes
+const progressTime = computed(() => dayjs.utc(props.unix * 1000).format("HH:mm:ss"));
+
+</script>
+<template>
+ {{ progressTime }}
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/SubscriptionEntry.vue b/pse-dashboard/src/components/SubscriptionEntry.vue
new file mode 100644
index 0000000..db3b45c
--- /dev/null
+++ b/pse-dashboard/src/components/SubscriptionEntry.vue
@@ -0,0 +1,118 @@
+<script setup>
+import { useLogger } from '@/logger.js'
+import { LastUpdate, ProgressTime } from '@/components'
+
+const props = defineProps({
+ sub: {
+ type: Object,
+ default: undefined
+ }
+});
+
+defineEmits(['unsubscribe']);
+
+const { copiedPodcast, copiedPodcastError } = useLogger();
+
+// share or copy the url of the podcast to clipboard
+async function sharePodcast() {
+ const shareData = {
+ title: props.sub.title,
+ url: props.sub.url
+ };
+
+ // share API
+ try {
+ await navigator.share(shareData);
+ return;
+ } catch (err) {
+ console.error(err);
+ }
+
+ // clipboard API
+ try {
+ await navigator.clipboard.writeText(shareData.url)
+ copiedPodcast();
+ return;
+ } catch (err) {
+ console.error(err);
+ }
+
+ copiedPodcastError();
+}
+
+</script>
+<template>
+ <div class="card">
+ <!-- title and timestamp -->
+ <div
+ class="card-header d-flex gap-3 py-3"
+ data-bs-toggle="collapse"
+ :data-bs-target="'#e' + $.uid"
+ >
+ <!-- Podcast Icon -->
+ <i
+ class="fa fa-podcast rounded-circle flex-shring-0"
+ style="font-size: 32px"
+ />
+
+ <div class="d-flex gap-2 w-100 justify-content-between">
+ <!-- Title -->
+ <div>
+ <h6 class="mb-0">
+ {{ sub.title || sub.url }}
+ </h6>
+ </div>
+ <div class="text-nowrap">
+ <!-- Timestamp -->
+ <small class="opacity-50">
+ <LastUpdate :unix="sub.timestamp * 1" />
+ </small>
+
+ <!-- Trash Button to unscubscribe from podcast -->
+ <!-- opens modal and emits unsubscribe event -->
+ <button
+ class="btn mx-2 btn-danger"
+ data-bs-toggle="modal"
+ data-bs-target="#delete-subs"
+ @click="$emit('unsubscribe', props.sub)"
+ >
+ <i class="fa fa-trash-can" />
+ </button>
+
+ <!-- Share Button (@click.stop should stops card to open but doesn't) -->
+ <button
+ class="btn ml-2 btn-secondary"
+ @click="sharePodcast"
+ >
+ <i class="fa fa-share" />
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- episode list -->
+ <div
+ :id="'e' + $.uid"
+ class="collapse"
+ data-bs-parent="#episodes-accordion"
+ >
+ <div class="card-body">
+ <ol>
+ <li
+ v-for="(episode, index) in sub.episodes"
+ :key="index"
+ >
+ {{ episode.title }}
+ <span class="opacity-50 float-end">
+ <ProgressTime :unix="episode.position" />/
+ <ProgressTime :unix="episode.total" />
+ </span>
+ </li>
+ </ol>
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/components/index.js b/pse-dashboard/src/components/index.js
new file mode 100644
index 0000000..d78c3d7
--- /dev/null
+++ b/pse-dashboard/src/components/index.js
@@ -0,0 +1,30 @@
+import DashboardLayout from './DashboardLayout.vue'
+import EpisodeEntry from './EpisodeEntry.vue'
+import ErrorLog from './ErrorLog.vue'
+import FloatingLabelInput from './FloatingLabelInput.vue'
+import FormLayout from './FormLayout.vue'
+import HelpModal from './HelpModal.vue'
+import LastUpdate from './LastUpdate.vue'
+import LoadingConditional from './LoadingConditional.vue'
+import NavBar from './NavBar.vue'
+import PasswordInput from './PasswordInput.vue'
+import PasswordValidator from './PasswordValidator.vue'
+import ProgressTime from './ProgressTime.vue'
+import SubscriptionEntry from './SubscriptionEntry.vue'
+
+export {
+ DashboardLayout,
+ EpisodeEntry,
+ ErrorLog,
+ FloatingLabelInput,
+ FormLayout,
+ HelpModal,
+ LastUpdate,
+ LoadingConditional,
+ NavBar,
+ PasswordInput,
+ PasswordValidator,
+ ProgressTime,
+ SubscriptionEntry,
+}
+
diff --git a/pse-dashboard/src/i18n.js b/pse-dashboard/src/i18n.js
new file mode 100644
index 0000000..e722eab
--- /dev/null
+++ b/pse-dashboard/src/i18n.js
@@ -0,0 +1,11 @@
+import { createI18n } from 'vue-i18n'
+import * as locales from '@/locales'
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'de',
+ messages: {...locales}
+});
+
+export default i18n
+
diff --git a/pse-dashboard/src/locales/de.help.html b/pse-dashboard/src/locales/de.help.html
new file mode 100644
index 0000000..f2fca3a
--- /dev/null
+++ b/pse-dashboard/src/locales/de.help.html
@@ -0,0 +1,10 @@
+<p>
+Hier stehen Hilfestellungen.
+</p>
+<br><br><br><br><br><br><br><br><br><br><br>
+<p>Bis hier unten. </p>
+<br><br><br><br><br><br><br><br><br><br><br>
+<br><br><br><br><br><br><br><br><br><br><br>
+<br><br><br><br><br><br><br><br><br><br><br>
+<p>Und noch viel weiter! </p>
+
diff --git a/pse-dashboard/src/locales/de.json b/pse-dashboard/src/locales/de.json
new file mode 100644
index 0000000..430e844
--- /dev/null
+++ b/pse-dashboard/src/locales/de.json
@@ -0,0 +1,77 @@
+{
+ "message": {
+ "addSubscription": "Abonnement hinzufügen",
+ "changePassword": "Passwort ändern",
+ "close": "Schließen",
+ "deleteAccount": "Account löschen",
+ "deleteAccountWarning": "Bist du sicher, dass du dein Konto mit dem Namen '{username}' löschen möchtest? Dabei gehen alle deine Abonnements und gehörten Episoden verloren. Du kannst aber jederzeit ein neues Konto erstellen. ",
+ "emailAddressRequest": "Bitte E-Mail-Adresse angeben",
+ "episode": "keine Episoden | eine Episode | {n} Episoden",
+ "exportData": "Daten exportieren",
+ "forgotPassword": "Passwort vergessen",
+ "gpodderInstanceRequest": "Gpodder-Instanz eingeben",
+ "help": "Hilfe",
+ "import": "Importieren",
+ "importData": "Daten importieren",
+ "instance": "Gpodder-Instanz",
+ "login": "Anmelden",
+ "loginRequest": "Bitte anmelden",
+ "logout": "Abmelden",
+ "mostRecentlyHeardEpisodes": "Zuletzt gehörte Episoden",
+ "mostRecentlyHeared": "Zuletzt gehört",
+ "newPassword": "Neues Passwort",
+ "newSubscription": "Neues Abonnement",
+ "noAccountYet": "Noch keinen Account",
+ "noEpisodes": "Du hast noch keine Episode angehört. ",
+ "noSubscriptions": "Du hast noch keine Abonnements hinzugefügt. ",
+ "oldPassword": "Altes Passwort",
+ "passwordRequest": "Passwort eingeben",
+ "personalData": "Personenbezogene Daten",
+ "podcast": "Podcast | Podcasts",
+ "registration": "Registrierung",
+ "rememberMe": "Angemeldet bleiben",
+ "repeat": "Wiederholen",
+ "repeatPassword": "Passwort wiederholen",
+ "selectAll": "Alle auswählen",
+ "send": "Absenden",
+ "setNewPassword": "Neues Passwort festlegen",
+ "settings": "Einstellungen",
+ "signUp": "Registrieren",
+ "userNameRequest": "Nutzername eingeben",
+ "unsubscribePodcasts": "Podcasts deabonnieren",
+ "unsubscribePodcastsWarning": "Bist du sicher, dass du folgende Podcast deabonnieren möchtest? Dabei werden auch alle Hörfortschritte der Abonnements gelöscht. ",
+ "unsubscribeSelected": "Ausgewählte deabonnieren",
+ "yourSubscriptions": "Deine abonnierten Podcasts"
+ },
+ "passwordRequirements": {
+ "passwordLength": "Mindestens {n} Zeichen Lang",
+ "passwordMatch": "Passwörter sind identisch",
+ "passwordNumbers": "Zahl",
+ "passwordSpecialChar": "Sonderzeichen",
+ "passwordUpperLower": "Klein- und Großbuchstaben"
+ },
+ "form": {
+ "emailAddress": "E-Mail-Adresse",
+ "password": "Passwort",
+ "username": "Nutzername"
+ },
+ "error": {
+ "accountCreated": "Konto wurde erstellt! Verifiziere deine E-Mail. ",
+ "accountDeleted": "Konto wurde erfolgreich gelöscht. Auf Wiedersehen. ",
+ "copiedPodcast": "Podcast wurde in die Zwischenablage kopiert!",
+ "copiedPodcastError": "Kann nichts in die Zwischenablage legen. ",
+ "gpodderImport": "Daten erfolgreich von GPodder-Instanz importiert. ",
+ "passwordChanged": "Passwort wurde erfolgreich geändert! ",
+ "passwordForgot": "E-Mail wurde gesendet. Schau in dein Postfach!",
+ "passwordRequirements": "Passwortanforderungen werden nicht erfüllt.",
+ "passwordReset": "Passwort wurde zurückgesetzt! Teste Dein neues Passwort. ",
+ "subscriptionAdded": "Abonnement wurde hinzugefügt!",
+ "400BadRequest": "Eingaben sind falsch.",
+ "401Unauthorized": "Nutzername oder Kennwort ist falsch. ",
+ "404NotFound": "Kein Nutzer mit diesen Eingaben gefunden.",
+ "connectionLost": "Kann keine Verbindung zum Server aufbauen. ",
+ "axiosError": "Huch, der Programmierer hat einen Fehler gemacht. ",
+ "pageNotFound": "Seite nicht gefunden. "
+ }
+}
+
diff --git a/pse-dashboard/src/locales/en.help.html b/pse-dashboard/src/locales/en.help.html
new file mode 100644
index 0000000..ac7dedd
--- /dev/null
+++ b/pse-dashboard/src/locales/en.help.html
@@ -0,0 +1,10 @@
+<p>
+Help is available here.
+</p>
+<br><br><br><br><br><br><br><br><br><br><br>
+<p>To down here. </p>
+<br><br><br><br><br><br><br><br><br><br><br>
+<br><br><br><br><br><br><br><br><br><br><br>
+<br><br><br><br><br><br><br><br><br><br><br>
+<p>And way beyond! </p>
+
diff --git a/pse-dashboard/src/locales/en.json b/pse-dashboard/src/locales/en.json
new file mode 100644
index 0000000..acc7f2d
--- /dev/null
+++ b/pse-dashboard/src/locales/en.json
@@ -0,0 +1,77 @@
+{
+ "message": {
+ "addSubscription": "Add Subscription",
+ "changePassword": "Change Password",
+ "close": "Close",
+ "deleteAccount": "Delete Account",
+ "deleteAccountWarning": "Are you sure you want to delete your account named '{username}'? You will lose all your subscriptions and listened episodes. However, you can always create a new account. ",
+ "episode": "no episodes | one episode | {n} episodes",
+ "emailAddressRequest": "Please enter your Email Address",
+ "exportData": "Export Data",
+ "forgotPassword": "Forgot Password",
+ "gpodderInstanceRequest": "Enter Gpodder-Instance ",
+ "help": "Help",
+ "import": "Import",
+ "importData": "Import Data",
+ "instance": "GPodder Instance",
+ "login": "Login",
+ "loginRequest": "Login Please",
+ "logout": "Logout",
+ "mostRecentlyHeardEpisodes": "Recently heard episodes",
+ "mostRecentlyHeared": "Recently Heard",
+ "newPassword": "New Password",
+ "newSubscription": "New Subscription",
+ "noAccountYet": "No Account Yet",
+ "noEpisodes": "Looks like you don't have listened to something yet. ",
+ "noSubscriptions": "Looks like you don't have any subscriptions yet. ",
+ "oldPassword": "Old Password",
+ "passwordRequest": "Enter Password",
+ "personalData": "Personal Data",
+ "podcast": "Podcast | Podcasts",
+ "registration": "Registration",
+ "rememberMe": "Remember Me",
+ "repeat": "Repeat",
+ "repeatPassword": "Repeat Password",
+ "selectAll": "Select All",
+ "send": "Send",
+ "setNewPassword": "Set new Password",
+ "settings": "Settings",
+ "signUp": "Sign Up",
+ "userNameRequest": "Enter Username",
+ "unsubscribePodcasts": "Unsubscribe from Podcasts",
+ "unsubscribePodcastsWarning": "Are you sure you want to unsubscribe from the following podcast? This will also delete all listening progress of the subscriptions. ",
+ "unsubscribeSelected": "Unsubscribe from Selected",
+ "yourSubscriptions": "Your Podcast Subscriptions"
+ },
+ "passwordRequirements": {
+ "passwordLength": "At least {n} characters long",
+ "passwordMatch": "Passwords are Identical",
+ "passwordNumbers": "Digit",
+ "passwordSpecialChar": "Symbol",
+ "passwordUpperLower": "Lowercase and Uppercase Letter"
+ },
+ "form": {
+ "emailAddress": "Email Address",
+ "password": "Password",
+ "username": "Username"
+ },
+ "error": {
+ "accountCreated": "Account created! Validate your mail. ",
+ "accountDeleted": "Account got deleted. We are sorry you go. ",
+ "copiedPodcast": "Copied Podcast to Clipboard!",
+ "copiedPodcastError": "Can't share Podcast. ",
+ "gpodderImport": "Imported data from GPodder-Instance. ",
+ "passwordChanged": "Password got changed! ",
+ "passwordForgot": "E-Mail was send. Look into your invoice!",
+ "passwordRequirements": "Password requirements are not met. ",
+ "passwordReset": "Password got reset! Test you new Password. ",
+ "subscriptionAdded": "Subscription got added to your list!",
+ "400BadRequest": "Inputs are incorrect. ",
+ "401Unauthorized": "Wrong Credentials.",
+ "404NotFound": "No user found with these inputs.",
+ "connectionLost": "Cannot establish a connection to the server.",
+ "axiosError": "Oops, the programmer made a mistake. ",
+ "pageNotFound": "Page not found."
+ }
+}
+
diff --git a/pse-dashboard/src/locales/index.js b/pse-dashboard/src/locales/index.js
new file mode 100644
index 0000000..64176a9
--- /dev/null
+++ b/pse-dashboard/src/locales/index.js
@@ -0,0 +1,13 @@
+import de from './de.json'
+import de_help from './de.help.html?raw'
+import en from './en.json'
+import en_help from './en.help.html?raw'
+
+de.message.helpModal = de_help;
+en.message.helpModal = en_help;
+
+export {
+ de,
+ en
+}
+
diff --git a/pse-dashboard/src/logger.js b/pse-dashboard/src/logger.js
new file mode 100644
index 0000000..8398fd8
--- /dev/null
+++ b/pse-dashboard/src/logger.js
@@ -0,0 +1,84 @@
+import { reactive } from 'vue'
+import i18n from '@/i18n'
+// import { useI18n } from 'vue-i18n'
+
+// const { t } = i18n.global;
+export const Logger = reactive({
+ items: [],
+ append(item) {
+ this.items.push(item);
+ },
+ delete(item) {
+ this.items = this.items.filter(e => e != item);
+ }
+});
+
+
+// error {type: "success" | "info" | "warning" | "danger", message: String, lifetime: number}
+export function useLogger() {
+ // const { t } = useI18n();
+ const { t } = i18n.global;
+
+ return {
+ append(item) {
+ Logger.append(item);
+ },
+ delete(item) {
+ Logger.delete(item);
+ },
+ passwordRequirementsError() {
+ Logger.append({type: "warning", message: t('form.password')})
+ },
+ passwordRequirements() {
+ Logger.append({type: "warning", message: t("error.passwordRequirements")});
+ },
+ passwordChanged() {
+ Logger.append({type: "success", message: t("error.passwordChanged")});
+ },
+ accountDeleted() {
+ Logger.append({type: "info", message: t("error.accountDeleted")});
+ },
+ gpodderImport() {
+ Logger.append({type: "info", message: t("error.gpodderImport")});
+ },
+ passwordReset() {
+ Logger.append({type: "info", message: t("error.passwordReset")});
+ },
+ passwordForgot() {
+ Logger.append({type: "info", message: t("error.passwordForgot")});
+ },
+ subscriptionAdded() {
+ Logger.append({type: "info", message: t("error.subscriptionAdded")});
+ },
+ accountCreated() {
+ Logger.append({type: "success", message: t("error.accountCreated")});
+ },
+ copiedPodcast() {
+ Logger.append({type: "info", message: t("error.copiedPodcast")});
+ },
+ copiedPodcastError() {
+ Logger.append({type: "warning", message: t("error.copiedPodcastError")});
+ },
+
+ badRequestError() {
+ Logger.append({type: "danger", message: t("error.400BadRequest")});
+ },
+ unauthorizedError() {
+ Logger.append({type: "danger", message: t("error.401Unauthorized")});
+ },
+ notFoundError() {
+ Logger.append({type: "danger", message: t("error.404NotFound")});
+ },
+ connectionLostError() {
+ Logger.append({type: "danger", message: t("error.connectionLost")});
+ },
+ axiosError() {
+ Logger.append({type: "danger", message: t("error.axiosError")});
+ },
+ pageNotFound() {
+ Logger.append({type: "warning", message: t("error.pageNotFound")});
+ }
+ }
+}
+
+
diff --git a/pse-dashboard/src/main.js b/pse-dashboard/src/main.js
new file mode 100644
index 0000000..04dd3c2
--- /dev/null
+++ b/pse-dashboard/src/main.js
@@ -0,0 +1,20 @@
+import { createApp } from 'vue'
+import router from '@/router'
+import i18n from '@/i18n'
+
+import App from '@/App.vue'
+
+import "bootstrap/dist/css/bootstrap.css"
+import '@/style.css'
+import "@fortawesome/fontawesome-free/css/all.css"
+
+try {
+ navigator.registerProtocolHandler("web+pod", "/subscriptions?add=%s", "Podcast");
+} catch (err) {
+ console.error(err);
+}
+
+createApp(App).use(router).use(i18n).mount('#app')
+
+// import "bootstrap/dist/js/bootstrap.js"
+
diff --git a/pse-dashboard/src/router.js b/pse-dashboard/src/router.js
new file mode 100644
index 0000000..573b0d7
--- /dev/null
+++ b/pse-dashboard/src/router.js
@@ -0,0 +1,116 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { store } from '@/store.js'
+import {
+ LoginView,
+ SubscriptionsView,
+ EpisodesView,
+ ForgotPasswordView,
+ SettingsView,
+ RegistrationView,
+ ResetPasswordView
+} from '@/views'
+import { useLogger } from '@/logger.js'
+
+const logger = useLogger();
+
+const routes = [
+ {
+ path: '/',
+ redirect: to => {
+ return store.isLoggedIn ? '/subscriptions' : '/login';
+ },
+ meta: { requiresAuth: false },
+ },
+ {
+ path: '/login',
+ name: 'Login',
+ component: LoginView,
+ meta: { requiresAuth: false },
+ },
+ {
+ path: '/forgotPassword',
+ name: 'ForgotPassword',
+ component: ForgotPasswordView,
+ meta: { requiresAuth: false },
+ },
+ {
+ path: '/registration',
+ name: 'Registration',
+ component: RegistrationView,
+ meta: { requiresAuth: false },
+ },
+ {
+ path: '/resetPassword',
+ name: 'ResetPassword',
+ component: ResetPasswordView,
+ props: router => ({
+ token: router.query.token,
+ username: router.query.username
+ }),
+ meta: { requiresAuth: false },
+ },
+ {
+ path: '/subscriptions',
+ name: 'Subscriptions',
+ component: SubscriptionsView,
+ meta: { requiresAuth: true }
+ },
+ {
+ path: '/episodes',
+ name: 'Episodes',
+ component: EpisodesView,
+ meta: { requiresAuth: true }
+ },
+ {
+ path: '/settings',
+ name: 'Settings',
+ component: SettingsView,
+ meta: { requiresAuth: true }
+ },
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'NotFound',
+ redirect: to => {
+ logger.pageNotFound();
+ return "/";
+ },
+ meta: { requiresAuth: false },
+ }
+]
+
+const baseURL = import.meta.env.BASE_URL || "/";
+console.log("Base-URL", baseURL);
+
+const router = createRouter({
+ history: createWebHistory(baseURL),
+ routes
+})
+
+router.beforeEach((to, from, next) => {
+ // instead of having to check every route record with
+ // to.matched.some(record => record.meta.requiresAuth)
+ if (to.meta.requiresAuth && !store.isLoggedIn) {
+ // this route requires auth, check if logged in
+ // if not, redirect to login page.
+ next({
+ path: '/',
+ // save the location we were at to come back later
+ query: { redirect: to.fullPath },
+ });
+ } else if (!to.meta.requiresAuth && store.isLoggedIn) {
+ next({
+ path: '/'
+ });
+ } else if (store.isLoggedIn && from.query.redirect) {
+ // user is logged in and there's a saved location in the query
+ // redirect them to that location
+ const redirect = from.query.redirect;
+ delete from.query.redirect;
+ next(redirect);
+ } else {
+ next();
+ }
+});
+
+export default router
+
diff --git a/pse-dashboard/src/store.js b/pse-dashboard/src/store.js
new file mode 100644
index 0000000..5f838e6
--- /dev/null
+++ b/pse-dashboard/src/store.js
@@ -0,0 +1,54 @@
+import { reactive } from 'vue'
+import { login, logout } from '@/api/pse-squared.js'
+
+const username = sessionStorage.getItem("username") || localStorage.getItem("username");
+const password = sessionStorage.getItem("password") || localStorage.getItem("password");
+
+export const store = reactive({
+ isLoggedIn: username && password,
+ username,
+ password,
+ async login({username, password}, persistant) {
+ try {
+ await login({username, password});
+ this.username = username;
+ this.password = password;
+ this.isLoggedIn = true;
+
+ setStorage(this, persistant);
+
+ return true;
+ } catch(err) {
+ console.error(err);
+ return false;
+ }
+ },
+ async logout() {
+ logout();
+ this.username = "";
+ this.password = "";
+ this.isLoggedIn = false;
+ clearStorage();
+ return true;
+ }
+});
+
+if (username && password) {
+ store.login({username, password});
+}
+
+function setStorage(data, persistant) {
+ if (persistant) {
+ localStorage.setItem("username", data.username);
+ localStorage.setItem("password", data.password);
+ }
+
+ sessionStorage.setItem("username", data.username);
+ sessionStorage.setItem("password", data.password);
+}
+
+function clearStorage() {
+ sessionStorage.clear();
+ localStorage.clear();
+}
+
diff --git a/pse-dashboard/src/style.css b/pse-dashboard/src/style.css
new file mode 100644
index 0000000..15fb716
--- /dev/null
+++ b/pse-dashboard/src/style.css
@@ -0,0 +1,33 @@
+:root {
+ --bs-body-bg: #f5f5f5 !important;
+}
+
+html, body {
+ height: 100%;
+}
+
+/* Style der Eingabefelder */
+form .form-control, form .form-input label {
+ border-radius: 0;
+ margin-bottom: -1px;
+}
+
+
+form > .form-input:first-of-type .form-control {
+ border-top-right-radius: 0.375rem;
+ border-top-left-radius: 0.375rem;
+}
+
+form > .form-input:first-of-type label {
+ border-top-right-radius: 0.375rem;
+}
+
+form > .form-input:last-of-type .form-control {
+ border-bottom-right-radius: 0.375rem;
+ border-bottom-left-radius: 0.375rem;
+}
+
+form > .form-input:last-of-type label {
+ border-bottom-right-radius: 0.375rem;
+}
+
diff --git a/pse-dashboard/src/views/EpisodesView.vue b/pse-dashboard/src/views/EpisodesView.vue
new file mode 100644
index 0000000..a7027b1
--- /dev/null
+++ b/pse-dashboard/src/views/EpisodesView.vue
@@ -0,0 +1,42 @@
+<script setup>
+import { DashboardLayout, EpisodeEntry, LoadingConditional } from '@/components'
+import { ref, onMounted } from 'vue';
+import { getEpisodeActions } from '@/api/pse-squared.js';
+
+const episodes = ref(null);
+const received = ref(false);
+
+onMounted(async () => {
+ try {
+ const response = await getEpisodeActions();
+
+ received.value = true;
+ episodes.value = response.data;
+ } catch(err) {
+ }
+});
+
+</script>
+<template>
+ <DashboardLayout>
+ <h1 class="h1 mb-4">
+ {{ $t("message.mostRecentlyHeardEpisodes") }}
+ </h1>
+
+ <LoadingConditional :waiting-for="received">
+ <div class="list-group w-auto">
+ <EpisodeEntry
+ v-for="(action, index) in episodes.actions"
+ :key="index"
+ :action="action"
+ />
+ </div>
+ <p v-if="episodes.actions.length == 0">
+ {{ $t('message.noEpisodes') }}
+ </p>
+ </LoadingConditional>
+ </DashboardLayout>
+</template>
+<style>
+</style>
+
diff --git a/pse-dashboard/src/views/ForgotPasswordView.vue b/pse-dashboard/src/views/ForgotPasswordView.vue
new file mode 100644
index 0000000..03d0ff1
--- /dev/null
+++ b/pse-dashboard/src/views/ForgotPasswordView.vue
@@ -0,0 +1,52 @@
+<script setup>
+import { FloatingLabelInput, FormLayout } from '@/components'
+import { useLogger } from '@/logger.js'
+import { ref } from 'vue'
+import { forgotPassword } from '@/api/pse-squared.js'
+
+const email = ref("");
+
+const { passwordForgot } = useLogger();
+
+async function formForgot() {
+ try {
+ await forgotPassword({ email: email.value });
+ passwordForgot();
+ } catch (err) {}
+}
+</script>
+<template>
+ <FormLayout>
+ <!-- Text über Texteingabefeld -->
+ <h1 class="h3 mb-3 fw-normal">
+ {{ $t("message.emailAddressRequest") }}
+ </h1>
+
+ <form @submit.prevent="formForgot">
+ <!-- Eingabefeld für E-Mail-Adresse -->
+ <FloatingLabelInput
+ v-model="email"
+ type="email"
+ :label="$t('form.emailAddress')"
+ />
+
+ <!-- Absende Knopf der Daten -->
+ <button
+ type="submit"
+ class="w-100 btn btn-lg btn-primary mt-2"
+ >
+ {{ $t("message.send") }}
+ </button>
+ </form>
+
+ <!-- Zurück zur Anmeldung -->
+ <router-link to="/">
+ <button class="w-100 btn btn-lg btn-secondary mt-2">
+ {{ $t("message.close") }}
+ </button>
+ </router-link>
+ </FormLayout>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/views/LoginView.vue b/pse-dashboard/src/views/LoginView.vue
new file mode 100644
index 0000000..303db3a
--- /dev/null
+++ b/pse-dashboard/src/views/LoginView.vue
@@ -0,0 +1,91 @@
+<script setup>
+import { ref } from 'vue';
+import { FloatingLabelInput, FormLayout, PasswordInput } from '@/components'
+import { store } from '@/store.js';
+import router from '@/router.js';
+
+const username = ref("");
+const password = ref("");
+const stayLoggedIn = ref(false);
+
+async function login(e) {
+ const success = await store.login({
+ username: username.value,
+ password: password.value
+ }, stayLoggedIn.value);
+
+ if (success) {
+ router.push("/subscriptions");
+ }
+}
+
+</script>
+<template>
+ <FormLayout>
+ <!-- Text über Texteingabe -->
+ <h1 class="h3 mb-3 fw-normal">
+ {{ $t("message.loginRequest") }}
+ </h1>
+
+ <form @submit.prevent="login">
+ <!-- Eingabefeld für Nutzername -->
+ <FloatingLabelInput
+ v-model="username"
+ :label="$t('form.username')"
+ />
+
+ <!-- Eingabefeld für Passwort -->
+ <PasswordInput
+ v-model="password"
+ :label="$t('form.password')"
+ />
+
+ <div class="row">
+ <!-- Angemeldet bleiben Checkbox -->
+ <div class="col-6">
+ <div class="checkbox mb-3">
+ <label>
+ <input
+ v-model="stayLoggedIn"
+ type="checkbox"
+ value="remember-me"
+ >
+ {{ $t("message.rememberMe") }}
+ </label>
+ </div>
+ </div>
+
+ <!-- Passwort vergessen Link -->
+ <div class="col-6">
+ <router-link to="/forgotPassword">
+ {{ $t("message.forgotPassword") }}?
+ </router-link>
+ </div>
+ </div>
+
+ <!-- Knopf um sich anzumelden -->
+ <button
+ type="submit"
+ class="w-100 btn btn-lg btn-primary"
+ >
+ {{ $t("message.login") }}
+ </button>
+
+ <!-- Registrieren Link -->
+ <p class="mt-1">
+ {{ $t("message.noAccountYet") }}?
+ <router-link to="/registration">
+ {{ $t("message.signUp") }}
+ </router-link>
+ </p>
+ </form>
+
+ <!-- Footer -->
+ <p class="mt-5 mb-3 text-muted">
+ &copy; 2023
+ </p>
+ </FormLayout>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/views/RegistrationView.vue b/pse-dashboard/src/views/RegistrationView.vue
new file mode 100644
index 0000000..2d561f3
--- /dev/null
+++ b/pse-dashboard/src/views/RegistrationView.vue
@@ -0,0 +1,78 @@
+<script setup>
+import { FloatingLabelInput, FormLayout, PasswordValidator } from '@/components'
+import { ref } from 'vue';
+import router from '@/router.js';
+import { register } from '@/api/pse-squared.js'
+import { useLogger } from '@/logger.js'
+
+const username = ref("");
+const email = ref("");
+const passwordModel = ref(null);
+
+const { passwordRequirements, accountCreated } = useLogger();
+
+async function formRegister() {
+ if(!passwordModel.value.valid) {
+ // log.append({type: "info", message: "Password requirements are not met"})
+ passwordRequirements();
+ return;
+ }
+
+ try {
+ await register({
+ username: username.value,
+ email: email.value,
+ password: passwordModel.value.password
+ });
+
+ accountCreated();
+ router.push("/login");
+ } catch (err) {
+ }
+}
+
+</script>
+<template>
+ <FormLayout>
+ <!-- Text über Texteingabefeld -->
+ <h1 class="h3 mb-3 fw-normal">
+ {{ $t("message.registration") }}
+ </h1>
+
+ <form @submit.prevent="formRegister">
+ <!-- Eingabefeld für Nutzernamen -->
+ <FloatingLabelInput
+ v-model="username"
+ type="text"
+ :label="$t('form.username')"
+ />
+
+ <!-- Eingabefeld für E-Mail-Adresse -->
+ <FloatingLabelInput
+ v-model="email"
+ type="email"
+ :label="$t('form.emailAddress')"
+ />
+
+ <!-- Passwort-Validierungs-Komponente -->
+ <PasswordValidator v-model="passwordModel" />
+
+ <!-- Absende Knopf für E-Mail-Adresse -->
+ <button
+ type="submit"
+ class="w-100 btn btn-lg btn-primary"
+ >
+ {{ $t("message.signUp") }}
+ </button>
+ </form>
+
+ <!-- Zurück zur Anmeldung -->
+ <router-link to="/">
+ <button class="w-100 btn btn-lg btn-secondary mt-2">
+ {{ $t("message.close") }}
+ </button>
+ </router-link>
+ </FormLayout>
+</template>
+<style scoped>
+</style>
diff --git a/pse-dashboard/src/views/ResetPasswordView.vue b/pse-dashboard/src/views/ResetPasswordView.vue
new file mode 100644
index 0000000..a617af3
--- /dev/null
+++ b/pse-dashboard/src/views/ResetPasswordView.vue
@@ -0,0 +1,72 @@
+<script setup>
+import { FormLayout, PasswordValidator } from '@/components'
+import { ref } from 'vue'
+import { useLogger } from '@/logger.js'
+import { resetPassword } from '@/api/pse-squared.js'
+import router from '@/router.js';
+
+const props = defineProps({
+ token: {
+ type: String,
+ default: ""
+ },
+ username: {
+ type: String,
+ default: ""
+ }
+});
+
+const { passwordRequirements, passwordReset } = useLogger();
+
+const passwordModel = ref(null);
+
+async function formResetPassword() {
+ if(!passwordModel.value.valid) {
+ // log.append({type: "info", message: "Password requirements are not met"})
+ passwordRequirements();
+ return;
+ }
+
+ try {
+ await resetPassword({
+ username: props.username,
+ password: passwordModel.value.password,
+ token: props.token
+ });
+
+ passwordReset();
+ router.push("/login");
+ } catch (err) {}
+}
+
+</script>
+<template>
+ <FormLayout>
+ <form @submit.prevent="formResetPassword">
+ <!-- Text über Texteingabefeld -->
+ <h1 class="h3 mb-3 fw-normal">
+ {{ $t("message.setNewPassword") }}
+ </h1>
+
+ <!-- Passwort-Validierungs-Komponente -->
+ <PasswordValidator v-model="passwordModel" />
+
+ <!-- Absende Knopf für E-Mail-Adresse -->
+ <button
+ type="submit"
+ class="w-100 btn btn-lg btn-primary"
+ >
+ {{ $t("message.send") }}
+ </button>
+
+ <!-- Zurück zur Anmeldung -->
+ <router-link to="/">
+ <button class="w-100 btn btn-lg btn-secondary mt-2">
+ {{ $t("message.close") }}
+ </button>
+ </router-link>
+ </form>
+ </FormLayout>
+</template>
+<style scoped>
+</style>
diff --git a/pse-dashboard/src/views/SettingsView.vue b/pse-dashboard/src/views/SettingsView.vue
new file mode 100644
index 0000000..4e5e486
--- /dev/null
+++ b/pse-dashboard/src/views/SettingsView.vue
@@ -0,0 +1,347 @@
+<script setup>
+import {
+ DashboardLayout,
+ FloatingLabelInput,
+ PasswordInput,
+ PasswordValidator,
+} from '@/components'
+import { ref } from 'vue';
+import { store } from '@/store.js'
+import useGPodder from '@/api/gpodder.js'
+import {
+ changePassword,
+ deleteAccount,
+ getEpisodeActions,
+ getTitles,
+ postEpisodeActions,
+ putSubscriptions,
+} from '@/api/pse-squared.js'
+import JSZip from 'jszip';
+import router from '@/router.js'
+import { useLogger } from '@/logger.js'
+import { saveAs } from 'file-saver';
+
+/******************************************************************************/
+/* change password */
+/******************************************************************************/
+
+const changePasswordOld = ref(null);
+const changePasswordNew = ref(null);
+
+const logger = useLogger();
+
+async function formChangePassword() {
+ if(!changePasswordNew.value.valid) {
+ logger.passwordRequirements();
+ return;
+ }
+
+ try {
+ await changePassword({
+ password: changePasswordOld.value,
+ new_password: changePasswordNew.value.password
+ });
+
+ await store.login({
+ username: store.username,
+ password: changePasswordNew.value.password
+ }, false);
+
+ changePasswordOld.value = "";
+ changePasswordNew.value = {password: "", valid: false};
+
+ logger.passwordChanged();
+ } catch (err) {}
+}
+
+/******************************************************************************/
+/* delete account */
+/******************************************************************************/
+
+const deletePassword = ref("");
+
+async function formDelete() {
+
+ // FIXME: backend
+ if (store.password != deletePassword.value) {
+ logger.badRequestError();
+ return;
+ }
+
+ try {
+ await deleteAccount({
+ password: deletePassword.value
+ });
+
+ logger.accountDeleted();
+ store.logout();
+ router.push("/login");
+ } catch (err) {}
+}
+
+/******************************************************************************/
+/* import from another GPodder instance */
+/******************************************************************************/
+
+const gPodderInstance = ref("");
+const gPodderUsername = ref("");
+const gPodderPassword = ref("");
+
+async function formImportGPodder() {
+ const instance = useGPodder({
+ baseURL: gPodderInstance.value
+ });
+
+ await instance.login({
+ username: gPodderUsername.value,
+ password: gPodderPassword.value
+ });
+
+ // download titles from instance and upload to pse-squared
+ try {
+ const response = await instance.getTitles();
+ const subscriptions = response.data.map(sub => sub.url);
+
+ await putSubscriptions(subscriptions);
+ } catch (err) {
+ console.error(err);
+ return;
+ }
+
+ // download episodes from instance and upload to pse-squared
+ try {
+ const response = await instance.getEpisodeActions();
+ const episodes = response.data.actions;
+
+ await postEpisodeActions(episodes);
+ } catch (err) {
+ console.error(err);
+ return;
+ }
+
+ logger.gpodderImport();
+}
+
+
+/******************************************************************************/
+/* import and export data */
+/******************************************************************************/
+
+async function importData(e) {
+ const file = e.target.files[0];
+
+ const zip = await JSZip.loadAsync(file);
+
+ // read and upload subscriptions
+ try {
+ const subscriptionsText = await zip.files["subscriptions.json"].async("string");
+ const subscriptions = JSON.parse(subscriptionsText).map(sub => sub.url);
+ console.log({subscriptions});
+
+ await putSubscriptions(subscriptions);
+ } catch(err) {
+ console.error(err);
+ }
+
+ // read and upload episodeActions
+ try {
+ const episodeActionsText = await zip.files["episodeActions.json"].async("string");
+ const episodeActions = JSON.parse(episodeActionsText).actions;
+
+ await postEpisodeActions(episodeActions);
+ } catch(err) {
+ console.error(err);
+ }
+}
+
+async function exportData() {
+ const zip = new JSZip();
+
+ // load subscriptions
+ try {
+ const subscriptionResponse = await getTitles();
+ zip.file("subscriptions.json", JSON.stringify(subscriptionResponse.data));
+ } catch(err) {
+ console.error(err);
+ }
+
+ // load episodeActions
+ try {
+ const episodeActionsResponse = await getEpisodeActions();
+ zip.file("episode-actions.json", JSON.stringify(episodeActionsResponse.data));
+ } catch(err) {
+ console.error(err);
+ }
+
+ // generate and save zip
+ zip.generateAsync({type:"blob"}).then(function(content) {
+ saveAs(content, "pse-export.zip");
+ });
+}
+
+</script>
+<template>
+ <DashboardLayout>
+ <!-- Überschrift -->
+ <h1 class="h1 mb-4">
+ {{ $t("message.settings") }}
+ </h1>
+
+ <!-- Passwort ändern Sektion -->
+ <form
+ class="mb-4"
+ @submit.prevent="formChangePassword"
+ >
+ <h2>{{ $t("message.changePassword") }}</h2>
+ <!-- Altes Passwort -->
+ <PasswordInput
+ v-model="changePasswordOld"
+ :label="$t('message.oldPassword')"
+ />
+
+ <!-- Neues Passwort -->
+ <PasswordValidator v-model="changePasswordNew" />
+
+ <button
+ type="submit"
+ class="btn btn-primary"
+ >
+ {{ $t("message.changePassword") }}
+ </button>
+ </form>
+
+ <!-- Gpodder-Import Sektion -->
+ <form
+ class="mb-4"
+ @submit.prevent="formImportGPodder"
+ >
+ <h2>Gpodder-{{ $t("message.import") }}</h2>
+
+ <!-- Gpodder Instanz -->
+ <FloatingLabelInput
+ v-model="gPodderInstance"
+ :label="$t('message.instance')"
+ />
+
+ <!-- Nutzername -->
+ <FloatingLabelInput
+ v-model="gPodderUsername"
+ :label="$t('form.username')"
+ />
+
+ <!-- Passwort -->
+ <PasswordInput
+ v-model="gPodderPassword"
+ :label="$t('form.password')"
+ />
+
+ <button
+ type="submit"
+ class="btn btn-primary"
+ >
+ {{ $t("message.importData") }}
+ </button>
+ </form>
+
+ <!-- Personenbezogene Daten im-/exportieren -->
+ <div class="mb-4">
+ <h2>{{ $t("message.personalData") }}</h2>
+ <label
+ for="file"
+ class="btn btn-success"
+ >
+ {{ $t("message.importData") }}
+ </label>
+ <input
+ id="file"
+ type="file"
+ accept=".zip,application/zip"
+ hidden
+ @change="importData"
+ >
+ <button
+ class="btn btn-warning mx-2"
+ @click="exportData"
+ >
+ {{ $t("message.exportData") }}
+ </button>
+ </div>
+
+ <!-- Account löschen -->
+ <div class="mb-4">
+ <h2>{{ $t("message.deleteAccount") }}</h2>
+
+ <button
+ class="btn btn-danger"
+ data-bs-toggle="modal"
+ data-bs-target="#delete-user"
+ >
+ {{ $t("message.deleteAccount") }}
+ </button>
+ </div>
+ </DashboardLayout>
+
+ <!-- Modal for confirming deletion of user -->
+ <div
+ id="delete-user"
+ class="modal"
+ tabindex="-1"
+ >
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <!-- Title of Modal -->
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t('message.deleteAccount') }}
+ </h5>
+ <button
+ type="button"
+ class="btn-close"
+ data-bs-dismiss="modal"
+ aria-label="Close"
+ />
+ </div>
+
+ <!-- list podcasts to unsubscribe from -->
+ <div class="modal-body">
+ <div
+ class="alert alert-warning d-flex align-items-center"
+ role="alert"
+ >
+ <i class="fs-2 me-3 fa fa-warning" />
+ <div>
+ {{ $t('message.deleteAccountWarning', {username: store.username}) }}
+ </div>
+ </div>
+
+ <PasswordInput
+ v-model="deletePassword"
+ :label="$t('form.password')"
+ />
+ </div>
+
+ <!-- buttons to dismiss or finaly unsubscribe -->
+ <div class="modal-footer">
+ <button
+ type="button"
+ class="btn btn-secondary"
+ data-bs-dismiss="modal"
+ >
+ {{ $t('message.close') }}
+ </button>
+ <button
+ type="button"
+ data-bs-dismiss="modal"
+ class="btn btn-danger"
+ @click="formDelete"
+ >
+ {{ $t('message.deleteAccount') }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/views/SubscriptionsView.vue b/pse-dashboard/src/views/SubscriptionsView.vue
new file mode 100644
index 0000000..045b5f1
--- /dev/null
+++ b/pse-dashboard/src/views/SubscriptionsView.vue
@@ -0,0 +1,270 @@
+<script setup>
+import { DashboardLayout, FloatingLabelInput, LoadingConditional, SubscriptionEntry } from '@/components';
+import { useLogger } from '@/logger.js'
+import { ref, onMounted } from 'vue';
+import { getTitles, putSubscriptions, postSubscriptions } from '@/api/pse-squared.js'
+import { Modal } from 'bootstrap';
+
+const titles = ref(null);
+const received = ref(false);
+
+const newSubscription = ref();
+const deletedSubscriptions = ref([]);
+
+const { subscriptionAdded } = useLogger();
+
+// if called from pseudo-protocol: show dialog to add subscription up front
+onMounted(() => {
+ loadData();
+
+ const urlParams = new URLSearchParams(window.location.search);
+
+ const addSubModal = new Modal("#add-sub");
+ if (urlParams.has('add')) {
+ newSubscription.value = decodeURIComponent(urlParams.get('add'))
+ .replace("web+pod://", "");
+ addSubModal.show();
+ }
+});
+
+// fetches all titles of the subscribed podcasts and sets visibility
+async function loadData() {
+ try {
+ const response = await getTitles();
+
+ received.value = true;
+ titles.value = response.data;
+ } catch(err) { }
+}
+
+// makes a addition request based in the url in the input field/pseudo protocoll
+async function addSubscription() {
+ received.value = false;
+ await putSubscriptions([newSubscription.value]);
+
+ // log.append({type: "info", message: "Subscription got added to your list!"});
+ subscriptionAdded();
+ newSubscription.value = "";
+
+ await loadData();
+}
+
+// toggles the selection of all subscriptions
+function selectAllSubscriptions() {
+ if ( deletedSubscriptions.value.length > 0 ) {
+ deletedSubscriptions.value = [];
+ } else {
+ deletedSubscriptions.value = titles.value;
+ }
+}
+
+// makes a deletion request from all selected podcasts
+async function unsubscribeFromSelected() {
+ received.value = false;
+
+ await postSubscriptions({
+ add: [],
+ remove: deletedSubscriptions.value.map(e => e.url)
+ });
+ deletedSubscriptions.value = [];
+
+ await loadData();
+}
+
+// the modal gets open by the id from the html attributes defined in the Subscription
+// component.
+function unsubscribeSingle(sub) {
+ deletedSubscriptions.value = [sub];
+}
+
+</script>
+<template>
+ <DashboardLayout>
+ <h1 class="h1 mb-4">
+ {{ $t("message.yourSubscriptions") }}
+ </h1>
+
+ <!-- add a new subscription by url -->
+ <form
+ class="input-group mb-3"
+ @submit.prevent="addSubscription"
+ >
+ <FloatingLabelInput
+ v-model="newSubscription"
+ :label="$t('message.newSubscription')"
+ />
+ <button
+ class="btn btn-success"
+ type="submit"
+ >
+ {{ $t("message.addSubscription") }}
+ </button>
+ </form>
+
+ <LoadingConditional :waiting-for="received">
+ <!-- user does not have subscriptions yet -->
+ <p v-if="titles.length == 0">
+ {{ $t("message.noSubscriptions") }}
+ </p>
+
+ <!-- display subscriptions -->
+ <div v-else>
+ <button
+ class="btn m-2 btn-success"
+ @click="selectAllSubscriptions"
+ >
+ {{ $t('message.selectAll') }}
+ </button>
+ <button
+ class="btn m-2 btn-primary"
+ :class="{disabled: deletedSubscriptions.length == 0}"
+ data-bs-toggle="modal"
+ data-bs-target="#delete-subs"
+ >
+ {{ $t('message.unsubscribeSelected') }}
+ </button>
+
+ <div id="episodes-accordion">
+ <div
+ v-for="sub in titles"
+ :key="sub.url"
+ class="form-check"
+ >
+ <input
+ v-model="deletedSubscriptions"
+ class="form-check-input mt-4"
+ type="checkbox"
+ :value="sub"
+ >
+ <SubscriptionEntry
+ :sub="sub"
+ @unsubscribe="unsubscribeSingle"
+ />
+ </div>
+ </div>
+ </div>
+ </LoadingConditional>
+ </DashboardLayout>
+
+ <!-- Modal for adding subscription with pseudo-protocol -->
+ <div
+ id="add-sub"
+ class="modal"
+ tabindex="-1"
+ >
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <!-- Title of Modal -->
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t('message.newSubscription') }}
+ </h5>
+ <button
+ type="button"
+ class="btn-close"
+ data-bs-dismiss="modal"
+ aria-label="Close"
+ />
+ </div>
+
+ <!-- display URL of Podcast to subcribe to -->
+ <div class="modal-body">
+ <FloatingLabelInput
+ v-model="newSubscription"
+ :label="$t('message.newSubscription')"
+ />
+ </div>
+
+ <!-- buttons to dismiss or accept the new subscription -->
+ <div class="modal-footer">
+ <button
+ type="button"
+ class="btn btn-secondary"
+ data-bs-dismiss="modal"
+ >
+ {{ $t('message.close') }}
+ </button>
+ <button
+ type="button"
+ data-bs-dismiss="modal"
+ class="btn btn-primary"
+ @click="addSubscription"
+ >
+ {{ $t('message.addSubscription') }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Modal for confirming deletion of subscriptions -->
+ <div
+ id="delete-subs"
+ class="modal"
+ tabindex="-1"
+ >
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <!-- Title of Modal -->
+ <div class="modal-header">
+ <h5 class="modal-title">
+ {{ $t('message.unsubscribePodcasts') }}
+ </h5>
+ <button
+ type="button"
+ class="btn-close"
+ data-bs-dismiss="modal"
+ aria-label="Close"
+ />
+ </div>
+
+ <!-- list podcasts to unsubscribe from -->
+ <div class="modal-body">
+ <div
+ class="alert alert-warning d-flex align-items-center"
+ role="alert"
+ >
+ <i class="fs-2 me-3 fa fa-warning" />
+ <div>
+ {{ $t('message.unsubscribePodcastsWarning') }}
+ </div>
+ </div>
+
+ <ul>
+ <li
+ v-for="sub in deletedSubscriptions"
+ :key="sub.url"
+ >
+ {{ sub.title || sub.url }}
+ <span class="opacity-50">
+ ({{ $t('message.episode', sub.episodes.length) }})
+ </span>
+ </li>
+ </ul>
+ </div>
+
+ <!-- buttons to dismiss or finaly unsubscribe -->
+ <div class="modal-footer">
+ <button
+ type="button"
+ class="btn btn-secondary"
+ data-bs-dismiss="modal"
+ >
+ {{ $t('message.close') }}
+ </button>
+ <button
+ type="button"
+ data-bs-dismiss="modal"
+ class="btn btn-primary"
+ @click="unsubscribeFromSelected"
+ >
+ {{ $t('message.unsubscribePodcasts') }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+</style>
+
diff --git a/pse-dashboard/src/views/index.js b/pse-dashboard/src/views/index.js
new file mode 100644
index 0000000..e4706f8
--- /dev/null
+++ b/pse-dashboard/src/views/index.js
@@ -0,0 +1,18 @@
+import EpisodesView from './EpisodesView.vue'
+import ForgotPasswordView from './ForgotPasswordView.vue'
+import LoginView from './LoginView.vue'
+import RegistrationView from './RegistrationView.vue'
+import ResetPasswordView from './ResetPasswordView.vue'
+import SettingsView from './SettingsView.vue'
+import SubscriptionsView from './SubscriptionsView.vue'
+
+export {
+ EpisodesView,
+ ForgotPasswordView,
+ LoginView,
+ RegistrationView,
+ ResetPasswordView,
+ SettingsView,
+ SubscriptionsView,
+}
+
diff --git a/pse-dashboard/vite.config.js b/pse-dashboard/vite.config.js
new file mode 100644
index 0000000..ff86a56
--- /dev/null
+++ b/pse-dashboard/vite.config.js
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import path from 'path'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [vue()],
+ base: process.env.BASE_DIR || "/",
+ resolve: {
+ alias: {
+ '@' : path.resolve(__dirname, './src'),
+ }
+ }
+})
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
diff --git a/reverse-proxy/Dockerfile b/reverse-proxy/Dockerfile
new file mode 100644
index 0000000..9b76a39
--- /dev/null
+++ b/reverse-proxy/Dockerfile
@@ -0,0 +1,10 @@
+# syntax=docker/dockerfile:1
+
+
+#
+# NGINX phase
+#
+FROM nginx:alpine
+
+COPY ./conf.d/nginx.conf /etc/nginx/templates/default.conf.tmpl
+
diff --git a/reverse-proxy/conf.d/nginx.conf b/reverse-proxy/conf.d/nginx.conf
new file mode 100644
index 0000000..43152cb
--- /dev/null
+++ b/reverse-proxy/conf.d/nginx.conf
@@ -0,0 +1,77 @@
+server {
+
+ listen 80;
+ server_name ${FRONTEND_DOMAIN};
+
+ ##########################
+ # Comment when using SSL #
+ ##########################
+
+ location / {
+ proxy_pass http://pse-frontend:80;
+ }
+
+ ############################
+ # Uncomment when using SSL #
+ ############################
+
+ # location /.well-known/acme-challenge/ {
+ # root /letsencrypt/;
+ # }
+
+ # return 301 https://${FRONTEND_DOMAIN}$request_uri;
+}
+
+server {
+
+ listen 80;
+ server_name ${BACKEND_DOMAIN};
+
+ ##########################
+ # Comment when using SSL #
+ ##########################
+
+ location / {
+ proxy_pass http://pse-backend:8080;
+ }
+
+ ############################
+ # Uncomment when using SSL #
+ ############################
+
+ # location /.well-known/acme-challenge/ {
+ # root /letsencrypt/;
+ # }
+
+ # return 301 https://${BACKEND_DOMAIN}$request_uri;
+}
+
+############################
+# Uncomment when using SSL #
+############################
+
+# server {
+# listen 443 ssl http2;
+# listen [::]:443 ssl http2;
+# server_name ${FRONTEND_DOMAIN};
+# # use the certificates
+# ssl_certificate /etc/letsencrypt/live/${FRONTEND_DOMAIN}/fullchain.pem;
+# ssl_certificate_key /etc/letsencrypt/live/${FRONTEND_DOMAIN}/privkey.pem;
+
+# location / {
+# proxy_pass http://pse-frontend:80;
+# }
+# }
+
+# server {
+# listen 443 ssl http2;
+# listen [::]:443 ssl http2;
+# server_name ${BACKEND_DOMAIN};
+# # use the certificates
+# ssl_certificate /etc/letsencrypt/live/${FRONTEND_DOMAIN}/fullchain.pem;
+# ssl_certificate_key /etc/letsencrypt/live/${FRONTEND_DOMAIN}/privkey.pem;
+
+# location / {
+# proxy_pass http://pse-backend:8080;
+# }
+# } \ No newline at end of file