diff options
author | Orangerot <purple@orangerot.dev> | 2024-06-19 00:14:49 +0200 |
---|---|---|
committer | Orangerot <purple@orangerot.dev> | 2024-06-27 12:11:14 +0200 |
commit | 5b8851b6c268d0e93c158908fbfae9f8473db5ff (patch) | |
tree | 7010eb85d86fa2da06ea4ffbcdb01a685d502ae8 /pse-server |
Diffstat (limited to 'pse-server')
92 files changed, 7848 insertions, 0 deletions
diff --git a/pse-server/.dockerignore b/pse-server/.dockerignore new file mode 100644 index 0000000..1de5659 --- /dev/null +++ b/pse-server/.dockerignore @@ -0,0 +1 @@ +target
\ No newline at end of file diff --git a/pse-server/.env b/pse-server/.env new file mode 100644 index 0000000..3ec0643 --- /dev/null +++ b/pse-server/.env @@ -0,0 +1,10 @@ +FRONTEND_DOMAIN=<YOUR FRONTEND DOMAIN> +BACKEND_DOMAIN=<YOUR BACKEND DOMAIN> + +SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP> +SPRING_MAIL_PORT=587 +SPRING_MAIL_USERNAME=<YOUR MAIL ADDRESS> +SPRING_MAIL_PASSWORD=<YOUR MAIL PASSWORD> + +# Ensure the container uses the timezone of the host +SERVER_TIMEZONE=Europe/Berlin diff --git a/pse-server/.gitignore b/pse-server/.gitignore new file mode 100644 index 0000000..8b99cd7 --- /dev/null +++ b/pse-server/.gitignore @@ -0,0 +1,39 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.env +src/main/resources/application.properties +src/main/resources/security.properties + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Security Properties ### +#security.properties diff --git a/pse-server/.gitlab-ci.yml b/pse-server/.gitlab-ci.yml new file mode 100644 index 0000000..9404844 --- /dev/null +++ b/pse-server/.gitlab-ci.yml @@ -0,0 +1,56 @@ +# https://about.gitlab.com/blog/2016/12/14/continuous-delivery-of-a-spring-boot-application-with-gitlab-ci-and-kubernetes/ + +services: + - mariadb:latest + +variables: + MYSQL_DATABASE: "demo" + MYSQL_ROOT_PASSWORD: "dbpass" + MYSQL_USER: "pse" + MYSQL_PASSWORD: "PSEsq1702!mdb" + SPRING_DATASOURCE_URL: "jdbc:mariadb://mariadb:3306/demo" + +stages: + - deploy + - build + - test + # - package + +checkstyle: + image: maven:3.8-eclipse-temurin-17-alpine + stage: test + script: + - "mvn checkstyle:check" + allow_failure: true + +spring-test: + image: maven:3.8-eclipse-temurin-17-alpine + stage: test + script: + - "apk update && apk add mysql-client" + - "mvn test" + +pages: + image: maven:3.8-eclipse-temurin-17-alpine + script: + - "apk update && apk add mysql-client" + - "mkdir -p public/{checkstyle,surefire}" + - "mvn checkstyle:checkstyle" + - "mv target/site/* public/checkstyle" + - "mvn surefire-report:report" + - "mv target/site/* public/surefire" + artifacts: + paths: + - public + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +# https://stackoverflow.com/questions/37403120/gitlab-ci-docker-executor-how-to-setup-mysql-service +maven-build: + image: maven:3.8-eclipse-temurin-17-alpine + stage: build + script: + - "mvn package -B -DskipTests" + artifacts: + paths: + - target/*.jar diff --git a/pse-server/.mvn/wrapper/maven-wrapper.properties b/pse-server/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..b74bf7f --- /dev/null +++ b/pse-server/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/pse-server/Dockerfile b/pse-server/Dockerfile new file mode 100644 index 0000000..2f74e27 --- /dev/null +++ b/pse-server/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 + +FROM maven:3.8-eclipse-temurin-17-alpine AS builder + +COPY . ./app +WORKDIR /app +RUN mvn package -B -DskipTests -DfinalName=server.jar + +FROM eclipse-temurin:17-jdk-jammy +COPY --from=builder /app/target/server.jar . + +# Ensure the docker container uses the same timezone as the server +ARG SERVER_TIMEZONE=Europe/Berlin +ENV TZ=$SERVER_TIMEZONE +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y tzdata + +ENV SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP> +ENV SPRING_MAIL_PORT=587 +ENV SPRING_MAIL_USERNAME=<YOUR MAIL ADDRESS> +ENV SPRING_MAIL_PASSWORD=<YOUR MAIL PASSWORD> +ENV EMAIL_DASHBOARD_BASE_URL=http://<YOUR FRONTEND DOMAIN> +ENV EMAIL_VERIFICATION_URL=http://<YOUR BACKEND DOMAIN>/api/2/auth/%s/verify.json +ENV EMAIL_RESET_URL_PATH=/resetPassword + +CMD ["java", "-server", "-Xms1G", "-Xmx1G", "-XX:+UseZGC", "-jar", "server.jar"] + diff --git a/pse-server/LICENSE b/pse-server/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/pse-server/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<https://www.gnu.org/licenses/>. diff --git a/pse-server/README.md b/pse-server/README.md new file mode 100644 index 0000000..0c7b04b --- /dev/null +++ b/pse-server/README.md @@ -0,0 +1,150 @@ +# Podcast Synchronisation made Efficient Server +> Gpodder Server written in Java Spring Boot + +## About + +The aim of this software project is to provide a synchronization server for +podcasts that is to be used by so-called podcatchers and that is as lean and +efficient as possible compared to other synchronizations Server. + +## Getting Started + +## Pre requirements + +- MariaDB-Server 10.6 +- Java JDK 17 + +### MariaDB + +Start the MariaDB Server and login as root to manage the databases. +```sh +$ sudo systemctl start mariadb # start mariadb +$ sudo mariadb --password # log in as root +``` + +Create Database and User. +```sql +create database demo; -- Creates the new database +create user 'pse'@'localhost' identified by 'PSEsq1702!mdb'; -- Creates the user +grant all on demo.* to 'pse'@'localhost'; -- Gives all privileges to the new user on the newly created database +``` + +### Spring Boot + +Compile and run the Spring Boot Project +```sh +$ mvn spring-boot:run +``` + +Compile to JAR for production +```sh +$ mvn package -B -DskipTests +``` + +Execute jar +``` +$ java -jar target/server.jar +``` + +### Configuration + +You can configuration by editing the files under `src/main/resources/`. + +``` +src/main/resources +├── application.properties # main config file +├── PasswordResetMail.txt # Mail text for reseting password +├── security.properties # keys for signing cookies/links +└── VerificationMail.txt # Mail text for verifying account +``` + +All configurations in the application.properties can also be set using +environment variables by putting all letters of the setting to uppercase and +replacing any non-word character to an underscore. + +``` +application.properties +---------------------- +spring.mail.host=<YOUR MAIL HOST SMTP> +spring.mail.port=587 +spring.mail.username=<YOUR MAIL ADDRESS> +spring.mail.password=<YOUR MAIL PASSWORD> + +enivorment variables +-------------------- +export SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP> +export SPRING_MAIL_PORT=587 +export SPRING_MAIL_USERNAME=<YOUR MAIL ADDRESS> +export SPRING_MAIL_PASSWORD=<YOUR MAIL PASSWORD> +``` + +## Docker-Compose (Java Spring + Database) + +> Note that you are running this backend standalone! +> Checkout `pse-docker` to run both front- and backend. + +Build the composition +```sh +$ docker compose build +``` + +Run the composition +```sh +$ docker compose run +``` + +The port is automatically exposed to port 80. +You can configure the composition by editing the `.env` and `docker-compose.yml` +files. + +## Docker (just Java Spring) + +> Note that you are running this server standalone! +> Checkout `pse-docker` to run both front- and backend. +> You need to connect to a Database to the image as well. + +The docker image can be build using +```sh +$ docker build -t pse-server . +``` + +Then the image can be run using +```sh +$ docker run -p 80:8080 -it pse-server +``` + +Here you can change the configuration by editing the `Dockerfile` by setting +ENV VARS: +``` +ENV SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP> +ENV SPRING_MAIL_PORT=587 +ENV SPRING_MAIL_USERNAME=<YOUR MAIL ADDRESS> +ENV SPRING_MAIL_PASSWORD=<YOUR MAIL PASSWORD> +ENV EMAIL_DASHBOARD_BASE_URL=http://<YOUR FRONTEND DOMAIN> +ENV EMAIL_VERIFICATION_URL=http://<YOUR BACKEND DOMAIN>/api/2/auth/%s/verify.json +ENV EMAIL_RESET_URL_PATH=/resetPassword +``` + +These can also be set by supplying them when starting the image +```sh +$ docker run -p 80:8080 -it pse-server -e "SPRING_MAIL_HOST=<YOUR MAIL HOST SMTP>" +``` + +Or you can use the env vars from the host by only writing the name of the var +```sh +$ docker run -p 80:8080 -it pse-server -e SPRING_MAIL_HOST +``` + +## Used Dependencies + +- Spring Web +- Spring Security +- Spring Mail Sender +- Spring Data JPA +- Lombok +- Rome (RSS parsing/fetching) + +## License + +This project is licensed under the AGPL-3 License - see the `LICENSE` file for details + diff --git a/pse-server/docker-compose.yml b/pse-server/docker-compose.yml new file mode 100644 index 0000000..35ad877 --- /dev/null +++ b/pse-server/docker-compose.yml @@ -0,0 +1,50 @@ +version: '3.9' + +services: + + maria_db: + image: "mariadb:10.4.28" + restart: always + networks: + - backend + environment: + MARIADB_DATABASE: demo + MARIADB_USER: pse + MARIADB_PASSWORD: PSEsq1702!mdb + MARIADB_RANDOM_ROOT_PASSWORD: yes + volumes: + - database:/var/lib/mysql + + pse-backend: + restart: always + hostname: pse-backend + network_mode: "bridge" + build: + context: . + dockerfile: Dockerfile + args: + SERVER_TIMEZONE: ${SERVER_TIMEZONE} + networks: + - backend + environment: + EMAIL_DASHBOARD_BASE_URL: http://${FRONTEND_DOMAIN} + EMAIL_VERIFICATION_URL: http://${BACKEND_DOMAIN}/api/2/auth/%s/verify.json + EMAIL_RESET_URL_PATH: /resetPassword + SPRING_MAIL_HOST: ${SPRING_MAIL_HOST} + SPRING_MAIL_PORT: ${SPRING_MAIL_PORT} + SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME} + SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD} + depends_on: + - maria_db + links: + - maria_db:maria_db + ports: + - 80:8080 + +networks: + frontend: + backend: + +volumes: + database: + diff --git a/pse-server/mvnw b/pse-server/mvnw new file mode 100755 index 0000000..8a8fb22 --- /dev/null +++ b/pse-server/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/pse-server/mvnw.cmd b/pse-server/mvnw.cmd new file mode 100644 index 0000000..1d8ab01 --- /dev/null +++ b/pse-server/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pse-server/pom.xml b/pse-server/pom.xml new file mode 100644 index 0000000..adfbf74 --- /dev/null +++ b/pse-server/pom.xml @@ -0,0 +1,150 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-parent</artifactId> + <version>3.0.2</version> + <relativePath/> <!-- lookup parent from repository --> + </parent> + <groupId>org.pse-squared</groupId> + <artifactId>server</artifactId> + <version>0.0.1-SNAPSHOT</version> + <name>server</name> + <description>Backend for Podcast Synchronisation made Efficient</description> + <properties> + <java.version>17</java.version> + </properties> + <dependencies> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-data-jpa</artifactId> + </dependency> + <dependency> + <groupId>org.mariadb.jdbc</groupId> + <artifactId>mariadb-java-client</artifactId> + <version>3.1.2</version> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-mail</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-security</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-web</artifactId> + </dependency> + <dependency> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>io.jsonwebtoken</groupId> + <artifactId>jjwt-api</artifactId> + <version>0.11.5</version> + </dependency> + <dependency> + <groupId>io.jsonwebtoken</groupId> + <artifactId>jjwt-impl</artifactId> + <version>0.11.5</version> + </dependency> + <dependency> + <groupId>io.jsonwebtoken</groupId> + <artifactId>jjwt-jackson</artifactId> + <version>0.11.5</version> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-test</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.springframework.security</groupId> + <artifactId>spring-security-test</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter-engine</artifactId> + <version>5.9.2</version> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-validation</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-configuration-processor</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>com.rometools</groupId> + <artifactId>rome</artifactId> + <version>1.18.0</version> + </dependency> + <dependency> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-checkstyle-plugin</artifactId> + <version>3.2.1</version> + <type>maven-plugin</type> + </dependency> + </dependencies> + <reporting> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-checkstyle-plugin</artifactId> + <version>3.2.1</version> + <reportSets> + <reportSet> + <reports> + <report>checkstyle</report> + </reports> + </reportSet> + </reportSets> + </plugin> + </plugins> + </reporting> + <build> + <finalName>server</finalName> + <plugins> + <plugin> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-maven-plugin</artifactId> + <version>${project.parent.version}</version> + <configuration> + <excludes> + <exclude> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + </exclude> + </excludes> + </configuration> + </plugin> + <plugin> + <groupId>org.jacoco</groupId> + <artifactId>jacoco-maven-plugin</artifactId> + <version>0.8.8</version> + <executions> + <execution> + <goals> + <goal>prepare-agent</goal> + </goals> + </execution> + <execution> + <id>report</id> + <phase>test</phase> + <goals> + <goal>report</goal> + </goals> + </execution> + </executions> + </plugin> + </plugins> + </build> +</project> diff --git a/pse-server/src/main/java/org/psesquared/server/ServerApplication.java b/pse-server/src/main/java/org/psesquared/server/ServerApplication.java new file mode 100644 index 0000000..a71f451 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/ServerApplication.java @@ -0,0 +1,27 @@ +package org.psesquared.server; + +import org.psesquared.server.config.EmailConfigProperties; +import org.psesquared.server.config.SecurityConfigProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; + +/** + * The main class responsible for starting the application. + */ +@SpringBootApplication +@EnableConfigurationProperties({SecurityConfigProperties.class, + EmailConfigProperties.class}) +public class ServerApplication { + + /** + * The main function starting the spring application. + * + * @param args Arguments may be given + */ + public static void main(final String[] args) { + SpringApplication.run(ServerApplication.class, args); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java new file mode 100644 index 0000000..f580969 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/AuthenticationController.java @@ -0,0 +1,251 @@ +package org.psesquared.server.authentication.api.controller; + +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.service.AuthenticationService; +import org.psesquared.server.config.EmailConfigProperties; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * This is a controller class for the Authentication API that handles the + * requests from the client concerning login/logout and user account management. + */ +@RequestMapping("/api/2") +@RestController +@RequiredArgsConstructor +public class AuthenticationController { + + /** + * The name of the HTTP location header. + */ + private static final String LOCATION_HEADER = "Location"; + + /** + * The service class that this controller calls to further process requests. + */ + private final AuthenticationService authenticationService; + + /** + * The properties class that is used to return some externally stored URLs. + */ + private final EmailConfigProperties emailConfigProperties; + + /** + * The API-endpoint for registering a new + * {@link org.psesquared.server.model.User} with a username, email address and + * password. In order for the account to be used, the registration process + * must be concluded with the verification of the email address. For this an + * email with a link for verification is sent to {@code userInfo.email()}. + * + * @param userInfo The request-wrapper containing username, email and + * password. + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} for invalid user information + * @see AuthenticationService#registerUser(UserInfoRequest) + */ + @PostMapping("/auth/register.json") + public ResponseEntity<String> registerUser( + @RequestBody final UserInfoRequest userInfo) { + return new ResponseEntity<>(authenticationService.registerUser(userInfo)); + } + + /** + * The API-endpoint for verifying a newly created + * {@link org.psesquared.server.model.User}. This method is invoked via the + * link in the verification email that is sent in + * {@link #registerUser(UserInfoRequest)}. + * On success, it transfers the user to the dashboard and on failure it sets + * the following status codes: + * {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} user exists and is already verified, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * + * @param username The username of the user that needs to be verified + * @param token The JWT that indicates the authority of the request + * @param response The {@link HttpServletResponse} for setting up a + * redirection to the frontend + * @see AuthenticationService#verifyRegistration(String, String) + */ + @GetMapping("/auth/{username}/verify.json") + public void verifyRegistration( + @PathVariable final String username, + @RequestParam("token") final String token, + @NonNull final HttpServletResponse response) { + HttpStatus status + = authenticationService.verifyRegistration(username, token); + if (status.equals(HttpStatus.OK)) { + response.setHeader(LOCATION_HEADER, + emailConfigProperties.dashboardBaseUrl()); + response.setStatus(HttpStatus.FOUND.value()); + } else { + response.setStatus(status.value()); + } + } + + /** + * The API-endpoint for setting a JWT access token with a lifespan of one hour + * as the "sessionid" cookie for authorization with further requests. <br> + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to log in + * @param response The {@link HttpServletResponse} for setting the "sessionid" + * cookie + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#login(String, HttpServletResponse) + */ + @PostMapping("/auth/{username}/login.json") + public ResponseEntity<String> login( + @PathVariable final String username, + @NonNull final HttpServletResponse response) { + return new ResponseEntity<>( + authenticationService.login(username, response)); + } + + /** + * The API-endpoint for invalidating the "sessionid" cookie containing a JWT + * access token. Following authorized requests require HTTP basic + * authentication or a new login. <br> + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to log out + * @param response The {@link HttpServletResponse} for invalidating the + * "sessionid" cookie + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#logout(String, HttpServletResponse) + */ + @PostMapping("/auth/{username}/logout.json") + public ResponseEntity<String> logout( + @PathVariable final String username, + @NonNull final HttpServletResponse response) { + return new ResponseEntity<>( + authenticationService.logout(username, response)); + } + + /** + * The API-endpoint for sending an email to the given address + * ({@link ForgotPasswordRequest#email()} with an url to reset the password of + * the user with that email address. + * + * @param email The email address of the user who wants to reset their + * password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#forgotPassword(String) + */ + @PostMapping("/auth/{email}/forgot.json") + public ResponseEntity<String> forgotPassword( + @PathVariable final String email) { + return new ResponseEntity<>(authenticationService.forgotPassword(email)); + } + + /** + * The API-endpoint for resetting the password of a + * {@link org.psesquared.server.model.User}. This method is invoked via the + * link in the verification email that is sent in + * {@link #forgotPassword(String)}. + * + * @param username The username of the user who wants to reset their + * password + * @param token The JWT that indicates the authority of the request + * @param requestBody The request-wrapper containing the new password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} password doesn't meet requirements, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#resetPassword(String, String, PasswordRequest) + */ + @PutMapping("/auth/{username}/resetpassword.json") + public ResponseEntity<String> resetPassword( + @PathVariable final String username, + @RequestParam("token") final String token, + @RequestBody final PasswordRequest requestBody) { + return new ResponseEntity<>( + authenticationService.resetPassword(username, token, requestBody)); + } + + /** + * The API-endpoint for changing the password of a + * {@link org.psesquared.server.model.User}, who is logged-in in the + * dashboard. + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to change their + * password + * @param requestBody The request-wrapper containing old and new password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} old password is wrong, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#changePassword(String, ChangePasswordRequest) + */ + @PutMapping("/auth/{username}/changepassword.json") + public ResponseEntity<String> changePassword( + @PathVariable final String username, + @RequestBody final ChangePasswordRequest requestBody) { + return new ResponseEntity<>( + authenticationService.changePassword(username, requestBody)); + } + + /** + * The API-endpoint for deleting a {@link org.psesquared.server.model.User}. + * This action is performed by a logged-in user from the dashboard. + * The user must enter their password ({@link PasswordRequest#password()}) + * and if correct, the user along with all associated data is deleted. + * (This is a secured endpoint requiring authorization via HTTP basic or JWT.) + * + * @param username The username of the user who wants to delete their + * account + * @param requestBody The request-wrapper containing the user's password + * @return {@link HttpStatus#OK} on success, <br> + * {@link HttpStatus#BAD_REQUEST} wrong password, <br> + * {@link HttpStatus#NOT_FOUND} user not found + * @see AuthenticationService#deleteUser(String, PasswordRequest) + */ + @DeleteMapping("/auth/{username}/delete.json") + public ResponseEntity<String> deleteUser( + @PathVariable final String username, + @RequestBody final PasswordRequest requestBody) { + return new ResponseEntity<>( + authenticationService.deleteUser(username, requestBody)); + } + + /** + * This API-endpoint exists for compatibility with podcatchers, especially + * AntennaPod and Kasts, which initially call this endpoint instead of + * {@link #login(String, HttpServletResponse)}. + * Accordingly, a call to this endpoint is internally treated as a login. + * In particular, devices remain unsupported. + * + * @param username The username of the user to be synchronized + * @param response The {@link HttpServletResponse} for setting the "sessionid" + * cookie + * @return A dummy response with a single dummy device for the given user + * @see AuthenticationService#login(String, HttpServletResponse) + */ + @GetMapping("/devices/{username}.json") + public ResponseEntity<List<DeviceWrapper>> getDeviceList( + @PathVariable final String username, + @NonNull final HttpServletResponse response) { + DeviceWrapper dummyDevice = new DeviceWrapper(); + return new ResponseEntity<>( + List.of(dummyDevice), + authenticationService.login(username, response)); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java new file mode 100644 index 0000000..d8b2357 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ChangePasswordRequest.java @@ -0,0 +1,15 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request for changing the password containing the old, i.e. current, and new + * password. + * + * @param oldPassword The user's current password + * @param newPassword The new password + */ +public record ChangePasswordRequest( + @JsonProperty(value = "password", required = true) String oldPassword, + @JsonProperty(value = "new_password", required = true) String newPassword) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java new file mode 100644 index 0000000..35dae3d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/DeviceWrapper.java @@ -0,0 +1,46 @@ +package org.psesquared.server.authentication.api.controller; + +/** + * This record wraps a dummy device that is required to be returned by <br> + * {@link AuthenticationController#getDeviceList(String, + * jakarta.servlet.http.HttpServletResponse)}. + * + * @param id The device id + * @param caption The caption, i.e. name, of the device + * @param type The device type + * @param subscriptions The number of subscriptions of the device + */ +public record DeviceWrapper( + String id, + String caption, + String type, + int subscriptions) { + + /** + * The id of the dummy device. + */ + private static final String DUMMY_ID = "dummy"; + + /** + * The name of the dummy device. + */ + private static final String DUMMY_DEVICE = "device"; + + /** + * The type of the dummy device. + */ + private static final String DUMMY_TYPE = "other"; + + /** + * The number of subscriptions of the dummy device. + */ + private static final int DUMMY_SUBSCRIPTIONS = 0; + + /** + * The no-args-constructor for a device-wrapper containing a dummy device. + */ + public DeviceWrapper() { + this(DUMMY_ID, DUMMY_DEVICE, DUMMY_TYPE, DUMMY_SUBSCRIPTIONS); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java new file mode 100644 index 0000000..700fb08 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/ForgotPasswordRequest.java @@ -0,0 +1,13 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request for sending an email with a link for resetting a user's password to + * the user's {@link #email} address. + * + * @param email The email address + */ +public record ForgotPasswordRequest( + @JsonProperty(required = true) String email) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java new file mode 100644 index 0000000..f772c7b --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/PasswordRequest.java @@ -0,0 +1,13 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request that contains a {@link #password}, which is either set as a new + * password or used for confirming the deletion of an account. + * + * @param password The password + */ +public record PasswordRequest( + @JsonProperty(required = true) String password) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java new file mode 100644 index 0000000..9c112b1 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/UserInfoRequest.java @@ -0,0 +1,16 @@ +package org.psesquared.server.authentication.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A request that contains the {@link #username}, {@link #email} address and + * {@link #password} for registering a new user. + * + * @param username The username + * @param email The email + * @param password The password + */ +public record UserInfoRequest(@JsonProperty(required = true) String username, + @JsonProperty(required = true) String email, + @JsonProperty(required = true) String password) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java new file mode 100644 index 0000000..79ae33d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/controller/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the highest logical layer of the authentication API + * ({@link org.psesquared.server.authentication.api}) - the controller layer. + * <br> + * It contains the + * {@link + * org.psesquared.server.authentication.api.controller.AuthenticationController} + * along with a series of wrapper classes for JSON request and response bodies. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.authentication.api.controller; diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java new file mode 100644 index 0000000..2073633 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/AuthenticationDao.java @@ -0,0 +1,62 @@ +package org.psesquared.server.authentication.api.data.access; + +import java.util.Optional; +import org.psesquared.server.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * This JPA repository manages all database transactions by automatically + * implementing the logic behind custom queries via method naming convention. + */ +@Repository +public interface AuthenticationDao extends JpaRepository<User, Long> { + + /** + * Checks if a user exists via their username. + * + * @param username The username + * @return {@code true} if the user with the given username exists, <br> + * {@code false} otherwise + */ + boolean existsByUsername(String username); + + /** + * Finds the {@link User} with the given username if present. + * + * @param username The username of the user that is being searched for + * @return An {@link Optional} containing the user with the given + * username if present + */ + Optional<User> findByUsername(String username); + + /** + * Finds the {@link User} with the given email address if present. + * + * @param email The email address of the user that is being searched for + * @return An {@link Optional} containing the user with the given email + * address if present + */ + Optional<User> findByEmail(String email); + + /** + * Finds a {@link User} with the given username if present or with the + * given email address otherwise. + * + * @param username The username of the user that is being searched for + * @param email The email address of the user that is being searched for + * @return An {@link Optional} containing the user with the given username + * or email address if present + */ + Optional<User> findByUsernameOrEmail(String username, String email); + + /** + * Deletes all users that haven't been verified yet and have registered + * before the time specified by the given timestamp. + * + * @param timestamp The timestamp representing the number of seconds from + * the epoch of 1970-01-01T00:00:00Z. + */ + void deleteAllByEnabledFalseAndCreatedAtLessThan(long timestamp); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java new file mode 100644 index 0000000..1b20cab --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/data/access/package-info.java @@ -0,0 +1,11 @@ +/** + * This package represents the lowest logical layer of the authentication API + * ({@link org.psesquared.server.authentication.api}) - the data-access layer. + * <br> + * It features the interface {@link + * org.psesquared.server.authentication.api.data.access.AuthenticationDao}. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.authentication.api.data.access; diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java new file mode 100644 index 0000000..e21c3fc --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/AuthenticationService.java @@ -0,0 +1,389 @@ +package org.psesquared.server.authentication.api.service; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.controller.ChangePasswordRequest; +import org.psesquared.server.authentication.api.controller.PasswordRequest; +import org.psesquared.server.authentication.api.controller.UserInfoRequest; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.psesquared.server.config.JwtService; +import org.psesquared.server.model.Role; +import org.psesquared.server.model.User; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * This service class manages all business logic associated with the + * authentication API. + * <br> + * It is called from the + * {@link + * org.psesquared.server.authentication.api.controller.AuthenticationController} + * and passes on requests concerning data access to the + * {@link AuthenticationDao}. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class AuthenticationService { + + /** + * A {@link User} is not enabled until verification. + */ + private static final boolean ENABLED_DEFAULT = false; + + /** + * A {@link User} becomes enabled after verification. + */ + private static final boolean VERIFIED = true; + + /** + * The age of an expired cookie. + */ + private static final int EXPIRED_AGE = 0; + + /** + * The name of the cookie used by podcatchers for authentication. + * If a {@link User} has logged in, this cookie holds the JWT. + */ + private static final String COOKIE_NAME = "sessionid"; + + /** + * Specifies that the cookie should be sent to all URLs. + */ + private static final String COOKIE_PATH_GLOBAL = "/"; + + /** + * The default role of a {@link User} is {@link Role#USER}. + */ + private static final Role DEFAULT_USER = Role.USER; + + /** + * The JPA repository that handles all user related database requests. + */ + private final AuthenticationDao authenticationDao; + + /** + * The class used for the encryption of passwords. + */ + private final PasswordEncoder passwordEncoder; + + /** + * The service class used for managing JWTs. + */ + private final JwtService jwtService; + + /** + * The service class used for sending emails. + */ + private final EmailServiceImpl emailService; + + /** + * The service class used for encrypting email addresses. + */ + private final EncryptionService encryptionService; + + /** + * The service class used for checking if the given user information meets + * the specified requirements. + */ + private final InputCheckService inputCheckService; + + /** + * This method is invoked by the register method of the authentication + * controller. + * <br> + * 1. Checks if the given user information meets the requirements. + * <br> + * 2. Checks if no user with the given username already exists (if so, + * and email as well as password match, the verification email is sent again). + * <br> + * 3. Creates a user with the given information and sends verification email. + * + * @param userInfo The wrapper object containing username, email and password + * @return @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} for invalid user information + */ + public HttpStatus registerUser(final UserInfoRequest userInfo) { + if (inputCheckService.checkUsernameInvalid(userInfo.username()) + || inputCheckService.checkEmailInvalid(userInfo.email()) + || inputCheckService.checkPasswordInvalid(userInfo.password())) { + return HttpStatus.BAD_REQUEST; + } + + final String encryptedEmailFromRequest + = encryptionService.saltAndHashEmail(userInfo.email()); + User user; + + try { + user = authenticationDao + .findByUsernameOrEmail(userInfo.username(), encryptedEmailFromRequest) + .orElseThrow(); + + if (user.isEnabled() + || !user.getEmail().equals(encryptedEmailFromRequest) + || !user.getUsername().equals(userInfo.username()) + || !passwordEncoder + .matches(userInfo.password(), user.getPassword())) { + return HttpStatus.BAD_REQUEST; + } + + user.setCreatedAt(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)); + + } catch (NoSuchElementException e) { + user = User.builder() + .username(userInfo.username()) + .email(encryptionService.saltAndHashEmail(userInfo.email())) + .password(passwordEncoder.encode(userInfo.password())) + .enabled(ENABLED_DEFAULT) + .createdAt(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .role(DEFAULT_USER) + .build(); + authenticationDao.save(user); + } + emailService.sendVerification(userInfo.email(), user); + return HttpStatus.OK; + } + + /** + * This method is invoked by the verifyRegistration method of the + * authentication controller. + * <br> + * If a not yet verified {@link User} with the given username exists, + * this user is verified via {@link User#setEnabled(boolean)}. + * + * @param username The username of the to be verified user + * @param token The JWT for authentication + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} user exists and is already verified, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus verifyRegistration(final String username, + final String token) { + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + + if (user.isEnabled()) { + return HttpStatus.BAD_REQUEST; + } + + if (!jwtService.isUrlTokenValid(token, user)) { + return HttpStatus.UNAUTHORIZED; + } + + user.setEnabled(VERIFIED); + return HttpStatus.OK; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the login method of the authentication + * controller. + * <br> + * Sets the "sessionid" cookie with a valid JWT for further authentication. + * + * @param username The username of the user who wants to log in + * @param response The {@link HttpServletResponse} for setting the "sessionid" + * cookie + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus login(final String username, + final HttpServletResponse response) { + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + var token = jwtService.generateAccessTokenString(user); + Cookie cookie = new Cookie(COOKIE_NAME, token); + cookie.setPath(COOKIE_PATH_GLOBAL); + response.addCookie(cookie); + return HttpStatus.OK; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the logout method of the authentication + * controller. + * <br> + * Invalidates the "sessionid" cookie. + * Thus, for further authentication until the next login only HTTP basic + * authentication (and no JWT authentication) is possible. + * + * @param username The username of the user who wants to log out + * @param response The {@link HttpServletResponse} for invalidating the + * "sessionid" cookie + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus logout(final String username, + final HttpServletResponse response) { + if (authenticationDao.existsByUsername(username)) { + Cookie cookie = new Cookie(COOKIE_NAME, null); + cookie.setMaxAge(EXPIRED_AGE); + cookie.setPath(COOKIE_PATH_GLOBAL); + response.addCookie(cookie); + return HttpStatus.OK; + } + return HttpStatus.NOT_FOUND; + } + + /** + * This method is invoked by the forgotPassword method of the authentication + * controller. + * <br> + * Sends an email with a link to reset the password to the given email + * address, if a user with this email address exists. + * + * @param email The email address of the user who wants to reset their + * password + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus forgotPassword(final String email) { + try { + var user = authenticationDao + .findByEmail(encryptionService.saltAndHashEmail(email)) + .orElseThrow(); + emailService.sendPasswordReset(email, user); + return HttpStatus.OK; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the resetPassword method of the authentication + * controller. + * <br> + * Sets a new password for the given user who has forgotten their + * password if the JWT is valid. + * + * @param username The username of the user who wants to reset their + * password + * @param token The JWT for authentication + * @param requestBody The request-wrapper containing the new password + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} password doesn't meet requirements, + * <br> + * {@link HttpStatus#UNAUTHORIZED} invalid token, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus resetPassword(final String username, + final String token, + final PasswordRequest requestBody) { + if (inputCheckService.checkPasswordInvalid(requestBody.password())) { + return HttpStatus.BAD_REQUEST; + } + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + if (jwtService.isUrlTokenValid(token, user)) { + user.setPassword(passwordEncoder.encode(requestBody.password())); + return HttpStatus.OK; + } + return HttpStatus.UNAUTHORIZED; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the changePassword method of the authentication + * controller. + * <br> + * Changes the password of a logged-in user. + * + * @param username The username of the user who wants to change their + * password + * @param requestBody The request-wrapper containing old and new password + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} old password is wrong, or new + * password doesn't meet requirements, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus changePassword(final String username, + final ChangePasswordRequest requestBody) { + if (inputCheckService.checkPasswordInvalid(requestBody.newPassword())) { + return HttpStatus.BAD_REQUEST; + } + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + if (passwordEncoder + .matches(requestBody.oldPassword(), user.getPassword())) { + user.setPassword(passwordEncoder.encode(requestBody.newPassword())); + return HttpStatus.OK; + } + return HttpStatus.BAD_REQUEST; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by the deleteUser method of the authentication + * controller. + * <br> + * Deletes the user with the given username if existent and if the given + * password for confirmation is correct. + * + * @param username The username of the user who wants to delete their account + * @param requestBody The request-wrapper containing the password for + * confirmation + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#BAD_REQUEST} wrong password, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus deleteUser(final String username, + final PasswordRequest requestBody) { + try { + var user = authenticationDao.findByUsername(username) + .orElseThrow(); + if (passwordEncoder.matches(requestBody.password(), user.getPassword())) { + authenticationDao.delete(user); + return HttpStatus.OK; + } + return HttpStatus.BAD_REQUEST; + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + } + + /** + * This method is invoked by {@link org.psesquared.server.util.Scheduler} + * for cleaning the database from expired {@link User}s. + * + * @param timestamp The timestamp representing the number of seconds from + * the epoch of 1970-01-01T00:00:00Z. + * @see AuthenticationDao#deleteAllByEnabledFalseAndCreatedAtLessThan(long) + */ + public void deleteInvalidUsersOlderThan(final long timestamp) { + authenticationDao.deleteAllByEnabledFalseAndCreatedAtLessThan(timestamp); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java new file mode 100644 index 0000000..c752286 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EmailServiceImpl.java @@ -0,0 +1,222 @@ +package org.psesquared.server.authentication.api.service; + +import lombok.RequiredArgsConstructor; +import org.psesquared.server.config.EmailConfigProperties; +import org.psesquared.server.config.JwtService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +/** + * This service class is responsible for sending emails to + * {@link org.springframework.security.core.userdetails.User}s. + * + * @see JavaMailSender + */ +@Service +@RequiredArgsConstructor +public class EmailServiceImpl { + + /** + * The {@link JavaMailSender} used for the sending of emails. + */ + private final JavaMailSender emailSender; + + /** + * The properties class that is used to return some externally stored URLs. + */ + private final EmailConfigProperties emailConfigProperties; + + /** + * The service class for managing the JWTs that are sent via email. + */ + private final JwtService jwtService; + + /** + * The email address from which the emails are sent. + */ + @Value("${spring.mail.username}") + private String sender; + + /** + * The subject of the email that is sent for account verification. + */ + private static final String VERIFICATION_MAIL_SUBJECT + = "Bestätige deine E-Mail-Adresse für unseren" + + " Podcast-Synchronisations-Server | Validate your Mail"; + + /** + * The subject of the email that is sent for resetting the password of a user. + */ + private static final String PASSWORD_RESET_MAIL_SUBJECT + = "Setze dein Passwort für unseren Podcast-Synchronisation-Server" + + " zurück! | Reset Password"; + + /** + * The placeholder for the username. + */ + private static final String USERNAME_MAIL_PLACEHOLDER = "username"; + + /** + * The placeholder for the verification URL. + */ + private static final String VERIFICATION_MAIL_PLACEHOLDER + = "verificationURL"; + + /** + * The placeholder for the URL for resetting the password of a user. + */ + private static final String PASSWORD_RESET_MAIL_PLACEHOLDER + = "passwordResetURL"; + + /** + * The question mark symbol announcing a URL query parameter. + */ + private static final String URL_QUERY_PARAM = "?"; + + /** + * The format of the username URL query parameter. + */ + private static final String USERNAME_PARAM = "username="; + + /** + * The separator for URL query parameters. + */ + private static final String PARAM_SEPARATOR = "&"; + + /** + * The format of the token URL query parameter. + */ + private static final String TOKEN_PARAM = "token="; + + /** + * The contents of the verification URL with placeholders read from an + * external file. + */ + @Value("#{T(org.psesquared.server.authentication.api.service" + + ".ResourceReader).readFileToString('VerificationMail.txt')}") + private String verificationMailText; + + /** + * The contents of the URL for resetting the password of a user with + * placeholders read from an external file. + */ + @Value("#{T(org.psesquared.server.authentication.api.service" + + ".ResourceReader).readFileToString('PasswordResetMail.txt')}") + private String passwordResetMailText; + + /** + * Sends a generic email to a user enabling him/her to perform a certain + * action when clicking on the contained url. + * This method uses a template which lies at resources and contains a + * "verificationURL"-placeholder, which is replaced by the url. + * + * @param to Recipients email address + * @param mailSubject Subject of the email + * @param body Body of the email + */ + private void sendMail(final String to, + final String mailSubject, + final String body) { + // send simple mail message with credential from application.properties + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(sender); + message.setTo(to); + message.setSubject(mailSubject); + message.setText(body); + emailSender.send(message); + } + + /** + * Substitutes username and URL placeholders in email template. + * + * @param template The email template with placeholders + * @param user The name of the user + * @param url The URL with the JWT for request authentication + * @return The email text with the actual username and URL + */ + private String substitutePlaceholders(final String template, + final UserDetails user, + final String url) { + return template + .replace(USERNAME_MAIL_PLACEHOLDER, user.getUsername()) + .replace(VERIFICATION_MAIL_PLACEHOLDER, url) + .replace(PASSWORD_RESET_MAIL_PLACEHOLDER, url); + } + + /** + * Generates the URL for verifying the account of a + * {@link org.springframework.security.core.userdetails.User} containing + * a JWT for authentication. + * + * @param userDetails The user details of the user who wants to verify their + * account + * @return The URL for verifying the user's account + */ + private String generateVerificationUrlString(final UserDetails userDetails) { + String token = jwtService.generateUrlTokenString(userDetails); + String verificationUrl + = String.format(emailConfigProperties.verificationUrl(), + userDetails.getUsername()); + + return verificationUrl + URL_QUERY_PARAM + TOKEN_PARAM + token; + } + + /** + * Generates the URL for resetting the password of a + * {@link org.springframework.security.core.userdetails.User} containing + * a JWT for authentication. + * + * @param userDetails The user details of the user who wants to reset their + * password + * @return The URL for resetting the user's password + */ + private String generatePasswordResetUrlString(final UserDetails userDetails) { + final String token = jwtService.generateUrlTokenString(userDetails); + return emailConfigProperties.dashboardBaseUrl() + + emailConfigProperties.resetUrlPath() + + URL_QUERY_PARAM + + USERNAME_PARAM + + userDetails.getUsername() + + PARAM_SEPARATOR + + TOKEN_PARAM + + token; + } + + /** + * Sends a validation E-Mail to validate a user account by clicking on the + * given URL. + * It uses a template which lies at resources/ValidationMail.txt and contains + * a "validationURL"-placeholder. + * + * @param to The email address of the user who wants to verify their account + * @param userDetails The user details of that user + */ + public void sendVerification(final String to, final UserDetails userDetails) { + final String url = generateVerificationUrlString(userDetails); + String mailText + = substitutePlaceholders(verificationMailText, userDetails, url); + + sendMail(to, VERIFICATION_MAIL_SUBJECT, mailText); + } + + /** + * Sends a password-reset E-Mail to a user with a URL which lets the user + * change his/her password. + * It uses a template which lies at resources/PasswordResetMail.txt and + * contains a "passwordResetURL"-placeholder. + * + * @param to The email address of the user who wants to reset their password + * @param userDetails The user details of that user + */ + public void sendPasswordReset(final String to, + final UserDetails userDetails) { + final String url = generatePasswordResetUrlString(userDetails); + String mailText + = substitutePlaceholders(passwordResetMailText, userDetails, url); + + sendMail(to, PASSWORD_RESET_MAIL_SUBJECT, mailText); + } +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java new file mode 100644 index 0000000..f9cb68c --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/EncryptionService.java @@ -0,0 +1,85 @@ +package org.psesquared.server.authentication.api.service; + +import io.jsonwebtoken.io.Decoders; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.config.SecurityConfigProperties; +import org.springframework.stereotype.Service; + +/** + * The service class responsible for encrypting the email addresses of + * {@link org.psesquared.server.model.User}s. + */ +@Service +@RequiredArgsConstructor +public class EncryptionService { + + /** + * The mask for a byte. + */ + private static final int BYTE_MASK = 0xff; + + /** + * The value added to the byte before conversion to String. + */ + private static final int ADDITION = 0x100; + + /** + * The hexadecimal radix. + */ + private static final int RADIX = 16; + + /** + * The index specifying the starting index for the substring method. + */ + private static final int BEGIN_INDEX = 1; + + /** + * The name of the hashing algorithm. + */ + private static final String SHA_512_ALGORITHM_NAME = "SHA-512"; + + /** + * The properties class that is used to return externally stored secret salt. + */ + private final SecurityConfigProperties securityConfigProperties; + + /** + * Encrypts a given email address by salting it with a fixed salt and hashing + * it afterwards. + * + * @param email The email address that needs to be salted and hashed + * @return The salted and hashed email address + */ + public String saltAndHashEmail(final String email) { + String generatedEmail = null; + try { + MessageDigest md = MessageDigest.getInstance(SHA_512_ALGORITHM_NAME); + md.update(getSalt()); + byte[] bytes = md.digest(email.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(Integer + .toString((b & BYTE_MASK) + ADDITION, RADIX) + .substring(BEGIN_INDEX)); + } + generatedEmail = sb.toString(); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + return generatedEmail; + } + + /** + * Returns the salt for encrypting email addresses in the form of + * base64-decoded bytes of a locally stored secret signing key. + * + * @return {@code byte[]} containing the salt + */ + private byte[] getSalt() { + return Decoders.BASE64.decode(securityConfigProperties.emailSigningKey()); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java new file mode 100644 index 0000000..be74a88 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/InputCheckService.java @@ -0,0 +1,170 @@ +package org.psesquared.server.authentication.api.service; + +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.InternetAddress; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * The service class responsible for checking if user information (i.e. + * username, email address and password) meets the specified requirements. + */ +@Service +@RequiredArgsConstructor +public class InputCheckService { + + /** + * The strict boolean for + * {@link InternetAddress#InternetAddress(String, boolean)}. + */ + private static final boolean STRICT = true; + + /** + * The return value for valid user information. + */ + private static final boolean VALID = false; + + /** + * The return value for invalid user information. + */ + private static final boolean INVALID = true; + + /** + * Asserts position at start of a line. + */ + private static final String REGEX_START = "^"; + + /** + * Matches any word character (equivalent to [a-zA-Z0-9_]) character '-' + * between 1 and 255 times. + */ + private static final String USERNAME_REGEX_GROUP = "[\\w\\u002d]{1,255}"; + + /** + * Asserts that the password contains at least one digit. + */ + private static final String PW_REGEX_GROUP1 = "(?=.*\\d)"; + + /** + * Asserts that the password contains at least one lower case character. + */ + private static final String PW_REGEX_GROUP2 = "(?=.*[a-z])"; + + /** + * Asserts that the password contains at least one upper case character. + */ + private static final String PW_REGEX_GROUP3 = "(?=.*[A-Z])"; + + /** + * Asserts that the password contains at least one special character from + * the list [€°§´] or the Punct script extension. + */ + private static final String PW_REGEX_GROUP4 = "(?=.*[\\p{Punct}€°§´])"; + + /** + * Asserts that the password contains only word characters + * (equivalent to [a-zA-Z0-9_]) and the special characters specified in + * {@link InputCheckService#PW_REGEX_GROUP4}. + */ + private static final String PW_REGEX_GROUP5 = "[\\w\\p{Punct}€°§´]{8,255}"; + + /** + * Asserts position at the end of a line. + */ + private static final String REGEX_END = "$"; + + + /** + * The complete regex for a valid username consisting of the following regex + * groups: + * <br> + * {@link #REGEX_START}, {@link #USERNAME_REGEX_GROUP}, {@link #REGEX_END}. + */ + private static final String USERNAME_REGEX = REGEX_START + + USERNAME_REGEX_GROUP + + REGEX_END; + + /** + * The complete regex for a valid password consisting of the following regex + * groups: + * <br> + * {@link #REGEX_START}, {@link #PW_REGEX_GROUP1}, {@link #PW_REGEX_GROUP2}, + * {@link #PW_REGEX_GROUP3}, {@link #PW_REGEX_GROUP4}, + * {@link #PW_REGEX_GROUP5}, {@link #REGEX_END}. + */ + private static final String PW_REGEX = REGEX_START + + PW_REGEX_GROUP1 + + PW_REGEX_GROUP2 + + PW_REGEX_GROUP3 + + PW_REGEX_GROUP4 + + PW_REGEX_GROUP5 + + REGEX_END; + + /** + * Checks if the given {@code username} meets the following requirements: + * <br> + * - contains only word characters (equivalent to [a-zA-Z0-9_]) + * and the character '-'. + * <br> + * - is between 1 and 255 characters long. + * + * @param username The username that needs to be validated + * @return {@code false} if the username meets the requirements, + * <br> + * {@code true} otherwise + */ + public boolean checkUsernameInvalid(final String username) { + return !Pattern + .compile(USERNAME_REGEX) + .matcher(username) + .matches(); + } + + /** + * Checks if the given email address conforms to the RFC822 standard using + * {@link InternetAddress#validate()}. + * + * @param email The email address that needs to be validated + * @return {@code false} if the username meets the requirements, + * <br> + * {@code true} otherwise + */ + public boolean checkEmailInvalid(final String email) { + try { + InternetAddress internetAddress = new InternetAddress(email, STRICT); + internetAddress.validate(); + return VALID; + } catch (AddressException e) { + return INVALID; + } + } + + /** + * Checks if the given {@code password} meets the following requirements: + * <br> + * - contains at least one digit. + * <br> + * - contains at least one lower case character. + * <br> + * - contains at least one upper case character. + * <br> + * - contains at least one special character from the list [€°§´] or the + * Punct script extension. + * <br> + * - contains only word characters (equivalent to [a-zA-Z0-9_]) and special + * characters specified above. + * + * @param password The username that needs to be validated + * @return {@code false} if the username meets the requirements, + * <br> + * {@code true} otherwise + */ + public boolean checkPasswordInvalid(final String password) { + return !Pattern + .compile(PW_REGEX) + .matcher(password) + .matches(); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java new file mode 100644 index 0000000..3501f9e --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/ResourceReader.java @@ -0,0 +1,33 @@ +package org.psesquared.server.authentication.api.service; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import org.apache.commons.io.IOUtils; + +/** + * This class allows reading text from files. + */ +public final class ResourceReader { + + /** + * Private constructor - cannot be called. + */ + private ResourceReader() { } + + /** + * This method reads text from a file specified by the given path. + * + * @param path The path to the file + * @return The contents of the file + * @throws java.io.IOException If an I/O error occurs + */ + public static String readFileToString(final String path) + throws java.io.IOException { + return IOUtils.toString(Objects.requireNonNull( + ResourceReader.class.getClassLoader().getResourceAsStream(path)), + StandardCharsets.UTF_8); + + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java new file mode 100644 index 0000000..9f16a0d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/authentication/api/service/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the logical middle layer of the authentication API + * ({@link org.psesquared.server.authentication.api}) - the service layer. + * <br> + * All business logic is handled here with the + * {@link + * org.psesquared.server.authentication.api.service.AuthenticationService} + * class, which in turn relies on some other service classes. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.authentication.api.service; diff --git a/pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java b/pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java new file mode 100644 index 0000000..a67e53d --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java @@ -0,0 +1,125 @@ +package org.psesquared.server.config; + +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * The application configuration class declaring several beans. + */ +@Configuration +@EnableScheduling +@EnableTransactionManagement +@EnableAsync +@RequiredArgsConstructor +public class ApplicationConfig implements WebMvcConfigurer { + + /** + * The message passed on to {@link UsernameNotFoundException}. + */ + private static final String USERNAME_NOT_FOUND + = "No user with the given username was found."; + + /** + * The JPA repository that handles user related database requests. + */ + private final AuthenticationDao authenticationDao; + + /** + * Returns a {@link UserDetailsService} bean for retrieving users via username + * from the database. + * + * @return {@link UserDetailsService} + */ + @Bean + public UserDetailsService userDetailsService() { + return username -> authenticationDao.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException(USERNAME_NOT_FOUND)); + } + + /** + * Returns an {@link AuthenticationProvider} bean for authenticating + * {@link org.springframework.security.core.userdetails.User}s with username + * and password using {@link #userDetailsService()} and + * {@link #passwordEncoder()}. + * + * @return {@link AuthenticationProvider} + */ + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService()); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + /** + * Returns a {@link BCryptPasswordEncoder} bean for password encryption. + * + * @return {@link PasswordEncoder} + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Returns an {@link AuthenticationManager} bean for processing authentication + * requests from the given {@link AuthenticationConfiguration}. + * + * @param config The application's authentication configuration + * @return {@link AuthenticationManager} + * @throws Exception When the authentication manager couldn't be retrieved + * from the given configuration + */ + @Bean + public AuthenticationManager authenticationManager( + final AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + /** + * Returns a {@link WebMvcConfigurer} bean with CORS enabled globally. + * + * @return {@link WebMvcConfigurer} + */ + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(@NonNull final CorsRegistry registry) { + registry + .addMapping("/**") + .allowedOrigins("*") + .allowedMethods("*"); + } + }; + } + + /** + * Registers an {@link AuthenticationValidatorInterceptor}. + * + * @param registry The {@link InterceptorRegistry} + */ + @Override + public void addInterceptors(final InterceptorRegistry registry) { + registry.addInterceptor(new AuthenticationValidatorInterceptor()); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java b/pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java new file mode 100644 index 0000000..3482164 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java @@ -0,0 +1,95 @@ +package org.psesquared.server.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; + +/** + * This interceptor class intercepts requests between the DispatcherServlet and + * the Controller (i.e. when already mapped to Controller method). + * It checks if the currently authenticated + * {@link org.psesquared.server.model.User} is the same user for whom the + * request is sent. + */ +public class AuthenticationValidatorInterceptor implements HandlerInterceptor { + + /** + * The return value for aborting the execution of the Controller method. + */ + private static final boolean ABORT_EXECUTION = false; + + /** + * The return value for resuming the execution of the Controller method. + */ + private static final boolean RESUME_EXECUTION = true; + + /** + * The name of the username URL path variable. + */ + private static final String PATH_VARIABLE_USERNAME = "username"; + + /** + * The default name associated with authentication. + */ + private static final String USERNAME_NO_AUTH = "anonymousUser"; + + /** + * Checks if the currently authenticated + * {@link org.psesquared.server.model.User} is the same user specified in the + * URL path variable of the request. + * + * @param request The {@link HttpServletRequest} + * @param response The {@link HttpServletResponse} + * @param handler The chosen handler + * @return {@code true} if the users match, + * <br> + * {@code false} otherwise + */ + @Override + public boolean preHandle(@NonNull final HttpServletRequest request, + @NonNull final HttpServletResponse response, + @NonNull final Object handler) { + + final String usernamePathVariable = extractUsernamePathVariable(request); + final AbstractAuthenticationToken auth + = (AbstractAuthenticationToken) SecurityContextHolder.getContext() + .getAuthentication(); + final String usernameAuthenticated; + + if (usernamePathVariable == null || auth == null) { + return RESUME_EXECUTION; + } + + usernameAuthenticated = auth.getName(); + if (usernameAuthenticated == null + || usernameAuthenticated.equals(USERNAME_NO_AUTH) + || usernameAuthenticated.equals(usernamePathVariable)) { + return RESUME_EXECUTION; + } + + return ABORT_EXECUTION; + } + + /** + * Extracts the username path variable from the {@link HttpServletRequest}. + * + * @param request The {@link HttpServletRequest} + * @return The value of the username path variable + */ + private String extractUsernamePathVariable(final HttpServletRequest request) { + // returns HttpServletRequest attribute that contains the URI templates map, + // mapping variable names to values + final Map<String, String> pathVariables = (Map<String, String>) request + .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + // this attribute is of type Map<String, String> per definition, + // so no type checks are needed + return (pathVariables != null) + ? pathVariables.get(PATH_VARIABLE_USERNAME) : null; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java b/pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java new file mode 100644 index 0000000..4b2c79e --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java @@ -0,0 +1,16 @@ +package org.psesquared.server.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The properties class that is used to return some externally stored URLs. + * + * @param dashboardBaseUrl The base URL of the PSE-Dashboard + * @param verificationUrl The URL for account verification + * @param resetUrlPath The URL for resetting the password of a user + */ +@ConfigurationProperties("email") +public record EmailConfigProperties(String dashboardBaseUrl, + String verificationUrl, + String resetUrlPath) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java b/pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..bf00ecb --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java @@ -0,0 +1,143 @@ +package org.psesquared.server.config; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.WebUtils; + +/** + * This filter class handles authentication via JWT. + * <br> + * Its method + * {@link + * #doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain)} + * is invoked before the mapping of the request to the Controller happens. + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + /** + * The URL for the unsecured register-API-endpoint. + */ + private static final String REGISTER_URL + = "/api/2/auth/register.json"; + + /** + * The URL for the unsecured forgotPassword-API-endpoint. + */ + private static final String FORGOT_URL + = "/api/2/auth/{email}/forgot.json"; + + /** + * The URL for the unsecured verify-API-endpoint. + */ + private static final String VERIFY_URL + = "/api/2/auth/{username}/verify.json"; + + /** + * The URL for the unsecured resetPassword-API-endpoint. + */ + private static final String RESET_PASSWORD_URL + = "/api/2/auth/{username}/resetpassword.json"; + + /** + * The name of the cookie used for JWT authentication. + */ + private static final String COOKIE_NAME = "sessionid"; + + /** + * The service class used for managing JWTs. + */ + private final JwtService jwtService; + + /** + * The service class used for retrieving users from the database. + */ + private final UserDetailsService userDetailsService; + + /** + * The filter method does nothing for the specified unsecured URLs + * and otherwise calls + * {@link #authenticateIfValid(Cookie, HttpServletRequest)}. + * + * @param request The {@link HttpServletRequest} + * @param response The {@link HttpServletResponse} + * @param filterChain The {@link FilterChain} + * @throws ServletException If error occurs when processing request + * @throws IOException If I/O error occurs + */ + @Override + protected void doFilterInternal(@NonNull final HttpServletRequest request, + @NonNull final HttpServletResponse response, + @NonNull final FilterChain filterChain) + throws ServletException, IOException { + + final Cookie cookie = WebUtils.getCookie(request, COOKIE_NAME); + final String url = request.getRequestURI(); + + if (url.equals(REGISTER_URL) || url.equals(FORGOT_URL) + || url.equals(VERIFY_URL) || url.equals(RESET_PASSWORD_URL) + || cookie == null) { + filterChain.doFilter(request, response); + return; + } + + authenticateIfValid(cookie, request); + filterChain.doFilter(request, response); + } + + /** + * Authenticates the {@link org.psesquared.server.model.User} associated with + * the JWT from the cookie if it is valid. + * + * @param cookie The cookie containing the JWT + * @param request The {@link HttpServletRequest} for creating a new + * authentication details instance + */ + private void authenticateIfValid(final Cookie cookie, + final HttpServletRequest request) { + final String jwt = cookie.getValue(); + final String usernameFromToken; + + try { + usernameFromToken = jwtService.extractAuthUsername(jwt); + } catch (ExpiredJwtException e) { + return; + } + + if (usernameFromToken != null + && SecurityContextHolder + .getContext() + .getAuthentication() == null) { + UserDetails userDetails + = userDetailsService.loadUserByUsername(usernameFromToken); + if (jwtService.isAuthTokenValid(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken + = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/JwtService.java b/pse-server/src/main/java/org/psesquared/server/config/JwtService.java new file mode 100644 index 0000000..157055a --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/JwtService.java @@ -0,0 +1,283 @@ +package org.psesquared.server.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +/** + * The service class responsible for creating, managing and validating JWTs. + */ +@Service +@RequiredArgsConstructor +public class JwtService { + + /** + * The boolean value for expressing that a JWT is not valid. + */ + private static final boolean INVALID = false; + + /** + * The 1h lifespan of an access token. + */ + private static final long ACCESS_TOKEN_LIFESPAN_MILLIS + = 1000 * 60 * (long) 60; + + /** + * The 24h lifespan of a URL token (verification/resetting password). + */ + private static final long URL_TOKEN_LIFESPAN_MILLIS + = ACCESS_TOKEN_LIFESPAN_MILLIS * 24; + + /** + * The properties class that is used to return externally stored signing key. + */ + private final SecurityConfigProperties securityConfigProperties; + + /** + * Extracts the username from a JWT for authentication. + * + * @param token The JWT + * @return The extracted username + */ + public String extractAuthUsername(final String token) { + return extractClaim(token, getAuthSigningKey(), Claims::getSubject); + } + + /** + * Extracts a generic claim from the JWT. + * + * @param <T> The type of the claim + * @param token The JWT + * @param signingKey The JWT signing key + * @param claimsResolver The function to resolve the claim + * @return The extracted generic claim + * @throws ExpiredJwtException If the JWT has expired + * @throws UnsupportedJwtException If the JWT is not supported + * @throws MalformedJwtException If the JWT is malformed + * @throws SignatureException If the signature doesn't match + * @throws IllegalArgumentException If the token has an inappropriate format + */ + public <T> T extractClaim(final String token, + final Key signingKey, + final Function<Claims, T> claimsResolver) + throws ExpiredJwtException, + UnsupportedJwtException, + MalformedJwtException, + SignatureException, + IllegalArgumentException { + + final Claims claims = extractAllClaims(token, signingKey); + return claimsResolver.apply(claims); + } + + /** + * Generates the JWT with additional claims and a lifespan for the + * {@link org.psesquared.server.model.User} with the given details. + * + * @param additionalClaims The {@link Map} with additional claims + * @param userDetails The user details + * @param tokenLifespan The lifespan of the token + * @param signingKey The JWT signing key + * @return The generated JWT + */ + public String generateTokenString(final Map<String, Object> additionalClaims, + final UserDetails userDetails, + final long tokenLifespan, + final Key signingKey) { + return Jwts.builder() + .setClaims(additionalClaims) + .setSubject(userDetails.getUsername()) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + tokenLifespan)) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * Generates a JWT access token for the + * {@link org.psesquared.server.model.User} with the given details. + * (no additional claims). + * + * @param userDetails The user details + * @return The generated JWT access token + */ + public String generateAccessTokenString(final UserDetails userDetails) { + //no additional claims supported but open for extension with roles e.g. + return generateTokenString(new HashMap<>(), + userDetails, + ACCESS_TOKEN_LIFESPAN_MILLIS, + getAuthSigningKey()); + } + + /** + * Generates a JWT URL token required for authentication/resetting password + * for the {@link org.psesquared.server.model.User} with the given details + * (no additional claims). + * + * @param userDetails The user details + * @return The generated JWT access token + */ + public String generateUrlTokenString(final UserDetails userDetails) { + return generateTokenString(new HashMap<>(), + userDetails, + URL_TOKEN_LIFESPAN_MILLIS, + getUrlSigningKey()); + } + + /** + * Validates the given JWT for authentication against the given + * {@link UserDetails} and checks if it has not expired. + * + * @param token The to be validated JWT + * @param userDetails The user details + * @return {@code true} if the JWT is valid, + * <br> + * {@code false} otherwise + */ + public boolean isAuthTokenValid(final String token, + final UserDetails userDetails) { + return isTokenValid(token, userDetails, getAuthSigningKey()); + } + + /** + * Validates the given JWT for URLs against the given {@link UserDetails} + * and checks if it has not expired. + * + * @param token The to be validated JWT + * @param userDetails The user details + * @return {@code true} if the JWT is valid, + * <br> + * {@code false} otherwise + */ + public boolean isUrlTokenValid(final String token, + final UserDetails userDetails) { + return isTokenValid(token, userDetails, getUrlSigningKey()); + } + + /** + * Validates the given JWT against the given {@link UserDetails} + * with the given signing key and checks if it has not expired. + * + * @param token The to be validated JWT + * @param userDetails The user details + * @param signingKey The JWT signing key + * @return {@code true} if the JWT is valid, + * <br> + * {@code false} otherwise + */ + private boolean isTokenValid(final String token, + final UserDetails userDetails, + final Key signingKey) { + try { + final String username = extractUsername(token, signingKey); + return username.equals(userDetails.getUsername()) + && !isTokenExpired(token, signingKey); + } catch (ExpiredJwtException + | UnsupportedJwtException + | MalformedJwtException + | SignatureException + | IllegalArgumentException e) { + return INVALID; + } + } + + /** + * Checks if the given JWT is expired. + * + * @param token The JWT + * @param signingKey The JWT signing key + * @return {@code true} if the JWT is expired, + * <br> + * {@code false} otherwise + */ + private boolean isTokenExpired(final String token, final Key signingKey) { + return extractExpiration(token, signingKey).before(new Date()); + } + + /** + * Extracts the username from a JWT. + * + * @param token The JWT + * @param signingKey The JWT signing key + * @return The extracted username + */ + private String extractUsername(final String token, final Key signingKey) { + return extractClaim(token, signingKey, Claims::getSubject); + } + + /** + * Extracts the expiration {@link Date} of the JWT. + * + * @param token The JWT + * @param signingKey The JWT signing key + * @return The expiration date + */ + private Date extractExpiration(final String token, final Key signingKey) { + return extractClaim(token, signingKey, Claims::getExpiration); + } + + /** + * Extracts all claims from the JWT in order for + * {@link #extractClaim(String, Key, Function)} to be able + * to filter out one claim. + * + * @param token The JWT + * @param signingKey The JWT signing key + * @return All claims of the JWT + * @throws ExpiredJwtException If the JWT has expired + * @throws UnsupportedJwtException If the JWT is not supported + * @throws MalformedJwtException If the JWT is malformed + * @throws SignatureException If the signature doesn't match + * @throws IllegalArgumentException If the token has an inappropriate format + */ + private Claims extractAllClaims(final String token, final Key signingKey) + throws ExpiredJwtException, + UnsupportedJwtException, + MalformedJwtException, + SignatureException, + IllegalArgumentException { + + return Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /** + * Returns the signing {@link Key} for signing the JWT. + * + * @return The signing key + */ + private Key getAuthSigningKey() { + byte[] keyBytes + = Decoders.BASE64.decode(securityConfigProperties.jwtAuthSigningKey()); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * Returns the signing {@link Key} for signing the JWT. + * + * @return The signing key + */ + private Key getUrlSigningKey() { + byte[] keyBytes + = Decoders.BASE64.decode(securityConfigProperties.jwtUrlSigningKey()); + return Keys.hmacShaKeyFor(keyBytes); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java new file mode 100644 index 0000000..8005160 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java @@ -0,0 +1,117 @@ +package org.psesquared.server.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +/** + * This class is responsible for configuring the {@link SecurityFilterChain} + * which determines the way authentication is handled with the server. + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + /** + * The URL of the unsecured register-API-endpoint. + */ + private static final String REGISTER_URL + = "/api/2/auth/register.json"; + + /** + * The URL of the unsecured forgotPassword-API-endpoint. + */ + private static final String FORGOT_URL + = "/api/2/auth/{email}/forgot.json"; + + /** + * The URL of the unsecured verify-API-endpoint. + */ + private static final String VERIFY_URL + = "/api/2/auth/{username}/verify.json"; + + /** + * The URL of the unsecured resetPassword-API-endpoint. + */ + private static final String RESET_PASSWORD_URL + = "/api/2/auth/{username}/resetpassword.json"; + + /** + * The authentication filter for JWT authentication. + */ + private final JwtAuthenticationFilter jwtAuthFilter; + + /** + * The authentication provider specified in {@link ApplicationConfig}. + */ + private final AuthenticationProvider authenticationProvider; + + /** + * Configures the {@link SecurityFilterChain} with {@link HttpSecurity} + * in the following way: + * <br> + * 1. JWT authentication ("sessionid" cookie) + * <br> + * 2. HTTP basic authentication ("Authorization" header) + * + * @param http The HTTP security class + * @return The security filter chain + * @throws Exception If an error occurs + */ + @Bean + public SecurityFilterChain securityFilterChain(final HttpSecurity http) + throws Exception { + http + .cors() + .and() + .csrf() + .disable() + .authorizeHttpRequests() + .requestMatchers( + REGISTER_URL, + FORGOT_URL, + VERIFY_URL, + RESET_PASSWORD_URL) + .permitAll() + .anyRequest() + .authenticated() + .and() + .authenticationProvider(authenticationProvider) + .addFilterBefore(jwtAuthFilter, + UsernamePasswordAuthenticationFilter.class) + .httpBasic() + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + return http.build(); + } + + /** + * Ensures CORS is processed before Spring Security. + * + * @return The specified CORS configuration source + */ + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowCredentials(true); + configuration.addAllowedOriginPattern("*"); + configuration.addAllowedHeader("*"); + configuration.addAllowedMethod("*"); + UrlBasedCorsConfigurationSource source + = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java new file mode 100644 index 0000000..74303fe --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java @@ -0,0 +1,17 @@ +package org.psesquared.server.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The properties class that is used to return externally stored signing key. + * + * @param jwtAuthSigningKey The base64-encoded JWT signing key for + * authentication + * @param jwtUrlSigningKey The base64-encoded JWT signing key for URLs + * @param emailSigningKey The base64-encoded salt for email encryption + */ +@ConfigurationProperties("security") +public record SecurityConfigProperties(String jwtAuthSigningKey, + String jwtUrlSigningKey, + String emailSigningKey) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/config/package-info.java b/pse-server/src/main/java/org/psesquared/server/config/package-info.java new file mode 100644 index 0000000..814be40 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/config/package-info.java @@ -0,0 +1,8 @@ +/** + * This package features all relevant classes for the application + * configuration and security. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.config; diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java new file mode 100644 index 0000000..9a0d898 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionController.java @@ -0,0 +1,129 @@ +package org.psesquared.server.episode.actions.api.controller; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.episode.actions.api.service.EpisodeActionService; +import org.psesquared.server.util.UpdateUrlsWrapper; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * This is a controller class for the Episode Action API that + * handles the requests from the client concerning the synchronization + * of episodes between clients. + * In the end an appropriate response is sent back to the user. + */ +@RequestMapping("/api/2/episodes/{username}.json") +@RestController +@RequiredArgsConstructor +public class EpisodeActionController { + + /** + * The service class that this controller calls to further process requests. + */ + private final EpisodeActionService episodeActionService; + + /** + * Takes a list of EpisodeActionPosts of a user and adds them to the database. + * + * @param username The username of the user uploading the + * EpisodeActions + * @param episodeActionPosts The list of EpisodeActionPosts to be uploaded + * @return The exit status of the function + */ + @PostMapping + public ResponseEntity<UpdateUrlsWrapper> addEpisodeActions( + @PathVariable final String username, + @RequestBody final List<EpisodeActionPost> episodeActionPosts) { + episodeActionService.addEpisodeActions(username, episodeActionPosts); + return ResponseEntity.ok(new UpdateUrlsWrapper()); + } + + /** + * Returns a list of all EpisodeActions a user has uploaded so far in the form + * of an EpisodeActionGetResponse. + * + * @param username The username of the user whose EpisodeActions are requested + * @return The exit status with a response body containing all requested + * EpisodeActions + */ + @GetMapping + public ResponseEntity<EpisodeActionGetResponse> getEpisodeActions( + @PathVariable final String username) { + EpisodeActionGetResponse responseBody + = new EpisodeActionGetResponse(episodeActionService + .getEpisodeActions(username)); + return ResponseEntity.ok(responseBody); + } + + /** + * Returns a list of EpisodeActions of a user for a given podcast in the form + * of an EpisodeActionGetResponse. + * + * @param username The username of the user whose EpisodeActions are + * requested + * @param podcastUrl The RSS-Feed URL of the podcast in question + * @return The exit status with a response body containing all requested + * EpisodeActions + */ + @GetMapping(params = {"podcast"}) + public ResponseEntity<EpisodeActionGetResponse> getEpisodeActionsOfPodcast( + @PathVariable final String username, + @RequestParam("podcastUrl") final String podcastUrl) { + EpisodeActionGetResponse responseBody + = new EpisodeActionGetResponse(episodeActionService + .getEpisodeActionsOfPodcast(username, podcastUrl)); + return ResponseEntity.ok(responseBody); + } + + /** + * Returns a list of EpisodeActions of a user since a given timestamp in the + * form of an EpisodeActionGetResponse. + * + * @param username The username of the user whose EpisodeActions are requested + * @param since The timestamp signifying how old the EpisodeActions are + * allowed to be + * @return The exit status with a response body containing all requested + * EpisodeActions + */ + @GetMapping(params = {"since"}) + public ResponseEntity<EpisodeActionGetResponse> getEpisodeActionsSince( + @PathVariable final String username, + @RequestParam("since") final long since) { + EpisodeActionGetResponse responseBody + = new EpisodeActionGetResponse(episodeActionService + .getEpisodeActionsSince(username, since)); + return ResponseEntity.ok(responseBody); + } + + /** + * Returns a list of EpisodeActions of a user for a given podcast, since a + * given time in the form of an EpisodeActionGetResponse. + * + * @param username The username of the user whose EpisodeActions are + * requested + * @param podcastUrl The RSS-Feed URL of the podcast in question + * @param since The timestamp signifying how old the EpisodeActions are + * allowed to be + * @return The exit status with a response body containing all requested + * EpisodeActions + */ + @GetMapping(params = {"podcast", "since"}) + public ResponseEntity<EpisodeActionGetResponse> + getEpisodeActionsOfPodcastSince( + @PathVariable final String username, + @RequestParam("podcastUrl") final String podcastUrl, + @RequestParam("since") final long since) { + EpisodeActionGetResponse responseBody + = new EpisodeActionGetResponse(episodeActionService + .getEpisodeActionsOfPodcastSince(username, podcastUrl, since)); + return ResponseEntity.ok(responseBody); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java new file mode 100644 index 0000000..d1facac --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionGetResponse.java @@ -0,0 +1,37 @@ +package org.psesquared.server.episode.actions.api.controller; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import lombok.Data; + +/** + * The Response Object for a GET-Request concerning an EpisodeAction. + * <br> + * May contain multiple EpisodeActions. + */ +@Data +public class EpisodeActionGetResponse { + + /** + * The list of EpisodeActionPosts. + */ + private final List<EpisodeActionPost> actions; + + /** + * The timestamp of the response. + */ + private final long timestamp; + + /** + * Instantiates a new EpisodeActionGetResponse with the current timestamp. + * + * @param episodeActionPosts A list of EpisodeActionPosts + */ + public EpisodeActionGetResponse( + final List<EpisodeActionPost> episodeActionPosts) { + this.actions = episodeActionPosts; + this.timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java new file mode 100644 index 0000000..28613f2 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/EpisodeActionPost.java @@ -0,0 +1,61 @@ +package org.psesquared.server.episode.actions.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.psesquared.server.model.EpisodeAction; + +/** + * An Episode Action that is being sent to the server via a POST Request. + * <br> + * If the user listened to an episode or did another action, an + * EpisodeActionPOST is uploaded. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EpisodeActionPost { + + /** + * The URL of the podcast the posted episode action belongs to. + */ + @JsonProperty(value = "podcast", required = true) + @NotBlank + private String podcastUrl; + + /** + * The URL of the corresponding episode. + */ + @JsonProperty(value = "episode", required = true) + @NotBlank + private String episodeUrl; + + /** + * The title of the corresponding episode. + */ + private String title; + + /** + * The GUID of the corresponding episode. + */ + private String guid; + + /** + * The total length of the corresponding episode in milliseconds. + */ + private int total; + + /** + * The actual episode action whose attributes are presented unwrapped. + * + * @see JsonUnwrapped + */ + @JsonUnwrapped + private EpisodeAction episodeAction; + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java new file mode 100644 index 0000000..3115012 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/controller/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the highest logical layer of the episode action API + * ({@link org.psesquared.server.episode.actions.api}) - the controller layer. + * <br> + * It contains the + * {@link + * org.psesquared.server.episode.actions.api.controller.EpisodeActionController} + * along with some wrapper classes for JSON request and response bodies. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.episode.actions.api.controller; diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java new file mode 100644 index 0000000..7f23312 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDao.java @@ -0,0 +1,107 @@ +package org.psesquared.server.episode.actions.api.data.access; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.psesquared.server.model.Action; +import org.psesquared.server.model.EpisodeAction; +import org.psesquared.server.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * A DAO interface responsible for transactions involving EpisodeActions. + */ +@Repository +public interface EpisodeActionDao extends JpaRepository<EpisodeAction, Long> { + + /** + * Find all EpisodeActions a user has uploaded. + * + * @param username The username of the user who uploaded the EpisodeActions + * @return The list of EpisodeActions regarding the user + */ + List<EpisodeAction> findByUserUsername(String username); + + /** + * Find all EpisodeActions of a user that concern a certain podcast identified + * by its RSS-Feed URL. + * + * @param username The username of the user who uploaded the EpisodeActions + * @param url The RSS-Feed URL of the podcast in question + * @return The list of EpisodeActions regarding the user and the given podcast + */ + List<EpisodeAction> findByUserUsernameAndEpisodeSubscriptionUrl( + String username, + String url); + + /** + * Deletes all EpisodeActions of a podcast for a user. + * + * @param username The username of the user + * @param url The podcast URL + */ + void deleteByUserUsernameAndEpisodeSubscriptionUrl( + String username, + String url); + + /** + * Checks if an EpisodeAction of the specified action type for a given user + * and episode already exists. + * + * @param user The user + * @param url The episode URL + * @param action The type of action + * @return {@code true} if such an EpisodeAction exists, + * <br> + * {@code false} otherwise + */ + boolean existsByUserAndEpisodeUrlAndAction(User user, + String url, + Action action); + + /** + * Finds the EpisodeAction of the specified action type and of an Episode + * for a User. + * + * @param user The user + * @param url The episode URL + * @param action The type of action + * @return An {@link Optional} containing the EpisodeAction if present + */ + Optional<EpisodeAction> findByUserAndEpisodeUrlAndAction(User user, + String url, + Action action); + + /** + * Find all EpisodeActions of a user since a given timestamp. + * + * @param username The username of the user + * @param timestamp The timestamp signifying how old an EpisodeAction is + * allowed + * to be + * @return A list containing all EpisodeActions not older than the timestamp + */ + List<EpisodeAction> findByUserUsernameAndTimestampGreaterThanEqual( + String username, + LocalDateTime timestamp); + + /** + * Find all EpisodeActions of a user since a given timestamp of a given + * podcast. + * + * @param username The username of the user + * @param timestamp The timestamp signifying how old an EpisodeAction is + * allowed to be + * @param url The RSS-Feed URL of the podcast whose EpisodeActions are + * requested + * @return A list containing all EpisodeActions of the given podcast not older + * than the timestamp + */ + List<EpisodeAction> + findByUserUsernameAndTimestampGreaterThanEqualAndEpisodeSubscriptionUrl( + String username, + LocalDateTime timestamp, + String url); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java new file mode 100644 index 0000000..16c647f --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDao.java @@ -0,0 +1,46 @@ +package org.psesquared.server.episode.actions.api.data.access; + +import java.util.Optional; +import org.psesquared.server.model.Episode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * A DAO interface responsible for transactions involving Episodes. + */ +@Repository +public interface EpisodeDao extends JpaRepository<Episode, Long> { + + /** + * Find an episode by its URL. + * + * @param url The URL of the episode + * @return The matching episode / NULL, if there was no match. + */ + Optional<Episode> findByUrl(String url); + + /** + * Returns true if there is an episode that matches a given URL. + * + * @param url The URL of the episode + * @return A boolean value signifying whether the episode exists + */ + boolean existsByUrl(String url); + + /** + * Returns true if there is an episode that matches a given GUID. + * + * @param guid The GUID of the episode + * @return A boolean value signifying whether the episode exists + */ + boolean existsByGuid(String guid); + + /** + * Find an episode by its GUID. + * + * @param guid The GUID of the episode + * @return The matching episode / NULL, if there was no match. + */ + Optional<Episode> findByGuid(String guid); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java new file mode 100644 index 0000000..b32f249 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/data/access/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the lowest logical layer of the episode action API + * ({@link org.psesquared.server.episode.actions.api}) - the data-access layer. + * <br> + * It features the interfaces {@link + * org.psesquared.server.episode.actions.api.data.access.EpisodeActionDao} + * and {@link + * org.psesquared.server.episode.actions.api.data.access.EpisodeDao}. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.episode.actions.api.data.access; diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java new file mode 100644 index 0000000..8c4f93a --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/EpisodeActionService.java @@ -0,0 +1,360 @@ +package org.psesquared.server.episode.actions.api.service; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost; +import org.psesquared.server.episode.actions.api.data.access.EpisodeActionDao; +import org.psesquared.server.episode.actions.api.data.access.EpisodeDao; +import org.psesquared.server.model.Action; +import org.psesquared.server.model.Episode; +import org.psesquared.server.model.EpisodeAction; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.model.User; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionActionDao; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionDao; +import org.psesquared.server.util.RssParser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * This service class manages all business logic associated with the + * episode action API. + * <br> + * It is called from the + * {@link + * org.psesquared.server.episode.actions.api.controller.EpisodeActionController} + * and passes on requests concerning data access mainly to the + * {@link EpisodeDao} and {@link EpisodeActionDao}. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class EpisodeActionService { + + /** + * The nano of second default value for + * {@link LocalDateTime#ofEpochSecond(long, int, ZoneOffset)}. + */ + private static final int NANO_OF_SECOND_DEFAULT = 0; + + /** + * The JPA repository that handles all episode action related database + * requests. + */ + private final EpisodeActionDao episodeActionDao; + + /** + * The JPA repository that handles all episode related database requests. + */ + private final EpisodeDao episodeDao; + + /** + * The JPA repository that handles all user related database requests. + */ + private final AuthenticationDao authenticationDao; + + /** + * The JPA repository that handles all subscription related database requests. + */ + private final SubscriptionDao subscriptionDao; + + /** + * The JPA repository that handles all subscription action related database + * requests. + */ + private final SubscriptionActionDao subscriptionActionDao; + + /** + * The class for asynchronously fetching data from RSS feeds. + */ + private final RssParser rssParser; + + /** + * A map of subscription that need to be fetched with the {@link RssParser}. + */ + private final Map<String, Subscription> subscriptionsToFetch + = new HashMap<>(); + + /** + * Takes a list of EpisodeActionPosts, converts them to EpisodeActions and + * saves them to the database. + * + * @param username The username of the user who uploads these + * EpisodeActions + * @param episodeActionPosts List of EpisodeActionPosts that were sent via the + * POST request + */ + public void addEpisodeActions( + final String username, + final List<EpisodeActionPost> episodeActionPosts) { + User user = authenticationDao.findByUsername(username).orElseThrow(); + List<EpisodeActionPost> filteredEpisodeActionPosts + = new ArrayList<>(filterNewestAction(episodeActionPosts)); + List<EpisodeAction> episodeActions + = episodeActionPostsToEpisodeActions(user, filteredEpisodeActionPosts); + addEpisodeActionsToDatabase(user, episodeActions); + validateDummyEpisodes(); + } + + private Collection<EpisodeActionPost> filterNewestAction( + final List<EpisodeActionPost> episodeActionPosts) { + Map<String, EpisodeActionPost> relevantEpisodeActionPosts = new HashMap<>(); + for (EpisodeActionPost episodeActionPost : episodeActionPosts) { + if (episodeActionPost.getEpisodeAction().getAction() != Action.PLAY) { + continue; + } + String url = episodeActionPost.getEpisodeUrl(); + if (relevantEpisodeActionPosts.containsKey(url)) { + EpisodeActionPost currentEpisodeActionPost + = relevantEpisodeActionPosts.get(url); + if (episodeActionPost.getEpisodeAction().getTimestamp() + .isAfter( + currentEpisodeActionPost.getEpisodeAction().getTimestamp())) { + relevantEpisodeActionPosts.put(url, episodeActionPost); + } + } else { + relevantEpisodeActionPosts.put(url, episodeActionPost); + } + } + return relevantEpisodeActionPosts.values(); + } + + private List<EpisodeAction> episodeActionPostsToEpisodeActions( + final User user, + final List<EpisodeActionPost> episodeActionPosts) { + List<EpisodeAction> episodeActions = new ArrayList<>(); + for (EpisodeActionPost episodeActionPost : episodeActionPosts) { + if (episodeActionPost.getEpisodeAction().getAction() == Action.PLAY) { + episodeActions.add(episodeActionPostToEpisodeAction( + user, + episodeActionPost)); + } + } + return episodeActions; + } + + private EpisodeAction episodeActionPostToEpisodeAction( + final User user, + final EpisodeActionPost episodeActionPost) { + EpisodeAction episodeAction = episodeActionPost.getEpisodeAction(); + episodeAction.setUser(user); + // If Subscription does not exist, create dummy Subscription + Subscription subscription = null; + if (!subscriptionDao.existsByUrl(episodeActionPost.getPodcastUrl())) { + subscription = new Subscription(); + subscription.setTimestamp( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)); + subscription.setUrl(episodeActionPost.getPodcastUrl()); + subscription = subscriptionDao.save(subscription); + // create Subscription Action + SubscriptionAction subscriptionAction = SubscriptionAction.builder() + .user(user) + .added(true) + .subscription( + subscriptionDao.findByUrl( + episodeActionPost.getPodcastUrl()).orElseThrow()) + .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .build(); + subscriptionActionDao.save(subscriptionAction); + } else { + subscription = subscriptionDao + .findByUrl(episodeActionPost.getPodcastUrl()).orElseThrow(); + } + Episode episode = getEpisodeFromDatabase(episodeActionPost); + episodeAction.setEpisode(episode); + subscription.addEpisode(episode); + return episodeAction; + } + + private Episode getEpisodeFromDatabase( + final EpisodeActionPost episodeActionPost) { + Episode episode; + String episodeUrl = episodeActionPost.getEpisodeUrl(); + String episodeGuid = episodeActionPost.getGuid(); + // If guid is passed and a matching episode exists get it + if (episodeGuid != null && episodeDao.existsByGuid(episodeGuid)) { + episode = episodeDao.findByGuid(episodeGuid).orElseThrow(); + } else if (episodeDao.existsByUrl(episodeUrl)) { + // No episode with matching guid found -> search by url + episode = episodeDao.findByUrl(episodeUrl).orElseThrow(); + // If guid was passed, pass it along to the database + if (episodeGuid != null) { + episode.setGuid(episodeGuid); + episodeDao.save(episode); + } + } else { + // Episode does not exist, so construct a new one + episode = createEpisode(episodeActionPost); + } + return episode; + } + + private Episode createEpisode(final EpisodeActionPost episodeActionPost) { + Episode episode = Episode.builder() + .title(episodeActionPost.getTitle()) + .url(episodeActionPost.getEpisodeUrl()) + .total(episodeActionPost.getTotal()) + .subscription(subscriptionDao + .findByUrl(episodeActionPost.getPodcastUrl()).orElseThrow()) + .build(); + if (episodeActionPost.getGuid() != null) { + episode.setGuid(episodeActionPost.getGuid()); + } + episodeDao.save(episode); + Subscription subscription = episode.getSubscription(); + subscriptionsToFetch.put(subscription.getUrl(), subscription); + return episode; + } + + private void addEpisodeActionsToDatabase( + final User user, + final List<EpisodeAction> episodeActions) { + for (EpisodeAction episodeAction : episodeActions) { + addEpisodeActionToDatabase(user, episodeAction); + } + } + + private void addEpisodeActionToDatabase( + final User user, + final EpisodeAction episodeAction) { + if (episodeActionDao.existsByUserAndEpisodeUrlAndAction( + user, + episodeAction.getEpisode().getUrl(), + episodeAction.getAction())) { + addNewestEpisodeActionToDatabase(user, episodeAction); + } else { + episodeActionDao.save(episodeAction); + } + } + + private void addNewestEpisodeActionToDatabase( + final User user, + final EpisodeAction episodeAction) { + EpisodeAction oldEpisodeAction + = episodeActionDao.findByUserAndEpisodeUrlAndAction( + user, + episodeAction.getEpisode().getUrl(), + episodeAction.getAction()).orElseThrow(); + if (episodeAction.getTimestamp().isAfter(oldEpisodeAction.getTimestamp())) { + episodeActionDao.delete(oldEpisodeAction); + episodeActionDao.save(episodeAction); + } + } + + private void validateDummyEpisodes() { + Collection<Subscription> subscriptions = subscriptionsToFetch.values(); + for (Subscription subscription : subscriptions) { + rssParser.validate(subscription); + } + subscriptionsToFetch.clear(); + } + + /** + * Gets all EpisodeActions of a user and converts them to EpisodeActionPosts + * before returning them. + * + * @param username The username of the user whose EpisodeActions are requested + * @return A list containing the requested EpisodeActions as + * EpisodeActionPosts + */ + public List<EpisodeActionPost> getEpisodeActions(final String username) { + List<EpisodeAction> episodeActions + = episodeActionDao.findByUserUsername(username); + return episodeActionsToEpisodeActionPosts(episodeActions); + } + + /** + * Gets all EpisodeActions of a user that correspond to a given podcast. + * Returns the EpisodeActions after converting them to EpisodeActionPosts. + * + * @param username The username of the user whose EpisodeActions are + * requested + * @param podcastUrl The RSS-Feed URL of the podcast + * @return A list containing the requested EpisodeActions as + * EpisodeActionPosts + */ + public List<EpisodeActionPost> getEpisodeActionsOfPodcast( + final String username, + final String podcastUrl) { + List<EpisodeAction> episodeActions + = episodeActionDao.findByUserUsernameAndEpisodeSubscriptionUrl( + username, + podcastUrl); + return episodeActionsToEpisodeActionPosts(episodeActions); + } + + /** + * Gets all EpisodeActions of a user since a given timestamp and converts them + * to EpisodeActionPosts before returning them. + * + * @param username The username of the user whose EpisodeActions are requested + * @param since the timestamp signifying how old the EpisodeActions are + * allowed to be + * @return A list containing the requested EpisodeActions as + * EpisodeActionPosts + */ + public List<EpisodeActionPost> getEpisodeActionsSince( + final String username, + final long since) { + LocalDateTime sinceTimestamp + = LocalDateTime.ofEpochSecond( + since, + NANO_OF_SECOND_DEFAULT, + ZoneOffset.UTC); + List<EpisodeAction> episodeActions + = episodeActionDao.findByUserUsernameAndTimestampGreaterThanEqual( + username, + sinceTimestamp); + return episodeActionsToEpisodeActionPosts(episodeActions); + } + + /** + * Gets all EpisodeActions of a user concerning a certain podcast since a + * given timestamp and converts them to EpisodeActionPosts before returning + * them. + * + * @param username The username of the user whose EpisodeActions are + * requested + * @param podcastUrl The RSS-Feed URL of the podcast + * @param since The timestamp signifying how old the EpisodeActions are + * allowed to be + * @return A list containing the requested EpisodeActions as + * EpisodeActionPosts + */ + public List<EpisodeActionPost> getEpisodeActionsOfPodcastSince( + final String username, + final String podcastUrl, + final long since) { + LocalDateTime sinceTimestamp + = LocalDateTime.ofEpochSecond( + since, + NANO_OF_SECOND_DEFAULT, + ZoneOffset.UTC); + List<EpisodeAction> episodeActions = episodeActionDao + .findByUserUsernameAndTimestampGreaterThanEqualAndEpisodeSubscriptionUrl( + username, + sinceTimestamp, + podcastUrl); + return episodeActionsToEpisodeActionPosts(episodeActions); + } + + private List<EpisodeActionPost> episodeActionsToEpisodeActionPosts( + final List<EpisodeAction> episodeActions) { + List<EpisodeActionPost> episodeActionPosts = new ArrayList<>(); + + for (EpisodeAction episodeAction : episodeActions) { + episodeActionPosts.add(episodeAction.toEpisodeActionPost()); + } + + return episodeActionPosts; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java new file mode 100644 index 0000000..c431539 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/episode/actions/api/service/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the logical middle layer of the episode action API + * ({@link org.psesquared.server.episode.actions.api}) - the service layer. + * <br> + * All business logic is handled here with the + * {@link + * org.psesquared.server.episode.actions.api.service.EpisodeActionService} + * class. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.episode.actions.api.service; diff --git a/pse-server/src/main/java/org/psesquared/server/model/Action.java b/pse-server/src/main/java/org/psesquared/server/model/Action.java new file mode 100644 index 0000000..19b1c51 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/Action.java @@ -0,0 +1,45 @@ +package org.psesquared.server.model; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * An enum with all different action types of an {@link EpisodeAction}. + */ +public enum Action { + + /** + * The download action type. + */ + DOWNLOAD, + + /** + * The play action type. + */ + PLAY, + + /** + * The delete action type. + */ + DELETE, + + /** + * The new action type. + */ + NEW, + + /** + * The flattr action type. + */ + FLATTR; + + /** + * Getter for the value of the "action" JSON property. + * + * @return The JSON value + */ + @JsonValue + public String getJsonProperty() { + return name().toLowerCase(); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/Episode.java b/pse-server/src/main/java/org/psesquared/server/model/Episode.java new file mode 100644 index 0000000..7a409fc --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/Episode.java @@ -0,0 +1,74 @@ +package org.psesquared.server.model; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * An episode of a podcast. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "episodes") +public class Episode implements Serializable { + + /** + * The primary key for the table. + */ + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", updatable = false) + private Long id; + + /** + * The GUID of an episode. + */ + @Column(name = "guid", unique = true) + private String guid; + + /** + * The URL where the episode is located at. + */ + @Column(name = "url", nullable = false) + private String url; + + /** + * The title of the episode. + */ + @Column(name = "title") + private String title; + + /** + * The total length of an episode. + */ + @Column(name = "total") + private int total; + + /** + * The podcast the episode is a part of. + */ + @ManyToOne(optional = false) + private Subscription subscription; + + /** + * The actions of an episode. + */ + @OneToMany(mappedBy = "episode", cascade = CascadeType.REMOVE) + private List<EpisodeAction> episodeActions; + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/EpisodeAction.java b/pse-server/src/main/java/org/psesquared/server/model/EpisodeAction.java new file mode 100644 index 0000000..a7e2375 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/EpisodeAction.java @@ -0,0 +1,101 @@ +package org.psesquared.server.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost; + +/** + * An action a user took regarding an episode of a podcast. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "episode_actions") +public class EpisodeAction implements Serializable { + + /** + * The primary key for the table. + */ + @JsonIgnore + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", updatable = false) + private Long id; + + /** + * The user who is responsible for the action. + */ + @JsonIgnore + @ManyToOne(optional = false) + private User user; + + /** + * The episode that is affected. + */ + @JsonIgnore + @ManyToOne(optional = false) + private Episode episode; + + /** + * The timestamp of when this action took place. + */ + @Column(name = "timestamp", + nullable = false) + private LocalDateTime timestamp; + + /** + * The type of action that happened. + */ + @JsonProperty(required = true) + @Column(name = "action", + nullable = false, + updatable = false) + private Action action; + + /** + * In case of play action: The starting time of the episode. + */ + @Column(name = "started", + updatable = false) + private int started; + + /** + * In case of play action: The time at which the episode was stopped. + */ + @Column(name = "position", + nullable = false, + updatable = false) + private int position; + + /** + * Generates a EpisodeActionPost from the given EpisodeAction for the + * EpisodeAction Controller. + * + * @return The generated EpisodeActionPost + */ + public EpisodeActionPost toEpisodeActionPost() { + String podcastUrl = this.getEpisode().getSubscription().getUrl(); + String episodeUrl = this.getEpisode().getUrl(); + String title = this.getEpisode().getTitle(); + String guid = this.getEpisode().getGuid(); + int total = this.getEpisode().getTotal(); + return + new EpisodeActionPost(podcastUrl, episodeUrl, title, guid, total, this); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/Role.java b/pse-server/src/main/java/org/psesquared/server/model/Role.java new file mode 100644 index 0000000..37a316f --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/Role.java @@ -0,0 +1,28 @@ +package org.psesquared.server.model; + +/** + * Available user roles. + */ +public enum Role { + + /** + * Standard role. + */ + USER, + + /** + * Privileged role. + */ + ADMIN; + + /** + * The starting index. + */ + private static final int FIRST_INDEX = 0; + + @Override + public String toString() { + return name().charAt(FIRST_INDEX) + name().substring(1).toLowerCase(); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/Subscription.java b/pse-server/src/main/java/org/psesquared/server/model/Subscription.java new file mode 100644 index 0000000..b130fca --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/Subscription.java @@ -0,0 +1,87 @@ +package org.psesquared.server.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.NamedAttributeNode; +import jakarta.persistence.NamedEntityGraph; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +/** + * A podcast that was subscribed. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "subscriptions") +@NamedEntityGraph(name = "graph.Subscription.episodes", + attributeNodes = @NamedAttributeNode("episodes")) +public class Subscription implements Serializable { + + /** + * A primary key for the table. + */ + @JsonIgnore + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", updatable = false) + private Long id; + + /** + * The URL for the RSS-Feed of the Podcast. + */ + @Column(name = "url", nullable = false) + private String url; + + /** + * The title of the Podcast. + */ + @Column(name = "title") + private String title; + + /** + * Timestamp of the last time the RSS-Feed was fetched. + */ + @Column(name = "timestamp") + private long timestamp; + + /** + * The list of SubscriptionActions of this podcast. + */ + @JsonIgnore + @OneToMany(mappedBy = "subscription", + cascade = CascadeType.REMOVE) + private List<SubscriptionAction> subscriptionActions; + + /** + * The episodes of a subscription. + */ + @JsonIgnore + @OneToMany(mappedBy = "subscription", cascade = CascadeType.REMOVE) + private final List<Episode> episodes = new ArrayList<>(); + + /** + * Adds an episode to the list of episodes. + * + * @param episode The to be added episode + */ + public void addEpisode(@NonNull final Episode episode) { + this.episodes.add(episode); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/SubscriptionAction.java b/pse-server/src/main/java/org/psesquared/server/model/SubscriptionAction.java new file mode 100644 index 0000000..3d850eb --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/SubscriptionAction.java @@ -0,0 +1,62 @@ +package org.psesquared.server.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * An action a user took regarding a podcast. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "subscription_actions") +public class SubscriptionAction implements Serializable { + + /** + * The primary key for the table. + */ + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", + updatable = false) + private int id; + + /** + * The user who took this action. + */ + @ManyToOne(optional = false) + private User user; + + /** + * The timestamp of when this action took place. + */ + @Column(name = "timestamp", + nullable = false) + private long timestamp; + + /** + * The podcast that was affected. + */ + @ManyToOne(optional = false) + private Subscription subscription; + + /** + * Whether the podcast was added or removed. + */ + @Column(name = "added", + nullable = false) + private boolean added; + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/User.java b/pse-server/src/main/java/org/psesquared/server/model/User.java new file mode 100644 index 0000000..7279aae --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/User.java @@ -0,0 +1,148 @@ +package org.psesquared.server.model; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.Collection; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * A user that synchronizes their podcasts via this server. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "users") +public class User implements UserDetails { + + /** + * The primary key for the table. + */ + @Id + @GeneratedValue(strategy=GenerationType.SEQUENCE) + @Column(name = "id", + updatable = false) + private Long id; + + /** + * The username of the user. + */ + @Column(name = "username", + unique = true, + nullable = false, + updatable = false) + private String username; + + /** + * The email address of the user. + */ + @Column(name = "email", + unique = true, + nullable = false) + private String email; + + /** + * The password of the user. + */ + @Column(name = "password", + nullable = false) + private String password; + + /** + * The verification status of the user. + */ + @Column(name = "enabled", + nullable = false) + private boolean enabled; + + /** + * Timestamp of when this user account was created. + */ + @Column(name = "created_at", + nullable = false, + updatable = false) + private long createdAt; + + /** + * The role of the user. + */ + @Column(name = "role", + nullable = false) + private Role role; + + /** + * The subscription actions the user made. + */ + @OneToMany(mappedBy = "user", + cascade = CascadeType.REMOVE) + private List<SubscriptionAction> subscriptionActions; + + /** + * The episode actions the user made. + */ + @OneToMany(mappedBy = "user", + cascade = CascadeType.REMOVE) + private List<EpisodeAction> episodeActions; + + /** + * Returns a collection with one {@link SimpleGrantedAuthority} + * with {@link #role}. + * + * @return The collection of granted authorities + */ + @Override + public Collection<? extends GrantedAuthority> getAuthorities() { + return List.of(new SimpleGrantedAuthority(role.toString())); + } + + /** + * Checks if this user account has not expired. + * + * @return {@code true} if the user account has not expired, + * <br> + * {@code false} otherwise + */ + @Override + public boolean isAccountNonExpired() { + return enabled; + } + + /** + * Checks if this user account is not locked. + * + * @return {@code true} if the user account is not locked, + * <br> + * {@code false} otherwise + */ + @Override + public boolean isAccountNonLocked() { + return enabled; + } + + /** + * Checks if this user account's credentials have not expired. + * + * @return {@code true} if the credentials have not expired, + * <br> + * {@code false} otherwise + */ + @Override + public boolean isCredentialsNonExpired() { + return enabled; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/model/package-info.java b/pse-server/src/main/java/org/psesquared/server/model/package-info.java new file mode 100644 index 0000000..2bb9c13 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/model/package-info.java @@ -0,0 +1,8 @@ +/** + * This package features all classes that map to database entities via ORM + * as well as some classes that the former rely on. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.model; diff --git a/pse-server/src/main/java/org/psesquared/server/package-info.java b/pse-server/src/main/java/org/psesquared/server/package-info.java new file mode 100644 index 0000000..95cfd41 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/package-info.java @@ -0,0 +1,5 @@ +/** + * This package features the + * {@link org.psesquared.server.ServerApplication} class. + */ +package org.psesquared.server; diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionController.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionController.java new file mode 100644 index 0000000..1ffe137 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionController.java @@ -0,0 +1,155 @@ +package org.psesquared.server.subscriptions.api.controller; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.subscriptions.api.service.SubscriptionService; +import org.psesquared.server.util.UpdateUrlsWrapper; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * This is a controller class for the Subscription API that handles the requests + * from the client concerning adding subscriptions, removing subscriptions and + * getting all current subscriptions. + * In the end an appropriate response is sent back to the user. + */ +@RestController +@RequiredArgsConstructor +public class SubscriptionController { + + /** + * The response for uploading subscriptions successfully. + */ + private static final String UPLOAD_SUCCESS = ""; + + /** + * The service class that this controller calls to further process requests. + */ + private final SubscriptionService subscriptionService; + + /** + * It takes a list of strings containing the URLs of all subscribed podcasts, + * and saves them to the database. + * + * @param username The username of the user + * @param deviceId The device ID of the device that is uploading the + * subscriptions (will be ignored in this implementation) + * @param subscriptions A list of strings, each string is the URL to a podcast + * RSS-Feed that was subscribed + * @return The response containing an empty String with + * <br> + * {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + @PutMapping(path = "/subscriptions/{username}/{deviceId}.json") + public ResponseEntity<String> uploadSubscriptions( + @PathVariable final String username, + @PathVariable final String deviceId, + @RequestBody final List<String> subscriptions) { + HttpStatus status + = subscriptionService.uploadSubscriptions(username, subscriptions); + return new ResponseEntity<>(UPLOAD_SUCCESS, status); + } + + /** + * This function returns a list of subscriptions for a given user. + * + * @param username The username of the user whose subscriptions you want + * to retrieve + * @param deviceId This is the unique identifier for the device of the + * user whose subscriptions are asked for + * (will be ignored in this implementation) + * @param functionJsonp This parameter is not supported in this implementation + * and is thus ignored + * @return A list of strings containing the RSS-Feed URLs of all subscribed + * podcasts + */ + @GetMapping(path = {"/subscriptions/{username}.json", + "/subscriptions/{username}/{deviceId}.json"}) + public ResponseEntity<List<String>> getSubscriptions( + @PathVariable final String username, + @PathVariable(required = false) final String deviceId, + @RequestParam(value = "jsonp", + required = false) final String functionJsonp) { + List<String> subscriptions = subscriptionService.getSubscriptions(username); + return ResponseEntity.ok(subscriptions); + } + + /** + * This function takes the information of added and removed podcasts in the + * form of a SubcriptionDelta as a JSON object. + * <br> + * After that, it applies the changes to the given user in the database. + * + * @param username The username of the user who is making changes to their + * subscriptions + * @param deviceId The device ID of the device that is requesting the update + * (will be ignored in this implementation) + * @param delta Contains all the changes that were made to the + * subscriptions of the user + * @return The response containing a placeholder for not + * supported function with + * <br> + * {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user or subscription not found + */ + @PostMapping(path = "/api/2/subscriptions/{username}/{deviceId}.json") + public ResponseEntity<UpdateUrlsWrapper> applySubscriptionDelta( + @PathVariable final String username, + @PathVariable final String deviceId, + @RequestBody final SubscriptionDelta delta) { + subscriptionService.applySubscriptionDelta(username, delta); + return ResponseEntity.ok(new UpdateUrlsWrapper()); + } + + /** + * It returns a list of all the changes to the subscriptions of a user since a + * given time. + * + * @param username The username of the user whose SubscriptionDeltas are being + * requested + * @param deviceId The device ID of the device that is requesting the delta + * (will be ignored in this implementation) + * @param since The timestamp of the last time the client checked for + * updates + * @return A response containing the SubscriptionDelta of all changes that + * were made in a JSON format + */ + @GetMapping(path = "/api/2/subscriptions/{username}/{deviceId}.json") + public ResponseEntity<SubscriptionDelta> getSubscriptionDelta( + @PathVariable final String username, + @PathVariable final String deviceId, + @RequestParam("since") final long since) { + SubscriptionDelta delta + = subscriptionService.getSubscriptionDelta(username, since); + return ResponseEntity.ok(delta); + } + + /** + * This function returns a list of podcasts a user is subscribed to. + * <br> + * This includes not only the podcast itself, but also the latest 20 Episodes + * of the podcast. + * + * @param username The username of the user whose podcasts are being requested + * @return A response containing a List of podcasts and their episodes the + * user is subscribed to + */ + @GetMapping(path = "/subscriptions/titles/{username}.json") + public ResponseEntity<List<SubscriptionTitles>> getTitles( + @PathVariable final String username) { + List<SubscriptionTitles> responseBody + = subscriptionService.getTitles(username); + return ResponseEntity.ok(responseBody); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionDelta.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionDelta.java new file mode 100644 index 0000000..7611b05 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionDelta.java @@ -0,0 +1,80 @@ +package org.psesquared.server.subscriptions.api.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import lombok.NonNull; + +/** + * SubscriptionDeltas contain all changes that were made to the subscriptions + * of a user (added / removed podcasts) at a certain time. + */ +public class SubscriptionDelta { + + /** + * The list of recently subscribed podcasts. + */ + @JsonProperty(required = true) + @NonNull + private final List<String> add; + + /** + * The list of recently unsubscribed podcasts. + */ + @JsonProperty(required = true) + @NonNull + private final List<String> remove; + + /** + * The timestamp of the delta. + */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private final long timestamp; + + /** + * Instantiates a new SubscriptionDelta with a current timestamp. + * + * @param addedPodcastUrls List of Strings containing the RSS-Feed URLs of + * all added podcasts + * @param removedPodcastUrls List of Strings containing the RSS-Feed URLs of + * all removed podcasts + */ + public SubscriptionDelta( + @org.springframework.lang.NonNull final List<String> addedPodcastUrls, + @org.springframework.lang.NonNull final List<String> removedPodcastUrls) { + this.add = addedPodcastUrls; + this.remove = removedPodcastUrls; + this.timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); + } + + /** + * Returns the list of RSS-Feed URLs of all added podcasts. + * + * @return RSS-Feed URLs of all added podcasts + */ + @org.springframework.lang.NonNull + public List<String> getAdd() { + return add; + } + + /** + * Returns the list of RSS-Feed URLs of all removed podcasts. + * + * @return RSS-Feed URLs of all removed podcasts + */ + @org.springframework.lang.NonNull + public List<String> getRemove() { + return remove; + } + + /** + * Returns the timestamp of when this Subscription Delta was uploaded. + * + * @return The timestamp of when this Subscription Delta was uploaded + */ + public long getTimestamp() { + return timestamp; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionTitles.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionTitles.java new file mode 100644 index 0000000..682497b --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/SubscriptionTitles.java @@ -0,0 +1,17 @@ +package org.psesquared.server.subscriptions.api.controller; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import java.util.List; +import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost; +import org.psesquared.server.model.Subscription; + +/** + * Contains a podcast and its latest 20 Episodes. + * + * @param subscription The podcast + * @param episodes The episodes of the podcast + */ +public record SubscriptionTitles( + @JsonUnwrapped Subscription subscription, + List<EpisodeActionPost> episodes) { +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/package-info.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/package-info.java new file mode 100644 index 0000000..0238039 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/controller/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the highest logical layer of the subscription API + * ({@link org.psesquared.server.subscriptions.api}) - the controller layer. + * <br> + * It contains the + * {@link + * org.psesquared.server.subscriptions.api.controller.SubscriptionController} + * along with some wrapper classes for JSON request and response bodies. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.subscriptions.api.controller; diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDao.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDao.java new file mode 100644 index 0000000..5d91453 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDao.java @@ -0,0 +1,91 @@ +package org.psesquared.server.subscriptions.api.data.access; + +import java.util.List; +import java.util.Optional; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * A DAO interface responsible for transactions involving SubscriptionActions. + */ +@Repository +public interface SubscriptionActionDao + extends JpaRepository<SubscriptionAction, Long> { + + /** + * True, if the given user is already subscribed to the given Subscription. + * + * @param user The user that could be subscribed + * @param subscription The subscription the user could be subscribed to + * @return A boolean value signifying whether the user is subscribed to the + * given subscription + */ + boolean existsByUserAndSubscription(User user, Subscription subscription); + + /** + * Find the SubscriptionAction signifying that the user is subscribed to the + * given Subscription. + * + * @param user The user who is subscribed to the subscription + * @param subscription The subscription that the user is subscribed to + * @return Contains the relevant SubscriptionAction. Could also be NULL if + * none was found. + */ + Optional<SubscriptionAction> findByUserAndSubscription( + User user, + Subscription subscription); + + /** + * Find the SubscriptionAction for a {@link User} with the given username + * and for a {@link Subscription} with the given URL. + * + * @param username The username of the user who is subscribed to + * the subscription + * @param subscriptionUrl The URL of the subscription that the user is + * subscribed to + * @return Contains the relevant SubscriptionAction. Could also be NULL if + * none was found. + */ + Optional<SubscriptionAction> findByUserUsernameAndSubscriptionUrl( + String username, String subscriptionUrl); + + /** + * All SubscriptionActions of a given user that were applied since a given + * timestamp are searched for and returned. + * + * @param username The username of the user whose SubscriptionActions are + * requested + * @param timestamp The timestamp signifying how old the SubscriptionActions + * are allowed to be + * @return A list of SubscriptionActions that have since been applied + */ + List<SubscriptionAction> findByUserUsernameAndTimestampGreaterThanEqual( + String username, + long timestamp); + + /** + * Returns a List of all Subscriptions the user is subscribed to. + * + * @param username The username of the user whose subscriptions are requested + * @return A list of subscriptions the user is subscribed to + */ + List<SubscriptionAction> findByUserUsernameAndAddedTrue(String username); + + /** + * Returns a List of RSS-Feed URLs of all podcasts the given user is + * subscribed to since a given timestamp. + * + * @param username The username of the user whose subscriptions are requested + * @param timestamp The timestamp signifying the time since when the user must + * have been subscribed + * @return List of RSS-Feed URLs + */ + List<SubscriptionAction> + findByUserUsernameAndAddedTrueAndTimestampGreaterThanEqual( + String username, + long timestamp); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDao.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDao.java new file mode 100644 index 0000000..d3d1fbf --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDao.java @@ -0,0 +1,34 @@ +package org.psesquared.server.subscriptions.api.data.access; + +import java.util.Optional; +import org.psesquared.server.model.Subscription; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * A DAO interface responsible for transactions involving Subscriptions. + */ +@Repository +public interface SubscriptionDao extends JpaRepository<Subscription, Long> { + + /** + * Find a subscription by its URL. + * + * @param url The URL of the subscription + * @return The found subscription (could be NULL, if there was no match) + */ + @EntityGraph(value = "graph.Subscription.episodes") + Optional<Subscription> findByUrl(String url); + + /** + * Returns true if the database already has a Subscription that has the given + * URL. + * + * @param url The URL of the Subscription that could already exist in the + * database + * @return A boolean value signifying the existence of the Subscription + */ + boolean existsByUrl(String url); + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/package-info.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/package-info.java new file mode 100644 index 0000000..db23d5f --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/data/access/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the lowest logical layer of the subscription API + * ({@link org.psesquared.server.subscriptions.api}) - the data-access layer. + * <br> + * It features the interfaces {@link + * org.psesquared.server.subscriptions.api.data.access.SubscriptionActionDao} + * and {@link + * org.psesquared.server.subscriptions.api.data.access.SubscriptionDao}. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.subscriptions.api.data.access; diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/SubscriptionService.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/SubscriptionService.java new file mode 100644 index 0000000..08dc1f9 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/SubscriptionService.java @@ -0,0 +1,299 @@ +package org.psesquared.server.subscriptions.api.service; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Map; +import java.util.HashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost; +import org.psesquared.server.episode.actions.api.data.access.EpisodeActionDao; +import org.psesquared.server.episode.actions.api.service.EpisodeActionService; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.model.User; +import org.psesquared.server.subscriptions.api.controller.SubscriptionDelta; +import org.psesquared.server.subscriptions.api.controller.SubscriptionTitles; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionActionDao; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionDao; +import org.psesquared.server.util.RssParser; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * This service class manages all business logic associated with the + * episode action API. + * <br> + * It is called from the + * {@link + * org.psesquared.server.subscriptions.api.controller.SubscriptionController} + * and passes on requests concerning data access mainly to the + * {@link SubscriptionDao} and {@link SubscriptionActionDao}. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class SubscriptionService { + + /** + * The error message that is logged if no subscription exists + * for a remove action. + */ + private static final String NO_SUB_WARNING + = "Subscription for remove action does not exist!"; + + /** + * The logger for logging some warnings. + */ + private static final Logger LOGGER + = Logger.getLogger(SubscriptionService.class.getName()); + + /** + * The class for fetching data from RSS feeds. + */ + private final RssParser rssParser; + + /** + * The JPA repository that handles all user related database requests. + */ + private final AuthenticationDao authenticationDao; + + /** + * The JPA repository that handles all subscription related database requests. + */ + private final SubscriptionDao subscriptionDao; + + /** + * The JPA repository that handles all subscription action related database + * requests. + */ + private final SubscriptionActionDao subscriptionActionDao; + + /** + * The JPA repository that handles all episode action related database + * requests. + */ + private final EpisodeActionDao episodeActionDao; + + /** + * The service class of the episode action API. + */ + private final EpisodeActionService episodeActionService; + + /** + * It takes a list of podcast URLs in the form of strings and checks if they + * exist in the database. + * If they do not exist yet, it creates them. + * <br> + * Then it checks, if the user already has a subscription action for each + * subscription, separately. + * If not, it creates one. + * If yes, it updates the action. + * + * @param username The username of the user + * @param subscriptionStrings List of Strings, each String is a URL of a + * podcast + * @return {@link HttpStatus#OK} on success, + * <br> + * {@link HttpStatus#NOT_FOUND} user not found + */ + public HttpStatus uploadSubscriptions( + final String username, + final List<String> subscriptionStrings) { + User user; + try { + user = authenticationDao.findByUsername(username) + .orElseThrow(); + } catch (NoSuchElementException e) { + return HttpStatus.NOT_FOUND; + } + + Subscription subscription; + for (String subscriptionString : subscriptionStrings) { + + try { + subscription + = subscriptionDao.findByUrl(subscriptionString).orElseThrow(); + } catch (NoSuchElementException e) { + subscription = Subscription.builder() + .url(subscriptionString) + .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .build(); + subscriptionDao.save(subscription); + rssParser.validate(subscription); + } + + try { + SubscriptionAction subscriptionAction + = subscriptionActionDao + .findByUserAndSubscription(user, subscription) + .orElseThrow(); + subscriptionAction.setAdded(true); + subscriptionAction.setTimestamp( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)); + subscriptionActionDao.save(subscriptionAction); + } catch (NoSuchElementException e) { + SubscriptionAction subscriptionAction = SubscriptionAction.builder() + .user(user) + .added(true) + .subscription(subscription) + .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .build(); + subscriptionActionDao.save(subscriptionAction); + } + } + + return HttpStatus.OK; + } + + /** + * It returns a URL List of all podcasts the user is subscribed to in the form + * of a String list. + * + * @param username The username of the user whose subscriptions are being + * requested + * @return A list of RSS-Feed URLs of all subscribed podcasts + */ + public List<String> getSubscriptions(final String username) { + List<SubscriptionAction> subscriptionActions + = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + List<String> subscriptionUrls = new ArrayList<>(); + for (SubscriptionAction subscriptionAction : subscriptionActions) { + subscriptionUrls.add(subscriptionAction.getSubscription().getUrl()); + } + return subscriptionUrls; + } + + /** + * All subscription changes of the user are uploaded to the database. + * + * @param username The username of the user uploading their subscription + * changes + * @param delta The subscription changes in the form of a SubscriptionDelta + * containing the added / removed subscriptions + */ + public void applySubscriptionDelta( + final String username, + final SubscriptionDelta delta) { + + SubscriptionDelta minimizedDelta = minimizeDelta(delta); + + uploadSubscriptions(username, minimizedDelta.getAdd()); + for (String removeSub : minimizedDelta.getRemove()) { + try { + SubscriptionAction subscriptionAction + = subscriptionActionDao + .findByUserUsernameAndSubscriptionUrl(username, removeSub) + .orElseThrow(); + subscriptionAction.setAdded(false); + subscriptionAction.setTimestamp( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)); + subscriptionActionDao.save(subscriptionAction); + episodeActionDao + .deleteByUserUsernameAndEpisodeSubscriptionUrl(username, removeSub); + } catch (NoSuchElementException e) { + LOGGER.log(Level.WARNING, NO_SUB_WARNING); + } + } + } + + private SubscriptionDelta minimizeDelta(SubscriptionDelta oldDelta){ + SubscriptionDelta minimizedDelta = new SubscriptionDelta(new ArrayList<>(), new ArrayList<>()); + + Map<String, Integer> deltaMap = new HashMap<>(); + for (String addString : oldDelta.getAdd()) { + if(deltaMap.containsKey(addString)) { + deltaMap.put(addString, deltaMap.get(addString) + 1); + } + else{ + deltaMap.put(addString, 1); + } + } + + for (String removeString : oldDelta.getRemove()) { + if(deltaMap.containsKey(removeString)) { + deltaMap.put(removeString, deltaMap.get(removeString) - 1); + } + else{ + deltaMap.put(removeString, -1); + } + } + + for(Map.Entry<String, Integer> deltaEntry : deltaMap.entrySet()) { + if(deltaEntry.getValue() > 0) { + minimizedDelta.getAdd().add(deltaEntry.getKey()); + } else if(deltaEntry.getValue() < 0) { + minimizedDelta.getRemove().add(deltaEntry.getKey()); + } + } + + return minimizedDelta; + } + + /** + * Returns a SubscriptionDelta of all changes made to a users subscriptions + * since a given point in time. + * + * @param username The username of the user whose subscription changes are + * being requested + * @param since The timestamp signifying how old the changes are allowed to + * be + * @return The SubscriptionDelta of all changes made since the given timestamp + */ + public SubscriptionDelta getSubscriptionDelta( + final String username, + final long since) { + List<String> added = new ArrayList<>(); + List<String> removed = new ArrayList<>(); + + List<SubscriptionAction> subscriptionActions = subscriptionActionDao + .findByUserUsernameAndTimestampGreaterThanEqual(username, since); + for (SubscriptionAction subscriptionAction : subscriptionActions) { + if (subscriptionAction.isAdded()) { + added.add(subscriptionAction.getSubscription().getUrl()); + } else { + removed.add(subscriptionAction.getSubscription().getUrl()); + } + } + + return new SubscriptionDelta(added, removed); + } + + /** + * Returns all Subscriptions and their 20 latest episodes of a given user as a + * List of SubscriptionTitles. + * + * @param username The username of the user whose subscriptions are being + * requested + * @return A list of SubscriptionTitles containing each Subscription and their + * 20 latest Episodes + */ + public List<SubscriptionTitles> getTitles(final String username) { + List<SubscriptionAction> subscriptionActions + = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + List<Subscription> subscriptions = new ArrayList<>(); + List<SubscriptionTitles> subscriptionTitlesList = new ArrayList<>(); + + for (SubscriptionAction subscriptionAction : subscriptionActions) { + subscriptions.add(subscriptionAction.getSubscription()); + } + + for (Subscription subscription : subscriptions) { + List<EpisodeActionPost> episodeActionPosts + = episodeActionService + .getEpisodeActionsOfPodcast(username, subscription.getUrl()); + SubscriptionTitles subscriptionTitles + = new SubscriptionTitles(subscription, episodeActionPosts); + subscriptionTitlesList.add(subscriptionTitles); + } + + return subscriptionTitlesList; + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/package-info.java b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/package-info.java new file mode 100644 index 0000000..9e2a598 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/subscriptions/api/service/package-info.java @@ -0,0 +1,13 @@ +/** + * This package represents the logical middle layer of the subscription API + * ({@link org.psesquared.server.subscriptions.api}) - the service layer. + * <br> + * All business logic is handled here with the + * {@link + * org.psesquared.server.subscriptions.api.service.SubscriptionService} + * class. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.subscriptions.api.service; diff --git a/pse-server/src/main/java/org/psesquared/server/util/RssParser.java b/pse-server/src/main/java/org/psesquared/server/util/RssParser.java new file mode 100644 index 0000000..7647fee --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/util/RssParser.java @@ -0,0 +1,257 @@ +package org.psesquared.server.util; + +import com.rometools.rome.feed.synd.SyndEnclosure; +import com.rometools.rome.feed.synd.SyndEntry; +import com.rometools.rome.feed.synd.SyndFeed; +import com.rometools.rome.io.FeedException; +import com.rometools.rome.io.SyndFeedInput; +import com.rometools.rome.io.XmlReader; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.DateTimeException; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.jdom2.Content; +import org.jdom2.Element; +import org.psesquared.server.episode.actions.api.data.access.EpisodeDao; +import org.psesquared.server.model.Episode; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionDao; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * The class responsible for fetching data from RSS feeds. + */ +@Component +@RequiredArgsConstructor +public class RssParser { + + /** + * The Index of the Map in the List of Maps that uses GUIDs as keys. + */ + private static final int GUID_KEY_MAP_INDEX = 0; + + /** + * The Index of the Map in the List of Maps that uses URLs as keys. + */ + private static final int URL_KEY_MAP_INDEX = 1; + + /** + * The JPA repository that handles all episode related database requests. + */ + private final EpisodeDao episodeDao; + + /** + * The JPA repository that handles all subscription related database requests. + */ + private final SubscriptionDao subscriptionDao; + + /** + * Validates that the RSS-Feed associated with the Subscription is of the + * expected Format and that the Episodes of the Subscription are part of the + * feed. + * If the Feed is invalid the Subscription is deleted using the + * SubscriptionDao. + * Otherwise, the Episodes that are Part of the Feed are saved using the + * EpisodeDao, those that are not are deleted using the EpisodeDao. + * + * @param subscription The Subscription to validate + */ + @Async + @Transactional + public void validate(final Subscription subscription) { + if (subscription == null) { + return; + } + + List<Map<String, Episode>> fetchedData + = fetchSubscriptionFeed(subscription); + if (fetchedData.get(URL_KEY_MAP_INDEX).isEmpty()) { + subscriptionDao.deleteById(subscription.getId()); + return; + } + + Subscription retrievedSubscription = subscriptionDao.save(subscription); + List<Episode> subscriptionEpisodes = retrievedSubscription.getEpisodes(); + if (subscriptionEpisodes == null + || subscriptionEpisodes.isEmpty()) { + return; + } + + List<Episode> invalidEpisodes = new ArrayList<>(); + List<Episode> validEpisodes = new ArrayList<>(); + for (Episode episode : subscriptionEpisodes) { + Episode fetchedEpisode + = getFetchedEpisode(episode, fetchedData); + if (fetchedEpisode == null) { + invalidEpisodes.add(episode); + } else { + fetchedEpisode.setId(episode.getId()); + validEpisodes.add(fetchedEpisode); + } + } + if (!invalidEpisodes.isEmpty()) { + episodeDao.deleteAll(invalidEpisodes); + } + if (!validEpisodes.isEmpty()) { + episodeDao.saveAll(validEpisodes); + } + } + + private List<Map<String, Episode>> fetchSubscriptionFeed( + final Subscription subscription) { + final List<Map<String, Episode>> empty + = List.of(new HashMap<>(), new HashMap<>()); + + if (subscription.getUrl() == null) { + return empty; + } + // fetch feed + URL feedUrl; + try { + feedUrl = new URL(subscription.getUrl()); + } catch (MalformedURLException e) { + return empty; + } + + SyndFeedInput input = new SyndFeedInput(); + SyndFeed feed; + try { + feed = input.build(new XmlReader(feedUrl)); + } catch (FeedException | IOException | IllegalArgumentException e) { + return empty; + } + + String subscriptionTitle = feed.getTitle(); + if (subscriptionTitle == null) { + return empty; + } + subscription.setTitle(subscriptionTitle); + + Map<String, Episode> episodesByGuid = new HashMap<>(); + Map<String, Episode> episodesByUrl = new HashMap<>(); + List<SyndEntry> entries = feed.getEntries(); + for (SyndEntry syndEntry : entries) { + // parse syndEntry to Episode + Episode episode = parseEpisode(syndEntry, subscription); + if (episode == null) { + return empty; + } + if (episode.getGuid() != null) { + episodesByGuid.put(episode.getGuid(), episode); + } + episodesByUrl.put(episode.getUrl(), episode); + } + return List.of(episodesByGuid, episodesByUrl); + } + + private Episode parseEpisode(final SyndEntry syndEntry, + final Subscription subscription) { + if (syndEntry == null) { + return null; + } + final String title = syndEntry.getTitle(); + final String guid = syndEntry.getUri(); + + List<SyndEnclosure> enclosureList = syndEntry.getEnclosures(); + if (enclosureList.size() != 1) { + return null; + } + SyndEnclosure enclosure = enclosureList.get(0); + String url = enclosure.getUrl(); + if (title == null || url == null) { + return null; + } + + int total = 0; + List<Element> itunesTags = syndEntry.getForeignMarkup(); + for (Element element : itunesTags) { + if (!element.getName().equals("duration")) { + continue; + } + + List<Content> content = element.getContent(); + if (content.size() != 1) { + return null; + } + String timeString = content.get(0).getValue(); + total = parseTimeToSeconds(timeString); + } + + return Episode.builder() + .guid(guid) + .url(url) + .title(title) + .total(total) + .subscription(subscription) + .build(); + } + + private Episode getFetchedEpisode( + final Episode episode, + final List<Map<String, Episode>> fetchedData) { + final String episodeUrl = episode.getUrl(); + if (episodeUrl == null) { + return null; + } + final String episodeGuid = episode.getGuid(); + if (fetchedData.get(GUID_KEY_MAP_INDEX).containsKey(episodeGuid)) { + return fetchedData.get(GUID_KEY_MAP_INDEX).get(episodeGuid); + } + return fetchedData.get(URL_KEY_MAP_INDEX).get(episodeUrl); + } + + private static int parseTimeToSeconds(final String time) { + final String delim = ":"; + + if (time == null) { + // Returning default value + return 0; + } + + if (!time.contains(delim)) { + try { + return Integer.parseInt(time); + } catch (NumberFormatException e) { + return 0; + } + } + + StringBuilder formattedTime = new StringBuilder(); + + String[] datetimeStrings = time.split(delim); + + if (datetimeStrings.length == 2) { + formattedTime.append("00" + delim); + } + + for (int i = 0; i < datetimeStrings.length; i++) { + String part = datetimeStrings[i]; + if (part.length() < 2) { + String toAdd = "0"; + part = toAdd.repeat(2 - part.length()) + part; + } + formattedTime.append(part); + + if (i + 1 < datetimeStrings.length) { + formattedTime.append(delim); + } + } + + int toReturn = 0; + + try { + toReturn = LocalTime.parse(formattedTime.toString()).toSecondOfDay(); + } catch (DateTimeException e) { + // Do nothing, default value has already been set + } + return toReturn; + } +} diff --git a/pse-server/src/main/java/org/psesquared/server/util/Scheduler.java b/pse-server/src/main/java/org/psesquared/server/util/Scheduler.java new file mode 100644 index 0000000..fbe8194 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/util/Scheduler.java @@ -0,0 +1,41 @@ +package org.psesquared.server.util; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import lombok.RequiredArgsConstructor; +import org.psesquared.server.authentication.api.service.AuthenticationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * A scheduler responsible for running scheduled actions. + */ +@Component +@RequiredArgsConstructor +public class Scheduler { + + /** + * The seconds of one day. + */ + private static final long ONE_DAY = 24 * 60 * (long) 60; + + /** + * The service class of the authentication API. + */ + @Autowired + private final AuthenticationService authenticationService; + + /** + * A scheduled operation that removes all non-verified users from the server, + * that haven't been verified since at least 24 hours. + * <br> + * Standard: Runs every day at 3 AM. + */ + @Scheduled(cron = "0 0 3 * * *") + public void clean() { + authenticationService.deleteInvalidUsersOlderThan( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) - ONE_DAY); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/util/UpdateUrlsWrapper.java b/pse-server/src/main/java/org/psesquared/server/util/UpdateUrlsWrapper.java new file mode 100644 index 0000000..004df12 --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/util/UpdateUrlsWrapper.java @@ -0,0 +1,35 @@ +package org.psesquared.server.util; + +import ch.qos.logback.core.joran.sanity.Pair; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * Placeholder for a function this implementation does not support. + */ +@Data +public class UpdateUrlsWrapper { + + /** + * The timestamp of this response-wrapper. + */ + private final long timestamp; + + /** + * An empty list of URL pairs. + */ + @JsonProperty(value = "update_urls") + private final List<Pair<String, String>> updateUrls = new ArrayList<>(); + + /** + * Creates a placeholder for a function this implementation does not support. + */ + public UpdateUrlsWrapper() { + this.timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); + } + +} diff --git a/pse-server/src/main/java/org/psesquared/server/util/package-info.java b/pse-server/src/main/java/org/psesquared/server/util/package-info.java new file mode 100644 index 0000000..169098c --- /dev/null +++ b/pse-server/src/main/java/org/psesquared/server/util/package-info.java @@ -0,0 +1,11 @@ +/** + * This package features the following utility classes: + * <br> + * {@link org.psesquared.server.util.RssParser}, + * {@link org.psesquared.server.util.Scheduler}, + * {@link org.psesquared.server.util.UpdateUrlsWrapper}. + * + * @author PSE-Squared Team + * @version 1.0 + */ +package org.psesquared.server.util; diff --git a/pse-server/src/main/resources/PasswordResetMail.txt b/pse-server/src/main/resources/PasswordResetMail.txt new file mode 100644 index 0000000..bf5e9fc --- /dev/null +++ b/pse-server/src/main/resources/PasswordResetMail.txt @@ -0,0 +1,34 @@ +Hallo username, + +wir haben festgestellt, dass du dein Passwort für unseren Podcast-Synchronisations-Server PSE-SQUARED vergessen hast. +Kein Problem, das kann jedem einmal passieren! + +Wenn du dein Passwort zurücksetzen möchtest, klicke einfach auf den folgenden Link und folge den Anweisungen: + +passwordResetURL + +Falls du dich nicht für unser Projekt angemeldet hast oder diese E-Mail irrtümlich erhalten hast, kannst du sie einfach ignorieren. + +Falls du Fragen oder Probleme hast, zögere nicht, uns zu kontaktieren. Wir helfen dir gerne weiter! + +Viele Grüße, +das PSE-SQUARED-Team + +----- + +Hello username, + +we have noticed that you have forgotten your password for our podcast synchronization server PSE-SQUARED. +No problem, this can happen to anyone! + +If you want to reset your password, just click on the following link and follow the instructions: + +passwordResetURL + +If you did not sign up for our project or received this email by mistake, you can simply ignore it. + +If you have any questions or problems, don't hesitate to contact us. We will be happy to help you! + +Best regards, +the PSE-SQUARED team + diff --git a/pse-server/src/main/resources/VerificationMail.txt b/pse-server/src/main/resources/VerificationMail.txt new file mode 100644 index 0000000..722b88b --- /dev/null +++ b/pse-server/src/main/resources/VerificationMail.txt @@ -0,0 +1,34 @@ +Hallo username, + +wir möchten sicherstellen, dass du vollständig für unseren Podcast-Synchonisations-Server PSE-Squared angemeldet bist +und Zugang zu allen Funktionen hast. Dazu ist es notwendig, dass du deine E-Mail-Adresse bestätigst. + +Klicke einfach auf den folgenden Link, um deine E-Mail-Adresse zu bestätigen: + +verificationURL + +Falls du dich nicht für unser Projekt angemeldet hast oder diese E-Mail irrtümlich erhalten hast, kannst du sie einfach ignorieren. + +Falls du Fragen oder Probleme hast, zögere nicht, uns zu kontaktieren. Wir helfen dir gerne weiter! + +Viele Grüße, +das PSE-SQUARED-Team + +----- + +Hello username, + +we want to make sure that you are fully signed up for our podcast synchronization server PSE-Squared +and have access to all features. For this purpose it is necessary that you confirm your email address. + +Just click on the following link to confirm your email address: + +verificationURL + +If you did not sign up for our project or received this email by mistake, you can simply ignore it. + +If you have any questions or problems, don't hesitate to contact us. We will be happy to help you! + +Best regards, +the PSE-SQUARED team + diff --git a/pse-server/src/main/resources/application.properties b/pse-server/src/main/resources/application.properties new file mode 100644 index 0000000..0ddba59 --- /dev/null +++ b/pse-server/src/main/resources/application.properties @@ -0,0 +1,31 @@ +# spring.datasource.url=jdbc:mariadb://maria_db:3306/demo?autoReconnect=true&maxReconnects=10 +spring.datasource.url=jdbc:mariadb://maria_db:3306/demo?autoReconnect=true&maxReconnects=10 +spring.datasource.username=pse +spring.datasource.password=PSEsq1702!mdb +spring.datasource.driver-class-name=org.mariadb.jdbc.Driver +spring.datasource.testWhileIdle=true +spring.datasource.validationQuery=SELECT 1 +spring.datasource.hikari.maxLifetime = 590000 +spring.jpa.show-sql=true + +# change to validate for production +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.jakarta.persistence.sharedCache.mode=UNSPECIFIED + +# email credentials +spring.mail.host=<YOUR MAIL HOST SMTP> +spring.mail.port=587 +spring.mail.username=<YOUR MAIL ADDRESS> +spring.mail.password=<YOUR MAIL PASSWORD> +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# urls for mails +email.dashboard-base-url=http://<YOUR FRONTEND DOMAIN> +email.verification-url=http://<YOUR BACKEND DOMAIN>/api/2/auth/%s/verify.json +email.reset-url-path=/resetPassword + +# secret signing keys +spring.config.import=security.properties diff --git a/pse-server/src/main/resources/security.properties b/pse-server/src/main/resources/security.properties new file mode 100644 index 0000000..f02370b --- /dev/null +++ b/pse-server/src/main/resources/security.properties @@ -0,0 +1,4 @@ +security.jwt-auth-signing-key=<YOUR JWT AUTH KEY> +security.jwt-url-signing-key=<YOUR JWT URL KEY> +security.email-signing-key=<YOUR EMAIL KEY> + diff --git a/pse-server/src/test/java/org/psesquared/server/BaseTest.java b/pse-server/src/test/java/org/psesquared/server/BaseTest.java new file mode 100644 index 0000000..0d200e6 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/BaseTest.java @@ -0,0 +1,163 @@ +package org.psesquared.server; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.File; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.psesquared.server.authentication.api.data.access.AuthenticationDao; +import org.psesquared.server.episode.actions.api.data.access.EpisodeActionDao; +import org.psesquared.server.episode.actions.api.data.access.EpisodeDao; +import org.psesquared.server.model.Action; +import org.psesquared.server.model.Episode; +import org.psesquared.server.model.EpisodeAction; +import org.psesquared.server.model.Role; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.model.User; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionActionDao; +import org.psesquared.server.subscriptions.api.data.access.SubscriptionDao; +import org.psesquared.server.util.RssParser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public abstract class BaseTest { + + @Autowired + public AuthenticationDao authenticationDao; + + @Autowired + public SubscriptionDao subscriptionDao; + + @Autowired + public EpisodeDao episodeDao; + + @Autowired + public SubscriptionActionDao subscriptionActionDao; + + @Autowired + public EpisodeActionDao episodeActionDao; + + @Autowired + public RssParser rssParser; + + public static int numberOfUsers = 1; + public static int numberOfSubscriptionsPerUser = 2; + public static int numberOfEpisodesPerSubscription = 3; + + @BeforeEach + @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) + public void setUp() { + setUpUsers(numberOfUsers, numberOfSubscriptionsPerUser, numberOfEpisodesPerSubscription); + } + + // URL Scheme: + // Subscriptions: file:/"your project path"/testfeeds/testPodcast0.xml + // Episodes: /testfeeds/testPodcast0/episode0 + private void setUpUsers(int userCount, int subCount, int epCount) { + for (int i = 0; i < userCount; i++) { + User user = new User(); + user.setUsername("testUser" + i); + user.setPassword("testPassword123!" + i); + user.setEmail(user.getUsername() + "@mail.de"); + user.setRole(Role.USER); + + // Store user in database + authenticationDao.save(user); + + // Check if the user exists in the database + Optional<User> savedUser = authenticationDao.findByUsername("testuser" + i); + assertNotNull(savedUser); + + setUpSubscriptionsAndEpisodes(subCount, epCount, user); + } + } + + private void setUpSubscriptionsAndEpisodes(int subCount, int epCount, User user) { + for (int i = 0; i < subCount; i++) { + Subscription subscription = new Subscription(); + subscription.setTitle("testPodcast" + i); + String url = new File(String.format("testfeeds/testPodcast%d.xml", i)).toURI().toString(); + subscription.setUrl(url); + subscription.setTimestamp(i * 1000000); + + // Save the Subscription in the database + subscriptionDao.save(subscription); + + // Check if the Subscription exists in the database + Optional<Subscription> savedSubscription = subscriptionDao + .findByUrl(url); + assertNotNull(savedSubscription); + + // create SubscriptionAction for User and Subscription + SubscriptionAction subscriptionAction = new SubscriptionAction(); + subscriptionAction.setAdded(true); + // subscriptionAction.setAdded(i % 2 == 0); (every other Action is marked + // as inactive/removed) + subscriptionAction.setUser(user); + subscriptionAction.setTimestamp(i * 1000000); + subscriptionAction.setSubscription(subscription); + + // save SubscriptionAction to the database + subscriptionActionDao.save(subscriptionAction); + + // Check if the SubscriptionAction exists in the database + Optional<SubscriptionAction> savedSubscriptionAction = subscriptionActionDao.findByUserAndSubscription(user, + subscription); + assertNotNull(savedSubscriptionAction); + + for (int j = 0; j < epCount; j++) { + Episode episode = new Episode(); + episode.setSubscription(subscription); + + String episodeUrl = String.format("/testfeeds/testPodcast%d/episode%d.mp3", i, j); + episode.setUrl(episodeUrl); + episode.setTotal((j + 1) * 100); + episode.setTitle("testEpisode" + j); + + // save the Episdoe in the database + episodeDao.save(episode); + + // Check if the Episode exists in the database + Optional<Episode> savedEpisode = episodeDao.findByUrl(episodeUrl); + assertNotNull(savedEpisode); + + // create EpisodeAction for User and Episode + EpisodeAction episodeAction = new EpisodeAction(); + episodeAction.setEpisode(episode); + episodeAction.setAction(Action.PLAY); + episodeAction.setUser(user); + episodeAction.setTimestamp(LocalDateTime.ofEpochSecond(j * 1000000, + 0, + ZoneOffset.UTC)); + episodeAction.setStarted((j + 1)); + episodeAction.setPosition((j + 1) * 10); + + // save EpisodeAction in the Database + episodeActionDao.save(episodeAction); + + // Check if the EpisodeAction exists in the database + Optional<EpisodeAction> savedEpisodeAction = episodeActionDao.findById(episodeAction.getId()); + assertNotNull(savedEpisodeAction); + } + + } + } + + @AfterEach + public void cleanUp() { + authenticationDao.deleteAll(); + episodeDao.deleteAll(); + episodeActionDao.deleteAll(); + subscriptionDao.deleteAll(); + subscriptionActionDao.deleteAll(); + } + +} diff --git a/pse-server/src/test/java/org/psesquared/server/ServerApplicationTests.java b/pse-server/src/test/java/org/psesquared/server/ServerApplicationTests.java new file mode 100644 index 0000000..68a6993 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/ServerApplicationTests.java @@ -0,0 +1,11 @@ +package org.psesquared.server; + +import org.junit.jupiter.api.Test; + +class ServerApplicationTests extends BaseTest { + + @Test + void contextLoads() { + } + +} diff --git a/pse-server/src/test/java/org/psesquared/server/TestAsyncConfig.java b/pse-server/src/test/java/org/psesquared/server/TestAsyncConfig.java new file mode 100644 index 0000000..6fe9803 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/TestAsyncConfig.java @@ -0,0 +1,16 @@ +package org.psesquared.server; + +import java.util.concurrent.Executor; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.core.task.SyncTaskExecutor; + +@TestConfiguration +public class TestAsyncConfig { + + @Bean + public Executor taskExecutor() { + return new SyncTaskExecutor(); + } +} diff --git a/pse-server/src/test/java/org/psesquared/server/authentication/api/data/access/AuthenticationDaoTest.java b/pse-server/src/test/java/org/psesquared/server/authentication/api/data/access/AuthenticationDaoTest.java new file mode 100644 index 0000000..c6325ad --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/authentication/api/data/access/AuthenticationDaoTest.java @@ -0,0 +1,51 @@ +package org.psesquared.server.authentication.api.data.access; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.psesquared.server.model.Role; +import org.psesquared.server.model.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class AuthenticationDaoTest { + + @Autowired + private AuthenticationDao authenticationDao; + + @BeforeEach + public void init() { + var user = User.builder() + .username("username") + .email("email") + .password("password") + .enabled(false) + .createdAt(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .role(Role.USER) + .build(); + authenticationDao.save(user); + } + + @Test + public void updateUser() { + var user = authenticationDao.findByUsername("username") + .orElseThrow(); + user.setEnabled(true); + } + + @AfterEach + public void assertUpdated() { + var foundUser = authenticationDao.findByUsername("username") + .orElseThrow(); + assertTrue(foundUser.isEnabled()); + } + +} diff --git a/pse-server/src/test/java/org/psesquared/server/authentication/api/service/AuthenticationServiceTest.java b/pse-server/src/test/java/org/psesquared/server/authentication/api/service/AuthenticationServiceTest.java new file mode 100644 index 0000000..c8f10b6 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/authentication/api/service/AuthenticationServiceTest.java @@ -0,0 +1,222 @@ +package org.psesquared.server.authentication.api.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.psesquared.server.BaseTest; +import org.psesquared.server.authentication.api.controller.ChangePasswordRequest; +import org.psesquared.server.authentication.api.controller.PasswordRequest; +import org.psesquared.server.authentication.api.controller.UserInfoRequest; +import org.psesquared.server.config.JwtService; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.model.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.crypto.password.PasswordEncoder; + +import jakarta.servlet.http.HttpServletResponse; + +public class AuthenticationServiceTest extends BaseTest { + + private static final String recipient = "pse-squared@outlook.com"; + + @Autowired + public AuthenticationService authenticationService; + + @Autowired + public JwtService jwtService; + + @Autowired + public EncryptionService encryptionService; + + @Autowired + PasswordEncoder passwordEncoder; + + @Test + public void testRegisterUser() { + UserInfoRequest newUserInfo = new UserInfoRequest("newUsername", "newUserMail@test.com", "123abcABC!"); + HttpStatus registrationStatus = authenticationService.registerUser(newUserInfo); + assertEquals(HttpStatus.OK, registrationStatus); + + UserInfoRequest wrongEmail = new UserInfoRequest("newUsername", "wrongNewUserMail@test.com", "123abcABC!"); + HttpStatus wrongEmailStatus = authenticationService.registerUser(wrongEmail); + assertEquals(HttpStatus.BAD_REQUEST, wrongEmailStatus); + + UserInfoRequest wrongPassword = new UserInfoRequest("newUsername", "newUserMail@test.com", "wrong123abcABC!"); + HttpStatus wrongPasswordStatus = authenticationService.registerUser(wrongPassword); + assertEquals(HttpStatus.BAD_REQUEST, wrongPasswordStatus); + + UserInfoRequest userInfo = new UserInfoRequest("testUser0", "testUser0@mail.de", "testPassword123!0"); + HttpStatus status = authenticationService.registerUser(userInfo); + assertEquals(HttpStatus.BAD_REQUEST, status); + } + + @Test + public void testInvalidVerifyRegistration() { + HttpStatus status = authenticationService.verifyRegistration("notARegisteredUser", "notAValidToken"); + assertEquals(HttpStatus.NOT_FOUND, status); + status = authenticationService.verifyRegistration("testUser0", "stillNotAValidToken"); + assertEquals(HttpStatus.UNAUTHORIZED, status); + User user = authenticationDao.findByUsername("testUser0").orElseThrow(); + user.setEnabled(true); + authenticationDao.save(user); + status = authenticationService.verifyRegistration("testUser0", "stillNotAValidToken"); + assertEquals(HttpStatus.BAD_REQUEST, status); + } + + @Test + public void testVerifyRegistration() { + User user = authenticationDao.findByUsername("testUser0").orElseThrow(); + String token = jwtService.generateUrlTokenString(user); + HttpStatus status = authenticationService.verifyRegistration("testUser0", token); + assertEquals(HttpStatus.OK, status); + Assertions.assertTrue(authenticationDao.findByUsername("testUser0").orElseThrow().isEnabled()); + } + + @Test + public void testLogin() { + HttpServletResponse response = new MockHttpServletResponse(); + HttpStatus status = authenticationService.login("notARegisteredUser", response); + assertEquals(HttpStatus.NOT_FOUND, status); + status = authenticationService.login("testUser0", response); + assertEquals(HttpStatus.OK, status); + } + + @Test + public void testLogout() { + HttpServletResponse response = new MockHttpServletResponse(); + HttpStatus status = authenticationService.logout("notARegisteredUser", response); + assertEquals(HttpStatus.NOT_FOUND, status); + status = authenticationService.logout("testUser0", response); + assertEquals(HttpStatus.OK, status); + } + + @Test + public void testForgotPassword() { + final String email = "testUser0@mail.de"; + HttpStatus status = authenticationService.forgotPassword(email); + assertEquals(HttpStatus.NOT_FOUND, status); + + User user = authenticationDao.findByUsername("testUser0").orElseThrow(); + user.setEmail(encryptionService.saltAndHashEmail(user.getEmail())); + authenticationDao.save(user); + + final String saltedAndHashedEmail = user.getEmail(); + + status = authenticationService.forgotPassword(saltedAndHashedEmail); + assertEquals(HttpStatus.NOT_FOUND, status); + status = authenticationService.forgotPassword(email); + assertEquals(HttpStatus.OK, status); + } + + @Test + public void testResetPassword() { + User user = authenticationDao.findByUsername("testUser0").orElseThrow(); + String token = ""; + PasswordRequest passwordRequest = new PasswordRequest(""); + HttpStatus status = authenticationService.resetPassword("notAValidUser", token, passwordRequest); + assertEquals(HttpStatus.BAD_REQUEST, status); + + final String password = "abcAbc123!"; + passwordRequest = new PasswordRequest(password); + status = authenticationService.resetPassword("notAValidUser", token, passwordRequest); + assertEquals(HttpStatus.NOT_FOUND, status); + + status = authenticationService.resetPassword(user.getUsername(), token, passwordRequest); + assertEquals(HttpStatus.UNAUTHORIZED, status); + + token = jwtService.generateUrlTokenString(user); + status = authenticationService.resetPassword(user.getUsername(), token, passwordRequest); + assertEquals(HttpStatus.OK, status); + + user = authenticationDao.findByUsername("testUser0").orElseThrow(); + assertTrue(passwordEncoder.matches(password, user.getPassword())); + } + + @Test + public void testChangePassword() { + User user = authenticationDao.findByUsername("testUser0").orElseThrow(); + ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest("", ""); + HttpStatus status = authenticationService.changePassword("notAValidUser", changePasswordRequest); + assertEquals(HttpStatus.BAD_REQUEST, status); + + final String newPassword = "abcAbc123!"; + changePasswordRequest = new ChangePasswordRequest("", newPassword); + status = authenticationService.changePassword("notAValidUser", changePasswordRequest); + assertEquals(HttpStatus.NOT_FOUND, status); + + changePasswordRequest = new ChangePasswordRequest("notTheRightPassword", newPassword); + status = authenticationService.changePassword(user.getUsername(), changePasswordRequest); + assertEquals(HttpStatus.BAD_REQUEST, status); + + changePasswordRequest = new ChangePasswordRequest(user.getPassword(), newPassword); + user.setPassword(passwordEncoder.encode(user.getPassword())); + authenticationDao.save(user); + status = authenticationService.changePassword(user.getUsername(), changePasswordRequest); + assertEquals(HttpStatus.OK, status); + } + + @Test + public void testDeleteUser() { + PasswordRequest passwordRequest = new PasswordRequest(""); + HttpStatus status = authenticationService.deleteUser("notAValidUser", passwordRequest); + assertEquals(HttpStatus.NOT_FOUND, status); + + User user = authenticationDao.findByUsername("testUser0").orElseThrow(); + + passwordRequest = new PasswordRequest("notTheRightPassword"); + status = authenticationService.deleteUser(user.getUsername(), passwordRequest); + assertEquals(HttpStatus.BAD_REQUEST, status); + + passwordRequest = new PasswordRequest(user.getPassword()); + user.setPassword(passwordEncoder.encode(user.getPassword())); + authenticationDao.save(user); + + status = authenticationService.deleteUser(user.getUsername(), passwordRequest); + assertEquals(HttpStatus.OK, status); + } + + @Test + public void testCascadeDelete() { + subscriptionActionDao.deleteAll(); + UserInfoRequest userInfo = new UserInfoRequest("username", recipient, "123abcABC!"); + authenticationService.registerUser(userInfo); + + var user = authenticationDao.findByUsername(userInfo.username()) + .orElseThrow(); + + var sub = Subscription.builder() + .url("url") + .title("title") + .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .build(); + subscriptionDao.save(sub); + + var subAction1 = SubscriptionAction.builder() + .user(user) + .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .subscription(sub) + .added(true) + .build(); + subscriptionActionDao.save(subAction1); + var subAction2 = SubscriptionAction.builder() + .user(user) + .timestamp(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .subscription(sub) + .added(false) + .build(); + subscriptionActionDao.save(subAction2); + + authenticationService.deleteUser(userInfo.username(), new PasswordRequest(userInfo.password())); + + assertEquals(0L, subscriptionActionDao.count()); + } + +} diff --git a/pse-server/src/test/java/org/psesquared/server/authentication/api/service/EmailServiceTests.java b/pse-server/src/test/java/org/psesquared/server/authentication/api/service/EmailServiceTests.java new file mode 100644 index 0000000..ed1061e --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/authentication/api/service/EmailServiceTests.java @@ -0,0 +1,36 @@ +package org.psesquared.server.authentication.api.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.psesquared.server.model.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class EmailServiceTests { + + @Autowired + private EmailServiceImpl emailService; + + private static final String recipient = "pse-squared@outlook.com"; + + private User user; + + @BeforeEach + void beforeEach() { + user = User.builder() + .username("Jeff") + .email(recipient) + .build(); + } + + @Test + void sendValidationMail() { + emailService.sendVerification(user.getEmail(), user); + } + + @Test + void sendPasswordResetMail() { + emailService.sendPasswordReset(user.getEmail(), user); + } +} diff --git a/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDaoTests.java b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDaoTests.java new file mode 100644 index 0000000..3448330 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeActionDaoTests.java @@ -0,0 +1,20 @@ +package org.psesquared.server.episode.actions.api.data.access; + +import org.junit.jupiter.api.Test; +import org.psesquared.server.BaseTest; +import org.psesquared.server.model.User; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class EpisodeActionDaoTests extends BaseTest { + + @Test + public void deleteUserTest() { + String username = "testUser0"; + User user = authenticationDao.findByUsername(username).orElseThrow(); + assertEquals(numberOfEpisodesPerSubscription * numberOfSubscriptionsPerUser, episodeActionDao.findByUserUsername(username).size()); + authenticationDao.delete(user); + assertEquals(0, episodeActionDao.findByUserUsername(username).size()); + } + +} diff --git a/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDaoTests.java b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDaoTests.java new file mode 100644 index 0000000..6c1d70f --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/data/access/EpisodeDaoTests.java @@ -0,0 +1,29 @@ +package org.psesquared.server.episode.actions.api.data.access; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.File; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.psesquared.server.BaseTest; +import org.psesquared.server.model.Subscription; + +public class EpisodeDaoTests extends BaseTest { + + @Test + public void deleteCascadeTest() { + String username = "testUser0"; + assertDoesNotThrow(() -> authenticationDao.findByUsername(username).orElseThrow()); + String subscriptionsUrl = new File("testfeeds/testPodcast0.xml").toURI().toString(); + Subscription subscription = subscriptionDao.findByUrl(subscriptionsUrl).orElseThrow(); + assertDoesNotThrow(() -> subscription.getEpisodes()); + Assertions.assertEquals(numberOfEpisodesPerSubscription, + episodeActionDao.findByUserUsernameAndEpisodeSubscriptionUrl(username, subscriptionsUrl).size()); + episodeDao.deleteAll(subscription.getEpisodes()); + Assertions.assertEquals(0, + episodeActionDao.findByUserUsernameAndEpisodeSubscriptionUrl(username, subscriptionsUrl).size()); + } + +} diff --git a/pse-server/src/test/java/org/psesquared/server/episode/actions/api/service/EpisodeActionServiceTests.java b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/service/EpisodeActionServiceTests.java new file mode 100644 index 0000000..75dd110 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/episode/actions/api/service/EpisodeActionServiceTests.java @@ -0,0 +1,368 @@ +package org.psesquared.server.episode.actions.api.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.psesquared.server.BaseTest; +import org.psesquared.server.episode.actions.api.controller.EpisodeActionPost; +import org.psesquared.server.model.Action; +import org.psesquared.server.model.Episode; +import org.psesquared.server.model.EpisodeAction; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.User; +import org.springframework.beans.factory.annotation.Autowired; + +public class EpisodeActionServiceTests extends BaseTest { + + @Autowired + private EpisodeActionService episodeActionService; + + @Test + public void addEpisodeActionsTest1() { + // episodeActionPosts zu Subscriptions und Episoden hinzufügen, die noch nicht + // existieren + List<EpisodeActionPost> episodeActionPosts = new ArrayList<>(); + String username = "testUser0"; + Assertions.assertEquals(numberOfEpisodesPerSubscription * numberOfSubscriptionsPerUser, + episodeActionDao.findByUserUsername(username).size()); + int numberOfNewEpisodes = 2; + for (int i = 0; i < numberOfNewEpisodes; i++) { + episodeActionPosts.add(EpisodeActionPost.builder() + .podcastUrl(new File("testfeeds/newTestSubscription1.xml").toURI().toString()) + .episodeUrl(String.format("/testfeeds/newTestSubscription1/episode%d.mp3", i)) + .title("testEpisode" + i) + .guid(UUID.randomUUID().toString()) + .total(10 * i) + .episodeAction(EpisodeAction.builder() + .timestamp(LocalDateTime.now()) + .action(Action.PLAY) + .started(i) + .position(i + 3) + .build()) + .build()); + } + episodeActionService.addEpisodeActions(username, episodeActionPosts); + + Assertions.assertEquals(numberOfSubscriptionsPerUser * numberOfEpisodesPerSubscription + numberOfNewEpisodes, + episodeActionDao.findByUserUsername(username).size()); + } + + @Test + public void addEpisodeActionsTest2() { + // Alte EpisodeAction durch neuere Überschreiben + + final String username = "testUser0"; + final String episodeUrl = "/testfeeds/testPodcast0/episode0.mp3"; + final User user = authenticationDao.findByUsername(username).orElseThrow(); + + EpisodeAction oldEpisodeActionTest = episodeActionDao + .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY).orElseThrow(); + List<EpisodeActionPost> episodeActionPosts = new ArrayList<>(); + episodeActionPosts.add(EpisodeActionPost.builder() + .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl()) + .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl()) + .title(oldEpisodeActionTest.getEpisode().getTitle()) + .guid(oldEpisodeActionTest.getEpisode().getGuid()) + .total(oldEpisodeActionTest.getEpisode().getTotal()) + .episodeAction(EpisodeAction.builder() + .timestamp(LocalDateTime.ofEpochSecond( + oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 1, + 0, + ZoneOffset.UTC)) + .action(Action.PLAY) + .started(oldEpisodeActionTest.getStarted()) + .position(oldEpisodeActionTest.getPosition()) + .build()) + .build()); + + LocalDateTime oldTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY) + .orElseThrow().getTimestamp(); + episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts); + LocalDateTime newTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY) + .orElseThrow().getTimestamp(); + assertNotEquals(oldTimestamp, newTimestamp); + } + + @Test + public void addEpisodeActionTest3() { + // EpisodeActions nicht nach Timestamp sortiert + // Überschreibe EpisodeAction durch neueste Action + + final String username = "testUser0"; + final String episodeUrl = "/testfeeds/testPodcast0/episode0.mp3"; + final User user = authenticationDao.findByUsername(username).orElseThrow(); + + EpisodeAction oldEpisodeActionTest = episodeActionDao + .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY).orElseThrow(); + List<EpisodeActionPost> episodeActionPosts = new ArrayList<>(); + episodeActionPosts.add(EpisodeActionPost.builder() + .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl()) + .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl()) + .title(oldEpisodeActionTest.getEpisode().getTitle()) + .guid(oldEpisodeActionTest.getEpisode().getGuid()) + .total(oldEpisodeActionTest.getEpisode().getTotal()) + .episodeAction(EpisodeAction.builder() + .timestamp(LocalDateTime.ofEpochSecond( + oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 2, + 0, + ZoneOffset.UTC)) + .action(Action.PLAY) + .started(oldEpisodeActionTest.getStarted()) + .position(oldEpisodeActionTest.getPosition()) + .build()) + .build()); + + episodeActionPosts.add(EpisodeActionPost.builder() + .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl()) + .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl()) + .title(oldEpisodeActionTest.getEpisode().getTitle()) + .guid(oldEpisodeActionTest.getEpisode().getGuid()) + .total(oldEpisodeActionTest.getEpisode().getTotal()) + .episodeAction(EpisodeAction.builder() + .timestamp(LocalDateTime.ofEpochSecond( + oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 1, + 0, + ZoneOffset.UTC)) + .action(Action.PLAY) + .started(oldEpisodeActionTest.getStarted()) + .position(oldEpisodeActionTest.getPosition()) + .build()) + .build()); + + episodeActionPosts.add(EpisodeActionPost.builder() + .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl()) + .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl()) + .title(oldEpisodeActionTest.getEpisode().getTitle()) + .guid(oldEpisodeActionTest.getEpisode().getGuid()) + .total(oldEpisodeActionTest.getEpisode().getTotal()) + .episodeAction(EpisodeAction.builder() + .timestamp(LocalDateTime.ofEpochSecond( + oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 3, + 0, + ZoneOffset.UTC)) + .action(Action.PLAY) + .started(oldEpisodeActionTest.getStarted()) + .position(oldEpisodeActionTest.getPosition()) + .build()) + .build()); + + LocalDateTime oldTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY) + .orElseThrow().getTimestamp(); + episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts); + LocalDateTime newTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY) + .orElseThrow().getTimestamp(); + assertTrue(newTimestamp.isAfter(oldTimestamp)); + assertEquals(3, (int) ChronoUnit.SECONDS.between(oldTimestamp, newTimestamp)); + } + + @Test + public void addEpisodeActionsTest4() { + // Ignoriere Episode Action, die nicht PLAY als Action hat + + final String username = "testUser0"; + final String episodeUrl = "/testfeeds/testPodcast0/episode0.mp3"; + final User user = authenticationDao.findByUsername(username).orElseThrow(); + + EpisodeAction oldEpisodeActionTest = episodeActionDao + .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY).orElseThrow(); + List<EpisodeActionPost> episodeActionPosts = new ArrayList<>(); + episodeActionPosts.add(EpisodeActionPost.builder() + .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl()) + .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl()) + .title(oldEpisodeActionTest.getEpisode().getTitle()) + .guid(oldEpisodeActionTest.getEpisode().getGuid()) + .total(oldEpisodeActionTest.getEpisode().getTotal()) + .episodeAction(EpisodeAction.builder() + .timestamp(LocalDateTime.ofEpochSecond( + oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 1, + 0, + ZoneOffset.UTC)) + .action(Action.DOWNLOAD) + .started(oldEpisodeActionTest.getStarted()) + .position(oldEpisodeActionTest.getPosition()) + .build()) + .build()); + + LocalDateTime oldTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY) + .orElseThrow().getTimestamp(); + episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts); + LocalDateTime newTimestamp = episodeActionDao.findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY) + .orElseThrow().getTimestamp(); + assertEquals(oldTimestamp, newTimestamp); + } + + @Test + public void addEpisodeActionsTest5() { + // Episode in Datenbank ohne GUID, EpisodeAction mit selbem Link mit GUID + + final String username = "testUser0"; + final String podcastName = new File("testfeeds/testPodcast0.xml").toURI().toString(); + final String episodeBaseUrl = "/testfeeds/testPodcast0/episode"; + final User user = authenticationDao.findByUsername(username).orElseThrow(); + + final int episodeIndex = numberOfEpisodesPerSubscription; + + // Setup - Episode ohne GUID einfügen + Optional<Subscription> savedSubscription = subscriptionDao.findByUrl(podcastName); + assertTrue(savedSubscription.isPresent()); + + final String episodeUrl = episodeBaseUrl + episodeIndex + ".mp3"; + + Episode newEpisode = new Episode(); + newEpisode.setSubscription(savedSubscription.get()); + newEpisode.setUrl(episodeUrl); + newEpisode.setTotal((episodeIndex + 1) * 100); + newEpisode.setTitle("testEpisode" + episodeIndex); + + episodeDao.save(newEpisode); + Optional<Episode> savedEpisode = episodeDao.findByUrl(episodeUrl); + assertTrue(savedEpisode.isPresent()); + assertNull(savedEpisode.get().getGuid()); + + EpisodeAction episodeAction = new EpisodeAction(); + episodeAction.setEpisode(newEpisode); + episodeAction.setAction(Action.PLAY); + episodeAction.setUser(user); + episodeAction.setTimestamp(LocalDateTime.ofEpochSecond( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + (long) episodeIndex * 1000000, + 0, + ZoneOffset.UTC)); + episodeAction.setStarted((episodeIndex + 1)); + episodeAction.setPosition((episodeIndex + 1) * 10); + + episodeActionDao.save(episodeAction); + Optional<EpisodeAction> savedEpisodeAction = episodeActionDao.findById(episodeAction.getId()); + assertTrue(savedEpisodeAction.isPresent()); + + EpisodeAction oldEpisodeActionTest = episodeActionDao + .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY).orElseThrow(); + List<EpisodeActionPost> episodeActionPosts = new ArrayList<>(); + episodeActionPosts.add(EpisodeActionPost.builder() + .podcastUrl(oldEpisodeActionTest.getEpisode().getSubscription().getUrl()) + .episodeUrl(oldEpisodeActionTest.getEpisode().getUrl()) + .title(oldEpisodeActionTest.getEpisode().getTitle()) + .guid("Alphabet") + .total(oldEpisodeActionTest.getEpisode().getTotal()) + .episodeAction(EpisodeAction.builder() + .timestamp(LocalDateTime.ofEpochSecond( + oldEpisodeActionTest.getTimestamp().toEpochSecond(ZoneOffset.UTC) + 1, + 0, + ZoneOffset.UTC)) + .action(Action.PLAY) + .started(oldEpisodeActionTest.getStarted()) + .position(oldEpisodeActionTest.getPosition()) + .build()) + .build()); + + LocalDateTime oldTimestamp = episodeActionDao + .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY) + .orElseThrow().getTimestamp(); + episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts); + LocalDateTime newTimestamp = episodeActionDao + .findByUserAndEpisodeUrlAndAction(user, episodeUrl, Action.PLAY) + .orElseThrow().getTimestamp(); + assertNotEquals(oldTimestamp, newTimestamp); + savedEpisode = episodeDao.findByUrl(episodeUrl); + assertTrue(savedEpisode.isPresent()); + assertEquals("Alphabet", savedEpisode.get().getGuid()); + } + + @Test + public void addEpisodeActionsTest6() { + // Episode nicht in Datenbank, EpisodeAction mit neuer Episode + + final String username = "testUser0"; + final String podcastName = new File("testfeeds/testPodcast0.xml").toURI().toString(); + final String episodeBaseUrl = "/testfeeds/testPodcast0/episode"; + final User user = authenticationDao.findByUsername(username).orElseThrow(); + + final int episodeIndex = numberOfEpisodesPerSubscription; + + // Setup - Episode ohne GUID einfügen + Optional<Subscription> savedSubscription = subscriptionDao.findByUrl(podcastName); + assertTrue(savedSubscription.isPresent()); + + final String episodeUrl = episodeBaseUrl + episodeIndex + ".mp3"; + + Optional<Episode> noEpisode = episodeDao.findByUrl(episodeUrl); + assertFalse(noEpisode.isPresent()); + + List<EpisodeActionPost> episodeActionPosts = new ArrayList<>(); + episodeActionPosts.add(EpisodeActionPost.builder() + .podcastUrl(podcastName) + .episodeUrl(episodeUrl) + .title("testEpisode" + episodeIndex) + .guid("Alphabet") + .total((episodeIndex + 1) * 100) + .episodeAction(EpisodeAction.builder() + .timestamp(LocalDateTime.ofEpochSecond( + LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) + (long) episodeIndex * 1000000, + 0, + ZoneOffset.UTC)) + .action(Action.PLAY) + .started((episodeIndex + 1)) + .position((episodeIndex + 1) * 10) + .build()) + .build()); + episodeActionService.addEpisodeActions(user.getUsername(), episodeActionPosts); + noEpisode = episodeDao.findByUrl(episodeUrl); + assertTrue(noEpisode.isPresent()); + } + + @Test + public void getEpisodeActionsTest() { + String username = "testUser0"; + List<EpisodeActionPost> episodeActionPosts = episodeActionService.getEpisodeActions(username); + assertEquals(numberOfSubscriptionsPerUser * numberOfEpisodesPerSubscription, episodeActionPosts.size()); + } + + @Test + public void getEpisodeActionsOfPodcastTest() { + String username = "testUser0"; + String subscriptionUrl = new File("testfeeds/testPodcast0.xml").toURI().toString(); + List<EpisodeActionPost> episodeActionPosts = episodeActionService.getEpisodeActionsOfPodcast(username, + subscriptionUrl); + assertEquals(numberOfEpisodesPerSubscription, episodeActionPosts.size()); + } + + @Test + public void getEpisodeActionsSinceTest() { + String username = "testUser0"; + // Jede Episode in einer Subscription hat einen Timestamp von zusätzlichen + // 1000000 zur Zeit 0 + int factor = 2; + int timeDifference = 1000000; + int numberOfEpisodes = numberOfSubscriptionsPerUser * numberOfEpisodesPerSubscription; + long since = factor * timeDifference; + List<EpisodeActionPost> episodeActionPosts = episodeActionService.getEpisodeActionsSince(username, since); + assertEquals(numberOfEpisodes - factor * numberOfSubscriptionsPerUser, episodeActionPosts.size()); + } + + @Test + public void getEpisodeActionsOfPodcastSinceTest() { + String username = "testUser0"; + String subscriptionUrl = new File("testfeeds/testPodcast0.xml").toURI().toString(); + // Jede EpisodeAction in einer Subscription hat einen Timestamp von zusätzlichen + // 1000000 zur Zeit 0 + int factor = 1; + int timeDifference = 1000000; + long since = factor * timeDifference; + List<EpisodeActionPost> episodeActionPosts = episodeActionService.getEpisodeActionsOfPodcastSince(username, + subscriptionUrl, since); + assertEquals(numberOfEpisodesPerSubscription - factor, episodeActionPosts.size()); + } +} diff --git a/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDaoTests.java b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDaoTests.java new file mode 100644 index 0000000..f296a72 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionActionDaoTests.java @@ -0,0 +1,89 @@ +package org.psesquared.server.subscriptions.api.data.access; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.util.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.psesquared.server.BaseTest; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.model.User; + +public class SubscriptionActionDaoTests extends BaseTest { + + @Test + public void existsByUserAndSubscriptionTest() { + String username = "testUser0"; + String subscriptionUrl = new File("testfeeds/testPodcast0.xml").toURI().toString(); + User user = authenticationDao.findByUsername(username).orElseThrow(); + Subscription subscription = subscriptionDao.findByUrl(subscriptionUrl).orElseThrow(); + boolean exists = subscriptionActionDao.existsByUserAndSubscription(user, subscription); + assertTrue(exists); + } + + @Test + public void findByUserAndSubscriptionTest() { + String username = "testUser0"; + String subscriptionUrl = new File("testfeeds/testPodcast0.xml").toURI().toString(); + User user = authenticationDao.findByUsername(username).orElseThrow(); + Subscription subscription = subscriptionDao.findByUrl(subscriptionUrl).orElseThrow(); + SubscriptionAction subscriptionAction = subscriptionActionDao.findByUserAndSubscription(user, subscription) + .orElseThrow(); + assertEquals(subscriptionAction.getSubscription().getUrl(), subscriptionUrl); + } + + @Test + public void findByUserUsernameAndTimestampGreaterThanEqualTest() { + String username = "testUser0"; + // Jede SubscriptionAction eines Nutzers hat einen Timestamp von zusätzlichen + // 1000000 zur Zeit 0 + int factor = 1; + int timeDifference = 1000000; + long since = factor * timeDifference; + List<SubscriptionAction> subscriptionActions = subscriptionActionDao + .findByUserUsernameAndTimestampGreaterThanEqual(username, since); + assertEquals(numberOfSubscriptionsPerUser - factor, subscriptionActions.size()); + } + + @Test + public void findByUserUsernameAndAddedTrueTest() { + String username = "testUser0"; + List<SubscriptionAction> subscriptionActions = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + assertEquals(subscriptionActions.size(), numberOfSubscriptionsPerUser); + } + + @Test + public void findByUserUsernameAndAddedTrueAndTimestampGreaterThanEqualTest() { + String username = "testUser0"; + // Jede SubscriptionAction eines Nutzers hat einen Timestamp von zusätzlichen + // 1000000 zur Zeit 0 + // int puffer = 1000; + int factor = 1; + int timeDifference = 1000000; + long since = factor * timeDifference; + List<SubscriptionAction> subscriptionActions = subscriptionActionDao + .findByUserUsernameAndTimestampGreaterThanEqual(username, since); + assertEquals(numberOfSubscriptionsPerUser - factor, subscriptionActions.size()); + // Eine EpisodeAction der gefundenen EpisodeActions auf added=false setzen + subscriptionActions.get(0).setAdded(false); + subscriptionActionDao.save(subscriptionActions.get(0)); + List<SubscriptionAction> subscriptionActions2 = subscriptionActionDao + .findByUserUsernameAndAddedTrueAndTimestampGreaterThanEqual(username, since); + assertEquals(numberOfSubscriptionsPerUser - factor - 1, subscriptionActions2.size()); + } + + @Test + public void deleteUserTest() { + String username = "testUser0"; + User user = authenticationDao.findByUsername(username).orElseThrow(); + Assertions.assertEquals(numberOfSubscriptionsPerUser, + subscriptionActionDao.findByUserUsernameAndAddedTrue(username).size()); + authenticationDao.delete(user); + Assertions.assertEquals(0, subscriptionActionDao.findByUserUsernameAndAddedTrue(username).size()); + } + +} diff --git a/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDaoTests.java b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDaoTests.java new file mode 100644 index 0000000..3ae0089 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/data/access/SubscriptionDaoTests.java @@ -0,0 +1,36 @@ +package org.psesquared.server.subscriptions.api.data.access; + +import org.junit.jupiter.api.Test; +import org.psesquared.server.BaseTest; +import org.psesquared.server.model.Subscription; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; + +public class SubscriptionDaoTests extends BaseTest { + + @Test + public void findByUrlTest() { + String url = new File("testfeeds/testPodcast0.xml").toURI().toString(); + Subscription subscription = subscriptionDao.findByUrl(url).orElseThrow(); + assertEquals(subscription.getUrl(), url); + } + + @Test + public void existsByUrlTest() { + String url = new File("testfeeds/testPodcast0.xml").toURI().toString(); + boolean exists = subscriptionDao.existsByUrl(url); + assertTrue(exists); + boolean notExists = subscriptionDao.existsByUrl("blablabla"); + assertFalse(notExists); + } + + @Test + public void testCascadeDelete() { + String url = new File("testfeeds/testPodcast0.xml").toURI().toString(); + Subscription subscription = subscriptionDao.findByUrl(url).orElseThrow(); + assertDoesNotThrow(() -> subscriptionDao.delete(subscription)); + } + +} diff --git a/pse-server/src/test/java/org/psesquared/server/subscriptions/api/service/SubscriptionServiceTests.java b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/service/SubscriptionServiceTests.java new file mode 100644 index 0000000..3345185 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/subscriptions/api/service/SubscriptionServiceTests.java @@ -0,0 +1,128 @@ +package org.psesquared.server.subscriptions.api.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.psesquared.server.BaseTest; +import org.psesquared.server.model.Subscription; +import org.psesquared.server.model.SubscriptionAction; +import org.psesquared.server.subscriptions.api.controller.SubscriptionDelta; +import org.psesquared.server.subscriptions.api.controller.SubscriptionTitles; +import org.springframework.beans.factory.annotation.Autowired; + +public class SubscriptionServiceTests extends BaseTest { + + @Autowired + SubscriptionService subscriptionService; + + @Test + public void uploadSubscriptionsTest() { + String username = "testUser0"; + assertDoesNotThrow(() -> authenticationDao.findByUsername(username).orElseThrow()); + String newSubscriptionUrl1 = new File("testfeeds/newTestSubscription1.xml").toURI().toString(); + String newSubscriptionUrl2 = new File("testfeeds/newTestSubscription2.xml").toURI().toString(); + List<String> subscriptionStrings = List.of(newSubscriptionUrl1, newSubscriptionUrl2); + + subscriptionService.uploadSubscriptions(username, subscriptionStrings); + List<SubscriptionAction> subscriptionActions = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + int size = subscriptionActions.size(); + assertEquals(numberOfSubscriptionsPerUser + 2, size); + + subscriptionActions.get(0).setAdded(false); + subscriptionActionDao.save(subscriptionActions.get(0)); + subscriptionActions = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + size = subscriptionActions.size(); + assertEquals(numberOfSubscriptionsPerUser + 1, size); + + String testPodcast0Url = new File("testfeeds/testPodcast0.xml").toURI().toString(); + List<String> subscriptionString = List.of(testPodcast0Url); + subscriptionService.uploadSubscriptions(username, subscriptionString); + subscriptionActions = subscriptionActionDao.findByUserUsernameAndAddedTrue(username); + size = subscriptionActions.size(); + assertEquals(numberOfSubscriptionsPerUser + 2, size); + } + + @Test + public void getSubscriptionsTest() { + String username = "testUser0"; + List<String> subscriptionStrings = subscriptionService.getSubscriptions(username); + assertEquals(subscriptionStrings.size(), numberOfSubscriptionsPerUser); + } + + @Test + public void applySubscriptionDeltaTest() { + String username = "testUser0"; + String subscriptionString = new File("testfeeds/testPodcast0.xml").toURI().toString();; + // Überprüfen, ob Anzahl der Subscriptions des Users mit der definierten Anzahl + // übereinstimmt + assertEquals(subscriptionService.getSubscriptions(username).size(), numberOfSubscriptionsPerUser); + // Subscription mithilfe des Subscription-Deltas entfernen + subscriptionService.applySubscriptionDelta(username, + new SubscriptionDelta(List.of(), List.of(subscriptionString))); + assertEquals(subscriptionService.getSubscriptions(username).size(), numberOfSubscriptionsPerUser - 1); + // Subscription mithilfe des Subscription-Deltas wieder hinzufügen + subscriptionService.applySubscriptionDelta(username, + new SubscriptionDelta(List.of(subscriptionString), List.of())); + assertEquals(subscriptionService.getSubscriptions(username).size(), numberOfSubscriptionsPerUser); + } + + @Test + public void getSubscriptionDeltaTest() { + String username = "testUser0"; + String subscriptionString = new File("testfeeds/testPodcast0.xml").toURI().toString(); + long since = 0; + // Überprüfen, ob Anzahl der Subscriptions des Users mit der definierten Anzahl + // übereinstimmt + SubscriptionDelta subscriptionDelta = subscriptionService.getSubscriptionDelta(username, since); + assertEquals(subscriptionDelta.getAdd().size(), numberOfSubscriptionsPerUser); + assertEquals(0, subscriptionDelta.getRemove().size()); + // Subscription mithilfe des Subscription-Deltas entfernen + subscriptionService.applySubscriptionDelta(username, + new SubscriptionDelta(List.of(), List.of(subscriptionString))); + subscriptionDelta = subscriptionService.getSubscriptionDelta(username, since); + assertEquals(subscriptionDelta.getAdd().size(), numberOfSubscriptionsPerUser - 1); + assertEquals(1, subscriptionDelta.getRemove().size()); + // Subscription mithilfe des Subscription-Deltas wieder hinzufügen + subscriptionService.applySubscriptionDelta(username, + new SubscriptionDelta(List.of(subscriptionString), List.of())); + + subscriptionDelta = subscriptionService.getSubscriptionDelta(username, since); + List<SubscriptionAction> subscriptionActions = subscriptionActionDao + .findByUserUsernameAndTimestampGreaterThanEqual(username, since); + LocalDateTime deltaTime = LocalDateTime + .ofEpochSecond(subscriptionDelta.getTimestamp(), 0, ZoneOffset.UTC); + LocalDateTime maxTime = deltaTime; + for (SubscriptionAction subscriptionAction : subscriptionActions) { + + LocalDateTime subTime = LocalDateTime + .ofEpochSecond(subscriptionAction.getTimestamp(), 0, ZoneOffset.UTC); + if (maxTime.isBefore(subTime)) { + maxTime = subTime; + } + } + assertEquals(deltaTime, maxTime); + assertEquals(subscriptionDelta.getAdd().size(), numberOfSubscriptionsPerUser); + assertEquals(0, subscriptionDelta.getRemove().size()); + } + + @Test + public void getTitlesTest() { + String username = "testUser0"; + List<SubscriptionTitles> subscriptionTitlesList = subscriptionService.getTitles(username); + assertEquals(numberOfSubscriptionsPerUser, subscriptionTitlesList.size()); + for (SubscriptionTitles subscriptionTitles : subscriptionTitlesList) { + assertEquals(numberOfEpisodesPerSubscription, subscriptionTitles.episodes().size()); + Optional<Subscription> savedSubscription = + subscriptionDao.findByUrl(subscriptionTitles.subscription().getUrl()); + assertTrue(savedSubscription.isPresent()); + } + } +} diff --git a/pse-server/src/test/java/org/psesquared/server/util/RssParserTests.java b/pse-server/src/test/java/org/psesquared/server/util/RssParserTests.java new file mode 100644 index 0000000..73c5497 --- /dev/null +++ b/pse-server/src/test/java/org/psesquared/server/util/RssParserTests.java @@ -0,0 +1,135 @@ +package org.psesquared.server.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; + +import org.junit.jupiter.api.Test; +import org.psesquared.server.BaseTest; +import org.psesquared.server.TestAsyncConfig; +import org.psesquared.server.model.Episode; +import org.psesquared.server.model.Subscription; +import org.springframework.test.context.ContextConfiguration; + +// Disable async behavior for this Test Class +@ContextConfiguration(classes = TestAsyncConfig.class) +public class RssParserTests extends BaseTest { + + private static final String TAGESSCHAU_URL = "https://www.tagesschau.de/multimedia/podcasts/mal-angenommen-feed-101.xml"; + private final Subscription tagesschauPodcast = Subscription.builder() + .url(TAGESSCHAU_URL) + .build(); + private final String relativePathToTestFeeds = "./testfeeds/"; + + @Test + public void testSubscriptionInvalid() { + Subscription nullSubscription = null; + Subscription nullUrlSubscription = Subscription.builder().build(); + Subscription invalidUrlSubscription = Subscription.builder() + .url("Not a url") + .title("Some inventive Title") + .build(); + + assertDoesNotThrow(() -> rssParser.validate(nullSubscription)); + assertDoesNotThrow(() -> rssParser.validate(nullUrlSubscription)); + assertDoesNotThrow(() -> rssParser.validate(invalidUrlSubscription)); + assertFalse(subscriptionDao.findByUrl(invalidUrlSubscription.getUrl()).isPresent()); + } + + // Does currently only work in debugger by making sure the Parser has time to + // validate, before the Test evaluates the Assertions. + @Test + public void testValidSubscription() { + final String expectedURL = "https://media.tagesschau.de/audio/2023/0125/AU-20230125-1854-5200.hi.mp3"; + final String expectedGuid = "tagesschau-podcast-mal-angenommen-tierrechte-101"; + final String expectedTitle = "Gleiche Rechte für Tiere? Was dann?"; + final int expectedTotal = 1442; + Episode expectedEpisode = Episode.builder().url(expectedURL).guid(expectedGuid).title(expectedTitle) + .total(expectedTotal).subscription(tagesschauPodcast).build(); + Episode testEpisode = Episode.builder().url(expectedURL).id(expectedEpisode.getId()).build(); + tagesschauPodcast.addEpisode(testEpisode); + + assertDoesNotThrow(() -> rssParser.validate(tagesschauPodcast)); + assertTrue(subscriptionDao.findByUrl(TAGESSCHAU_URL).isPresent()); + } + + @Test + public void currentDirTest() { + //System.out.println(System.getProperty("user.dir")); + final String pathToTestFile = relativePathToTestFeeds + "dirtest.txt"; + File testFile = new File(pathToTestFile); + assertTrue(testFile.exists() && !testFile.isDirectory()); + } + + public void testByteHamsterEdgeCasePodcast() { + final String subscriptionURL = "https://tools.bytehamster.com/podcast/rss.xml"; + final String firstEpisodeURL = "http://tools.bytehamster.com/podcast/piano.mp3?1.mp3"; + final String lastEpisodeURL = "http://tools.bytehamster.com/podcast/piano.mp3?13.mp3"; + + Subscription subscription = Subscription.builder().url(subscriptionURL).build(); + Episode firstEpisode = Episode.builder().url(firstEpisodeURL) + .subscription(subscription).build(); + subscription.addEpisode(firstEpisode); + + Episode lastEpisode = Episode.builder().url(lastEpisodeURL) + .subscription(subscription).build(); + subscription.addEpisode(lastEpisode); + subscriptionDao.save(subscription); + episodeDao.save(firstEpisode); + episodeDao.save(lastEpisode); + + // Feed contains an Episode that does not meet minimum requirements, so + // Subscription and its Episodes should be deleted + assertDoesNotThrow(() -> rssParser.validate(subscription)); + assertFalse(subscriptionDao.findByUrl(subscriptionURL).isPresent()); + assertFalse(episodeDao.findByUrl(firstEpisodeURL).isPresent()); + assertFalse(episodeDao.findByUrl(lastEpisodeURL).isPresent()); + } + + @Test + public void testDeletionOfEpisodeNotInFeed() { + final String subscriptionURL = "https://tools.bytehamster.com/podcast/alwaysNew.php"; + final String notIncludedURL = "http://tools.bytehamster.com/podcast/piano.mp3?2023-03-15-21:53:21.mp3"; + + Subscription subscription = Subscription.builder().url(subscriptionURL).build(); + Episode episodeToDelete = Episode.builder().url(notIncludedURL) + .subscription(subscription).build(); + subscription.addEpisode(episodeToDelete); + subscriptionDao.save(subscription); + episodeDao.save(episodeToDelete); + + // Feed does not contain the Episode, so the Episode should be deleted, but the + // Subscription remains + assertDoesNotThrow(() -> rssParser.validate(subscription)); + assertTrue(subscriptionDao.findByUrl(subscriptionURL).isPresent()); + assertFalse(episodeDao.findByUrl(notIncludedURL).isPresent()); + } + + @Test + public void testValidateBaseTestFeed() { + final String testPodcastUrl = new File("testfeeds/testPodcast0.xml").toURI().toString(); + Subscription subscription = subscriptionDao.findByUrl(testPodcastUrl).orElseThrow(); + assertDoesNotThrow(() -> rssParser.validate(subscription)); + assertNotNull(subscriptionDao.findByUrl(testPodcastUrl)); + } + + @Test + public void testTimeParsing() { + final String subscriptionUrl = new File("testfeeds/timeTestPodcast.xml").toURI().toString(); + Subscription subscription = subscriptionDao.save(Subscription.builder().url(subscriptionUrl).build()); + assertDoesNotThrow(() -> rssParser.validate(subscription)); + assertFalse(subscriptionDao.findByUrl(subscriptionUrl).isPresent()); + } + + @Test + public void doubleEnclosureTest() { + final String subscriptionUrl = new File("testfeeds/multipleEnclosuresFeed.xml").toURI().toString(); + Subscription subscription = subscriptionDao.save(Subscription.builder().url(subscriptionUrl).build()); + assertDoesNotThrow(() -> rssParser.validate(subscription)); + assertFalse(subscriptionDao.findByUrl(subscriptionUrl).isPresent()); + } + +} diff --git a/pse-server/testfeeds/dirtest.txt b/pse-server/testfeeds/dirtest.txt new file mode 100644 index 0000000..28c9395 --- /dev/null +++ b/pse-server/testfeeds/dirtest.txt @@ -0,0 +1 @@ +Lol
\ No newline at end of file diff --git a/pse-server/testfeeds/multipleEnclosuresFeed.xml b/pse-server/testfeeds/multipleEnclosuresFeed.xml new file mode 100644 index 0000000..52a6630 --- /dev/null +++ b/pse-server/testfeeds/multipleEnclosuresFeed.xml @@ -0,0 +1,17 @@ +<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" + xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0"> + <channel> + <title>doubleEnclosurePodcast</title> + <item> + <title>doubleEnclosureEpisode</title> + <itunes:duration>00:03:20</itunes:duration> + <enclosure + url="/testfeeds/doubleEnclosurePodcast/enclosure1.mp3" + type="audio/mp3" /> + <enclosure + url="/testfeeds/doubleEnclosurePodcast/enclosure2.mp3" + type="audio/mp3" /> + </item> + </channel> +</rss>
\ No newline at end of file diff --git a/pse-server/testfeeds/newTestSubscription1.xml b/pse-server/testfeeds/newTestSubscription1.xml new file mode 100644 index 0000000..a6ab832 --- /dev/null +++ b/pse-server/testfeeds/newTestSubscription1.xml @@ -0,0 +1,28 @@ +<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" + xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0"> + <channel> + <title>newTestSubscription1</title> + <item> + <title>newTestEpisode0</title> + <itunes:duration>00:01:40</itunes:duration> + <enclosure + url="/testfeeds/newTestSubscription1/episode0.mp3" + type="audio/mp3" /> + </item> + <item> + <title>newTestEpisode1</title> + <itunes:duration>00:03:20</itunes:duration> + <enclosure + url="/testfeeds/newTestSubscription1/episode1.mp3" + type="audio/mp3" /> + </item> + <item> + <title>newTestEpisode2</title> + <itunes:duration>00:05:00</itunes:duration> + <enclosure + url="/testfeeds/newTestSubscription1/episode2.mp3" + type="audio/mp3" /> + </item> + </channel> +</rss>
\ No newline at end of file diff --git a/pse-server/testfeeds/newTestSubscription2.xml b/pse-server/testfeeds/newTestSubscription2.xml new file mode 100644 index 0000000..a014e5e --- /dev/null +++ b/pse-server/testfeeds/newTestSubscription2.xml @@ -0,0 +1,28 @@ +<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" + xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0"> + <channel> + <title>newTestSubscription2</title> + <item> + <title>newTestEpisode0</title> + <itunes:duration>00:01:40</itunes:duration> + <enclosure + url="/testfeeds/newTestSubscription2/episode0.mp3" + type="audio/mp3" /> + </item> + <item> + <title>newTestEpisode1</title> + <itunes:duration>00:03:20</itunes:duration> + <enclosure + url="/testfeeds/newTestSubscription2/episode1.mp3" + type="audio/mp3" /> + </item> + <item> + <title>newTestEpisode2</title> + <itunes:duration>00:05:00</itunes:duration> + <enclosure + url="/testfeeds/newTestSubscription2/episode2.mp3" + type="audio/mp3" /> + </item> + </channel> +</rss>
\ No newline at end of file diff --git a/pse-server/testfeeds/template.txt b/pse-server/testfeeds/template.txt new file mode 100644 index 0000000..1b4a8eb --- /dev/null +++ b/pse-server/testfeeds/template.txt @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"> + <channel> + <title>Dafna's Zebra Podcast</title> + <itunes:owner> + <itunes:email>dafna@example.com</itunes:email> + </itunes:owner> + <itunes:author>Dafna</itunes:author> + <description>A pet-owner's guide to the popular striped equine.</description> + <itunes:image href="https://www.example.com/podcasts/dafnas-zebras/img/dafna-zebra-pod-logo.jpg"/> + <language>en-us</language> + <link>https://www.example.com/podcasts/dafnas-zebras/</link> + <item> + <title>Top 10 myths about caring for a zebra</title> + <description>Here are the top 10 misunderstandings about the care, feeding, and breeding of these lovable striped animals.</description> + <pubDate>Tue, 14 Mar 2017 12:00:00 GMT</pubDate> + <enclosure url="https://www.example.com/podcasts/dafnas-zebras/audio/toptenmyths.mp3" + type="audio/mpeg" length="34216300"/> + <itunes:duration>30:00</itunes:duration> + <guid isPermaLink="false">dzpodtop10</guid> + </item> + <item> + <title>Keeping those stripes neat and clean</title> + <description>Keeping your zebra clean is time consuming, but worth the effort.</description> + <pubDate>Fri, 24 Feb 2017 12:00:00 GMT</pubDate> + <enclosure url="https://www.example.com/podcasts/dafnas-zebras/audio/cleanstripes.mp3" + type="audio/mpeg" length="26004388"/> + <itunes:duration>22:48</itunes:duration> + <guid>dzpodclean</guid> + </item> + </channel> +</rss>
\ No newline at end of file diff --git a/pse-server/testfeeds/testPodcast0.xml b/pse-server/testfeeds/testPodcast0.xml new file mode 100644 index 0000000..c7656eb --- /dev/null +++ b/pse-server/testfeeds/testPodcast0.xml @@ -0,0 +1,35 @@ +<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" + xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0"> + <channel> + <title>testPodcast0</title> + <item> + <title>testEpisode0</title> + <itunes:duration>00:01:40</itunes:duration> + <enclosure + url="/testfeeds/testPodcast0/episode0.mp3" + type="audio/mp3" /> + </item> + <item> + <title>testEpisode1</title> + <itunes:duration>00:03:20</itunes:duration> + <enclosure + url="/testfeeds/testPodcast0/episode1.mp3" + type="audio/mp3" /> + </item> + <item> + <title>testEpisode2</title> + <itunes:duration>00:05:00</itunes:duration> + <enclosure + url="/testfeeds/testPodcast0/episode2.mp3" + type="audio/mp3" /> + </item> + <item> + <title>testEpisode3</title> + <itunes:duration>01:00:00</itunes:duration> + <enclosure + url="/testfeeds/testPodcast0/episode3.mp3" + type="audio/mp3" /> + </item> + </channel> +</rss>
\ No newline at end of file diff --git a/pse-server/testfeeds/testPodcast1.xml b/pse-server/testfeeds/testPodcast1.xml new file mode 100644 index 0000000..a7eeb63 --- /dev/null +++ b/pse-server/testfeeds/testPodcast1.xml @@ -0,0 +1,28 @@ +<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" + xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0"> + <channel> + <title>testPodcast1</title> + <item> + <title>testEpisode0</title> + <itunes:duration>00:01:40</itunes:duration> + <enclosure + url="/testfeeds/testPodcast1/episode0.mp3" + type="audio/mp3" /> + </item> + <item> + <title>testEpisode1</title> + <itunes:duration>00:03:20</itunes:duration> + <enclosure + url="/testfeeds/testPodcast1/episode1.mp3" + type="audio/mp3" /> + </item> + <item> + <title>testEpisode2</title> + <itunes:duration>00:05:00</itunes:duration> + <enclosure + url="/testfeeds/testPodcast1/episode2.mp3" + type="audio/mp3" /> + </item> + </channel> +</rss>
\ No newline at end of file diff --git a/pse-server/testfeeds/timeTestPodcast.xml b/pse-server/testfeeds/timeTestPodcast.xml new file mode 100644 index 0000000..0f24193 --- /dev/null +++ b/pse-server/testfeeds/timeTestPodcast.xml @@ -0,0 +1,42 @@ +<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" + xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0"> + <channel> + <title>timeTestPodcast</title> + <item> + <title>anInteger</title> + <itunes:duration>123</itunes:duration> + <enclosure + url="/testfeeds/timeTestPodcast/anInteger.mp3" + type="audio/mp3" /> + </item> + <item> + <title>notAnInteger</title> + <itunes:duration>00-2*</itunes:duration> + <enclosure + url="/testfeeds/timeTestPodcast/notAnInteger.mp3" + type="audio/mp3" /> + </item> + <item> + <title>miutesAndSeconds</title> + <itunes:duration>10:50</itunes:duration> + <enclosure + url="/testfeeds/timeTestPodcast/minutesAndSeconds.mp3" + type="audio/mp3" /> + </item> + <item> + <title>noMinutes</title> + <itunes:duration>01::01</itunes:duration> + <enclosure + url="/testfeeds/timeTestPodcast/noMinutes.mp3" + type="audio/mp3" /> + </item> + <item> + <title>emptyDurationTag</title> + <itunes:duration></itunes:duration> + <enclosure + url="/testfeeds/timeTestPodcast/emptyDurationTag.mp3" + type="audio/mp3" /> + </item> + </channel> +</rss>
\ No newline at end of file |