From 5b8851b6c268d0e93c158908fbfae9f8473db5ff Mon Sep 17 00:00:00 2001 From: Orangerot Date: Wed, 19 Jun 2024 00:14:49 +0200 Subject: Initial commit --- .env | 10 + .gitignore | 1 + LICENSE | 661 ++++ README.md | 82 + docker-compose.yml | 104 + pse-dashboard/.env.production | 2 + pse-dashboard/.eslintrc.js | 15 + pse-dashboard/.gitignore | 26 + pse-dashboard/.gitlab-ci.yml | 31 + pse-dashboard/.vscode/extensions.json | 3 + pse-dashboard/Dockerfile | 24 + pse-dashboard/LICENSE | 661 ++++ pse-dashboard/README.md | 83 + pse-dashboard/conf.d/nginx.conf | 11 + pse-dashboard/index.html | 13 + pse-dashboard/package-lock.json | 3393 ++++++++++++++++++++ pse-dashboard/package.json | 29 + pse-dashboard/public/logo.svg | 211 ++ pse-dashboard/src/App.vue | 13 + pse-dashboard/src/api/gpodder.js | 97 + pse-dashboard/src/api/gpodder.test.js | 29 + pse-dashboard/src/api/pse-squared.js | 61 + pse-dashboard/src/assets/logo.svg | 211 ++ pse-dashboard/src/components/DashboardLayout.vue | 10 + pse-dashboard/src/components/EpisodeEntry.vue | 53 + pse-dashboard/src/components/ErrorLog.vue | 36 + .../src/components/FloatingLabelInput.vue | 35 + pse-dashboard/src/components/FormLayout.vue | 24 + pse-dashboard/src/components/HelpModal.vue | 44 + pse-dashboard/src/components/LastUpdate.vue | 46 + .../src/components/LoadingConditional.vue | 18 + pse-dashboard/src/components/NavBar.vue | 134 + pse-dashboard/src/components/PasswordInput.vue | 45 + pse-dashboard/src/components/PasswordValidator.vue | 112 + pse-dashboard/src/components/ProgressTime.vue | 23 + pse-dashboard/src/components/SubscriptionEntry.vue | 118 + pse-dashboard/src/components/index.js | 30 + pse-dashboard/src/i18n.js | 11 + pse-dashboard/src/locales/de.help.html | 10 + pse-dashboard/src/locales/de.json | 77 + pse-dashboard/src/locales/en.help.html | 10 + pse-dashboard/src/locales/en.json | 77 + pse-dashboard/src/locales/index.js | 13 + pse-dashboard/src/logger.js | 84 + pse-dashboard/src/main.js | 20 + pse-dashboard/src/router.js | 116 + pse-dashboard/src/store.js | 54 + pse-dashboard/src/style.css | 33 + pse-dashboard/src/views/EpisodesView.vue | 42 + pse-dashboard/src/views/ForgotPasswordView.vue | 52 + pse-dashboard/src/views/LoginView.vue | 91 + pse-dashboard/src/views/RegistrationView.vue | 78 + pse-dashboard/src/views/ResetPasswordView.vue | 72 + pse-dashboard/src/views/SettingsView.vue | 347 ++ pse-dashboard/src/views/SubscriptionsView.vue | 270 ++ pse-dashboard/src/views/index.js | 18 + pse-dashboard/vite.config.js | 14 + pse-server/.dockerignore | 1 + pse-server/.env | 10 + pse-server/.gitignore | 39 + pse-server/.gitlab-ci.yml | 56 + pse-server/.mvn/wrapper/maven-wrapper.properties | 2 + pse-server/Dockerfile | 27 + pse-server/LICENSE | 661 ++++ pse-server/README.md | 150 + pse-server/docker-compose.yml | 50 + pse-server/mvnw | 316 ++ pse-server/mvnw.cmd | 188 ++ pse-server/pom.xml | 150 + .../org/psesquared/server/ServerApplication.java | 27 + .../api/controller/AuthenticationController.java | 251 ++ .../api/controller/ChangePasswordRequest.java | 15 + .../api/controller/DeviceWrapper.java | 46 + .../api/controller/ForgotPasswordRequest.java | 13 + .../api/controller/PasswordRequest.java | 13 + .../api/controller/UserInfoRequest.java | 16 + .../api/controller/package-info.java | 13 + .../api/data/access/AuthenticationDao.java | 62 + .../api/data/access/package-info.java | 11 + .../api/service/AuthenticationService.java | 389 +++ .../api/service/EmailServiceImpl.java | 222 ++ .../api/service/EncryptionService.java | 85 + .../api/service/InputCheckService.java | 170 + .../authentication/api/service/ResourceReader.java | 33 + .../authentication/api/service/package-info.java | 13 + .../server/config/ApplicationConfig.java | 125 + .../config/AuthenticationValidatorInterceptor.java | 95 + .../server/config/EmailConfigProperties.java | 16 + .../server/config/JwtAuthenticationFilter.java | 143 + .../org/psesquared/server/config/JwtService.java | 283 ++ .../psesquared/server/config/SecurityConfig.java | 117 + .../server/config/SecurityConfigProperties.java | 17 + .../org/psesquared/server/config/package-info.java | 8 + .../api/controller/EpisodeActionController.java | 129 + .../api/controller/EpisodeActionGetResponse.java | 37 + .../actions/api/controller/EpisodeActionPost.java | 61 + .../actions/api/controller/package-info.java | 13 + .../actions/api/data/access/EpisodeActionDao.java | 107 + .../actions/api/data/access/EpisodeDao.java | 46 + .../actions/api/data/access/package-info.java | 13 + .../actions/api/service/EpisodeActionService.java | 360 +++ .../episode/actions/api/service/package-info.java | 13 + .../java/org/psesquared/server/model/Action.java | 45 + .../java/org/psesquared/server/model/Episode.java | 74 + .../org/psesquared/server/model/EpisodeAction.java | 101 + .../java/org/psesquared/server/model/Role.java | 28 + .../org/psesquared/server/model/Subscription.java | 87 + .../server/model/SubscriptionAction.java | 62 + .../java/org/psesquared/server/model/User.java | 148 + .../org/psesquared/server/model/package-info.java | 8 + .../java/org/psesquared/server/package-info.java | 5 + .../api/controller/SubscriptionController.java | 155 + .../api/controller/SubscriptionDelta.java | 80 + .../api/controller/SubscriptionTitles.java | 17 + .../subscriptions/api/controller/package-info.java | 13 + .../api/data/access/SubscriptionActionDao.java | 91 + .../api/data/access/SubscriptionDao.java | 34 + .../api/data/access/package-info.java | 13 + .../api/service/SubscriptionService.java | 299 ++ .../subscriptions/api/service/package-info.java | 13 + .../java/org/psesquared/server/util/RssParser.java | 257 ++ .../java/org/psesquared/server/util/Scheduler.java | 41 + .../psesquared/server/util/UpdateUrlsWrapper.java | 35 + .../org/psesquared/server/util/package-info.java | 11 + .../src/main/resources/PasswordResetMail.txt | 34 + pse-server/src/main/resources/VerificationMail.txt | 34 + .../src/main/resources/application.properties | 31 + pse-server/src/main/resources/security.properties | 4 + .../test/java/org/psesquared/server/BaseTest.java | 163 + .../psesquared/server/ServerApplicationTests.java | 11 + .../org/psesquared/server/TestAsyncConfig.java | 16 + .../api/data/access/AuthenticationDaoTest.java | 51 + .../api/service/AuthenticationServiceTest.java | 222 ++ .../api/service/EmailServiceTests.java | 36 + .../api/data/access/EpisodeActionDaoTests.java | 20 + .../actions/api/data/access/EpisodeDaoTests.java | 29 + .../api/service/EpisodeActionServiceTests.java | 368 +++ .../data/access/SubscriptionActionDaoTests.java | 89 + .../api/data/access/SubscriptionDaoTests.java | 36 + .../api/service/SubscriptionServiceTests.java | 128 + .../org/psesquared/server/util/RssParserTests.java | 135 + pse-server/testfeeds/dirtest.txt | 1 + pse-server/testfeeds/multipleEnclosuresFeed.xml | 17 + pse-server/testfeeds/newTestSubscription1.xml | 28 + pse-server/testfeeds/newTestSubscription2.xml | 28 + pse-server/testfeeds/template.txt | 33 + pse-server/testfeeds/testPodcast0.xml | 35 + pse-server/testfeeds/testPodcast1.xml | 28 + pse-server/testfeeds/timeTestPodcast.xml | 42 + reverse-proxy/Dockerfile | 10 + reverse-proxy/conf.d/nginx.conf | 77 + 151 files changed, 15923 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 pse-dashboard/.env.production create mode 100644 pse-dashboard/.eslintrc.js create mode 100644 pse-dashboard/.gitignore create mode 100644 pse-dashboard/.gitlab-ci.yml create mode 100644 pse-dashboard/.vscode/extensions.json create mode 100644 pse-dashboard/Dockerfile create mode 100644 pse-dashboard/LICENSE create mode 100644 pse-dashboard/README.md create mode 100644 pse-dashboard/conf.d/nginx.conf create mode 100644 pse-dashboard/index.html create mode 100644 pse-dashboard/package-lock.json create mode 100644 pse-dashboard/package.json create mode 100644 pse-dashboard/public/logo.svg create mode 100644 pse-dashboard/src/App.vue create mode 100644 pse-dashboard/src/api/gpodder.js create mode 100644 pse-dashboard/src/api/gpodder.test.js create mode 100644 pse-dashboard/src/api/pse-squared.js create mode 100644 pse-dashboard/src/assets/logo.svg create mode 100644 pse-dashboard/src/components/DashboardLayout.vue create mode 100644 pse-dashboard/src/components/EpisodeEntry.vue create mode 100644 pse-dashboard/src/components/ErrorLog.vue create mode 100644 pse-dashboard/src/components/FloatingLabelInput.vue create mode 100644 pse-dashboard/src/components/FormLayout.vue create mode 100644 pse-dashboard/src/components/HelpModal.vue create mode 100644 pse-dashboard/src/components/LastUpdate.vue create mode 100644 pse-dashboard/src/components/LoadingConditional.vue create mode 100644 pse-dashboard/src/components/NavBar.vue create mode 100644 pse-dashboard/src/components/PasswordInput.vue create mode 100644 pse-dashboard/src/components/PasswordValidator.vue create mode 100644 pse-dashboard/src/components/ProgressTime.vue create mode 100644 pse-dashboard/src/components/SubscriptionEntry.vue create mode 100644 pse-dashboard/src/components/index.js create mode 100644 pse-dashboard/src/i18n.js create mode 100644 pse-dashboard/src/locales/de.help.html create mode 100644 pse-dashboard/src/locales/de.json create mode 100644 pse-dashboard/src/locales/en.help.html create mode 100644 pse-dashboard/src/locales/en.json create mode 100644 pse-dashboard/src/locales/index.js create mode 100644 pse-dashboard/src/logger.js create mode 100644 pse-dashboard/src/main.js create mode 100644 pse-dashboard/src/router.js create mode 100644 pse-dashboard/src/store.js create mode 100644 pse-dashboard/src/style.css create mode 100644 pse-dashboard/src/views/EpisodesView.vue create mode 100644 pse-dashboard/src/views/ForgotPasswordView.vue create mode 100644 pse-dashboard/src/views/LoginView.vue create mode 100644 pse-dashboard/src/views/RegistrationView.vue create mode 100644 pse-dashboard/src/views/ResetPasswordView.vue create mode 100644 pse-dashboard/src/views/SettingsView.vue create mode 100644 pse-dashboard/src/views/SubscriptionsView.vue create mode 100644 pse-dashboard/src/views/index.js create mode 100644 pse-dashboard/vite.config.js create mode 100644 pse-server/.dockerignore create mode 100644 pse-server/.env create mode 100644 pse-server/.gitignore create mode 100644 pse-server/.gitlab-ci.yml create mode 100644 pse-server/.mvn/wrapper/maven-wrapper.properties create mode 100644 pse-server/Dockerfile create mode 100644 pse-server/LICENSE create mode 100644 pse-server/README.md create mode 100644 pse-server/docker-compose.yml create mode 100755 pse-server/mvnw create mode 100644 pse-server/mvnw.cmd create mode 100644 pse-server/pom.xml create mode 100644 pse-server/src/main/java/org/psesquared/server/ServerApplication.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java create mode 100644 pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java create mode 100644 pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java create mode 100644 pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java create mode 100644 pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java create mode 100644 pse-server/src/main/java/org/psesquared/server/config/JwtService.java create mode 100644 pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java create mode 100644 pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java create mode 100644 pse-server/src/main/java/org/psesquared/server/config/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java create mode 100644 pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java create mode 100644 pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java create mode 100644 pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java create mode 100644 pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java create mode 100644 pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java create mode 100644 pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/model/Action.java create mode 100644 pse-server/src/main/java/org/psesquared/server/model/Episode.java create mode 100644 pse-server/src/main/java/org/psesquared/server/model/EpisodeAction.java create mode 100644 pse-server/src/main/java/org/psesquared/server/model/Role.java create mode 100644 pse-server/src/main/java/org/psesquared/server/model/Subscription.java create mode 100644 pse-server/src/main/java/org/psesquared/server/model/SubscriptionAction.java create mode 100644 pse-server/src/main/java/org/psesquared/server/model/User.java create mode 100644 pse-server/src/main/java/org/psesquared/server/model/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionController.java create mode 100644 pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionDelta.java create mode 100644 pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionTitles.java create mode 100644 pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDao.java create mode 100644 pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDao.java create mode 100644 pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/SubscriptionService.java create mode 100644 pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/package-info.java create mode 100644 pse-server/src/main/java/org/psesquared/server/util/RssParser.java create mode 100644 pse-server/src/main/java/org/psesquared/server/util/Scheduler.java create mode 100644 pse-server/src/main/java/org/psesquared/server/util/UpdateUrlsWrapper.java create mode 100644 pse-server/src/main/java/org/psesquared/server/util/package-info.java create mode 100644 pse-server/src/main/resources/PasswordResetMail.txt create mode 100644 pse-server/src/main/resources/VerificationMail.txt create mode 100644 pse-server/src/main/resources/application.properties create mode 100644 pse-server/src/main/resources/security.properties create mode 100644 pse-server/src/test/java/org/psesquared/server/BaseTest.java create mode 100644 pse-server/src/test/java/org/psesquared/server/ServerApplicationTests.java create mode 100644 pse-server/src/test/java/org/psesquared/server/TestAsyncConfig.java create mode 100644 pse-server/src/test/java/org/psesquared/server/authentication/api/data/access/AuthenticationDaoTest.java create mode 100644 pse-server/src/test/java/org/psesquared/server/authentication/api/service/AuthenticationServiceTest.java create mode 100644 pse-server/src/test/java/org/psesquared/server/authentication/api/service/EmailServiceTests.java create mode 100644 pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDaoTests.java create mode 100644 pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDaoTests.java create mode 100644 pse-server/src/test/java/org/psesquared/server/episode/actions/api/service/EpisodeActionServiceTests.java create mode 100644 pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDaoTests.java create mode 100644 pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDaoTests.java create mode 100644 pse-server/src/test/java/org/psesquared/server/subscriptions/api/service/SubscriptionServiceTests.java create mode 100644 pse-server/src/test/java/org/psesquared/server/util/RssParserTests.java create mode 100644 pse-server/testfeeds/dirtest.txt create mode 100644 pse-server/testfeeds/multipleEnclosuresFeed.xml create mode 100644 pse-server/testfeeds/newTestSubscription1.xml create mode 100644 pse-server/testfeeds/newTestSubscription2.xml create mode 100644 pse-server/testfeeds/template.txt create mode 100644 pse-server/testfeeds/testPodcast0.xml create mode 100644 pse-server/testfeeds/testPodcast1.xml create mode 100644 pse-server/testfeeds/timeTestPodcast.xml create mode 100644 reverse-proxy/Dockerfile create mode 100644 reverse-proxy/conf.d/nginx.conf diff --git a/.env b/.env new file mode 100644 index 0000000..3ec0643 --- /dev/null +++ b/.env @@ -0,0 +1,10 @@ +FRONTEND_DOMAIN= +BACKEND_DOMAIN= + +SPRING_MAIL_HOST= +SPRING_MAIL_PORT=587 +SPRING_MAIL_USERNAME= +SPRING_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. + 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. + + + Copyright (C) + + 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 . + +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 +. 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= +BACKEND_DOMAIN= + +SPRING_MAIL_HOST= +SPRING_MAIL_PORT=587 +SPRING_MAIL_USERNAME= +SPRING_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:// + 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. + 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. + + + Copyright (C) + + 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 . + +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 +. 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:// 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:// -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 @@ + + + + + + + PSE-Dashboard + + +
+ + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Podcast Synchronisation made Efficient + + + + 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 @@ + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Podcast Synchronisation made Efficient + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ +

+Hier stehen Hilfestellungen. +

+










+

Bis hier unten.

+










+










+










+

Und noch viel weiter!

+ 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 @@ +

+Help is available here. +

+










+

To down here.

+










+










+










+

And way beyond!

+ 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 @@ + + + + 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 @@ + + + + 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 @@ + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + 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 @@ + + + + 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= +BACKEND_DOMAIN= + +SPRING_MAIL_HOST= +SPRING_MAIL_PORT=587 +SPRING_MAIL_USERNAME= +SPRING_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= +ENV SPRING_MAIL_PORT=587 +ENV SPRING_MAIL_USERNAME= +ENV SPRING_MAIL_PASSWORD= +ENV EMAIL_DASHBOARD_BASE_URL=http:// +ENV EMAIL_VERIFICATION_URL=http:///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. + 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. + + + Copyright (C) + + 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 . + +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 +. 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= +spring.mail.port=587 +spring.mail.username= +spring.mail.password= + +enivorment variables +-------------------- +export SPRING_MAIL_HOST= +export SPRING_MAIL_PORT=587 +export SPRING_MAIL_USERNAME= +export SPRING_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= +ENV SPRING_MAIL_PORT=587 +ENV SPRING_MAIL_USERNAME= +ENV SPRING_MAIL_PASSWORD= +ENV EMAIL_DASHBOARD_BASE_URL=http:// +ENV EMAIL_VERIFICATION_URL=http:///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=" +``` + +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 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.0.2 + + + org.pse-squared + server + 0.0.1-SNAPSHOT + server + Backend for Podcast Synchronisation made Efficient + + 17 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.mariadb.jdbc + mariadb-java-client + 3.1.2 + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-web + + + org.projectlombok + lombok + true + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + org.junit.jupiter + junit-jupiter-engine + 5.9.2 + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-configuration-processor + true + + + com.rometools + rome + 1.18.0 + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.2.1 + maven-plugin + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.2.1 + + + + checkstyle + + + + + + + + server + + + org.springframework.boot + spring-boot-maven-plugin + ${project.parent.version} + + + + org.projectlombok + lombok + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.8 + + + + prepare-agent + + + + report + test + + report + + + + + + + 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,
+ * {@link HttpStatus#BAD_REQUEST} for invalid user information + * @see AuthenticationService#registerUser(UserInfoRequest) + */ + @PostMapping("/auth/register.json") + public ResponseEntity 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,
+ * {@link HttpStatus#BAD_REQUEST} user exists and is already verified, + *
+ * {@link HttpStatus#UNAUTHORIZED} invalid token,
+ * {@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.
+ * (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,
+ * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#login(String, HttpServletResponse) + */ + @PostMapping("/auth/{username}/login.json") + public ResponseEntity 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.
+ * (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,
+ * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#logout(String, HttpServletResponse) + */ + @PostMapping("/auth/{username}/logout.json") + public ResponseEntity 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,
+ * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#forgotPassword(String) + */ + @PostMapping("/auth/{email}/forgot.json") + public ResponseEntity 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,
+ * {@link HttpStatus#BAD_REQUEST} password doesn't meet requirements, + *
+ * {@link HttpStatus#UNAUTHORIZED} invalid token,
+ * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#resetPassword(String, String, PasswordRequest) + */ + @PutMapping("/auth/{username}/resetpassword.json") + public ResponseEntity 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,
+ * {@link HttpStatus#BAD_REQUEST} old password is wrong,
+ * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#changePassword(String, ChangePasswordRequest) + */ + @PutMapping("/auth/{username}/changepassword.json") + public ResponseEntity 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,
+ * {@link HttpStatus#BAD_REQUEST} wrong password,
+ * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#deleteUser(String, PasswordRequest) + */ + @DeleteMapping("/auth/{username}/delete.json") + public ResponseEntity 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> 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
+ * {@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. + *
+ * 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 { + + /** + * Checks if a user exists via their username. + * + * @param username The username + * @return {@code true} if the user with the given username exists,
+ * {@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 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 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 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. + *
+ * 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. + *
+ * 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. + *
+ * 1. Checks if the given user information meets the requirements. + *
+ * 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). + *
+ * 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, + *
+ * {@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. + *
+ * 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, + *
+ * {@link HttpStatus#BAD_REQUEST} user exists and is already verified, + *
+ * {@link HttpStatus#UNAUTHORIZED} invalid token, + *
+ * {@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. + *
+ * 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, + *
+ * {@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. + *
+ * 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, + *
+ * {@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. + *
+ * 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, + *
+ * {@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. + *
+ * 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, + *
+ * {@link HttpStatus#BAD_REQUEST} password doesn't meet requirements, + *
+ * {@link HttpStatus#UNAUTHORIZED} invalid token, + *
+ * {@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. + *
+ * 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, + *
+ * {@link HttpStatus#BAD_REQUEST} old password is wrong, or new + * password doesn't meet requirements, + *
+ * {@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. + *
+ * 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, + *
+ * {@link HttpStatus#BAD_REQUEST} wrong password, + *
+ * {@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: + *
+ * {@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: + *
+ * {@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: + *
+ * - contains only word characters (equivalent to [a-zA-Z0-9_]) + * and the character '-'. + *
+ * - 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, + *
+ * {@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, + *
+ * {@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: + *
+ * - contains at least one digit. + *
+ * - contains at least one lower case character. + *
+ * - contains at least one upper case character. + *
+ * - contains at least one special character from the list [€°§´] or the + * Punct script extension. + *
+ * - 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, + *
+ * {@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. + *
+ * 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, + *
+ * {@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 pathVariables = (Map) request + .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + // this attribute is of type Map 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. + *
+ * 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 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 extractClaim(final String token, + final Key signingKey, + final Function 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 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, + *
+ * {@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, + *
+ * {@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, + *
+ * {@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, + *
+ * {@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: + *
+ * 1. JWT authentication ("sessionid" cookie) + *
+ * 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 addEpisodeActions( + @PathVariable final String username, + @RequestBody final List 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 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 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 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 + 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. + *
+ * May contain multiple EpisodeActions. + */ +@Data +public class EpisodeActionGetResponse { + + /** + * The list of EpisodeActionPosts. + */ + private final List 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 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. + *
+ * 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. + *
+ * 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 { + + /** + * 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 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 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, + *
+ * {@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 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 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 + 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 { + + /** + * Find an episode by its URL. + * + * @param url The URL of the episode + * @return The matching episode / NULL, if there was no match. + */ + Optional 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 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. + *
+ * 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. + *
+ * 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 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 episodeActionPosts) { + User user = authenticationDao.findByUsername(username).orElseThrow(); + List filteredEpisodeActionPosts + = new ArrayList<>(filterNewestAction(episodeActionPosts)); + List episodeActions + = episodeActionPostsToEpisodeActions(user, filteredEpisodeActionPosts); + addEpisodeActionsToDatabase(user, episodeActions); + validateDummyEpisodes(); + } + + private Collection filterNewestAction( + final List episodeActionPosts) { + Map 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 episodeActionPostsToEpisodeActions( + final User user, + final List episodeActionPosts) { + List 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 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 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 getEpisodeActions(final String username) { + List 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 getEpisodeActionsOfPodcast( + final String username, + final String podcastUrl) { + List 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 getEpisodeActionsSince( + final String username, + final long since) { + LocalDateTime sinceTimestamp + = LocalDateTime.ofEpochSecond( + since, + NANO_OF_SECOND_DEFAULT, + ZoneOffset.UTC); + List 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 getEpisodeActionsOfPodcastSince( + final String username, + final String podcastUrl, + final long since) { + LocalDateTime sinceTimestamp + = LocalDateTime.ofEpochSecond( + since, + NANO_OF_SECOND_DEFAULT, + ZoneOffset.UTC); + List episodeActions = episodeActionDao + .findByUserUsernameAndTimestampGreaterThanEqualAndEpisodeSubscriptionUrl( + username, + sinceTimestamp, + podcastUrl); + return episodeActionsToEpisodeActionPosts(episodeActions); + } + + private List episodeActionsToEpisodeActionPosts( + final List episodeActions) { + List 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. + *
+ * 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 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 subscriptionActions; + + /** + * The episodes of a subscription. + */ + @JsonIgnore + @OneToMany(mappedBy = "subscription", cascade = CascadeType.REMOVE) + private final List 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 subscriptionActions; + + /** + * The episode actions the user made. + */ + @OneToMany(mappedBy = "user", + cascade = CascadeType.REMOVE) + private List episodeActions; + + /** + * Returns a collection with one {@link SimpleGrantedAuthority} + * with {@link #role}. + * + * @return The collection of granted authorities + */ + @Override + public Collection 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, + *
+ * {@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, + *
+ * {@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, + *
+ * {@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 + *
+ * {@link HttpStatus#OK} on success, + *
+ * {@link HttpStatus#NOT_FOUND} user not found + */ + @PutMapping(path = "/subscriptions/{username}/{deviceId}.json") + public ResponseEntity uploadSubscriptions( + @PathVariable final String username, + @PathVariable final String deviceId, + @RequestBody final List 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> getSubscriptions( + @PathVariable final String username, + @PathVariable(required = false) final String deviceId, + @RequestParam(value = "jsonp", + required = false) final String functionJsonp) { + List 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. + *
+ * 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 + *
+ * {@link HttpStatus#OK} on success, + *
+ * {@link HttpStatus#NOT_FOUND} user or subscription not found + */ + @PostMapping(path = "/api/2/subscriptions/{username}/{deviceId}.json") + public ResponseEntity 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 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. + *
+ * 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> getTitles( + @PathVariable final String username) { + List 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 add; + + /** + * The list of recently unsubscribed podcasts. + */ + @JsonProperty(required = true) + @NonNull + private final List 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 addedPodcastUrls, + @org.springframework.lang.NonNull final List 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 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 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 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. + *
+ * 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 { + + /** + * 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 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 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 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 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 + 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 { + + /** + * 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 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. + *
+ * 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. + *
+ * 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. + *
+ * 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, + *
+ * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus uploadSubscriptions( + final String username, + final List 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 getSubscriptions(final String username) { + List subscriptionActions + = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + List 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 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 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 added = new ArrayList<>(); + List removed = new ArrayList<>(); + + List 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 getTitles(final String username) { + List subscriptionActions + = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + List subscriptions = new ArrayList<>(); + List subscriptionTitlesList = new ArrayList<>(); + + for (SubscriptionAction subscriptionAction : subscriptionActions) { + subscriptions.add(subscriptionAction.getSubscription()); + } + + for (Subscription subscription : subscriptions) { + List 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. + *
+ * 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> fetchedData + = fetchSubscriptionFeed(subscription); + if (fetchedData.get(URL_KEY_MAP_INDEX).isEmpty()) { + subscriptionDao.deleteById(subscription.getId()); + return; + } + + Subscription retrievedSubscription = subscriptionDao.save(subscription); + List subscriptionEpisodes = retrievedSubscription.getEpisodes(); + if (subscriptionEpisodes == null + || subscriptionEpisodes.isEmpty()) { + return; + } + + List invalidEpisodes = new ArrayList<>(); + List 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> fetchSubscriptionFeed( + final Subscription subscription) { + final List> 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 episodesByGuid = new HashMap<>(); + Map episodesByUrl = new HashMap<>(); + List 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 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 itunesTags = syndEntry.getForeignMarkup(); + for (Element element : itunesTags) { + if (!element.getName().equals("duration")) { + continue; + } + + List 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> 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. + *
+ * 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> 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: + *
+ * {@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= +spring.mail.port=587 +spring.mail.username= +spring.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:// +email.verification-url=http:///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= +security.jwt-url-signing-key= +security.email-signing-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 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 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 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 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 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 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 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 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 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 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 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 savedEpisodeAction = episodeActionDao.findById(episodeAction.getId()); + assertTrue(savedEpisodeAction.isPresent()); + + EpisodeAction oldEpisodeActionTest = episodeActionDao + .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY).orElseThrow(); + List 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 savedSubscription = subscriptionDao.findByUrl(podcastName); + assertTrue(savedSubscription.isPresent()); + + final String episodeUrl = episodeBaseUrl + episodeIndex + ".mp3"; + + Optional noEpisode = episodeDao.findByUrl(episodeUrl); + assertFalse(noEpisode.isPresent()); + + List 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 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 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 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 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 subscriptionActions = subscriptionActionDao + .findByUserUsernameAndTimestampGreaterThanEqual(username, since); + assertEquals(numberOfSubscriptionsPerUser - factor, subscriptionActions.size()); + } + + @Test + public void findByUserUsernameAndAddedTrueTest() { + String username = "testUser0"; + List 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 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 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 subscriptionStrings = List.of(newSubscriptionUrl1, newSubscriptionUrl2); + + subscriptionService.uploadSubscriptions(username, subscriptionStrings); + List 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 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 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 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 subscriptionTitlesList = subscriptionService.getTitles(username); + assertEquals(numberOfSubscriptionsPerUser, subscriptionTitlesList.size()); + for (SubscriptionTitles subscriptionTitles : subscriptionTitlesList) { + assertEquals(numberOfEpisodesPerSubscription, subscriptionTitles.episodes().size()); + Optional 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 @@ + + + doubleEnclosurePodcast + + doubleEnclosureEpisode + 00:03:20 + + + + + \ 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 @@ + + + newTestSubscription1 + + newTestEpisode0 + 00:01:40 + + + + newTestEpisode1 + 00:03:20 + + + + newTestEpisode2 + 00:05:00 + + + + \ 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 @@ + + + newTestSubscription2 + + newTestEpisode0 + 00:01:40 + + + + newTestEpisode1 + 00:03:20 + + + + newTestEpisode2 + 00:05:00 + + + + \ 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 @@ + + + + Dafna's Zebra Podcast + + dafna@example.com + + Dafna + A pet-owner's guide to the popular striped equine. + + en-us + https://www.example.com/podcasts/dafnas-zebras/ + + Top 10 myths about caring for a zebra + Here are the top 10 misunderstandings about the care, feeding, and breeding of these lovable striped animals. + Tue, 14 Mar 2017 12:00:00 GMT + + 30:00 + dzpodtop10 + + + Keeping those stripes neat and clean + Keeping your zebra clean is time consuming, but worth the effort. + Fri, 24 Feb 2017 12:00:00 GMT + + 22:48 + dzpodclean + + + \ 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 @@ + + + testPodcast0 + + testEpisode0 + 00:01:40 + + + + testEpisode1 + 00:03:20 + + + + testEpisode2 + 00:05:00 + + + + testEpisode3 + 01:00:00 + + + + \ 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 @@ + + + testPodcast1 + + testEpisode0 + 00:01:40 + + + + testEpisode1 + 00:03:20 + + + + testEpisode2 + 00:05:00 + + + + \ 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 @@ + + + timeTestPodcast + + anInteger + 123 + + + + notAnInteger + 00-2* + + + + miutesAndSeconds + 10:50 + + + + noMinutes + 01::01 + + + + emptyDurationTag + + + + + \ 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 -- cgit v1.2.3